Skip to content

Commit f574073

Browse files
DEVOPS-688 feat: add support for clariti org pooling (#5)
* DEVOPS-688 chore: add agents.md * DEVOPS-688 feat: add utilities for interacting with the Clariti Salesforce CLI plugin * DEVOPS-688 feat: enhance org import command to support importing from Clariti with optional pool ID * DEVOPS-688 feat: improve pool ID handling in org import and resolve_pool_id functions * DEVOPS-688 feat: update import command help text to clarify usage with Clariti Org Pooling System * DEVOPS-688 feat: add validation to org_import for USERNAME_OR_ALIAS and --pool-id conflict; enhance error handling in checkout_org_from_pool and set_sf_alias * DEVOPS-688 feat: update org_import and Clariti utilities to support optional pool ID; enhance docstrings for clarity * DEVOPS-688 docs: enhance org import documentation to include Clariti Org Pooling System support and usage instructions * DEVOPS-688 docs: update org import command usage to clarify sfdx_alias and cci_alias options * DEVOPS-688 test: add unit test for org import to reject conflicting USERNAME_OR_ALIAS and --pool-id * DEVOPS-688 test: add comprehensive unit tests for org checkout and pool ID resolution * DEVOPS-688 chore: improve alias sanatization Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Dipak Parmar <hi@dipak.tech> * DEVOPS-688 chore: remove redundant @pytest.mark.usefixtures("tmp_path") decorator. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Dipak Parmar <hi@dipak.tech> * DEVOPS-688 docs: improve error message for setting Salesforce alias --------- Signed-off-by: Dipak Parmar <hi@dipak.tech> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 6e609ac commit f574073

7 files changed

Lines changed: 702 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
- `cumulusci/` holds the package; CLI commands live in `cli/`, pluggable tasks in `tasks/`, and shared helpers in `utils/`.
5+
- Tests sit in `cumulusci/tests`, org-level suites in `integration_tests/`, Robot assets in `robot/`, and docs in `docs/`.
6+
- Release scaffolding is kept under `metadeploy/`, Salesforce metadata samples in `src/`, and reusable datasets in `datasets/`.
7+
- Project-level automation is configured via `cumulusci.yml`, with its schema helpers in `cumulusci/schema/`.
8+
9+
## Build, Test, and Development Commands
10+
- `uv venv && source .venv/bin/activate` followed by `uv sync --dev` prepares a local environment.
11+
- `uv run pytest -q` (or `make test`) runs the fast pytest suite; add `-k keyword` to focus runs.
12+
- `make lint` invokes flake8; run `uv run black .` and `uv run isort .` before committing for consistent formatting.
13+
- `make docs` builds the Sphinx site, `make dist` creates release artifacts, and `uv run cci doctor` verifies CLI wiring.
14+
15+
## Coding Style & Naming Conventions
16+
- Target Python 3.11+, 4-space indentation, Black's 88-character width, snake_case for modules/functions, CapWords for classes.
17+
- Keep code in the domain-specific module tree and name new tests `test_<feature>.py` so pytest auto-discovers them.
18+
- Type hints are encouraged; Pyright basic mode covers the whitelisted files in `pyproject.toml`.
19+
20+
## Testing Guidelines
21+
- Mirror production modules with pytest files and reuse fixtures in `cumulusci/tests` to avoid brittle setups.
22+
- Use `pytest.mark` markers already registered (e.g., `metadeploy`, `use_real_env`) for slow or external cases.
23+
- Refresh VCR-backed tests with `make vcr`; when hitting real orgs, pass `--org <alias>` and keep secrets out of the repo.
24+
- `make coverage` reports coverage—ensure new logic is backed by unit tests or Robot suites where it makes sense.
25+
26+
## Commit & Pull Request Guidelines
27+
- Follow the observed format `<ticket> <type>: imperative summary` (e.g., `DEVOPS-657 feat: extend update_dependency task`).
28+
- Keep commits scoped and reference issues or Trailhead work items in the body when additional context helps reviewers.
29+
- PRs should describe the change, note test evidence (`pytest`, `make docs`, etc.), and flag follow-up tasks early.
30+
- Attach CLI output or screenshots for user-facing updates and refresh docs or release notes if behavior shifts.
31+
32+
## Configuration & Security Tips
33+
- Never store Salesforce credentials in the repo; rely on CumulusCI keychains or environment variables instead.
34+
- Regenerate `cumulusci/schema/cumulusci.jsonschema.json` with `make schema` when expanding `cumulusci.yml` structures and validate new YAML against it.

cumulusci/cli/org.py

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@
2020
)
2121
from cumulusci.salesforce_api.utils import get_simple_salesforce_connection
2222
from cumulusci.utils import parse_api_datetime
23+
from typing import Optional
24+
25+
import click
26+
27+
from cumulusci.utils.clariti import (
28+
ClaritiError,
29+
build_default_org_name,
30+
checkout_org_from_pool,
31+
resolve_pool_id,
32+
set_sf_alias,
33+
)
2334

2435
from .runtime import CliRuntime, pass_runtime
2536

@@ -235,11 +246,101 @@ def org_default(runtime, org_name, unset):
235246
click.echo("There is no default org")
236247

237248

238-
@org.command(name="import", help="Import an org from Salesforce DX")
239-
@click.argument("username_or_alias")
240-
@orgname_option_or_argument(required=True)
249+
@org.command(name="import", help="Import an org from Salesforce DX or Clariti Org Pooling System")
250+
@click.argument("username_or_alias", required=False)
251+
@orgname_option_or_argument(required=False)
252+
@click.option(
253+
"--pool-id",
254+
help="Clariti pool id to checkout a persistent org. "
255+
"Falls back to .clariti.json if omitted.",
256+
)
241257
@pass_runtime(require_keychain=True)
242-
def org_import(runtime: CliRuntime, username_or_alias: str, org_name: str):
258+
def org_import(
259+
runtime: CliRuntime,
260+
username_or_alias: str,
261+
org_name: str,
262+
pool_id: Optional[str] = None,
263+
):
264+
"""Import a Salesforce org into the CCI keychain.
265+
266+
:param runtime: Active CLI runtime injected by Click.
267+
:param username_or_alias: Username or alias to import when not using Clariti.
268+
:param org_name: Desired keychain name for the org.
269+
:param pool_id: Optional Clariti pool identifier.
270+
:raises click.UsageError: if mutually-exclusive arguments are provided.
271+
:raises click.ClickException: for Clariti or SFDX import failures.
272+
"""
273+
if pool_id and username_or_alias:
274+
raise click.UsageError(
275+
"Provide either USERNAME_OR_ALIAS or --pool-id, but not both. "
276+
"Use --org to name the Clariti org checkout."
277+
)
278+
279+
if pool_id or not username_or_alias:
280+
project_root = (
281+
runtime.project_config.repo_root
282+
if runtime.project_config is not None
283+
else None
284+
)
285+
try:
286+
resolved_pool_id = resolve_pool_id(pool_id, project_root)
287+
except ClaritiError as err:
288+
raise click.ClickException(str(err)) from err
289+
290+
if resolved_pool_id:
291+
click.echo(
292+
f"Checking out org from Clariti pool {resolved_pool_id}..."
293+
)
294+
else:
295+
click.echo(
296+
"Checking out org from Clariti pool configured in .clariti.json..."
297+
)
298+
try:
299+
checkout = checkout_org_from_pool(
300+
resolved_pool_id,
301+
alias=org_name,
302+
)
303+
except ClaritiError as err:
304+
raise click.ClickException(str(err)) from err
305+
306+
username_or_alias = checkout.username
307+
308+
if not org_name:
309+
org_name = build_default_org_name(
310+
checkout.username,
311+
checkout.alias,
312+
)
313+
click.echo(
314+
f"No org name provided. Using '{org_name}' for this Clariti org."
315+
)
316+
317+
if checkout.alias and checkout.alias != org_name:
318+
click.echo(
319+
"Clariti assigned Salesforce alias "
320+
f"'{checkout.alias}' to {checkout.username}"
321+
)
322+
323+
alias_success, alias_error = set_sf_alias(org_name, checkout.username)
324+
if alias_success:
325+
click.echo(
326+
f"Set Salesforce CLI alias '{org_name}' "
327+
f"for {checkout.username}"
328+
)
329+
elif alias_error:
330+
click.echo(
331+
click.style(
332+
f"Warning: Unable to set Salesforce CLI alias '{org_name}': "
333+
f"{alias_error}",
334+
fg="yellow",
335+
)
336+
)
337+
338+
if not username_or_alias:
339+
raise click.UsageError(
340+
"Please provide a username or alias, or specify a Clariti pool id."
341+
)
342+
if not org_name:
343+
raise click.UsageError("Please specify ORGNAME or --org ORGNAME.")
243344
# Import the org from the SFDX keychain as an SfdxOrgConfig
244345
# The `sfdx` key ensures we can reload using the right class.
245346
org_config = SfdxOrgConfig(
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from types import SimpleNamespace
2+
3+
import pytest
4+
from click.testing import CliRunner
5+
6+
from cumulusci.cli.org import org
7+
8+
9+
class FakeRuntime:
10+
def __init__(self):
11+
self.project_config = SimpleNamespace(repo_root="/tmp")
12+
13+
def _load_keychain(self):
14+
# Tests bypass actual keychain loading.
15+
pass
16+
17+
18+
def test_org_import_rejects_username_and_pool_id(tmp_path):
19+
def test_org_import_rejects_username_and_pool_id(tmp_path):
20+
runner = CliRunner()
21+
runtime = FakeRuntime()
22+
runtime.project_config.repo_root = str(tmp_path)
23+
24+
result = runner.invoke(
25+
org,
26+
["import", "example@force.com", "--org", "alias", "--pool-id", "Pool42"],
27+
obj=runtime,
28+
)
29+
30+
assert result.exit_code != 0
31+
assert "Provide either USERNAME_OR_ALIAS" in result.output
32+
assert "--pool-id" in result.output
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import json
2+
from types import SimpleNamespace
3+
4+
import pytest
5+
6+
from cumulusci.utils.clariti import (
7+
ClaritiCheckoutResult,
8+
ClaritiError,
9+
build_default_org_name,
10+
checkout_org_from_pool,
11+
resolve_pool_id,
12+
set_sf_alias,
13+
)
14+
15+
16+
def test_resolve_pool_id_prefers_explicit():
17+
assert resolve_pool_id("Pool42", None) == "Pool42"
18+
19+
20+
def test_resolve_pool_id_requires_config_when_missing(tmp_path):
21+
config_path = tmp_path / ".clariti.json"
22+
config_path.write_text("{}")
23+
24+
resolved = resolve_pool_id(None, str(tmp_path))
25+
26+
assert resolved is None
27+
28+
29+
def test_resolve_pool_id_missing_file(tmp_path):
30+
with pytest.raises(ClaritiError) as exc:
31+
resolve_pool_id(None, str(tmp_path))
32+
33+
message = str(exc.value)
34+
assert "pool id" in message
35+
assert ".clariti.json" in message
36+
37+
38+
def test_checkout_org_from_pool_parses_username(monkeypatch):
39+
payload = {
40+
"orgId": "00D123",
41+
"username": "user@example.com",
42+
"alias": "foo",
43+
"poolId": "Pool42",
44+
}
45+
46+
def fake_run(command, check, text, capture_output, env):
47+
assert command[:4] == ["sf", "clariti", "org", "checkout"]
48+
assert "--json" in command
49+
return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="")
50+
51+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
52+
53+
result = checkout_org_from_pool("Pool42", alias="MyAlias")
54+
55+
assert isinstance(result, ClaritiCheckoutResult)
56+
assert result.username == "user@example.com"
57+
assert result.alias == "foo"
58+
assert result.org_id == "00D123"
59+
assert result.pool_id == "Pool42"
60+
assert result.raw == payload
61+
62+
63+
def test_checkout_org_from_pool_reads_nested_username(monkeypatch):
64+
payload = {
65+
"status": 0,
66+
"result": {
67+
"org": {"username": "nested@example.com"},
68+
},
69+
}
70+
71+
def fake_run(command, check, text, capture_output, env):
72+
assert "--pool-id" in command
73+
return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="")
74+
75+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
76+
77+
result = checkout_org_from_pool("PoolNested")
78+
79+
assert result.username == "nested@example.com"
80+
assert result.alias is None
81+
82+
83+
def test_checkout_org_from_pool_without_pool_id(monkeypatch):
84+
payload = {
85+
"username": "user@example.com",
86+
"poolId": "Pool-From-Config",
87+
}
88+
89+
def fake_run(command, check, text, capture_output, env):
90+
assert "--pool-id" not in command
91+
return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="")
92+
93+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
94+
95+
result = checkout_org_from_pool(None)
96+
97+
assert result.username == "user@example.com"
98+
99+
100+
def test_checkout_org_from_pool_handles_failure(monkeypatch):
101+
def fake_run(command, check, text, capture_output, env):
102+
return SimpleNamespace(returncode=1, stdout="", stderr="No orgs available")
103+
104+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
105+
106+
with pytest.raises(ClaritiError) as exc:
107+
checkout_org_from_pool("EmptyPool")
108+
109+
message = str(exc.value)
110+
assert "Clariti checkout failed" in message
111+
assert "No orgs available" in message
112+
113+
114+
def test_checkout_org_from_pool_formats_json_error(monkeypatch):
115+
payload = {
116+
"name": "ClaritiOrgCheckoutError",
117+
"message": "Failed to get org from pool: No healthy orgs available in this pool",
118+
}
119+
120+
def fake_run(command, check, text, capture_output, env):
121+
return SimpleNamespace(
122+
returncode=1, stdout=json.dumps(payload), stderr=""
123+
)
124+
125+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
126+
127+
with pytest.raises(ClaritiError) as exc:
128+
checkout_org_from_pool("Pool42")
129+
130+
message = str(exc.value)
131+
assert "Clariti checkout failed" in message
132+
assert "ClaritiOrgCheckoutError" in message
133+
assert "No healthy orgs" in message
134+
135+
136+
def test_checkout_org_from_pool_formats_json_error_debug(monkeypatch):
137+
payload = {
138+
"name": "ClaritiOrgCheckoutError",
139+
"message": "Failed", "extra": "details",
140+
}
141+
142+
def fake_run(command, check, text, capture_output, env):
143+
return SimpleNamespace(
144+
returncode=1, stdout=json.dumps(payload), stderr=""
145+
)
146+
147+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
148+
149+
from cumulusci.core.debug import set_debug_mode
150+
151+
with set_debug_mode(True):
152+
with pytest.raises(ClaritiError) as exc:
153+
checkout_org_from_pool("Pool42")
154+
155+
message = str(exc.value)
156+
assert "Clariti checkout failed" in message
157+
assert "Clariti raw response" in message
158+
assert json.dumps(payload, indent=2, sort_keys=True) in message
159+
160+
161+
def test_set_sf_alias_success(monkeypatch):
162+
def fake_run(command, check, text, capture_output, env):
163+
assert command == ["sf", "alias", "set", "target=user@example.com"]
164+
return SimpleNamespace(returncode=0, stdout="", stderr="")
165+
166+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
167+
168+
success, message = set_sf_alias("target", "user@example.com")
169+
170+
assert success is True
171+
assert message is None
172+
173+
174+
def test_set_sf_alias_failure(monkeypatch):
175+
def fake_run(command, check, text, capture_output, env):
176+
return SimpleNamespace(returncode=1, stdout="", stderr="Alias failure")
177+
178+
monkeypatch.setattr("cumulusci.utils.clariti.subprocess.run", fake_run)
179+
180+
success, message = set_sf_alias("target", "username")
181+
182+
assert success is False
183+
assert message is not None and message.startswith("Clariti alias failed")
184+
assert "Alias failure" in message
185+
186+
187+
def test_build_default_org_name_prefers_alias():
188+
assert build_default_org_name("user@example.com", "alias") == "alias"
189+
190+
191+
def test_build_default_org_name_sanitizes_username():
192+
assert (
193+
build_default_org_name("user.with+symbol@example.com")
194+
== "user_with_symbol_example_com"
195+
)

0 commit comments

Comments
 (0)