import json from collections.abc import Iterable from pathlib import Path from types import SimpleNamespace from typing import Any import pytest from pydantic import BaseModel from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus 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 tests.workflow_support import WorkflowHarness, make_workflow_harness 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") 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 pull_request_harness( tmp_path: Path, repository: FakeRepository, gitea: FakeGitea, *, changed: bool = True, responses: Iterable[BaseModel] = (), ) -> WorkflowHarness: return make_workflow_harness( settings=SimpleNamespace( workspaces_dir=tmp_path / "workspaces", implement_model="provider/model", implement_variant="high", ), repository=repository, gitea=gitea, changed=changed, responses=responses, clone_sha="head-sha", sync_sha="head-sha", commit_sha="new-sha", ) 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() repository = FakeRepository(workflow=existing, plan=plan_workflow()) gitea = FakeGitea(pull()) harness = pull_request_harness( tmp_path, repository, gitea, responses=[result(), ReviewReport(summary="Ready", findings=[])], ) body = await iterate_implementation( job(JobKind.ITERATE_IMPLEMENT), harness.run, harness.services, ) assert harness.run.linked_workflows == [(existing.id, "synchronizing branch")] assert harness.development.workspaces == [existing.workspace_path] assert harness.opencode.created_sessions == [] assert [call["session_id"] for call in harness.opencode.resume_calls] == [ "primary-session", "reviewer-session", ] assert [call["result_type"] for call in harness.opencode.resume_calls] == [ AgentResult, ReviewReport, ] iterate_prompt = harness.prompts.calls[0] assert iterate_prompt[0] == "implementation_iterate" assert iterate_prompt[1]["message"] == "Handle the review." review_prompt = harness.prompts.calls[1] assert review_prompt[0] == "implementation_review" assert set(review_prompt[1]) == {"issue_context", "artifact", "pull_context"} assert review_prompt[1]["issue_context"].startswith("Repository: org/repo\n") assert review_prompt[1]["artifact"] == "Canonical plan" assert review_prompt[1]["pull_context"] == iterate_prompt[1]["context"] assert harness.opencode.resume_calls[1] == { "session_id": "reviewer-session", "prompt": "rendered implementation_review", "model": "provider/model", "variant": "high", "workspace": existing.workspace_path, "schema_name": "review.json", "result_type": ReviewReport, } assert harness.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: repository = FakeRepository(workflow=existing) gitea = FakeGitea(pull()) harness = pull_request_harness(tmp_path, repository, gitea) with pytest.raises(JobRejected) as error: await iterate_implementation( job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number), harness.run, harness.services, ) assert str(error.value) == message assert repository.saved_workflows == [] assert gitea.pull_calls == [] assert harness.git.calls == [] assert harness.development.workspaces == [] assert harness.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: repository = FakeRepository(workflow=existing) harness = pull_request_harness(tmp_path, repository, FakeGitea(pull_info)) with pytest.raises(JobRejected) as error: await iterate_implementation( job(JobKind.ITERATE_IMPLEMENT), harness.run, harness.services, ) assert str(error.value) == message assert repository.saved_workflows == [] assert harness.git.calls == [] assert harness.development.workspaces == [] assert harness.opencode.resume_calls == [] async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes( tmp_path: Path, ) -> None: repository = FakeRepository() harness = pull_request_harness( tmp_path, repository, FakeGitea(pull()), responses=[result("Fix empty input")], ) body = await fix_pull_request(job(JobKind.FIX), harness.run, harness.services) workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo" assert harness.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 harness.development.workspaces == [workspace] assert harness.opencode.created_sessions == [(workspace, "fix")] assert harness.run.linked_sessions == ["fix-session"] assert harness.opencode.resume_calls[0]["session_id"] == "fix-session" assert harness.opencode.resume_calls[0]["result_type"] is AgentResult assert harness.prompts.calls[0][0] == "fix" assert "`src/widget.py:8`: Add a guard." in harness.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`" ) @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."), ], ids=["missing-pr", "closed"], ) async def test_fix_pull_request_requires_open_pr_before_clone( tmp_path: Path, pr_number: int | None, pull_info: PullRequestInfo, message: str, ) -> None: gitea = FakeGitea(pull_info) harness = pull_request_harness(tmp_path, FakeRepository(), gitea) with pytest.raises(JobRejected) as error: await fix_pull_request( job(JobKind.FIX, pr_number=pr_number), harness.run, harness.services, ) assert str(error.value) == message assert harness.git.calls == [] assert harness.development.workspaces == [] assert harness.opencode.resume_calls == [] assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)])