import json from pathlib import Path from types import SimpleNamespace from typing import Any, cast import pytest 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.implementation import implement from agentci.workflows.model import AgentResult, ReviewReport from agentci.workflows.render import JobRejected from agentci.workflows.services import WorkflowServices class RecordingRun(JobRun): def __init__(self) -> None: self.stages: list[str] = [] self.created_workflows: list[tuple[Workflow, str]] = [] self.linked_sessions: list[str] = [] async def stage(self, stage: str) -> None: self.stages.append(stage) async def create_workflow(self, workflow: Workflow, stage: str) -> None: self.created_workflows.append((workflow, stage)) async def link_session(self, session_id: str) -> None: self.linked_sessions.append(session_id) class FakeRepository: def __init__( self, *, implementations: list[Workflow] | None = None, plan: Workflow | None = None, ) -> None: self.implementations = implementations or [] self.plan = plan self.saved_workflows: list[Workflow] = [] async def implementation_workflows(self, *_args: object) -> list[Workflow]: return self.implementations async def latest_workflow(self, *_args: object) -> Workflow | None: return self.plan async def operational_comment_ids(self, *_args: object) -> set[int]: return {99} async def save_workflow(self, workflow: Workflow) -> None: self.saved_workflows.append(workflow) class FakeGitea: def __init__( self, *, existing_pulls: dict[int, PullRequestInfo] | None = None, ) -> None: self.existing_pulls = existing_pulls or {} self.created_pulls: list[tuple[str, str, dict[str, str]]] = [] self.default_branch_calls: list[tuple[str, str]] = [] async def default_branch(self, owner: str, repo: str) -> str: self.default_branch_calls.append((owner, repo)) return "main" async def issue(self, *_args: object) -> IssueInfo: return IssueInfo(7, "Fix widget", "The widget is broken.", "open") async def issue_comments(self, *_args: object) -> list[CommentInfo]: return [ CommentInfo(12, "alice", "Please cover empty input.", "2026-07-01"), CommentInfo(99, "agentci", "Job queued", "2026-07-02"), ] async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo: return self.existing_pulls[number] async def create_pull_request(self, owner: str, repo: str, **values: str) -> PullRequestInfo: self.created_pulls.append((owner, repo, values)) return pull(number=42, branch=values["head"]) 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 "base-sha" async def create_branch(self, workspace: Path, branch: str) -> None: self.calls.append(("create_branch", workspace, branch)) 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 "commit-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, result: AgentResult, report: ReviewReport) -> None: self.result = result self.report = report 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) -> AgentResult | ReviewReport: self.resume_calls.append(values) if values["result_type"] is AgentResult: return self.result assert values["result_type"] is ReviewReport return self.report def job() -> Job: return Job( id="job-1", kind=JobKind.IMPLEMENT, target_key="org/repo:issue:7", repo_owner="org", repo_name="repo", issue_number=7, pr_number=None, requester="alice", comment_id=10, delivery_id="delivery-1", receive_sequence=1, command_body="/agent implement", message="Keep the change focused.", ) def workflow(*, pr_number: int | None = None) -> Workflow: return Workflow( id=f"old-{pr_number}", kind=WorkflowKind.IMPLEMENT, repo_owner="org", repo_name="repo", issue_number=7, workspace_path=Path("/old/repo"), base_sha="old-sha", pr_number=pr_number, status=WorkflowStatus.COMPLETED, ) def pull( *, number: int = 8, state: str = "open", merged: bool = False, branch: str = "agent/old", ) -> PullRequestInfo: return PullRequestInfo( number=number, title="Existing PR", body="Body", state=state, merged=merged, base_branch="main", head_branch=branch, head_sha="head-sha", head_owner="org", head_repo="repo", ) def make_services( tmp_path: Path, *, changed: bool = True, implementations: list[Workflow] | None = None, existing_pulls: dict[int, PullRequestInfo] | None = None, plan: Workflow | None = None, ) -> tuple[ WorkflowServices, FakeRepository, FakeGitea, RecordingGit, RecordingDevelopment, RecordingPrompts, RecordingOpenCode, ]: repository = FakeRepository(implementations=implementations, plan=plan) gitea = FakeGitea(existing_pulls=existing_pulls) git = RecordingGit(changed=changed) development = RecordingDevelopment() prompts = RecordingPrompts() opencode = RecordingOpenCode( AgentResult( summary_markdown="# Implement widget\n\nHandled empty input.", tests=["pytest: passed"], ), ReviewReport(summary="Ready", findings=[]), ) services = cast( WorkflowServices, SimpleNamespace( settings=SimpleNamespace( branch_prefix="agent", workspaces_dir=tmp_path / "workspaces", gitea_url="https://git.example.test", implement_model="provider/model", implement_variant="high", implement_review_rounds=2, ), repository=repository, gitea=gitea, git=git, opencode=opencode, prompts=prompts, development=development, ), ) return services, repository, gitea, git, development, prompts, opencode async def test_initial_implementation_completes_review_commit_push_and_pr( tmp_path: Path, ) -> None: stale = workflow(pr_number=8) services, repository, gitea, git, development, prompts, opencode = make_services( tmp_path, implementations=[stale], existing_pulls={8: pull(state="closed")}, ) run = RecordingRun() body = await implement(job(), run, services) assert len(run.created_workflows) == 1 created, created_stage = run.created_workflows[0] assert created_stage == "installing development environment" assert created.status is WorkflowStatus.ACTIVE assert created.branch == f"agent/issue-7-{created.id[:8]}" assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo" assert created.base_sha == "base-sha" assert development.workspaces == [created.workspace_path] assert run.linked_sessions == ["implementation-session"] assert opencode.created_sessions == [ (created.workspace_path, "implementation"), (created.workspace_path, "implementation-review"), ] assert [call["result_type"] for call in opencode.resume_calls] == [ AgentResult, ReviewReport, ] initial_prompt = prompts.calls[0] assert initial_prompt[0] == "implement_initial" assert initial_prompt[1]["artifact"] == "(no canonical plan)" assert initial_prompt[1]["request"] == "Keep the change focused." assert "Please cover empty input." in initial_prompt[1]["context"] assert "Job queued" not in initial_prompt[1]["context"] assert git.calls == [ ("clone", "org", "repo", "main", created.workspace_path), ("create_branch", created.workspace_path, created.branch), ("has_changes", created.workspace_path), ("diff_check", created.workspace_path), ("commit", created.workspace_path, "agent: Implement widget"), ("push", created.workspace_path, created.branch, True), ] assert gitea.created_pulls == [ ( "org", "repo", { "title": "Agent: Fix widget", "body": ( "Closes #7\n\n" "## Implementation\n\n# Implement widget\n\nHandled empty input.\n\n" "## Validation\n\n- pytest: passed\n\n_Created by Agent CI._" ), "head": created.branch, "base": "main", }, ) ] completed = repository.saved_workflows[-1] assert completed.id == created.id assert completed.status is WorkflowStatus.COMPLETED assert completed.pr_number == 42 assert completed.primary_session_id == "implementation-session" assert completed.reviewer_session_id == "implementation-review-session" assert AgentResult.model_validate_json(completed.artifact or "").tests == ["pytest: passed"] assert json.loads(completed.review_json or "") == { "summary": "Ready", "findings": [], } assert body == ( f"\n" "Pull request created: https://git.example.test/org/repo/pulls/42\n\n" "## Agent result\n\n# Implement widget\n\nHandled empty input.\n\n" "## Validation\n\n- pytest: passed\n\nCommit: `commit-sha`" ) async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr( tmp_path: Path, ) -> None: services, repository, gitea, git, _, _, _ = make_services(tmp_path, changed=False) run = RecordingRun() with pytest.raises( JobRejected, match="OpenCode completed without producing any file changes", ): await implement(job(), run, services) assert [call[0] for call in git.calls] == [ "clone", "create_branch", "has_changes", ] assert gitea.created_pulls == [] assert repository.saved_workflows assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows) assert "creating pull request" not in run.stages @pytest.mark.parametrize( ("existing_pull", "message"), [ ( pull(state="open"), "Agent PR #8 is already open. Use `/agent iterate` on that pull request.", ), ( pull(state="closed", merged=True), "Agent PR #8 has already been merged for this issue.", ), ], ) async def test_initial_implementation_rejects_duplicate_agent_pr_before_clone( tmp_path: Path, existing_pull: PullRequestInfo, message: str, ) -> None: services, _, gitea, git, development, _, opencode = make_services( tmp_path, implementations=[workflow(pr_number=8)], existing_pulls={8: existing_pull}, ) with pytest.raises(JobRejected) as error: await implement(job(), RecordingRun(), services) assert str(error.value) == message assert git.calls == [] assert development.workspaces == [] assert opencode.resume_calls == [] assert gitea.default_branch_calls == []