"""Tests for ClaudeReviewer using Claude Code CLI subprocess.""" import json import pytest from release_agent.models.review import ReviewResult from release_agent.tools.claude_review import ( ClaudeReviewer, _build_prompt, _parse_cli_output, _truncate_diff, ) MAX_DIFF_CHARS = 100_000 # --------------------------------------------------------------------------- # Helpers — fake subprocess runner # --------------------------------------------------------------------------- def _make_cli_output( verdict: str = "approve", summary: str = "LGTM", issues: list | None = None, ) -> str: """Build a JSON string mimicking Claude Code CLI --output-format json.""" structured = { "verdict": verdict, "summary": summary, "issues": issues or [], } return json.dumps({"result": "", "structured_output": structured}) def _make_subprocess_runner( stdout: str = "", stderr: str = "", returncode: int = 0, ): """Return a fake run_subprocess callable that records calls.""" calls: list[dict] = [] async def fake_run(*, cmd, cwd, timeout): calls.append({"cmd": cmd, "cwd": cwd, "timeout": timeout}) return (stdout, stderr, returncode) return fake_run, calls # --------------------------------------------------------------------------- # _truncate_diff tests # --------------------------------------------------------------------------- class TestTruncateDiff: def test_short_diff_not_truncated(self) -> None: diff = "short diff" assert _truncate_diff(diff) == diff def test_exact_limit_not_truncated(self) -> None: diff = "x" * MAX_DIFF_CHARS assert _truncate_diff(diff) == diff def test_over_limit_truncated(self) -> None: diff = "x" * (MAX_DIFF_CHARS + 1000) result = _truncate_diff(diff) assert len(result) < len(diff) assert "TRUNCATED" in result # --------------------------------------------------------------------------- # _build_prompt tests # --------------------------------------------------------------------------- class TestBuildPrompt: def test_contains_pr_title(self) -> None: prompt = _build_prompt(diff="d", pr_title="My Title", repo_name="repo") assert "My Title" in prompt def test_contains_repo_name(self) -> None: prompt = _build_prompt(diff="d", pr_title="t", repo_name="my-repo") assert "my-repo" in prompt def test_contains_diff(self) -> None: prompt = _build_prompt(diff="UNIQUE_DIFF", pr_title="t", repo_name="r") assert "UNIQUE_DIFF" in prompt # --------------------------------------------------------------------------- # _parse_cli_output tests # --------------------------------------------------------------------------- class TestParseCliOutput: def test_parses_structured_output(self) -> None: stdout = _make_cli_output(verdict="approve", summary="Good") result = _parse_cli_output(stdout) assert isinstance(result, ReviewResult) assert result.verdict == "approve" assert result.summary == "Good" def test_parses_request_changes(self) -> None: stdout = _make_cli_output( verdict="request_changes", summary="Has issues", issues=[{"severity": "blocker", "description": "SQL injection"}], ) result = _parse_cli_output(stdout) assert result.verdict == "request_changes" assert len(result.issues) == 1 assert result.has_blockers is True def test_parses_issues_with_optional_fields(self) -> None: stdout = _make_cli_output( verdict="request_changes", summary="Issues found", issues=[{ "severity": "warning", "description": "Style issue", "file_path": "src/foo.py", "suggestion": "Fix it", }], ) result = _parse_cli_output(stdout) assert result.issues[0].file_path == "src/foo.py" assert result.issues[0].suggestion == "Fix it" def test_empty_issues_no_blockers(self) -> None: stdout = _make_cli_output(verdict="approve", summary="Clean", issues=[]) result = _parse_cli_output(stdout) assert result.has_blockers is False assert len(result.issues) == 0 def test_result_field_as_json_string(self) -> None: """When structured_output is absent, falls back to parsing result as JSON.""" inner = {"verdict": "approve", "summary": "OK", "issues": []} stdout = json.dumps({"result": json.dumps(inner)}) result = _parse_cli_output(stdout) assert result.verdict == "approve" def test_invalid_json_raises(self) -> None: with pytest.raises(ValueError, match="Failed to parse"): _parse_cli_output("not json at all") def test_missing_structured_output_and_result_raises(self) -> None: with pytest.raises(ValueError, match="No structured_output"): _parse_cli_output(json.dumps({"other": "data"})) def test_non_dict_structured_output_raises(self) -> None: stdout = json.dumps({"structured_output": ["not", "a", "dict"]}) with pytest.raises(ValueError, match="Expected dict"): _parse_cli_output(stdout) def test_result_is_non_json_string_raises(self) -> None: stdout = json.dumps({"result": "just plain text, not json"}) with pytest.raises(ValueError, match="not valid JSON"): _parse_cli_output(stdout) # --------------------------------------------------------------------------- # ClaudeReviewer construction tests # --------------------------------------------------------------------------- class TestClaudeReviewerConstruction: def test_can_be_instantiated(self) -> None: reviewer = ClaudeReviewer() assert reviewer is not None def test_custom_claude_cmd(self) -> None: reviewer = ClaudeReviewer(claude_cmd="/usr/local/bin/claude") assert reviewer._claude_cmd == "/usr/local/bin/claude" def test_custom_timeout(self) -> None: reviewer = ClaudeReviewer(timeout=60) assert reviewer._timeout == 60 # --------------------------------------------------------------------------- # review_pr tests # --------------------------------------------------------------------------- class TestReviewPr: async def test_returns_review_result(self) -> None: stdout = _make_cli_output(verdict="approve", summary="Looks good") runner, _ = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) result = await reviewer.review_pr( diff="diff --git a/foo.py ...", pr_title="Fix bug", repo_name="my-repo", ) assert isinstance(result, ReviewResult) assert result.verdict == "approve" async def test_passes_cwd_to_subprocess(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr( diff="diff", pr_title="PR", repo_name="repo", cwd="/path/to/worktree", ) assert calls[0]["cwd"] == "/path/to/worktree" async def test_cmd_includes_claude_p(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") cmd = calls[0]["cmd"] assert cmd[0] == "claude" assert "-p" in cmd async def test_cmd_includes_output_format_json(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") cmd = calls[0]["cmd"] idx = cmd.index("--output-format") assert cmd[idx + 1] == "json" async def test_cmd_includes_json_schema(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") cmd = calls[0]["cmd"] assert "--json-schema" in cmd async def test_cmd_includes_allowed_tools(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") cmd = calls[0]["cmd"] idx = cmd.index("--allowedTools") assert "Read" in cmd[idx + 1] async def test_cmd_includes_system_prompt(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") cmd = calls[0]["cmd"] assert "--system-prompt" in cmd async def test_nonzero_exit_raises(self) -> None: runner, _ = _make_subprocess_runner( stdout="", stderr="error occurred", returncode=1 ) reviewer = ClaudeReviewer(run_subprocess=runner) with pytest.raises(RuntimeError, match="exited with code 1"): await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") async def test_timeout_passed_to_subprocess(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner, timeout=120) await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") assert calls[0]["timeout"] == 120 async def test_pr_title_in_prompt(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr( diff="d", pr_title="Specific Title", repo_name="r" ) cmd = calls[0]["cmd"] prompt = cmd[cmd.index("-p") + 1] assert "Specific Title" in prompt async def test_repo_name_in_prompt(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr( diff="d", pr_title="t", repo_name="special-repo" ) cmd = calls[0]["cmd"] prompt = cmd[cmd.index("-p") + 1] assert "special-repo" in prompt async def test_cwd_none_when_not_provided(self) -> None: stdout = _make_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") assert calls[0]["cwd"] is None async def test_request_changes_with_issues(self) -> None: stdout = _make_cli_output( verdict="request_changes", summary="Problems found", issues=[ {"severity": "blocker", "description": "Security flaw"}, {"severity": "warning", "description": "Missing docs"}, ], ) runner, _ = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) result = await reviewer.review_pr(diff="d", pr_title="t", repo_name="r") assert result.verdict == "request_changes" assert len(result.issues) == 2 assert result.has_blockers is True # --------------------------------------------------------------------------- # ClaudeReviewer.generate_ticket_content tests # --------------------------------------------------------------------------- def _make_ticket_cli_output(summary: str = "My summary", description: str = "My desc") -> str: """Build a JSON string mimicking Claude Code CLI output for ticket generation.""" structured = {"summary": summary, "description": description} return json.dumps({"result": "", "structured_output": structured}) class TestGenerateTicketContent: """Tests for ClaudeReviewer.generate_ticket_content.""" async def test_returns_tuple_of_summary_and_description(self) -> None: stdout = _make_ticket_cli_output(summary="Fix login bug", description="Detailed desc") runner, _ = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) result = await reviewer.generate_ticket_content( diff="edit: main.py", pr_title="Fix login", repo_name="backend" ) assert isinstance(result, tuple) assert len(result) == 2 async def test_returns_correct_summary(self) -> None: stdout = _make_ticket_cli_output(summary="Implement OAuth2 login") runner, _ = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) summary, _ = await reviewer.generate_ticket_content( diff="d", pr_title="Add OAuth", repo_name="auth-service" ) assert summary == "Implement OAuth2 login" async def test_returns_correct_description(self) -> None: stdout = _make_ticket_cli_output(description="This adds OAuth2 support for the login flow") runner, _ = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) _, description = await reviewer.generate_ticket_content( diff="d", pr_title="Add OAuth", repo_name="auth-service" ) assert description == "This adds OAuth2 support for the login flow" async def test_uses_json_schema_with_summary_and_description_fields(self) -> None: stdout = _make_ticket_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r") cmd = calls[0]["cmd"] # Verify --json-schema flag was used assert "--json-schema" in cmd schema_idx = cmd.index("--json-schema") schema_json = cmd[schema_idx + 1] schema = json.loads(schema_json) assert "summary" in schema["properties"] assert "description" in schema["properties"] async def test_passes_pr_title_in_prompt(self) -> None: stdout = _make_ticket_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.generate_ticket_content( diff="d", pr_title="My Unique PR Title", repo_name="r" ) cmd_str = " ".join(calls[0]["cmd"]) assert "My Unique PR Title" in cmd_str async def test_passes_repo_name_in_prompt(self) -> None: stdout = _make_ticket_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.generate_ticket_content( diff="d", pr_title="t", repo_name="my-special-repo" ) cmd_str = " ".join(calls[0]["cmd"]) assert "my-special-repo" in cmd_str async def test_passes_cwd_to_subprocess(self) -> None: stdout = _make_ticket_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.generate_ticket_content( diff="d", pr_title="t", repo_name="r", cwd="/some/path" ) assert calls[0]["cwd"] == "/some/path" async def test_cwd_none_by_default(self) -> None: stdout = _make_ticket_cli_output() runner, calls = _make_subprocess_runner(stdout=stdout) reviewer = ClaudeReviewer(run_subprocess=runner) await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r") assert calls[0]["cwd"] is None async def test_raises_on_nonzero_exit_code(self) -> None: runner, _ = _make_subprocess_runner(stdout="", stderr="Error", returncode=1) reviewer = ClaudeReviewer(run_subprocess=runner) with pytest.raises(RuntimeError, match="Claude CLI"): await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r") async def test_raises_on_invalid_json_output(self) -> None: runner, _ = _make_subprocess_runner(stdout="not json at all") reviewer = ClaudeReviewer(run_subprocess=runner) with pytest.raises((ValueError, Exception)): await reviewer.generate_ticket_content(diff="d", pr_title="t", repo_name="r")