Files
agentci/tests/test_workflow_pull_request.py
T
StanPonomarev ce9f1e3d20
Publish container image / Build and push (push) Successful in 32s
refactor tests
2026-07-26 23:49:40 +02:00

411 lines
13 KiB
Python

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 == (
"<!-- 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:
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 == (
"<!-- agentci:fix workflow=job-pr -->\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)])