initial impl
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agentci.domain.models import (
|
||||
AgentResult,
|
||||
Job,
|
||||
JobKind,
|
||||
ReviewFinding,
|
||||
ReviewReport,
|
||||
ReviewSeverity,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
)
|
||||
from agentci.workflows.code_review import CodeReviewLoop
|
||||
|
||||
|
||||
def serious_report() -> ReviewReport:
|
||||
return ReviewReport(
|
||||
summary="Needs work",
|
||||
findings=[
|
||||
ReviewFinding(
|
||||
severity=ReviewSeverity.MAJOR,
|
||||
title="Missing check",
|
||||
detail="A check is absent.",
|
||||
recommendation="Add it.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class FakeCodex:
|
||||
def __init__(self, reports: list[ReviewReport]) -> None:
|
||||
self.reports = iter(reports)
|
||||
self.reviews = 0
|
||||
self.revisions = 0
|
||||
|
||||
async def start(self, **_kwargs):
|
||||
self.reviews += 1
|
||||
return "reviewer", next(self.reports)
|
||||
|
||||
async def resume(self, **kwargs):
|
||||
if kwargs["result_type"] is ReviewReport:
|
||||
self.reviews += 1
|
||||
return next(self.reports)
|
||||
self.revisions += 1
|
||||
return AgentResult(summary_markdown=f"revision {self.revisions}", tests=[])
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
async def update_job(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def update_workflow(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class FakePrompts:
|
||||
def render(self, name, **_kwargs):
|
||||
return name
|
||||
|
||||
|
||||
def objects(rounds: int, reports: list[ReviewReport]):
|
||||
codex = FakeCodex(reports)
|
||||
settings = SimpleNamespace(
|
||||
implement_review_rounds=rounds,
|
||||
implement_model="model",
|
||||
implement_reasoning="high",
|
||||
)
|
||||
deps = SimpleNamespace(
|
||||
settings=settings,
|
||||
codex=codex,
|
||||
storage=FakeStorage(),
|
||||
prompts=FakePrompts(),
|
||||
)
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=Path("."),
|
||||
base_sha="abc",
|
||||
primary_session_id="primary",
|
||||
)
|
||||
job = Job(
|
||||
id="job",
|
||||
kind=JobKind.IMPLEMENT,
|
||||
target_key="target",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=1,
|
||||
)
|
||||
return CodeReviewLoop(deps), codex, workflow, job # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_stops_after_clean_second_review() -> None:
|
||||
clean = ReviewReport(summary="Ready", findings=[])
|
||||
loop, codex, workflow, job = objects(4, [serious_report(), clean])
|
||||
_, report = await loop.run(
|
||||
job, workflow, "context", AgentResult(summary_markdown="initial", tests=[])
|
||||
)
|
||||
assert not report.has_serious_findings
|
||||
assert codex.reviews == 2
|
||||
assert codex.revisions == 1
|
||||
|
||||
|
||||
async def test_does_not_make_unreviewed_final_revision() -> None:
|
||||
loop, codex, workflow, job = objects(
|
||||
3, [serious_report(), serious_report(), serious_report()]
|
||||
)
|
||||
_, report = await loop.run(
|
||||
job, workflow, "context", AgentResult(summary_markdown="initial", tests=[])
|
||||
)
|
||||
assert report.has_serious_findings
|
||||
assert codex.reviews == 3
|
||||
assert codex.revisions == 2
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from agentci.adapters.codex import _session_id
|
||||
|
||||
|
||||
def test_extracts_thread_id_from_jsonl() -> None:
|
||||
output = '\n'.join(
|
||||
[
|
||||
'{"type":"turn.started"}',
|
||||
'{"type":"thread.started","thread_id":"abc-123"}',
|
||||
"not json",
|
||||
]
|
||||
)
|
||||
assert _session_id(output) == "abc-123"
|
||||
|
||||
|
||||
def test_missing_thread_id_returns_none() -> None:
|
||||
assert _session_id('{"type":"turn.completed"}') is None
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
|
||||
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
|
||||
from agentci.domain.models import CommandName, JobKind
|
||||
|
||||
|
||||
def test_ignores_non_commands() -> None:
|
||||
assert parse_command("please run /agent plan") is None
|
||||
|
||||
|
||||
def test_parses_multiline_message() -> None:
|
||||
command = parse_command("/agent implement focus on API\nand add tests")
|
||||
assert command is not None
|
||||
assert command.name is CommandName.IMPLEMENT
|
||||
assert command.message == "focus on API\nand add tests"
|
||||
assert resolve_job_kind(command, is_pull_request=False) is JobKind.IMPLEMENT
|
||||
|
||||
|
||||
def test_discuss_requires_message() -> None:
|
||||
with pytest.raises(CommandError, match="requires a message"):
|
||||
parse_command("/agent discuss")
|
||||
|
||||
|
||||
def test_rejects_wrong_location() -> None:
|
||||
command = parse_command("/agent fix")
|
||||
assert command is not None
|
||||
with pytest.raises(CommandError, match="pull request"):
|
||||
resolve_job_kind(command, is_pull_request=False)
|
||||
|
||||
|
||||
def test_resolves_iterate_by_location() -> None:
|
||||
command = parse_command("/agent iterate refine tests")
|
||||
assert command is not None
|
||||
assert resolve_job_kind(command, is_pull_request=False) is JobKind.ITERATE_PLAN
|
||||
assert resolve_job_kind(command, is_pull_request=True) is JobKind.ITERATE_IMPLEMENT
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from agentci.adapters.gitea_models import CommentInfo, IssueInfo
|
||||
from agentci.workflows.context import ContextBuilder
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
async def issue(self, *_args):
|
||||
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
|
||||
|
||||
async def issue_comments(self, *_args):
|
||||
return [
|
||||
CommentInfo(1, "alice", "Details", "2026-01-01"),
|
||||
CommentInfo(2, "agentci", "Agent job queued", "2026-01-02"),
|
||||
]
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
async def operational_comment_ids(self, *_args):
|
||||
return {2}
|
||||
|
||||
|
||||
async def test_issue_context_excludes_operational_comments() -> None:
|
||||
builder = ContextBuilder(FakeGitea(), FakeStorage()) # type: ignore[arg-type]
|
||||
context = await builder.issue_context("org", "repo", 2)
|
||||
assert "Broken widget" in context
|
||||
assert "Details" in context
|
||||
assert "Agent job queued" not in context
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_python_files_are_at_most_250_lines() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
files = [*root.glob("src/**/*.py"), *root.glob("tests/**/*.py")]
|
||||
oversized = {
|
||||
str(path.relative_to(root)): len(path.read_text().splitlines())
|
||||
for path in files
|
||||
if len(path.read_text().splitlines()) > 250
|
||||
}
|
||||
assert oversized == {}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.storage import Storage
|
||||
from agentci.domain.models import (
|
||||
Job,
|
||||
JobKind,
|
||||
JobStatus,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
WorkflowStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def storage(tmp_path: Path) -> Storage:
|
||||
migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
value = Storage(tmp_path / "state.sqlite3", migrations)
|
||||
await value.initialize()
|
||||
return value
|
||||
|
||||
|
||||
def make_job(job_id: str = "job-1") -> Job:
|
||||
return Job(
|
||||
id=job_id,
|
||||
kind=JobKind.PLAN,
|
||||
target_key="alice/repo:issue:3",
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=10,
|
||||
)
|
||||
|
||||
|
||||
async def test_enqueue_is_idempotent_and_claims_fifo(storage: Storage) -> None:
|
||||
assert await storage.enqueue("delivery-1", make_job())
|
||||
assert not await storage.enqueue("delivery-1", make_job("job-2"))
|
||||
claimed = await storage.claim_next()
|
||||
assert claimed is not None
|
||||
assert claimed.id == "job-1"
|
||||
assert claimed.status is JobStatus.RUNNING
|
||||
assert await storage.claim_next() is None
|
||||
|
||||
|
||||
async def test_recovers_running_job_as_failed(storage: Storage) -> None:
|
||||
await storage.enqueue("delivery-1", make_job())
|
||||
assert await storage.claim_next() is not None
|
||||
recovered = await storage.recover_running()
|
||||
assert [job.id for job in recovered] == ["job-1"]
|
||||
assert await storage.claim_next() is None
|
||||
|
||||
|
||||
async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) -> None:
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=tmp_path / "repo",
|
||||
base_sha="abc",
|
||||
artifact="# Plan",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await storage.create_workflow(workflow)
|
||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
assert loaded is not None
|
||||
assert loaded.artifact == "# Plan"
|
||||
assert loaded.workspace_path == tmp_path / "repo"
|
||||
|
||||
|
||||
async def test_tracks_operational_comments(storage: Storage) -> None:
|
||||
await storage.enqueue("delivery-1", make_job())
|
||||
await storage.set_job_comment("job-1", "accepted_comment_id", 21)
|
||||
await storage.set_job_comment("job-1", "started_comment_id", 22)
|
||||
assert await storage.operational_comment_ids("alice", "repo", 3) == {21, 22}
|
||||
|
||||
|
||||
async def test_failed_followup_does_not_invalidate_completed_workflow(
|
||||
storage: Storage, tmp_path: Path
|
||||
) -> None:
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=tmp_path / "repo",
|
||||
base_sha="abc",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await storage.create_workflow(workflow)
|
||||
job = make_job()
|
||||
job.workflow_id = workflow.id
|
||||
await storage.enqueue("delivery-1", job)
|
||||
await storage.fail_job_workflow(job.id)
|
||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
assert loaded is not None
|
||||
assert loaded.status is WorkflowStatus.COMPLETED
|
||||
@@ -0,0 +1,111 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self) -> None:
|
||||
self.jobs = []
|
||||
self.deliveries: set[str] = set()
|
||||
|
||||
async def enqueue(self, delivery_id, job):
|
||||
if delivery_id in self.deliveries:
|
||||
return False
|
||||
self.deliveries.add(delivery_id)
|
||||
self.jobs.append(job)
|
||||
return True
|
||||
|
||||
async def record_delivery(self, delivery_id, _comment_id):
|
||||
if delivery_id in self.deliveries:
|
||||
return False
|
||||
self.deliveries.add(delivery_id)
|
||||
return True
|
||||
|
||||
async def set_job_comment(self, *_args):
|
||||
return None
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, permitted: bool = True) -> None:
|
||||
self.permitted = permitted
|
||||
self.comments: list[str] = []
|
||||
|
||||
async def has_write_permission(self, *_args):
|
||||
return self.permitted
|
||||
|
||||
async def create_comment(self, _owner, _repo, _number, body):
|
||||
self.comments.append(body)
|
||||
return len(self.comments)
|
||||
|
||||
|
||||
def payload(body: str, *, is_pull: bool = False) -> dict:
|
||||
value = {
|
||||
"action": "created",
|
||||
"comment": {"id": 8, "body": body, "user": {"login": "alice"}},
|
||||
"repository": {
|
||||
"name": "repo",
|
||||
"owner": {"login": "org"},
|
||||
},
|
||||
"issue": {"number": 4},
|
||||
"is_pull": is_pull,
|
||||
}
|
||||
if is_pull:
|
||||
value["pull_request"] = {"number": 4}
|
||||
return value
|
||||
|
||||
|
||||
def test_extracts_pull_request_event() -> None:
|
||||
event = _event_from_payload("delivery", payload("/agent fix now", is_pull=True))
|
||||
assert event is not None
|
||||
assert event.pr_number == 4
|
||||
assert event.target_key == "org/repo:pr:4"
|
||||
|
||||
|
||||
async def test_authorized_command_is_queued() -> None:
|
||||
storage = FakeStorage()
|
||||
gitea = FakeGitea()
|
||||
container = SimpleNamespace(storage=storage, gitea=gitea)
|
||||
event = _event_from_payload("delivery", payload("/agent plan consider migrations"))
|
||||
assert event is not None
|
||||
response = await _handle_command(container, event)
|
||||
assert response.status_code == 202
|
||||
assert len(storage.jobs) == 1
|
||||
assert "queued" in gitea.comments[0]
|
||||
|
||||
|
||||
async def test_unauthorized_command_is_rejected_and_deduplicated() -> None:
|
||||
storage = FakeStorage()
|
||||
gitea = FakeGitea(permitted=False)
|
||||
container = SimpleNamespace(storage=storage, gitea=gitea)
|
||||
event = _event_from_payload("delivery", payload("/agent implement"))
|
||||
assert event is not None
|
||||
await _handle_command(container, event)
|
||||
await _handle_command(container, event)
|
||||
assert storage.jobs == []
|
||||
assert len(gitea.comments) == 1
|
||||
assert "write permission" in gitea.comments[0]
|
||||
|
||||
|
||||
async def test_non_command_does_not_query_permission() -> None:
|
||||
class ExplodingGitea(FakeGitea):
|
||||
async def has_write_permission(self, *_args):
|
||||
raise AssertionError("permission lookup should not run")
|
||||
|
||||
container = SimpleNamespace(storage=FakeStorage(), gitea=ExplodingGitea())
|
||||
event = _event_from_payload("delivery", payload("ordinary discussion"))
|
||||
assert event is not None
|
||||
response = await _handle_command(container, event)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_rejects_bad_signature() -> None:
|
||||
assert not valid_signature(b"secret", b"{}", "bad")
|
||||
|
||||
|
||||
def test_accepts_valid_signature() -> None:
|
||||
body = json.dumps(payload("ordinary comment")).encode()
|
||||
signature = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
|
||||
assert valid_signature(b"secret", body, signature)
|
||||
Reference in New Issue
Block a user