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 == ( "\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 == ( "\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)])