diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1eedbb1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run unit tests + run: pytest tests/unit -v --tb=short + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t codehawk:ci . diff --git a/README.md b/README.md index 508519d..1f372f2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # CodeHawk +[![CI](https://github.com/dsiddharth2/codehawk/actions/workflows/ci.yml/badge.svg)](https://github.com/dsiddharth2/codehawk/actions/workflows/ci.yml) +[![Docker](https://github.com/dsiddharth2/codehawk/actions/workflows/publish-docker.yml/badge.svg)](https://github.com/dsiddharth2/codehawk/actions/workflows/publish-docker.yml) +[![Docker Pulls](https://img.shields.io/docker/pulls/dsiddharth2/codehawk)](https://hub.docker.com/r/dsiddharth2/codehawk) +[![Docker Image Size](https://img.shields.io/docker/image-size/dsiddharth2/codehawk/latest)](https://hub.docker.com/r/dsiddharth2/codehawk) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-yellow.svg)](https://www.python.org/downloads/) + An AI-powered pull request review pipeline that runs in CI, produces structured findings, scores code quality, and posts inline comments to Azure DevOps or GitHub — automatically. --- diff --git a/tests/conftest.py b/tests/conftest.py index 6dbc8ae..d2e2d1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,7 @@ "repo": "MyOrg/MyRepo", "vcs": "ado", "review_modes": ["standard", "security"], + "summary": "Test summary for sample findings.", "tool_calls": 12, "agent": "codex", "findings": [ diff --git a/tests/unit/test_batch_review.py b/tests/unit/test_batch_review.py index 9abdb9e..e594035 100644 --- a/tests/unit/test_batch_review.py +++ b/tests/unit/test_batch_review.py @@ -67,7 +67,7 @@ def _make_batch_result(findings: List[Dict], input_tokens: int = 100, }, } if review_mode: - result["review_mode"] = review_mode + result["review_modes"] = [review_mode] if summary: result["summary"] = summary return result diff --git a/tests/unit/test_graph_builder.py b/tests/unit/test_graph_builder.py index 20f80e2..c465be4 100644 --- a/tests/unit/test_graph_builder.py +++ b/tests/unit/test_graph_builder.py @@ -86,12 +86,11 @@ def test_returns_store_on_success(self, mocker): assert result is mock_store - def test_prints_diagnostic_on_failure(self, mocker, capsys): + def test_prints_diagnostic_on_failure(self, mocker, caplog): mocker.patch("config.get_settings", return_value=_mock_settings()) failing_build = MagicMock(side_effect=RuntimeError("workspace parse error")) with patch.dict(sys.modules, _crg_sys_modules(build_fn=failing_build)): graph_builder.build_graph(Path("/workspace")) - captured = capsys.readouterr() - assert "Graph build skipped" in captured.out + assert "Graph build skipped" in caplog.text diff --git a/tests/unit/test_phase2_scoring.py b/tests/unit/test_phase2_scoring.py index 93360d1..7605152 100644 --- a/tests/unit/test_phase2_scoring.py +++ b/tests/unit/test_phase2_scoring.py @@ -25,6 +25,7 @@ def _write_findings(tmp_path, findings, fix_verifications=None, review_modes=Non "repo": REPO, "vcs": "ado", "review_modes": review_modes or ["standard"], + "summary": "Test summary.", "tool_calls": len(findings), "agent": "codex", "findings": findings, diff --git a/tests/unit/test_post_findings.py b/tests/unit/test_post_findings.py index eb49519..c8ad2f2 100644 --- a/tests/unit/test_post_findings.py +++ b/tests/unit/test_post_findings.py @@ -302,6 +302,7 @@ def test_gate_passes_with_no_criticals(self, tmp_path): "repo": "Org/Repo", "vcs": "ado", "review_modes": ["standard"], + "summary": "Test summary.", "findings": [ { "id": "cr-001", @@ -312,6 +313,7 @@ def test_gate_passes_with_no_criticals(self, tmp_path): "title": "t", "message": "m", "confidence": 0.9, + "suggestion": None, } ], } @@ -330,6 +332,7 @@ def test_fix_verifications_in_output(self, tmp_path): "repo": "Org/Repo", "vcs": "ado", "review_modes": ["standard"], + "summary": "Test summary.", "findings": [], "fix_verifications": [ {"cr_id": "cr-001", "status": "fixed", "reason": "Issue resolved"}, @@ -372,6 +375,7 @@ def _make_findings_with_fix_verifications(self, tmp_path, vcs="ado", fix_verific "repo": "Org/Repo", "vcs": vcs, "review_modes": ["standard"], + "summary": "Test summary.", "findings": [], "fix_verifications": fix_verifications, } @@ -461,6 +465,7 @@ def test_score_comparison_false_when_no_fix_verifications(self, tmp_path): "repo": "Org/Repo", "vcs": "ado", "review_modes": ["standard"], + "summary": "Test summary.", "findings": [], } path = tmp_path / "findings.json" @@ -644,6 +649,7 @@ def test_github_dry_run_produces_valid_output(self, tmp_path): "repo": "org/myrepo", "vcs": "github", "review_modes": ["standard"], + "summary": "Test summary.", "findings": [ { "id": "cr-001", @@ -654,6 +660,7 @@ def test_github_dry_run_produces_valid_output(self, tmp_path): "title": "Magic number", "message": "Use a named constant", "confidence": 0.85, + "suggestion": None, } ], } @@ -671,6 +678,7 @@ def test_github_path_calls_fetch_cr_ids_when_not_dry_run(self, tmp_path): "repo": "org/repo", "vcs": "github", "review_modes": ["standard"], + "summary": "Test summary.", "findings": [], } path = tmp_path / "findings.json" @@ -732,7 +740,7 @@ class TestUsageInOutput: def test_usage_present_when_in_findings(self, tmp_path): data = { "pr_id": 1, "repo": "R", "vcs": "ado", - "review_modes": ["standard"], "findings": [], + "review_modes": ["standard"], "summary": "Test summary.", "findings": [], "usage": { "input_tokens": 15000, "output_tokens": 3200, "total_tokens": 18200, "model": "o3", @@ -751,7 +759,7 @@ def test_usage_present_when_in_findings(self, tmp_path): def test_no_usage_produces_null(self, tmp_path): data = { "pr_id": 1, "repo": "R", "vcs": "ado", - "review_modes": ["standard"], "findings": [], + "review_modes": ["standard"], "summary": "Test summary.", "findings": [], } path = tmp_path / "findings.json" path.write_text(json.dumps(data)) @@ -762,7 +770,7 @@ def test_no_usage_produces_null(self, tmp_path): def test_usage_with_unknown_model(self, tmp_path): data = { "pr_id": 1, "repo": "R", "vcs": "ado", - "review_modes": ["standard"], "findings": [], + "review_modes": ["standard"], "summary": "Test summary.", "findings": [], "usage": { "input_tokens": 5000, "output_tokens": 1000, "total_tokens": 6000, "model": "some-new-model", @@ -784,7 +792,7 @@ def test_summary_includes_token_section(self): usage = Usage(input_tokens=10000, output_tokens=2000, total_tokens=12000, model="o3", duration_seconds=30.0) cost = {"model": "o3", "input_cost_usd": 0.02, "output_cost_usd": 0.016, "total_cost_usd": 0.036} md = pf._build_summary_markdown( - findings_file=MagicMock(pr_id=1, repo="R", review_modes=["standard"]), + findings_file=MagicMock(pr_id=1, repo="R", review_modes=["standard"], summary=None), filtered_findings=[], score=None, gate_result={"passed": True, "reasons": []}, @@ -798,7 +806,7 @@ def test_summary_includes_token_section(self): def test_summary_no_usage_section_when_absent(self): md = pf._build_summary_markdown( - findings_file=MagicMock(pr_id=1, repo="R", review_modes=["standard"]), + findings_file=MagicMock(pr_id=1, repo="R", review_modes=["standard"], summary=None), filtered_findings=[], score=None, gate_result={"passed": True, "reasons": []}, @@ -809,7 +817,7 @@ def test_summary_no_usage_section_when_absent(self): def test_summary_shows_duration(self): usage = Usage(input_tokens=5000, output_tokens=1000, total_tokens=6000, model="o3", duration_seconds=12.5) md = pf._build_summary_markdown( - findings_file=MagicMock(pr_id=1, repo="R", review_modes=["standard"]), + findings_file=MagicMock(pr_id=1, repo="R", review_modes=["standard"], summary=None), filtered_findings=[], score=None, gate_result={"passed": True, "reasons": []}, diff --git a/tests/unit/test_review_job.py b/tests/unit/test_review_job.py index 97805ab..a1497a6 100644 --- a/tests/unit/test_review_job.py +++ b/tests/unit/test_review_job.py @@ -125,20 +125,17 @@ def test_ignores_invalid_json(self): def _inject_activity_module(fetch_cls): """Return a sys.modules patch dict that makes activities.fetch_pr_details_activity - importable inside create_findings() without requiring the real ADO SDK. - - Also injects a mock models.review_models so FetchPRDetailsInput is constructable - even if the real module has unavailable dependencies. + and activities.fetch_file_diff_activity importable inside create_findings() + without requiring the real ADO SDK. """ mock_activity_mod = MagicMock() mock_activity_mod.FetchPRDetailsActivity = fetch_cls - mock_models_mod = MagicMock() - mock_models_mod.FetchPRDetailsInput = MagicMock(return_value=MagicMock()) + mock_diff_mod = MagicMock() return { "activities.fetch_pr_details_activity": mock_activity_mod, - "models.review_models": mock_models_mod, + "activities.fetch_file_diff_activity": mock_diff_mod, } @@ -158,6 +155,8 @@ def test_changed_files_passed_to_runner(self, mock_runner_cls, _bg, tmp_path): ] mock_pr = MagicMock() mock_pr.file_changes = file_changes + mock_pr.source_commit_id = "" + mock_pr.target_commit_id = "" mock_fetch_cls.return_value.execute.return_value = mock_pr mock_result = _make_agent_result() @@ -228,6 +227,8 @@ def test_prompt_contains_changed_files_section(self, mock_runner_cls, _bg, tmp_p file_changes = [_FakeFileChange("src/foo.py")] mock_pr = MagicMock() mock_pr.file_changes = file_changes + mock_pr.source_commit_id = "" + mock_pr.target_commit_id = "" mock_fetch_cls.return_value.execute.return_value = mock_pr mock_result = _make_agent_result() @@ -252,7 +253,6 @@ def test_prompt_contains_changed_files_section(self, mock_runner_cls, _bg, tmp_p prompt_arg = mock_runner.run.call_args.args[0] assert "Pre-fetched PR Data" in prompt_arg assert "src/foo.py" in prompt_arg - assert "get_change_analysis" in prompt_arg # --------------------------------------------------------------------------- diff --git a/tests/unit/test_turn_budget.py b/tests/unit/test_turn_budget.py index 1487d58..e7d4cb3 100644 --- a/tests/unit/test_turn_budget.py +++ b/tests/unit/test_turn_budget.py @@ -139,8 +139,8 @@ def test_turn_budget_stated(self): def test_graph_first_strategy_when_has_graph(self): prompt = build_system_prompt(40, has_graph=True) - assert "FIRST tool call MUST be `get_change_analysis`" in prompt - assert "Do NOT read files one-by-one" in prompt + assert "pre-computed" in prompt.lower() or "Pre-computed" in prompt or "pre-fetched" in prompt.lower() + assert "Do NOT call `get_change_analysis`" in prompt def test_no_graph_instructs_diffs(self): prompt = build_system_prompt(40, has_graph=False)