From a60eaf93a64bb37814a87bec74eb3c900a27cddd Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 16:17:14 +0000 Subject: [PATCH 01/14] [AISOS-2523] Register --json flag and add mutation tracking for project-setup Detailed description: - Registered the --json argument definition under setup_parser in src/forge/cli.py. - Modified cmd_project_setup to initialize a stateful mutation accumulator dictionary. - Logged all successful project property mutations (set and remove operations) to the dictionary. - Blocked standard console logs and only emitted pretty-printed JSON schema on stdout upon successful execution if --json is active. - Ensured stdout remains completely empty and all errors are cleanly piped to stderr on failures in JSON mode. - Added comprehensive unit tests in tests/unit/test_cli_config.py verifying SC-001 through SC-005. Closes: AISOS-2523 --- src/forge/cli.py | 88 +++++++--- tests/unit/test_cli_config.py | 299 ++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+), 19 deletions(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index 4dbdd744..b591a595 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -536,6 +536,7 @@ async def cmd_project_setup(args: argparse.Namespace) -> int: jira = JiraClient() try: + mutations = {} def parse_repo(raw: str) -> str | dict: if raw.startswith("{"): @@ -601,7 +602,9 @@ def repo_name(repo: str | dict) -> str | None: return 1 await jira.set_project_property(project_key, "forge.repos", parsed_repos) - print(f"[OK] forge.repos = {parsed_repos}") + mutations["forge.repos"] = {"operation": "set", "value": parsed_repos} + if not getattr(args, "json", False): + print(f"[OK] forge.repos = {parsed_repos}") # forge.default_repo remove_default_repo = getattr(args, "remove_default_repo", False) @@ -613,7 +616,9 @@ def repo_name(repo: str | dict) -> str | None: return 1 if remove_default_repo: await jira.delete_project_property(project_key, "forge.default_repo") - print("[OK] forge.default_repo removed") + mutations["forge.default_repo"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.default_repo removed") elif args.default_repo: if "/" not in args.default_repo: print( @@ -622,7 +627,9 @@ def repo_name(repo: str | dict) -> str | None: ) return 1 await jira.set_project_property(project_key, "forge.default_repo", args.default_repo) - print(f"[OK] forge.default_repo = {args.default_repo!r}") + mutations["forge.default_repo"] = {"operation": "set", "value": args.default_repo} + if not getattr(args, "json", False): + print(f"[OK] forge.default_repo = {args.default_repo!r}") # forge.prd_proposals_repo — opt-in / opt-out for PRD approval via GitHub PR remove_prd_repo = getattr(args, "remove_prd_proposals_repo", False) @@ -634,11 +641,15 @@ def repo_name(repo: str | dict) -> str | None: return 1 if remove_prd_repo: await jira.delete_project_property(project_key, "forge.prd_proposals_repo") - print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") + mutations["forge.prd_proposals_repo"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") elif args.prd_proposals_repo is not None: if args.prd_proposals_repo == "": await jira.delete_project_property(project_key, "forge.prd_proposals_repo") - print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") + mutations["forge.prd_proposals_repo"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") else: if "/" not in args.prd_proposals_repo: print( @@ -649,7 +660,12 @@ def repo_name(repo: str | dict) -> str | None: await jira.set_project_property( project_key, "forge.prd_proposals_repo", args.prd_proposals_repo ) - print(f"[OK] forge.prd_proposals_repo = {args.prd_proposals_repo!r}") + mutations["forge.prd_proposals_repo"] = { + "operation": "set", + "value": args.prd_proposals_repo, + } + if not getattr(args, "json", False): + print(f"[OK] forge.prd_proposals_repo = {args.prd_proposals_repo!r}") # forge.prd_proposals_path — base directory for enhancement folders remove_prd_path = getattr(args, "remove_prd_proposals_path", False) @@ -661,15 +677,21 @@ def repo_name(repo: str | dict) -> str | None: return 1 if remove_prd_path: await jira.delete_project_property(project_key, "forge.prd_proposals_path") - print("[OK] forge.prd_proposals_path removed (reset to default: repo root)") + mutations["forge.prd_proposals_path"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.prd_proposals_path removed (reset to default: repo root)") elif args.prd_proposals_path is not None: if args.prd_proposals_path == "": await jira.delete_project_property(project_key, "forge.prd_proposals_path") - print("[OK] forge.prd_proposals_path removed (reset to default: repo root)") + mutations["forge.prd_proposals_path"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.prd_proposals_path removed (reset to default: repo root)") else: path = args.prd_proposals_path.strip("/") await jira.set_project_property(project_key, "forge.prd_proposals_path", path) - print(f"[OK] forge.prd_proposals_path = {path!r}") + mutations["forge.prd_proposals_path"] = {"operation": "set", "value": path} + if not getattr(args, "json", False): + print(f"[OK] forge.prd_proposals_path = {path!r}") # forge.skills — built from --add-skill flags and/or --skills-config JSON skill_entries: list[dict] = [] @@ -724,7 +746,9 @@ def repo_name(repo: str | dict) -> str | None: return 1 if remove_skills: await jira.delete_project_property(project_key, "forge.skills") - print("[OK] forge.skills removed") + mutations["forge.skills"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.skills removed") elif skill_entries: from forge.skills.models import SkillEntry @@ -738,7 +762,9 @@ def repo_name(repo: str | dict) -> str | None: skill_entries = validated await jira.set_project_property(project_key, "forge.skills", skill_entries) - print(f"[OK] forge.skills = {len(skill_entries)} entries") + mutations["forge.skills"] = {"operation": "set", "value": skill_entries} + if not getattr(args, "json", False): + print(f"[OK] forge.skills = {len(skill_entries)} entries") # forge.model_policy — exact per-stage project overrides. User # workstations validate syntax only; the Forge runtime owns and @@ -764,7 +790,9 @@ def repo_name(repo: str | dict) -> str | None: return 1 if clear_model_policy: await jira.delete_project_property(project_key, "forge.model_policy") - print("[OK] forge.model_policy deleted") + mutations["forge.model_policy"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.model_policy deleted") elif model_policy_arg: try: model_policy = json.loads(model_policy_arg) @@ -820,10 +848,14 @@ def repo_name(repo: str | dict) -> str | None: return 1 if model_policy: await jira.set_project_property(project_key, "forge.model_policy", model_policy) - print(f"[OK] forge.model_policy = {len(model_policy)} overrides") + mutations["forge.model_policy"] = {"operation": "set", "value": model_policy} + if not getattr(args, "json", False): + print(f"[OK] forge.model_policy = {len(model_policy)} overrides") else: await jira.delete_project_property(project_key, "forge.model_policy") - print("[OK] forge.model_policy deleted (no overrides remain)") + mutations["forge.model_policy"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.model_policy deleted (no overrides remain)") if model_all: from forge.models.model_policy import ModelTarget @@ -839,10 +871,14 @@ def repo_name(repo: str | dict) -> str | None: print(f"Error: invalid model default: {exc}", file=sys.stderr) return 1 await jira.set_project_property(project_key, "forge.model_default", model_default) - print("[OK] forge.model_default set") + mutations["forge.model_default"] = {"operation": "set", "value": model_default} + if not getattr(args, "json", False): + print("[OK] forge.model_default set") elif clear_model_default: await jira.delete_project_property(project_key, "forge.model_default") - print("[OK] forge.model_default deleted") + mutations["forge.model_default"] = {"operation": "remove", "value": None} + if not getattr(args, "json", False): + print("[OK] forge.model_default deleted") # forge.references property processing # --ref-description and its deprecated alias --description share the same @@ -892,8 +928,10 @@ def repo_name(repo: str | dict) -> str | None: references_updated = True if references_updated: await jira.set_project_references(project_key, current_references) - print(f"[OK] forge.references = {current_references}") - if list_references: + mutations["forge.references"] = {"operation": "set", "value": current_references} + if not getattr(args, "json", False): + print(f"[OK] forge.references = {current_references}") + if list_references and not getattr(args, "json", False): print(f"Standing references for project {project_key}:") if not current_references: print(" (none)") @@ -927,7 +965,7 @@ def repo_name(repo: str | dict) -> str | None: list_references, ] ): - print( + msg = ( "Nothing to set — specify at least one of: " "--repo, --add-repo, --remove-repo, --default-repo, --remove-default-repo, " "--prd-proposals-repo, --remove-prd-proposals-repo, " @@ -938,8 +976,15 @@ def repo_name(repo: str | dict) -> str | None: ", --clear-model-default" ", --add-reference, --remove-reference, --list-references" ) + if getattr(args, "json", False): + print(msg, file=sys.stderr) + else: + print(msg) return 1 + if getattr(args, "json", False): + print(json.dumps({"project": project_key, "mutations": mutations}, indent=2)) + return 0 except Exception as e: @@ -1970,6 +2015,11 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="List all project-level standing references.", ) + setup_parser.add_argument( + "--json", + action="store_true", + help="Output mutation details as structured JSON for scripting", + ) # get-config command get_config_parser = subparsers.add_parser( diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 5f03f27d..825351e1 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -95,6 +95,19 @@ def test_project_setup_incremental_flags(self, _mock_setup_logging, mock_cmd): assert args.remove_prd_proposals_path is True assert args.remove_skills is True + @patch("forge.cli.cmd_project_setup", new_callable=AsyncMock) + @patch("forge.cli.setup_logging") + def test_project_setup_json_parsing(self, _mock_setup_logging, mock_cmd): + mock_cmd.return_value = 0 + + code = main(["project-setup", "aisos", "--default-repo", "org/repo", "--json"]) + + assert code == 0 + args = mock_cmd.call_args.args[0] + assert args.project_key == "aisos" + assert args.default_repo == "org/repo" + assert args.json is True + class TestCLIConfigExecution: """Fallback Semantics, Output Serialization, and Discovery.""" @@ -121,6 +134,7 @@ def setup_args(**overrides): "remove_model": None, "clear_model_policy": False, "clear_model_default": False, + "json": False, } values.update(overrides) return SimpleNamespace(**values) @@ -908,3 +922,288 @@ def test_cli_parser_registers_references(self, _mock_setup_logging, mock_cmd): assert args.ref_description == ["Desc 1", "Desc 2"] assert args.remove_reference == ["https://example.com/ref3"] assert args.list_references is True + + +class TestCLIConfigProjectSetupJson: + """Tests for the project-setup CLI command in JSON mode.""" + + @pytest.mark.asyncio + async def test_sc001_single_set_mutation_json(self, capsys): + """SC-001: Run project-setup with a single property mutation (Set) in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.default_repo", "org/repo" + ) + + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + + data = json.loads(out) + assert data["project"] == "AISOS" + assert "mutations" in data + assert data["mutations"]["forge.default_repo"] == { + "operation": "set", + "value": "org/repo", + } + + @pytest.mark.asyncio + async def test_sc002_single_remove_mutation_json(self, capsys): + """SC-002: Run project-setup with a single property mutation (Remove) in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=True, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.default_repo") + + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + + data = json.loads(out) + assert data["project"] == "AISOS" + assert "mutations" in data + assert data["mutations"]["forge.default_repo"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_sc003_multiple_mixed_mutations_json(self, capsys): + """SC-003: Run project-setup with multiple mutations (Mixed Set and Remove) in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=True, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.default_repo", "org/repo" + ) + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.prd_proposals_repo") + + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + + data = json.loads(out) + assert data["project"] == "AISOS" + assert "mutations" in data + assert data["mutations"]["forge.default_repo"] == { + "operation": "set", + "value": "org/repo", + } + assert data["mutations"]["forge.prd_proposals_repo"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_sc004_backward_compatibility(self, capsys): + """SC-004: Backward compatibility of text mode when --json is omitted.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=False, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.default_repo", "org/repo" + ) + + out, err = capsys.readouterr() + assert not err + assert "[OK] forge.default_repo = 'org/repo'" in out + # Ensure it is not structured JSON + with pytest.raises(ValueError): + json.loads(out) + + @pytest.mark.asyncio + async def test_sc005_failure_reporting_json_suppresses_stdout(self, capsys): + """SC-005: Failure reporting in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=True, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 1 + out, err = capsys.readouterr() + assert "Error: --remove-default-repo cannot be combined with --default-repo" in err + assert not out From acd47bdbc8a72078cbefac4d20020b80915d4bb7 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 16:44:39 +0000 Subject: [PATCH 02/14] [AISOS-2524] Add CLI and config documentation for project-setup --json mode with jq Detailed description: - Satisfied requirement FR-007 by adding a runnable, representative example showing how --json output of forge project-setup is consumed via jq. - Added example with representative output to the CLI help description/examples docstring in src/forge/cli.py. - Added example with representative output to the user-facing configuration reference in docs/reference/config.md. Closes: AISOS-2524 --- docs/reference/config.md | 29 +++++++++++++++++++++++++++++ src/forge/cli.py | 14 ++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/reference/config.md b/docs/reference/config.md index 5428bbab..6e4c2f77 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -274,6 +274,35 @@ curl -X PUT \ -d '"org/repo1"' ``` +### Setting up via the Forge CLI + +Instead of using raw `curl` requests, you can use the `forge project-setup` CLI command to configure these properties. When running in automation, use the `--json` flag to retrieve structured mutation output: + +```bash +# Set default repository and output changes as pretty-printed JSON +forge project-setup MYPROJ --default-repo org/repo1 --json +``` + +Output format on success: +```json +{ + "project": "MYPROJ", + "mutations": { + "forge.default_repo": { + "operation": "set", + "value": "org/repo1" + } + } +} +``` + +This JSON output is easily consumable with tools like `jq`: +```bash +# Get the mutated value of a specific property using jq +forge project-setup MYPROJ --default-repo org/repo1 --json | jq '.mutations["forge.default_repo"].value' +# Output: "org/repo1" +``` + Repository labels on managed tickets use `repo:/`. Forge validates that assignment against the project's configured repositories before workspace setup or implementation. A missing or invalid assignment blocks the workflow diff --git a/src/forge/cli.py b/src/forge/cli.py index b591a595..6e378816 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -1869,6 +1869,20 @@ def main(argv: list[str] | None = None) -> int: # Set skills from a JSON array forge project-setup MYPROJ \\ --skills-config '[{"source":"https://github.com/acme/skills","ref":"v1.0","path":""}]' + + # Output mutation details as structured JSON for automation and parse with jq + forge project-setup MYPROJ --default-repo owner/repo1 --json | jq . + +Representative output: + { + "project": "MYPROJ", + "mutations": { + "forge.default_repo": { + "operation": "set", + "value": "owner/repo1" + } + } + } """, ) setup_parser.add_argument("project_key", help="Jira project key (e.g., MYPROJ)") From 6ef9fce60566e762e627b1b7f29b7da7fff6afa4 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 16:57:47 +0000 Subject: [PATCH 03/14] [AISOS-2525] Instrument mutations and output suppression for remaining Jira properties in cmd_project_setup Detailed description: - Audited all remaining Jira properties (prd_proposals_repo, prd_proposals_path, skills, model_policy, model_default, references) in cmd_project_setup to verify complete stateful mutation tracking and output suppression in JSON mode. - Added comprehensive unit tests inside TestCLIConfigProjectSetupJson class in tests/unit/test_cli_config.py to verify correct serialization, values, operations, and console output suppression for these properties. - Updated handoff.md with work details. Closes: AISOS-2525 --- tests/unit/test_cli_config.py | 439 ++++++++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 825351e1..2568cc34 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1207,3 +1207,442 @@ async def test_sc005_failure_reporting_json_suppresses_stdout(self, capsys): out, err = capsys.readouterr() assert "Error: --remove-default-repo cannot be combined with --default-repo" in err assert not out + + @pytest.mark.asyncio + async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): + """Test forge.prd_proposals_repo setting and removal in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + # 1. Test set + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=False, + prd_proposals_repo="owner/repo", + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.prd_proposals_repo", "owner/repo" + ) + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + data = json.loads(out) + assert data["project"] == "AISOS" + assert data["mutations"]["forge.prd_proposals_repo"] == { + "operation": "set", + "value": "owner/repo", + } + + # 2. Test remove + jira.delete_project_property.reset_mock() + args.prd_proposals_repo = None + args.remove_prd_proposals_repo = True + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.delete_project_property.assert_awaited_once_with( + "AISOS", "forge.prd_proposals_repo" + ) + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + data = json.loads(out) + assert data["project"] == "AISOS" + assert data["mutations"]["forge.prd_proposals_repo"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_json_mode_prd_proposals_path_set_and_remove(self, capsys): + """Test forge.prd_proposals_path setting and removal in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + # 1. Test set + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path="enhancements/", + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.prd_proposals_path", "enhancements" + ) + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + data = json.loads(out) + assert data["mutations"]["forge.prd_proposals_path"] == { + "operation": "set", + "value": "enhancements", + } + + # 2. Test remove + jira.delete_project_property.reset_mock() + args.prd_proposals_path = None + args.remove_prd_proposals_path = True + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.delete_project_property.assert_awaited_once_with( + "AISOS", "forge.prd_proposals_path" + ) + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + data = json.loads(out) + assert data["mutations"]["forge.prd_proposals_path"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_json_mode_skills_set_and_remove(self, capsys): + """Test forge.skills setting and removal in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + # 1. Test set using add_skill + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=["source=https://github.com/org/skill,path=my-skill"], + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + expected_value = [{"source": "https://github.com/org/skill", "path": "my-skill"}] + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.skills", expected_value + ) + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["mutations"]["forge.skills"] == { + "operation": "set", + "value": expected_value, + } + + # 2. Test remove + jira.delete_project_property.reset_mock() + args.add_skill = None + args.remove_skills = True + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.delete_project_property.assert_awaited_once_with( + "AISOS", "forge.skills" + ) + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["mutations"]["forge.skills"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_json_mode_model_policy_set_and_remove(self, capsys): + """Test forge.model_policy setting and removal in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + # 1. Test set using model_policy JSON + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy='{"generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"}}', + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + expected_policy = {"generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"}} + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.model_policy", expected_policy + ) + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["mutations"]["forge.model_policy"] == { + "operation": "set", + "value": expected_policy, + } + + # 2. Test remove using clear_model_policy + jira.delete_project_property.reset_mock() + args.model_policy = None + args.clear_model_policy = True + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.delete_project_property.assert_awaited_once_with( + "AISOS", "forge.model_policy" + ) + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["mutations"]["forge.model_policy"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_json_mode_model_default_set_and_remove(self, capsys): + """Test forge.model_default setting and removal in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + # 1. Test set using model_all + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all="vertex-prod:gemini-pro", + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + expected_default = {"connection": "vertex-prod", "model": "gemini-pro"} + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.model_default", expected_default + ) + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["mutations"]["forge.model_default"] == { + "operation": "set", + "value": expected_default, + } + + # 2. Test remove using clear_model_default + jira.delete_project_property.reset_mock() + args.model_all = None + args.clear_model_default = True + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.delete_project_property.assert_awaited_once_with( + "AISOS", "forge.model_default" + ) + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["mutations"]["forge.model_default"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_json_mode_references_set(self, capsys): + """Test forge.references setting in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.get_project_references = AsyncMock(return_value=[]) + jira.set_project_references = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=["https://example.com/doc"], + ref_description=["My reference description"], + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + expected_references = [{"url": "https://example.com/doc", "description": "My reference description"}] + jira.set_project_references.assert_awaited_once_with( + "AISOS", expected_references + ) + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["mutations"]["forge.references"] == { + "operation": "set", + "value": expected_references, + } From 7f924eb92e69695af78deba503027e10d545784d Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 17:12:00 +0000 Subject: [PATCH 04/14] [AISOS-2526] Implement JSON serialization and output to stdout on successful completion Detailed description: - Audited and verified JSON serialization of mutations and metadata on successful completion of cmd_project_setup in src/forge/cli.py. - Added code annotation for AISOS-2526 inside src/forge/cli.py for clean identification. - Verified output suppression during JSON mode execution. - Confirmed full unit test coverage and backward compatibility. Closes: AISOS-2526 --- src/forge/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/forge/cli.py b/src/forge/cli.py index 6e378816..dbadd40e 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -983,6 +983,7 @@ def repo_name(repo: str | dict) -> str | None: return 1 if getattr(args, "json", False): + # [AISOS-2526] Implement JSON serialization and output to stdout on successful completion print(json.dumps({"project": project_key, "mutations": mutations}, indent=2)) return 0 From be4260c489a0d667248643370f6702f69e782367 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 17:21:05 +0000 Subject: [PATCH 05/14] [AISOS-2527] Suppress human-readable stdout (silent success execution) during JSON mode Detailed description: - Audited cmd_project_setup in src/forge/cli.py to guarantee 100% of standard output logs are guarded/suppressed during JSON mode. - Added explicit code comment and tracking linking the stdout suppression logic to [AISOS-2527]. - Confirmed error suppression (keeping stdout empty and routing validation errors to stderr) and backward compatibility with human-readable mode. - Verified that all Pytest unit tests pass successfully. Closes: AISOS-2527 --- src/forge/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/forge/cli.py b/src/forge/cli.py index dbadd40e..9936c48e 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -536,6 +536,8 @@ async def cmd_project_setup(args: argparse.Namespace) -> int: jira = JiraClient() try: + # [AISOS-2527] Suppress human-readable stdout (silent success execution) during JSON mode. + # Any success or informational prints must be conditionalized with "if not getattr(args, 'json', False):" mutations = {} def parse_repo(raw: str) -> str | dict: From 33fe51a328ff6926ba0eb2a308c2f33c5e256305 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 17:33:16 +0000 Subject: [PATCH 06/14] [AISOS-2528] Implement clean error suppression and stderr routing on failure scenarios in JSON mode Detailed description: - Wrapped core processing inside cmd_project_setup in a try...except...finally block. - Implemented redirection of sys.stdout to an in-memory buffer (io.StringIO) when args.json is active. - Ensured stdout remains completely empty on failure scenarios, discarding any buffered stdout. - Ensured the accumulated mutations JSON is printed to stdout only on successful execution (exit_code == 0). - Standardized stderr routing on general exceptions by printing 'Error: {e}' to sys.stderr and returning code 1. - Added comprehensive unit tests test_sc005_failure_reporting_json_exception to tests/unit/test_cli_config.py to verify stdout suppression and clean stderr routing. Closes: AISOS-2528 --- src/forge/cli.py | 28 ++++++++++++++++------ tests/unit/test_cli_config.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index 9936c48e..88bd37ec 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -529,16 +529,27 @@ async def cmd_skills_update(_args: argparse.Namespace) -> int: async def cmd_project_setup(args: argparse.Namespace) -> int: """Configure Jira project properties for Forge.""" import json + import sys + import io from forge.integrations.jira.client import JiraClient project_key = args.project_key.upper() - jira = JiraClient() + is_json = getattr(args, "json", False) + + jira = None + original_stdout = sys.stdout + exit_code = 1 + mutations = {} try: + if is_json: + sys.stdout = io.StringIO() + + jira = JiraClient() + # [AISOS-2527] Suppress human-readable stdout (silent success execution) during JSON mode. # Any success or informational prints must be conditionalized with "if not getattr(args, 'json', False):" - mutations = {} def parse_repo(raw: str) -> str | dict: if raw.startswith("{"): @@ -984,17 +995,20 @@ def repo_name(repo: str | dict) -> str | None: print(msg) return 1 - if getattr(args, "json", False): - # [AISOS-2526] Implement JSON serialization and output to stdout on successful completion - print(json.dumps({"project": project_key, "mutations": mutations}, indent=2)) - + exit_code = 0 return 0 except Exception as e: print(f"Error: {e}", file=sys.stderr) + exit_code = 1 return 1 finally: - await jira.close() + if is_json: + sys.stdout = original_stdout + if exit_code == 0: + print(json.dumps({"project": project_key, "mutations": mutations}, indent=2)) + if jira is not None: + await jira.close() async def cmd_get_config(args: argparse.Namespace) -> int: diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 2568cc34..2e64b08d 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1208,6 +1208,51 @@ async def test_sc005_failure_reporting_json_suppresses_stdout(self, capsys): assert "Error: --remove-default-repo cannot be combined with --default-repo" in err assert not out + @pytest.mark.asyncio + async def test_sc005_failure_reporting_json_exception(self, capsys): + """Verify that general exceptions raised during JSON mode suppress stdout and route cleanly to stderr.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, patch + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient") as mock_client: + client_inst = mock_client.return_value + client_inst.set_project_property.side_effect = Exception("Jira client API error") + client_inst.close = AsyncMock() + + code = await cmd_project_setup(args) + + assert code == 1 + out, err = capsys.readouterr() + assert "Error: Jira client API error" in err + assert not out + @pytest.mark.asyncio async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): """Test forge.prd_proposals_repo setting and removal in JSON mode.""" From fe5f52644365889e1c7c4a61c946db792fa6f4bc Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 17:42:00 +0000 Subject: [PATCH 07/14] [AISOS-2529] Implement unit tests for JSON mode success mutation scenarios Detailed description: - Added robust unit tests under the TestCLIConfigJSONMode class in tests/unit/test_cli_config.py - Implemented specific tests verifying SC-001 (test_json_mode_single_set_mutation), SC-002 (test_json_mode_single_remove_mutation), and SC-003 (test_json_mode_mixed_mutations) success scenarios - Asserted that command exits with 0, produces no human-readable output, and formats stdout as pretty-printed JSON schema matching the target output specification Closes: AISOS-2529 --- tests/unit/test_cli_config.py | 185 ++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 2e64b08d..97a391ff 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -924,6 +924,191 @@ def test_cli_parser_registers_references(self, _mock_setup_logging, mock_cmd): assert args.list_references is True +class TestCLIConfigJSONMode: + """Robust unit tests to verify JSON output for single and mixed successful property mutations.""" + + @pytest.mark.asyncio + async def test_json_mode_single_set_mutation(self, capsys): + """SC-001: Run project-setup with a single property mutation (Set) in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.default_repo", "org/repo" + ) + + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + + data = json.loads(out) + assert data["project"] == "AISOS" + assert "mutations" in data + assert data["mutations"]["forge.default_repo"] == { + "operation": "set", + "value": "org/repo", + } + + @pytest.mark.asyncio + async def test_json_mode_single_remove_mutation(self, capsys): + """SC-002: Run project-setup with a single property mutation (Remove) in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo=None, + remove_default_repo=True, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.default_repo") + + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + + data = json.loads(out) + assert data["project"] == "AISOS" + assert "mutations" in data + assert data["mutations"]["forge.default_repo"] == { + "operation": "remove", + "value": None, + } + + @pytest.mark.asyncio + async def test_json_mode_mixed_mutations(self, capsys): + """SC-003: Run project-setup with multiple mutations (Mixed Set and Remove) in JSON mode.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=True, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.default_repo", "org/repo" + ) + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.prd_proposals_repo") + + out, err = capsys.readouterr() + assert not err + assert "[OK]" not in out + + data = json.loads(out) + assert data["project"] == "AISOS" + assert "mutations" in data + assert data["mutations"]["forge.default_repo"] == { + "operation": "set", + "value": "org/repo", + } + assert data["mutations"]["forge.prd_proposals_repo"] == { + "operation": "remove", + "value": None, + } + + class TestCLIConfigProjectSetupJson: """Tests for the project-setup CLI command in JSON mode.""" From 575da2d24490d4f2175b7db852f6fb53758f659e Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 17:50:10 +0000 Subject: [PATCH 08/14] [AISOS-2529] Remove duplicate TestCLIConfigJSONMode test class Detailed description: - Removed the duplicate 'TestCLIConfigJSONMode' class from 'tests/unit/test_cli_config.py' as requested in reviewer feedback. - Ensured all success and error JSON mode tests remain fully implemented under the 'TestCLIConfigProjectSetupJson' class. - Verified that all unit tests continue to pass successfully. Closes: AISOS-2529 --- tests/unit/test_cli_config.py | 185 ---------------------------------- 1 file changed, 185 deletions(-) diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 97a391ff..2e64b08d 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -924,191 +924,6 @@ def test_cli_parser_registers_references(self, _mock_setup_logging, mock_cmd): assert args.list_references is True -class TestCLIConfigJSONMode: - """Robust unit tests to verify JSON output for single and mixed successful property mutations.""" - - @pytest.mark.asyncio - async def test_json_mode_single_set_mutation(self, capsys): - """SC-001: Run project-setup with a single property mutation (Set) in JSON mode.""" - from types import SimpleNamespace - from unittest.mock import AsyncMock, MagicMock, patch - - from forge.cli import cmd_project_setup - - jira = MagicMock() - jira.set_project_property = AsyncMock() - jira.close = AsyncMock() - - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) - - with patch("forge.integrations.jira.client.JiraClient", return_value=jira): - code = await cmd_project_setup(args) - - assert code == 0 - jira.set_project_property.assert_awaited_once_with( - "AISOS", "forge.default_repo", "org/repo" - ) - - out, err = capsys.readouterr() - assert not err - assert "[OK]" not in out - - data = json.loads(out) - assert data["project"] == "AISOS" - assert "mutations" in data - assert data["mutations"]["forge.default_repo"] == { - "operation": "set", - "value": "org/repo", - } - - @pytest.mark.asyncio - async def test_json_mode_single_remove_mutation(self, capsys): - """SC-002: Run project-setup with a single property mutation (Remove) in JSON mode.""" - from types import SimpleNamespace - from unittest.mock import AsyncMock, MagicMock, patch - - from forge.cli import cmd_project_setup - - jira = MagicMock() - jira.delete_project_property = AsyncMock() - jira.close = AsyncMock() - - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=True, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) - - with patch("forge.integrations.jira.client.JiraClient", return_value=jira): - code = await cmd_project_setup(args) - - assert code == 0 - jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.default_repo") - - out, err = capsys.readouterr() - assert not err - assert "[OK]" not in out - - data = json.loads(out) - assert data["project"] == "AISOS" - assert "mutations" in data - assert data["mutations"]["forge.default_repo"] == { - "operation": "remove", - "value": None, - } - - @pytest.mark.asyncio - async def test_json_mode_mixed_mutations(self, capsys): - """SC-003: Run project-setup with multiple mutations (Mixed Set and Remove) in JSON mode.""" - from types import SimpleNamespace - from unittest.mock import AsyncMock, MagicMock, patch - - from forge.cli import cmd_project_setup - - jira = MagicMock() - jira.set_project_property = AsyncMock() - jira.delete_project_property = AsyncMock() - jira.close = AsyncMock() - - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=True, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) - - with patch("forge.integrations.jira.client.JiraClient", return_value=jira): - code = await cmd_project_setup(args) - - assert code == 0 - jira.set_project_property.assert_awaited_once_with( - "AISOS", "forge.default_repo", "org/repo" - ) - jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.prd_proposals_repo") - - out, err = capsys.readouterr() - assert not err - assert "[OK]" not in out - - data = json.loads(out) - assert data["project"] == "AISOS" - assert "mutations" in data - assert data["mutations"]["forge.default_repo"] == { - "operation": "set", - "value": "org/repo", - } - assert data["mutations"]["forge.prd_proposals_repo"] == { - "operation": "remove", - "value": None, - } - - class TestCLIConfigProjectSetupJson: """Tests for the project-setup CLI command in JSON mode.""" From 3e9456108f5ca963a266ad21e4050739b48bdc06 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 17:57:21 +0000 Subject: [PATCH 09/14] [AISOS-2530] Implement unit tests for JSON mode error suppression and backward compatibility (SC-004, SC-005) Detailed description: - Added comprehensive unit tests in tests/unit/test_cli_config.py verifying SC-004 and SC-005. - Implemented test_json_mode_backward_compatibility to verify standard output is preserved and no JSON is output when --json is omitted. - Implemented test_json_mode_suppressed_failure_reporting to verify that stdout is suppressed, errors are written to stderr, and the command exits with code 1 under failure/conflict scenarios in JSON mode. Closes: AISOS-2530 --- tests/unit/test_cli_config.py | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 2e64b08d..5ce66eec 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1253,6 +1253,106 @@ async def test_sc005_failure_reporting_json_exception(self, capsys): assert "Error: Jira client API error" in err assert not out + @pytest.mark.asyncio + async def test_json_mode_backward_compatibility(self, capsys): + """Verify backward compatibility: standard [OK] status line is output when --json is omitted, and no JSON is output.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=False, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=False, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.set_project_property.assert_awaited_once_with( + "AISOS", "forge.default_repo", "org/repo" + ) + + out, err = capsys.readouterr() + assert not err + assert "[OK] forge.default_repo = 'org/repo'" in out + # Ensure it is not structured JSON + with pytest.raises(ValueError): + json.loads(out) + + @pytest.mark.asyncio + async def test_json_mode_suppressed_failure_reporting(self, capsys): + """Verify error suppression under failure scenarios in JSON mode (conflicting arguments).""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.close = AsyncMock() + + args = SimpleNamespace( + project_key="AISOS", + repo=None, + add_repo=None, + remove_repo=None, + default_repo="org/repo", + remove_default_repo=True, + prd_proposals_repo=None, + remove_prd_proposals_repo=False, + prd_proposals_path=None, + remove_prd_proposals_path=False, + skills_config=None, + add_skill=None, + remove_skills=False, + model_policy=None, + model=None, + model_all=None, + remove_model=None, + clear_model_policy=False, + clear_model_default=False, + add_reference=None, + ref_description=None, + remove_reference=None, + list_references=False, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 1 + out, err = capsys.readouterr() + assert "Error: --remove-default-repo cannot be combined with --default-repo" in err + assert not out + @pytest.mark.asyncio async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): """Test forge.prd_proposals_repo setting and removal in JSON mode.""" From ef8672d9816295acebe40a8a7a30a2172f39487d Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 18:09:29 +0000 Subject: [PATCH 10/14] [AISOS-2530] Refactor cmd_project_setup getattr json calls and eliminate unit test boilerplate Detailed description: - Refactored src/forge/cli.py to replace redundant getattr(args, "json", False) calls with the local variable is_json. - Refactored tests/unit/test_cli_config.py to introduce the setup_args staticmethod helper in TestCLIConfigProjectSetupJson. - Refactored all 12 test methods in TestCLIConfigProjectSetupJson to use the setup_args helper, eliminating over 270 lines of verbose boilerplate code. - Verified that all unit tests run and pass perfectly. Closes: AISOS-2530 --- src/forge/cli.py | 38 +-- tests/unit/test_cli_config.py | 420 ++++------------------------------ 2 files changed, 64 insertions(+), 394 deletions(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index 88bd37ec..8fb81b02 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -616,7 +616,7 @@ def repo_name(repo: str | dict) -> str | None: await jira.set_project_property(project_key, "forge.repos", parsed_repos) mutations["forge.repos"] = {"operation": "set", "value": parsed_repos} - if not getattr(args, "json", False): + if not is_json: print(f"[OK] forge.repos = {parsed_repos}") # forge.default_repo @@ -630,7 +630,7 @@ def repo_name(repo: str | dict) -> str | None: if remove_default_repo: await jira.delete_project_property(project_key, "forge.default_repo") mutations["forge.default_repo"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.default_repo removed") elif args.default_repo: if "/" not in args.default_repo: @@ -641,7 +641,7 @@ def repo_name(repo: str | dict) -> str | None: return 1 await jira.set_project_property(project_key, "forge.default_repo", args.default_repo) mutations["forge.default_repo"] = {"operation": "set", "value": args.default_repo} - if not getattr(args, "json", False): + if not is_json: print(f"[OK] forge.default_repo = {args.default_repo!r}") # forge.prd_proposals_repo — opt-in / opt-out for PRD approval via GitHub PR @@ -655,13 +655,13 @@ def repo_name(repo: str | dict) -> str | None: if remove_prd_repo: await jira.delete_project_property(project_key, "forge.prd_proposals_repo") mutations["forge.prd_proposals_repo"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") elif args.prd_proposals_repo is not None: if args.prd_proposals_repo == "": await jira.delete_project_property(project_key, "forge.prd_proposals_repo") mutations["forge.prd_proposals_repo"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") else: if "/" not in args.prd_proposals_repo: @@ -677,7 +677,7 @@ def repo_name(repo: str | dict) -> str | None: "operation": "set", "value": args.prd_proposals_repo, } - if not getattr(args, "json", False): + if not is_json: print(f"[OK] forge.prd_proposals_repo = {args.prd_proposals_repo!r}") # forge.prd_proposals_path — base directory for enhancement folders @@ -691,19 +691,19 @@ def repo_name(repo: str | dict) -> str | None: if remove_prd_path: await jira.delete_project_property(project_key, "forge.prd_proposals_path") mutations["forge.prd_proposals_path"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.prd_proposals_path removed (reset to default: repo root)") elif args.prd_proposals_path is not None: if args.prd_proposals_path == "": await jira.delete_project_property(project_key, "forge.prd_proposals_path") mutations["forge.prd_proposals_path"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.prd_proposals_path removed (reset to default: repo root)") else: path = args.prd_proposals_path.strip("/") await jira.set_project_property(project_key, "forge.prd_proposals_path", path) mutations["forge.prd_proposals_path"] = {"operation": "set", "value": path} - if not getattr(args, "json", False): + if not is_json: print(f"[OK] forge.prd_proposals_path = {path!r}") # forge.skills — built from --add-skill flags and/or --skills-config JSON @@ -760,7 +760,7 @@ def repo_name(repo: str | dict) -> str | None: if remove_skills: await jira.delete_project_property(project_key, "forge.skills") mutations["forge.skills"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.skills removed") elif skill_entries: from forge.skills.models import SkillEntry @@ -776,7 +776,7 @@ def repo_name(repo: str | dict) -> str | None: await jira.set_project_property(project_key, "forge.skills", skill_entries) mutations["forge.skills"] = {"operation": "set", "value": skill_entries} - if not getattr(args, "json", False): + if not is_json: print(f"[OK] forge.skills = {len(skill_entries)} entries") # forge.model_policy — exact per-stage project overrides. User @@ -804,7 +804,7 @@ def repo_name(repo: str | dict) -> str | None: if clear_model_policy: await jira.delete_project_property(project_key, "forge.model_policy") mutations["forge.model_policy"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.model_policy deleted") elif model_policy_arg: try: @@ -862,12 +862,12 @@ def repo_name(repo: str | dict) -> str | None: if model_policy: await jira.set_project_property(project_key, "forge.model_policy", model_policy) mutations["forge.model_policy"] = {"operation": "set", "value": model_policy} - if not getattr(args, "json", False): + if not is_json: print(f"[OK] forge.model_policy = {len(model_policy)} overrides") else: await jira.delete_project_property(project_key, "forge.model_policy") mutations["forge.model_policy"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.model_policy deleted (no overrides remain)") if model_all: @@ -885,12 +885,12 @@ def repo_name(repo: str | dict) -> str | None: return 1 await jira.set_project_property(project_key, "forge.model_default", model_default) mutations["forge.model_default"] = {"operation": "set", "value": model_default} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.model_default set") elif clear_model_default: await jira.delete_project_property(project_key, "forge.model_default") mutations["forge.model_default"] = {"operation": "remove", "value": None} - if not getattr(args, "json", False): + if not is_json: print("[OK] forge.model_default deleted") # forge.references property processing @@ -942,9 +942,9 @@ def repo_name(repo: str | dict) -> str | None: if references_updated: await jira.set_project_references(project_key, current_references) mutations["forge.references"] = {"operation": "set", "value": current_references} - if not getattr(args, "json", False): + if not is_json: print(f"[OK] forge.references = {current_references}") - if list_references and not getattr(args, "json", False): + if list_references and not is_json: print(f"Standing references for project {project_key}:") if not current_references: print(" (none)") @@ -989,7 +989,7 @@ def repo_name(repo: str | dict) -> str | None: ", --clear-model-default" ", --add-reference, --remove-reference, --list-references" ) - if getattr(args, "json", False): + if is_json: print(msg, file=sys.stderr) else: print(msg) diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 5ce66eec..c3f76f56 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -927,10 +927,40 @@ def test_cli_parser_registers_references(self, _mock_setup_logging, mock_cmd): class TestCLIConfigProjectSetupJson: """Tests for the project-setup CLI command in JSON mode.""" + @staticmethod + def setup_args(**overrides): + values = { + "project_key": "AISOS", + "repo": None, + "add_repo": None, + "remove_repo": None, + "default_repo": None, + "remove_default_repo": False, + "prd_proposals_repo": None, + "remove_prd_proposals_repo": False, + "prd_proposals_path": None, + "remove_prd_proposals_path": False, + "skills_config": None, + "add_skill": None, + "remove_skills": False, + "model_policy": None, + "model": None, + "model_all": None, + "remove_model": None, + "clear_model_policy": False, + "clear_model_default": False, + "add_reference": None, + "ref_description": None, + "remove_reference": None, + "list_references": False, + "json": True, + } + values.update(overrides) + return SimpleNamespace(**values) + @pytest.mark.asyncio async def test_sc001_single_set_mutation_json(self, capsys): """SC-001: Run project-setup with a single property mutation (Set) in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -939,32 +969,7 @@ async def test_sc001_single_set_mutation_json(self, capsys): jira.set_project_property = AsyncMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(default_repo="org/repo") with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -989,7 +994,6 @@ async def test_sc001_single_set_mutation_json(self, capsys): @pytest.mark.asyncio async def test_sc002_single_remove_mutation_json(self, capsys): """SC-002: Run project-setup with a single property mutation (Remove) in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -998,32 +1002,7 @@ async def test_sc002_single_remove_mutation_json(self, capsys): jira.delete_project_property = AsyncMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=True, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(remove_default_repo=True) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1046,7 +1025,6 @@ async def test_sc002_single_remove_mutation_json(self, capsys): @pytest.mark.asyncio async def test_sc003_multiple_mixed_mutations_json(self, capsys): """SC-003: Run project-setup with multiple mutations (Mixed Set and Remove) in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1056,32 +1034,7 @@ async def test_sc003_multiple_mixed_mutations_json(self, capsys): jira.delete_project_property = AsyncMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=True, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(default_repo="org/repo", remove_prd_proposals_repo=True) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1111,7 +1064,6 @@ async def test_sc003_multiple_mixed_mutations_json(self, capsys): @pytest.mark.asyncio async def test_sc004_backward_compatibility(self, capsys): """SC-004: Backward compatibility of text mode when --json is omitted.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1120,32 +1072,7 @@ async def test_sc004_backward_compatibility(self, capsys): jira.set_project_property = AsyncMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=False, - ) + args = self.setup_args(default_repo="org/repo", json=False) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1165,7 +1092,6 @@ async def test_sc004_backward_compatibility(self, capsys): @pytest.mark.asyncio async def test_sc005_failure_reporting_json_suppresses_stdout(self, capsys): """SC-005: Failure reporting in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1173,32 +1099,7 @@ async def test_sc005_failure_reporting_json_suppresses_stdout(self, capsys): jira = MagicMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=True, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(default_repo="org/repo", remove_default_repo=True) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1211,35 +1112,9 @@ async def test_sc005_failure_reporting_json_suppresses_stdout(self, capsys): @pytest.mark.asyncio async def test_sc005_failure_reporting_json_exception(self, capsys): """Verify that general exceptions raised during JSON mode suppress stdout and route cleanly to stderr.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, patch - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(default_repo="org/repo") with patch("forge.integrations.jira.client.JiraClient") as mock_client: client_inst = mock_client.return_value @@ -1256,7 +1131,6 @@ async def test_sc005_failure_reporting_json_exception(self, capsys): @pytest.mark.asyncio async def test_json_mode_backward_compatibility(self, capsys): """Verify backward compatibility: standard [OK] status line is output when --json is omitted, and no JSON is output.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1265,32 +1139,7 @@ async def test_json_mode_backward_compatibility(self, capsys): jira.set_project_property = AsyncMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=False, - ) + args = self.setup_args(default_repo="org/repo", json=False) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1310,7 +1159,6 @@ async def test_json_mode_backward_compatibility(self, capsys): @pytest.mark.asyncio async def test_json_mode_suppressed_failure_reporting(self, capsys): """Verify error suppression under failure scenarios in JSON mode (conflicting arguments).""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1318,32 +1166,7 @@ async def test_json_mode_suppressed_failure_reporting(self, capsys): jira = MagicMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo="org/repo", - remove_default_repo=True, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(default_repo="org/repo", remove_default_repo=True) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1356,7 +1179,6 @@ async def test_json_mode_suppressed_failure_reporting(self, capsys): @pytest.mark.asyncio async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): """Test forge.prd_proposals_repo setting and removal in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1366,32 +1188,7 @@ async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): jira.close = AsyncMock() # 1. Test set - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=False, - prd_proposals_repo="owner/repo", - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(prd_proposals_repo="owner/repo") with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1435,7 +1232,6 @@ async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): @pytest.mark.asyncio async def test_json_mode_prd_proposals_path_set_and_remove(self, capsys): """Test forge.prd_proposals_path setting and removal in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1445,32 +1241,7 @@ async def test_json_mode_prd_proposals_path_set_and_remove(self, capsys): jira.close = AsyncMock() # 1. Test set - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path="enhancements/", - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(prd_proposals_path="enhancements/") with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1512,7 +1283,6 @@ async def test_json_mode_prd_proposals_path_set_and_remove(self, capsys): @pytest.mark.asyncio async def test_json_mode_skills_set_and_remove(self, capsys): """Test forge.skills setting and removal in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1522,32 +1292,7 @@ async def test_json_mode_skills_set_and_remove(self, capsys): jira.close = AsyncMock() # 1. Test set using add_skill - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=["source=https://github.com/org/skill,path=my-skill"], - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(add_skill=["source=https://github.com/org/skill,path=my-skill"]) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1588,7 +1333,6 @@ async def test_json_mode_skills_set_and_remove(self, capsys): @pytest.mark.asyncio async def test_json_mode_model_policy_set_and_remove(self, capsys): """Test forge.model_policy setting and removal in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1598,32 +1342,7 @@ async def test_json_mode_model_policy_set_and_remove(self, capsys): jira.close = AsyncMock() # 1. Test set using model_policy JSON - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy='{"generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"}}', - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(model_policy='{"generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"}}') with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1664,7 +1383,6 @@ async def test_json_mode_model_policy_set_and_remove(self, capsys): @pytest.mark.asyncio async def test_json_mode_model_default_set_and_remove(self, capsys): """Test forge.model_default setting and removal in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1674,32 +1392,7 @@ async def test_json_mode_model_default_set_and_remove(self, capsys): jira.close = AsyncMock() # 1. Test set using model_all - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all="vertex-prod:gemini-pro", - remove_model=None, - clear_model_policy=False, - clear_model_default=False, - add_reference=None, - ref_description=None, - remove_reference=None, - list_references=False, - json=True, - ) + args = self.setup_args(model_all="vertex-prod:gemini-pro") with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) @@ -1740,7 +1433,6 @@ async def test_json_mode_model_default_set_and_remove(self, capsys): @pytest.mark.asyncio async def test_json_mode_references_set(self, capsys): """Test forge.references setting in JSON mode.""" - from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from forge.cli import cmd_project_setup @@ -1749,31 +1441,9 @@ async def test_json_mode_references_set(self, capsys): jira.set_project_references = AsyncMock() jira.close = AsyncMock() - args = SimpleNamespace( - project_key="AISOS", - repo=None, - add_repo=None, - remove_repo=None, - default_repo=None, - remove_default_repo=False, - prd_proposals_repo=None, - remove_prd_proposals_repo=False, - prd_proposals_path=None, - remove_prd_proposals_path=False, - skills_config=None, - add_skill=None, - remove_skills=False, - model_policy=None, - model=None, - model_all=None, - remove_model=None, - clear_model_policy=False, - clear_model_default=False, + args = self.setup_args( add_reference=["https://example.com/doc"], ref_description=["My reference description"], - remove_reference=None, - list_references=False, - json=True, ) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): From 1e1db7d3858e558eeaa0e188038e115d632a3149 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 18:25:48 +0000 Subject: [PATCH 11/14] [AISOS-2519-docs] Update stale config reference documentation Detailed description: - Documented error handling and exit codes in JSON mode for the forge project-setup command in docs/reference/config.md. - Verified that all other documentation files in the repository are completely accurate and up-to-date with recent branch changes. Closes: AISOS-2519-docs --- docs/reference/config.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/reference/config.md b/docs/reference/config.md index 6e4c2f77..5f875946 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -303,6 +303,13 @@ forge project-setup MYPROJ --default-repo org/repo1 --json | jq '.mutations["for # Output: "org/repo1" ``` +### Error Handling in JSON Mode + +When running with `--json`, if an error or exception occurs during execution: +- Standard output (`stdout`) is completely suppressed and remains empty. +- The error description is written directly to standard error (`stderr`). +- The command exits with a non-zero exit code (`1`). + Repository labels on managed tickets use `repo:/`. Forge validates that assignment against the project's configured repositories before workspace setup or implementation. A missing or invalid assignment blocks the workflow From d85153baff469dab69f2832798c96102a172dbb8 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 18:38:59 +0000 Subject: [PATCH 12/14] [AISOS-2519-ci-fix] Sort and organize imports in src/forge/cli.py Detailed description: - Sorted the import block inside the cmd_project_setup function in src/forge/cli.py to comply with Ruff rule I001. - Verified file formatting and validated that all CLI-related tests and other suite tests continue to pass perfectly. Closes: AISOS-2519-ci-fix --- src/forge/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index 8fb81b02..cbc26ffe 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -528,9 +528,9 @@ async def cmd_skills_update(_args: argparse.Namespace) -> int: async def cmd_project_setup(args: argparse.Namespace) -> int: """Configure Jira project properties for Forge.""" + import io import json import sys - import io from forge.integrations.jira.client import JiraClient From c93801e00a63efec798d0f7bface9dc6e9991ab1 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 30 Sep 2026 18:47:08 +0000 Subject: [PATCH 13/14] [AISOS-2519-review-ci-fix-1] Fix breaking container runner unit test and clean formatting Detailed description: - Patched ContainerRunner in test_implement_work.py to fix unit test failure when podman is not installed. - Cleanly formatted and linted modified files tests/unit/test_cli_config.py and tests/unit/workflow/nodes/test_implement_work.py. Closes: AISOS-2519-review-ci-fix-1 --- tests/unit/test_cli_config.py | 46 +++++++++---------- .../workflow/nodes/test_implement_work.py | 1 + 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index c3f76f56..119e65d2 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1180,6 +1180,7 @@ async def test_json_mode_suppressed_failure_reporting(self, capsys): async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): """Test forge.prd_proposals_repo setting and removal in JSON mode.""" from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup jira = MagicMock() @@ -1216,9 +1217,7 @@ async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): code = await cmd_project_setup(args) assert code == 0 - jira.delete_project_property.assert_awaited_once_with( - "AISOS", "forge.prd_proposals_repo" - ) + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.prd_proposals_repo") out, err = capsys.readouterr() assert not err assert "[OK]" not in out @@ -1233,6 +1232,7 @@ async def test_json_mode_prd_proposals_repo_set_and_remove(self, capsys): async def test_json_mode_prd_proposals_path_set_and_remove(self, capsys): """Test forge.prd_proposals_path setting and removal in JSON mode.""" from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup jira = MagicMock() @@ -1268,9 +1268,7 @@ async def test_json_mode_prd_proposals_path_set_and_remove(self, capsys): code = await cmd_project_setup(args) assert code == 0 - jira.delete_project_property.assert_awaited_once_with( - "AISOS", "forge.prd_proposals_path" - ) + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.prd_proposals_path") out, err = capsys.readouterr() assert not err assert "[OK]" not in out @@ -1284,6 +1282,7 @@ async def test_json_mode_prd_proposals_path_set_and_remove(self, capsys): async def test_json_mode_skills_set_and_remove(self, capsys): """Test forge.skills setting and removal in JSON mode.""" from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup jira = MagicMock() @@ -1299,9 +1298,7 @@ async def test_json_mode_skills_set_and_remove(self, capsys): assert code == 0 expected_value = [{"source": "https://github.com/org/skill", "path": "my-skill"}] - jira.set_project_property.assert_awaited_once_with( - "AISOS", "forge.skills", expected_value - ) + jira.set_project_property.assert_awaited_once_with("AISOS", "forge.skills", expected_value) out, err = capsys.readouterr() assert not err data = json.loads(out) @@ -1319,9 +1316,7 @@ async def test_json_mode_skills_set_and_remove(self, capsys): code = await cmd_project_setup(args) assert code == 0 - jira.delete_project_property.assert_awaited_once_with( - "AISOS", "forge.skills" - ) + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.skills") out, err = capsys.readouterr() assert not err data = json.loads(out) @@ -1334,6 +1329,7 @@ async def test_json_mode_skills_set_and_remove(self, capsys): async def test_json_mode_model_policy_set_and_remove(self, capsys): """Test forge.model_policy setting and removal in JSON mode.""" from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup jira = MagicMock() @@ -1342,13 +1338,17 @@ async def test_json_mode_model_policy_set_and_remove(self, capsys): jira.close = AsyncMock() # 1. Test set using model_policy JSON - args = self.setup_args(model_policy='{"generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"}}') + args = self.setup_args( + model_policy='{"generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"}}' + ) with patch("forge.integrations.jira.client.JiraClient", return_value=jira): code = await cmd_project_setup(args) assert code == 0 - expected_policy = {"generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"}} + expected_policy = { + "generate_prd": {"connection": "anthropic", "model": "claude-3-5-sonnet"} + } jira.set_project_property.assert_awaited_once_with( "AISOS", "forge.model_policy", expected_policy ) @@ -1369,9 +1369,7 @@ async def test_json_mode_model_policy_set_and_remove(self, capsys): code = await cmd_project_setup(args) assert code == 0 - jira.delete_project_property.assert_awaited_once_with( - "AISOS", "forge.model_policy" - ) + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.model_policy") out, err = capsys.readouterr() assert not err data = json.loads(out) @@ -1384,6 +1382,7 @@ async def test_json_mode_model_policy_set_and_remove(self, capsys): async def test_json_mode_model_default_set_and_remove(self, capsys): """Test forge.model_default setting and removal in JSON mode.""" from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup jira = MagicMock() @@ -1419,9 +1418,7 @@ async def test_json_mode_model_default_set_and_remove(self, capsys): code = await cmd_project_setup(args) assert code == 0 - jira.delete_project_property.assert_awaited_once_with( - "AISOS", "forge.model_default" - ) + jira.delete_project_property.assert_awaited_once_with("AISOS", "forge.model_default") out, err = capsys.readouterr() assert not err data = json.loads(out) @@ -1434,6 +1431,7 @@ async def test_json_mode_model_default_set_and_remove(self, capsys): async def test_json_mode_references_set(self, capsys): """Test forge.references setting in JSON mode.""" from unittest.mock import AsyncMock, MagicMock, patch + from forge.cli import cmd_project_setup jira = MagicMock() @@ -1450,10 +1448,10 @@ async def test_json_mode_references_set(self, capsys): code = await cmd_project_setup(args) assert code == 0 - expected_references = [{"url": "https://example.com/doc", "description": "My reference description"}] - jira.set_project_references.assert_awaited_once_with( - "AISOS", expected_references - ) + expected_references = [ + {"url": "https://example.com/doc", "description": "My reference description"} + ] + jira.set_project_references.assert_awaited_once_with("AISOS", expected_references) out, err = capsys.readouterr() assert not err data = json.loads(out) diff --git a/tests/unit/workflow/nodes/test_implement_work.py b/tests/unit/workflow/nodes/test_implement_work.py index 1b752d06..3af39682 100644 --- a/tests/unit/workflow/nodes/test_implement_work.py +++ b/tests/unit/workflow/nodes/test_implement_work.py @@ -71,6 +71,7 @@ async def execute(state, *_args, **_kwargs): AsyncMock(side_effect=lambda _state, _jira, prompt: prompt), ), patch("forge.workflow.nodes.implement_work.post_status_comment", AsyncMock()), + patch("forge.workflow.nodes.implement_work.ContainerRunner", MagicMock()), patch( "forge.workflow.nodes.implement_work.run_and_persist_execution", AsyncMock(side_effect=execute), From 16ce1a9c908109f158bb7f4a67bd28a4843b8569 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 1 Oct 2026 07:27:58 +0000 Subject: [PATCH 14/14] [AISOS-2519-review-fix] review: address PR feedback Detailed description: - Include references in JSON response when listing references in JSON mode in src/forge/cli.py - Move JiraClient close inside the main try block to fail gracefully on close exceptions in src/forge/cli.py - Add regression tests test_json_mode_list_references and test_json_mode_close_failure to tests/unit/test_cli_config.py Closes: AISOS-2519-review-fix --- src/forge/cli.py | 14 ++++++++-- tests/unit/test_cli_config.py | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/forge/cli.py b/src/forge/cli.py index cbc26ffe..d99b1e5c 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -541,6 +541,7 @@ async def cmd_project_setup(args: argparse.Namespace) -> int: original_stdout = sys.stdout exit_code = 1 mutations = {} + current_references = [] try: if is_json: @@ -995,6 +996,9 @@ def repo_name(repo: str | dict) -> str | None: print(msg) return 1 + if jira is not None: + await jira.close() + jira = None exit_code = 0 return 0 @@ -1006,9 +1010,15 @@ def repo_name(repo: str | dict) -> str | None: if is_json: sys.stdout = original_stdout if exit_code == 0: - print(json.dumps({"project": project_key, "mutations": mutations}, indent=2)) + out_payload = {"project": project_key, "mutations": mutations} + if getattr(args, "list_references", False): + out_payload["references"] = current_references + print(json.dumps(out_payload, indent=2)) if jira is not None: - await jira.close() + import contextlib + + with contextlib.suppress(Exception): + await jira.close() async def cmd_get_config(args: argparse.Namespace) -> int: diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 119e65d2..6d6a0386 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1459,3 +1459,55 @@ async def test_json_mode_references_set(self, capsys): "operation": "set", "value": expected_references, } + + @pytest.mark.asyncio + async def test_json_mode_list_references(self, capsys): + """Test listing references in JSON mode without mutation.""" + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + mock_references = [{"url": "https://example.com/doc", "description": "Existing doc"}] + jira.get_project_references = AsyncMock(return_value=mock_references) + jira.close = AsyncMock() + + args = self.setup_args( + list_references=True, + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 0 + jira.get_project_references.assert_awaited_once_with("AISOS") + out, err = capsys.readouterr() + assert not err + data = json.loads(out) + assert data["references"] == mock_references + assert data["mutations"] == {} + + @pytest.mark.asyncio + async def test_json_mode_close_failure(self, capsys): + """Test close failure in JSON mode routes error and returns 1.""" + from unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.close = AsyncMock(side_effect=Exception("Failed to close Jira client")) + + args = self.setup_args( + default_repo="org/repo", + json=True, + ) + + with patch("forge.integrations.jira.client.JiraClient", return_value=jira): + code = await cmd_project_setup(args) + + assert code == 1 + out, err = capsys.readouterr() + assert not out + assert "Failed to close Jira client" in err