diff --git a/docs/reference/config.md b/docs/reference/config.md index 5428bbab..5f875946 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -274,6 +274,42 @@ 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" +``` + +### 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 diff --git a/src/forge/cli.py b/src/forge/cli.py index 4dbdd744..d99b1e5c 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -528,14 +528,29 @@ 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 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 = {} + current_references = [] 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):" def parse_repo(raw: str) -> str | dict: if raw.startswith("{"): @@ -601,7 +616,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 is_json: + print(f"[OK] forge.repos = {parsed_repos}") # forge.default_repo remove_default_repo = getattr(args, "remove_default_repo", False) @@ -613,7 +630,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 is_json: + print("[OK] forge.default_repo removed") elif args.default_repo: if "/" not in args.default_repo: print( @@ -622,7 +641,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 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 remove_prd_repo = getattr(args, "remove_prd_proposals_repo", False) @@ -634,11 +655,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 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") - print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") + mutations["forge.prd_proposals_repo"] = {"operation": "remove", "value": None} + if not is_json: + print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)") else: if "/" not in args.prd_proposals_repo: print( @@ -649,7 +674,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 is_json: + 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 +691,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 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") - print("[OK] forge.prd_proposals_path removed (reset to default: repo root)") + mutations["forge.prd_proposals_path"] = {"operation": "remove", "value": None} + 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) - print(f"[OK] forge.prd_proposals_path = {path!r}") + mutations["forge.prd_proposals_path"] = {"operation": "set", "value": path} + 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 skill_entries: list[dict] = [] @@ -724,7 +760,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 is_json: + print("[OK] forge.skills removed") elif skill_entries: from forge.skills.models import SkillEntry @@ -738,7 +776,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 is_json: + 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 +804,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 is_json: + print("[OK] forge.model_policy deleted") elif model_policy_arg: try: model_policy = json.loads(model_policy_arg) @@ -820,10 +862,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 is_json: + 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 is_json: + print("[OK] forge.model_policy deleted (no overrides remain)") if model_all: from forge.models.model_policy import ModelTarget @@ -839,10 +885,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 is_json: + 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 is_json: + print("[OK] forge.model_default deleted") # forge.references property processing # --ref-description and its deprecated alias --description share the same @@ -892,8 +942,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 is_json: + print(f"[OK] forge.references = {current_references}") + if list_references and not is_json: print(f"Standing references for project {project_key}:") if not current_references: print(" (none)") @@ -927,7 +979,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,15 +990,35 @@ def repo_name(repo: str | dict) -> str | None: ", --clear-model-default" ", --add-reference, --remove-reference, --list-references" ) + if is_json: + print(msg, file=sys.stderr) + else: + print(msg) return 1 + if jira is not None: + await jira.close() + jira = None + 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: + 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: + import contextlib + + with contextlib.suppress(Exception): + await jira.close() async def cmd_get_config(args: argparse.Namespace) -> int: @@ -1824,6 +1896,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)") @@ -1970,6 +2056,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..6d6a0386 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,592 @@ 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.""" + + @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 unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.close = AsyncMock() + + args = self.setup_args(default_repo="org/repo") + + 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 unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.delete_project_property = AsyncMock() + jira.close = AsyncMock() + + args = self.setup_args(remove_default_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.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 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 = 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) + + 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 unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.set_project_property = AsyncMock() + jira.close = AsyncMock() + + 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) + + 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 unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.close = AsyncMock() + + 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) + + 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_sc005_failure_reporting_json_exception(self, capsys): + """Verify that general exceptions raised during JSON mode suppress stdout and route cleanly to stderr.""" + from unittest.mock import AsyncMock, patch + + args = self.setup_args(default_repo="org/repo") + + 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_backward_compatibility(self, capsys): + """Verify backward compatibility: standard [OK] status line is output when --json is omitted, and no JSON is output.""" + 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 = 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) + + 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 unittest.mock import AsyncMock, MagicMock, patch + + from forge.cli import cmd_project_setup + + jira = MagicMock() + jira.close = AsyncMock() + + 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) + + 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 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 = self.setup_args(prd_proposals_repo="owner/repo") + + 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 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 = self.setup_args(prd_proposals_path="enhancements/") + + 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 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 = 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) + + 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 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 = 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"} + } + 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 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 = 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) + + 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 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 = self.setup_args( + add_reference=["https://example.com/doc"], + ref_description=["My reference description"], + ) + + 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, + } + + @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 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),