rewrite phase 1
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.model import AgentResult, ReviewReport
|
||||
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
|
||||
from agentci.workflows.render import JobRejected
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
class RecordingRun(JobRun):
|
||||
def __init__(self) -> None:
|
||||
self.stages: list[str] = []
|
||||
self.linked_workflows: list[tuple[str, str]] = []
|
||||
self.linked_sessions: list[str] = []
|
||||
|
||||
async def stage(self, stage: str) -> None:
|
||||
self.stages.append(stage)
|
||||
|
||||
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||
self.linked_workflows.append((workflow_id, stage))
|
||||
|
||||
async def link_session(self, session_id: str) -> None:
|
||||
self.linked_sessions.append(session_id)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workflow: Workflow | None = None,
|
||||
plan: Workflow | None = None,
|
||||
) -> None:
|
||||
self.workflow = workflow
|
||||
self.plan = plan
|
||||
self.saved_workflows: list[Workflow] = []
|
||||
|
||||
async def workflow_for_pr(self, *_args: object) -> Workflow | None:
|
||||
return self.workflow
|
||||
|
||||
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
||||
return self.plan
|
||||
|
||||
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
||||
return set()
|
||||
|
||||
async def save_workflow(self, workflow: Workflow) -> None:
|
||||
self.workflow = workflow
|
||||
self.saved_workflows.append(workflow)
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, pull: PullRequestInfo) -> None:
|
||||
self.pull = pull
|
||||
self.pull_calls: list[tuple[str, str, int]] = []
|
||||
|
||||
async def pull_request(self, owner: str, repo: str, number: int) -> PullRequestInfo:
|
||||
self.pull_calls.append((owner, repo, number))
|
||||
return self.pull
|
||||
|
||||
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
||||
return [CommentInfo(1, "alice", "Please add coverage.", "2026-07-01")]
|
||||
|
||||
async def pull_reviews(self, *_args: object) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": 3,
|
||||
"user": {"login": "bob"},
|
||||
"state": "REQUEST_CHANGES",
|
||||
"body": "Handle empty input.",
|
||||
}
|
||||
]
|
||||
|
||||
async def review_comments(self, *_args: object) -> list[dict[str, Any]]:
|
||||
return [{"path": "src/widget.py", "new_position": 8, "body": "Add a guard."}]
|
||||
|
||||
async def pull_commits(self, *_args: object) -> list[dict[str, Any]]:
|
||||
return [{"sha": "abcdef1234567890", "commit": {"message": "Initial change"}}]
|
||||
|
||||
async def issue(self, *_args: object) -> IssueInfo:
|
||||
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
|
||||
|
||||
|
||||
class RecordingGit:
|
||||
def __init__(self, *, changed: bool = True) -> None:
|
||||
self.changed = changed
|
||||
self.calls: list[tuple[Any, ...]] = []
|
||||
|
||||
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||
self.calls.append(("clone", owner, repo, branch, destination))
|
||||
return "head-sha"
|
||||
|
||||
async def sync_branch(self, workspace: Path, branch: str) -> str:
|
||||
self.calls.append(("sync_branch", workspace, branch))
|
||||
return "head-sha"
|
||||
|
||||
async def has_changes(self, workspace: Path) -> bool:
|
||||
self.calls.append(("has_changes", workspace))
|
||||
return self.changed
|
||||
|
||||
async def diff_check(self, workspace: Path) -> None:
|
||||
self.calls.append(("diff_check", workspace))
|
||||
|
||||
async def commit(self, workspace: Path, message: str) -> str:
|
||||
self.calls.append(("commit", workspace, message))
|
||||
return "new-sha"
|
||||
|
||||
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
|
||||
self.calls.append(("push", workspace, branch, set_upstream))
|
||||
|
||||
|
||||
class RecordingDevelopment:
|
||||
description = "Python 3.13"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.workspaces: list[Path] = []
|
||||
|
||||
async def prepare(self, workspace: Path) -> None:
|
||||
self.workspaces.append(workspace)
|
||||
|
||||
|
||||
class RecordingPrompts:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
self.calls.append((name, values))
|
||||
return f"rendered {name}"
|
||||
|
||||
|
||||
class RecordingOpenCode:
|
||||
def __init__(self, responses: list[BaseModel] | None = None) -> None:
|
||||
self.responses = list(responses or [])
|
||||
self.created_sessions: list[tuple[Path, str]] = []
|
||||
self.resume_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def create_session(self, workspace: Path, title: str) -> str:
|
||||
self.created_sessions.append((workspace, title))
|
||||
return f"{title}-session"
|
||||
|
||||
async def resume(self, **values: Any) -> BaseModel:
|
||||
self.resume_calls.append(values)
|
||||
response = self.responses.pop(0)
|
||||
assert isinstance(response, values["result_type"])
|
||||
return response
|
||||
|
||||
|
||||
def job(kind: JobKind, *, pr_number: int | None = 12) -> Job:
|
||||
return Job(
|
||||
id="job-pr",
|
||||
kind=kind,
|
||||
target_key="org/repo:pr:12",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
pr_number=pr_number,
|
||||
requester="alice",
|
||||
comment_id=9,
|
||||
delivery_id="delivery-pr",
|
||||
receive_sequence=1,
|
||||
command_body="/agent iterate",
|
||||
message="Handle the review.",
|
||||
)
|
||||
|
||||
|
||||
def implementation_workflow(
|
||||
*,
|
||||
status: WorkflowStatus = WorkflowStatus.COMPLETED,
|
||||
runtime: str = "opencode",
|
||||
branch: str | None = "agent/issue-7",
|
||||
primary_session_id: str | None = "primary-session",
|
||||
reviewer_session_id: str | None = "reviewer-session",
|
||||
) -> Workflow:
|
||||
return Workflow(
|
||||
id="implementation-flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
workspace_path=Path("/workspace/implementation"),
|
||||
base_sha="base-sha",
|
||||
runtime=runtime,
|
||||
branch=branch,
|
||||
pr_number=12,
|
||||
primary_session_id=primary_session_id,
|
||||
reviewer_session_id=reviewer_session_id,
|
||||
artifact='{"summary_markdown":"Prior","tests":[]}',
|
||||
review_json='{"summary":"Prior review","findings":[]}',
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def plan_workflow() -> Workflow:
|
||||
return Workflow(
|
||||
id="plan-flow",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=7,
|
||||
workspace_path=Path("/workspace/plan"),
|
||||
base_sha="base",
|
||||
artifact="Canonical plan",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
|
||||
|
||||
def pull(
|
||||
*,
|
||||
state: str = "open",
|
||||
merged: bool = False,
|
||||
branch: str = "agent/issue-7",
|
||||
) -> PullRequestInfo:
|
||||
return PullRequestInfo(
|
||||
number=12,
|
||||
title="Fix widget",
|
||||
body="Implementation body",
|
||||
state=state,
|
||||
merged=merged,
|
||||
base_branch="main",
|
||||
head_branch=branch,
|
||||
head_sha="abcdef1234567890",
|
||||
head_owner="contributor",
|
||||
head_repo="fork",
|
||||
)
|
||||
|
||||
|
||||
def make_services(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
workflow: Workflow | None = None,
|
||||
pull_info: PullRequestInfo | None = None,
|
||||
plan: Workflow | None = None,
|
||||
changed: bool = True,
|
||||
responses: list[BaseModel] | None = None,
|
||||
) -> tuple[
|
||||
WorkflowServices,
|
||||
FakeRepository,
|
||||
FakeGitea,
|
||||
RecordingGit,
|
||||
RecordingDevelopment,
|
||||
RecordingPrompts,
|
||||
RecordingOpenCode,
|
||||
]:
|
||||
repository = FakeRepository(workflow=workflow, plan=plan)
|
||||
gitea = FakeGitea(pull_info or pull())
|
||||
git = RecordingGit(changed=changed)
|
||||
development = RecordingDevelopment()
|
||||
prompts = RecordingPrompts()
|
||||
opencode = RecordingOpenCode(responses)
|
||||
services = cast(
|
||||
WorkflowServices,
|
||||
SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
workspaces_dir=tmp_path / "workspaces",
|
||||
implement_model="provider/model",
|
||||
implement_variant="high",
|
||||
),
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
development=development,
|
||||
prompts=prompts,
|
||||
opencode=opencode,
|
||||
),
|
||||
)
|
||||
return services, repository, gitea, git, development, prompts, opencode
|
||||
|
||||
|
||||
def result(summary: str = "# Refine widget") -> AgentResult:
|
||||
return AgentResult(summary_markdown=summary, tests=["pytest: passed"])
|
||||
|
||||
|
||||
async def test_iterate_implementation_reuses_sessions_reviews_commits_and_pushes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
existing = implementation_workflow()
|
||||
services, repository, _, git, development, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
workflow=existing,
|
||||
plan=plan_workflow(),
|
||||
responses=[result(), ReviewReport(summary="Ready", findings=[])],
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), run, services)
|
||||
|
||||
assert run.linked_workflows == [(existing.id, "synchronizing branch")]
|
||||
assert development.workspaces == [existing.workspace_path]
|
||||
assert opencode.created_sessions == []
|
||||
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||
"primary-session",
|
||||
"reviewer-session",
|
||||
]
|
||||
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||
AgentResult,
|
||||
ReviewReport,
|
||||
]
|
||||
iterate_prompt = prompts.calls[0]
|
||||
assert iterate_prompt[0] == "implementation_iterate"
|
||||
assert iterate_prompt[1]["message"] == "Handle the review."
|
||||
assert "Handle empty input." in iterate_prompt[1]["context"]
|
||||
review_prompt = prompts.calls[1]
|
||||
assert review_prompt[0] == "implementation_review"
|
||||
assert review_prompt[1]["artifact"] == "Canonical plan"
|
||||
assert "The widget is broken." in review_prompt[1]["issue_context"]
|
||||
|
||||
assert git.calls == [
|
||||
("sync_branch", existing.workspace_path, "agent/issue-7"),
|
||||
("has_changes", existing.workspace_path),
|
||||
("diff_check", existing.workspace_path),
|
||||
("commit", existing.workspace_path, "agent iterate: Refine widget"),
|
||||
("push", existing.workspace_path, "agent/issue-7", False),
|
||||
]
|
||||
saved = repository.saved_workflows[-1]
|
||||
assert AgentResult.model_validate_json(saved.artifact or "") == result()
|
||||
assert json.loads(saved.review_json or "") == {
|
||||
"summary": "Ready",
|
||||
"findings": [],
|
||||
}
|
||||
assert body == (
|
||||
"<!-- agentci:iteration workflow=implementation-flow -->\n"
|
||||
"## Agent result\n\n# Refine widget\n\n"
|
||||
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pr_number", "existing", "message"),
|
||||
[
|
||||
(None, implementation_workflow(), "This command requires a pull request."),
|
||||
(
|
||||
12,
|
||||
None,
|
||||
"This is not an open agent-created implementation PR. Use `/agent fix`.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(status=WorkflowStatus.ACTIVE),
|
||||
"This is not an open agent-created implementation PR. Use `/agent fix`.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(runtime="codex"),
|
||||
"The implementation predates OpenCode and cannot be resumed.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(primary_session_id=None),
|
||||
"The implementation sessions cannot be resumed.",
|
||||
),
|
||||
(
|
||||
12,
|
||||
implementation_workflow(reviewer_session_id=None),
|
||||
"The implementation sessions cannot be resumed.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_iterate_implementation_rejects_missing_stale_or_incompatible_workflow(
|
||||
tmp_path: Path,
|
||||
pr_number: int | None,
|
||||
existing: Workflow | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, repository, gitea, git, development, _, opencode = make_services(
|
||||
tmp_path, workflow=existing
|
||||
)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await iterate_implementation(
|
||||
job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number),
|
||||
RecordingRun(),
|
||||
services,
|
||||
)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert repository.saved_workflows == []
|
||||
assert gitea.pull_calls == []
|
||||
assert git.calls == []
|
||||
assert development.workspaces == []
|
||||
assert opencode.resume_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pull_info", "existing", "message"),
|
||||
[
|
||||
(
|
||||
pull(state="closed"),
|
||||
implementation_workflow(),
|
||||
"Implementation iteration requires an open pull request.",
|
||||
),
|
||||
(
|
||||
pull(branch="renamed-branch"),
|
||||
implementation_workflow(),
|
||||
"The pull request head branch no longer matches its workflow.",
|
||||
),
|
||||
],
|
||||
ids=["closed", "branch-mismatch"],
|
||||
)
|
||||
async def test_iterate_implementation_rejects_closed_or_stale_branch_before_checkout(
|
||||
tmp_path: Path,
|
||||
pull_info: PullRequestInfo,
|
||||
existing: Workflow,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, repository, _, git, development, _, opencode = make_services(
|
||||
tmp_path, workflow=existing, pull_info=pull_info
|
||||
)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert repository.saved_workflows == []
|
||||
assert git.calls == []
|
||||
assert development.workspaces == []
|
||||
assert opencode.resume_calls == []
|
||||
|
||||
|
||||
async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
services, repository, _, git, development, prompts, opencode = make_services(
|
||||
tmp_path,
|
||||
responses=[result("Fix empty input")],
|
||||
)
|
||||
run = RecordingRun()
|
||||
|
||||
body = await fix_pull_request(job(JobKind.FIX), run, services)
|
||||
|
||||
workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo"
|
||||
assert git.calls == [
|
||||
("clone", "contributor", "fork", "agent/issue-7", workspace),
|
||||
("has_changes", workspace),
|
||||
("diff_check", workspace),
|
||||
("commit", workspace, "agent fix: Fix empty input"),
|
||||
("push", workspace, "agent/issue-7", False),
|
||||
]
|
||||
assert development.workspaces == [workspace]
|
||||
assert opencode.created_sessions == [(workspace, "fix")]
|
||||
assert run.linked_sessions == ["fix-session"]
|
||||
assert opencode.resume_calls[0]["session_id"] == "fix-session"
|
||||
assert opencode.resume_calls[0]["result_type"] is AgentResult
|
||||
assert prompts.calls[0][0] == "fix"
|
||||
assert "`src/widget.py:8`: Add a guard." in prompts.calls[0][1]["context"]
|
||||
assert repository.saved_workflows == []
|
||||
assert body == (
|
||||
"<!-- agentci:fix workflow=job-pr -->\n"
|
||||
"## Agent result\n\nFix empty input\n\n"
|
||||
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
|
||||
)
|
||||
|
||||
|
||||
async def test_fix_pull_request_rejects_clean_worktree_before_push(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
services, _, _, git, _, _, _ = make_services(
|
||||
tmp_path,
|
||||
changed=False,
|
||||
responses=[result("No changes required")],
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
JobRejected,
|
||||
match="OpenCode completed without producing any file changes",
|
||||
):
|
||||
await fix_pull_request(job(JobKind.FIX), RecordingRun(), services)
|
||||
|
||||
assert [call[0] for call in git.calls] == ["clone", "has_changes"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pr_number", "pull_info", "message"),
|
||||
[
|
||||
(None, pull(), "This command requires a pull request."),
|
||||
(12, pull(state="closed"), "Fixes require an open pull request."),
|
||||
(12, pull(state="closed", merged=True), "Fixes require an open pull request."),
|
||||
],
|
||||
ids=["missing-pr", "closed", "merged"],
|
||||
)
|
||||
async def test_fix_pull_request_requires_open_pr_before_clone(
|
||||
tmp_path: Path,
|
||||
pr_number: int | None,
|
||||
pull_info: PullRequestInfo,
|
||||
message: str,
|
||||
) -> None:
|
||||
services, _, gitea, git, development, _, opencode = make_services(tmp_path, pull_info=pull_info)
|
||||
|
||||
with pytest.raises(JobRejected) as error:
|
||||
await fix_pull_request(job(JobKind.FIX, pr_number=pr_number), RecordingRun(), services)
|
||||
|
||||
assert str(error.value) == message
|
||||
assert git.calls == []
|
||||
assert development.workspaces == []
|
||||
assert opencode.resume_calls == []
|
||||
assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)])
|
||||
Reference in New Issue
Block a user