Files
agentci/tests/test_workflow_pull_request.py
2026-07-22 23:49:44 +02:00

503 lines
16 KiB
Python

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.integrations.gitea.models 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)])