agent: implement issue #5 - Feat: 实现 agent 与 human 在 PR 中的交互
This commit is contained in:
@@ -8,7 +8,13 @@ import httpx
|
||||
from agent_gitea.config import AppConfig
|
||||
from agent_gitea.gitea import GiteaClient
|
||||
from agent_gitea.models import AgentResult, TaskState
|
||||
from agent_gitea.service import TaskRunner, close_issues_for_merged_pull_requests, scan_issues, sync_repositories
|
||||
from agent_gitea.service import (
|
||||
TaskRunner,
|
||||
close_issues_for_merged_pull_requests,
|
||||
scan_issues,
|
||||
scan_pull_request_feedback,
|
||||
sync_repositories,
|
||||
)
|
||||
|
||||
|
||||
def make_config(tmp_path: Path, **overrides: object) -> AppConfig:
|
||||
@@ -167,10 +173,17 @@ class FakeWorkspaceManager:
|
||||
self.root = root
|
||||
self.diff = diff
|
||||
self.pushed: list[str] = []
|
||||
self.resumed: list[tuple[str, Path | None]] = []
|
||||
|
||||
def prepare(self, repo, issue, branch_name):
|
||||
path = self.root / branch_name.replace("/", "_")
|
||||
path.mkdir(parents=True)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def prepare_existing_branch(self, repo, issue, branch_name, workspace_path):
|
||||
self.resumed.append((branch_name, workspace_path))
|
||||
path = Path(workspace_path) if workspace_path else self.root / branch_name.replace("/", "_")
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def has_diff(self, workspace, base_ref="origin/HEAD"):
|
||||
@@ -233,6 +246,16 @@ def seed_task(db):
|
||||
return db.create_task(repo.id, 1)
|
||||
|
||||
|
||||
def transition_to_human_review_ready(db, task_id: int, *, pr_number: int = 5, branch_name: str | None = None):
|
||||
db.transition(task_id, TaskState.CLAIMED)
|
||||
db.transition(task_id, TaskState.PLANNING)
|
||||
db.transition(task_id, TaskState.IMPLEMENTING, branch_name=branch_name)
|
||||
db.transition(task_id, TaskState.TESTING)
|
||||
db.transition(task_id, TaskState.PR_OPENED, pr_number=pr_number)
|
||||
db.transition(task_id, TaskState.REVIEWING)
|
||||
return db.transition(task_id, TaskState.HUMAN_REVIEW_READY, clear_lease=True)
|
||||
|
||||
|
||||
def test_run_task_success_posts_review_comments(db, tmp_path):
|
||||
config = make_config(tmp_path)
|
||||
seed_task(db)
|
||||
@@ -272,6 +295,115 @@ def test_run_task_success_posts_review_comments(db, tmp_path):
|
||||
assert [path for _, path, _ in requests].count("/api/v1/repos/acme/service/issues/5/comments") == 2
|
||||
|
||||
|
||||
def test_scan_pull_request_feedback_queues_task_for_new_human_comment(db):
|
||||
task = seed_task(db)
|
||||
transition_to_human_review_ready(db, task.id, pr_number=5, branch_name="agent/issue-1-ready-issue")
|
||||
db.clear_pr_feedback_pending(task.id, last_seen_comment_id=2)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "agent-bot"})
|
||||
if request.url.path == "/api/v1/repos/acme/service/pulls/5":
|
||||
return httpx.Response(200, json={"number": 5, "state": "open", "merged": False})
|
||||
if request.url.path == "/api/v1/repos/acme/service/issues/5/comments":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"id": 1, "body": "review report", "user": {"login": "agent-bot"}},
|
||||
{"id": 2, "body": "summary", "user": {"login": "agent-bot"}},
|
||||
{"id": 3, "body": "Please add a regression test.", "user": {"login": "alice"}},
|
||||
{"id": 4, "body": "internal note", "user": {"login": "agent-bot"}},
|
||||
],
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
queued = scan_pull_request_feedback(db, make_client(handler))
|
||||
task_after_scan = db.get_task(task.id)
|
||||
|
||||
assert queued == 1
|
||||
assert task_after_scan is not None
|
||||
assert task_after_scan.state == TaskState.DISCOVERED
|
||||
assert db.has_pending_pr_feedback(task.id)
|
||||
assert db.get_pr_feedback_cursor(task.id) == 2
|
||||
|
||||
|
||||
def test_scan_pull_request_feedback_advances_cursor_without_human_comments(db):
|
||||
task = seed_task(db)
|
||||
transition_to_human_review_ready(db, task.id, pr_number=5, branch_name="agent/issue-1-ready-issue")
|
||||
db.clear_pr_feedback_pending(task.id, last_seen_comment_id=2)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "agent-bot"})
|
||||
if request.url.path == "/api/v1/repos/acme/service/pulls/5":
|
||||
return httpx.Response(200, json={"number": 5, "state": "open", "merged": False})
|
||||
if request.url.path == "/api/v1/repos/acme/service/issues/5/comments":
|
||||
return httpx.Response(200, json=[{"id": 3, "body": "summary", "user": {"login": "agent-bot"}}])
|
||||
return httpx.Response(404)
|
||||
|
||||
queued = scan_pull_request_feedback(db, make_client(handler))
|
||||
|
||||
assert queued == 0
|
||||
assert not db.has_pending_pr_feedback(task.id)
|
||||
assert db.get_pr_feedback_cursor(task.id) == 3
|
||||
assert db.get_task(task.id).state == TaskState.HUMAN_REVIEW_READY # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_run_task_with_pending_pr_feedback_updates_existing_pr(db, tmp_path):
|
||||
config = make_config(tmp_path)
|
||||
task = seed_task(db)
|
||||
workspace_path = tmp_path / "work" / "existing"
|
||||
transition_to_human_review_ready(db, task.id, pr_number=5, branch_name="agent/issue-1-ready-issue")
|
||||
db.transition(task.id, TaskState.DISCOVERED, clear_lease=True)
|
||||
db.clear_pr_feedback_pending(task.id, last_seen_comment_id=2)
|
||||
db.mark_pr_feedback_pending(task.id)
|
||||
db.conn.execute("UPDATE tasks SET workspace_path = ? WHERE id = ?", (str(workspace_path), task.id))
|
||||
db.conn.commit()
|
||||
requests: list[tuple[str, str, dict]] = []
|
||||
next_comment_id = 3
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal next_comment_id
|
||||
payload = json.loads(request.content.decode() or "{}")
|
||||
requests.append((request.method, request.url.path, payload))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "agent-bot"})
|
||||
if request.url.path == "/api/v1/repos/acme/service/issues/5/comments" and request.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"id": 1, "body": "review report", "user": {"login": "agent-bot"}},
|
||||
{"id": 2, "body": "summary", "user": {"login": "agent-bot"}},
|
||||
{"id": 3, "body": "Please add a regression test.", "user": {"login": "alice"}},
|
||||
],
|
||||
)
|
||||
if request.url.path == "/api/v1/repos/acme/service/issues/5/comments" and request.method == "POST":
|
||||
next_comment_id += 1
|
||||
return httpx.Response(201, json={"id": next_comment_id, "body": payload["body"], "user": {"login": "agent-bot"}})
|
||||
return httpx.Response(404)
|
||||
|
||||
workspace_manager = FakeWorkspaceManager(tmp_path / "work")
|
||||
finished = TaskRunner(
|
||||
db=db,
|
||||
config=config,
|
||||
gitea=make_client(handler),
|
||||
workspace_manager=workspace_manager,
|
||||
command_runner=FakeRunner(),
|
||||
worker_id="worker",
|
||||
).run_once()
|
||||
|
||||
assert finished is not None
|
||||
assert finished.state == TaskState.HUMAN_REVIEW_READY
|
||||
assert finished.pr_number == 5
|
||||
assert workspace_manager.resumed == [("agent/issue-1-ready-issue", workspace_path)]
|
||||
assert workspace_manager.pushed == ["agent/issue-1-ready-issue"]
|
||||
assert workspace_manager.commit_message == "agent: address PR #5 feedback for issue #1"
|
||||
assert not any(path == "/api/v1/repos/acme/service/pulls" for _, path, _ in requests)
|
||||
assert [run["role"] for run in db.list_agent_runs(task.id)] == ["implementer_pr_feedback", "reviewer"]
|
||||
assert db.get_pr_feedback_cursor(task.id) == 6
|
||||
assert not db.has_pending_pr_feedback(task.id)
|
||||
|
||||
|
||||
def test_close_issues_for_merged_pull_requests_closes_linked_issue(db):
|
||||
repo = db.upsert_repository(
|
||||
owner="acme",
|
||||
|
||||
Reference in New Issue
Block a user