Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 .
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

---
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_batch_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions tests/unit/test_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/unit/test_phase2_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 14 additions & 6 deletions tests/unit/test_post_findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -312,6 +313,7 @@ def test_gate_passes_with_no_criticals(self, tmp_path):
"title": "t",
"message": "m",
"confidence": 0.9,
"suggestion": None,
}
],
}
Expand All @@ -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"},
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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,
}
],
}
Expand All @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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))
Expand All @@ -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",
Expand All @@ -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": []},
Expand All @@ -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": []},
Expand All @@ -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": []},
Expand Down
16 changes: 8 additions & 8 deletions tests/unit/test_review_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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


# ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_turn_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading