Compare commits

...

2 Commits

7 changed files with 105 additions and 22 deletions

View File

@@ -1,5 +1,7 @@
# Agentic Gitea Issue-to-PR Manager # Agentic Gitea Issue-to-PR Manager
[中文说明](README_CN.md)
Local, auditable CLI service that scans configured Gitea repositories for eligible issues, runs an implementer agent in an isolated workspace, opens a pull request, runs a reviewer agent, and posts a standardized human review summary. Local, auditable CLI service that scans configured Gitea repositories for eligible issues, runs an implementer agent in an isolated workspace, opens a pull request, runs a reviewer agent, and posts a standardized human review summary.
The MVP never merges pull requests. The MVP never merges pull requests.

33
README_CN.md Normal file
View File

@@ -0,0 +1,33 @@
# Agentic Gitea Issue-to-PR Manager
[English](README.md)
一个本地运行、可审计的 CLI 服务,用于扫描已配置 Gitea 仓库中的合格 issue在隔离工作区中运行实现 agent创建 pull request运行评审 agent并发布标准化的人工评审摘要。
MVP 版本不会合并 pull request。
## 快速开始
```bash
uv sync
cp config.example.yaml config.yaml
# 在 .env 中写入你的 token例如 GITEA_TOKEN=...
agent-gitea --config config.yaml sync-repos
agent-gitea --config config.yaml scan-issues
agent-gitea --config config.yaml run-once
```
`sync-repos` 会通过 `/user/repos` 发现已认证 Gitea 用户拥有的仓库;仓库不会列在配置文件中。
`worker` 会持续同步仓库、扫描 issue并处理符合条件的任务。
## 命令
- `agent-gitea sync-repos`
- `agent-gitea scan-issues`
- `agent-gitea run-once`
- `agent-gitea worker`
- `agent-gitea show-task <task_id>`
- `agent-gitea retry-task <task_id>`
- `agent-gitea cancel-task <task_id>`
所需的配置结构请参考 `config.example.yaml`

View File

@@ -34,7 +34,8 @@ Issue URL: {issue.html_url}
Implement the requested change in this workspace. Keep the change scoped to this issue. Implement the requested change in this workspace. Keep the change scoped to this issue.
Run the relevant tests before finishing. Run the relevant tests before finishing.
Write `AGENT_IMPLEMENTATION_REPORT.md` in the workspace root using this exact section contract: Write `.agent-output/AGENT_IMPLEMENTATION_REPORT.md` using this exact section contract.
Keep the section headings exactly as written below, but write the section content in Chinese:
- Summary - Summary
- Files changed - Files changed
@@ -55,7 +56,9 @@ Issue: #{issue.issue_number} {issue.title}
Review the implementation currently checked out in this workspace. Focus on correctness, Review the implementation currently checked out in this workspace. Focus on correctness,
scope control, test evidence, and human risks. Do not modify code. scope control, test evidence, and human risks. Do not modify code.
Write `AGENT_REVIEW_REPORT.md` in the workspace root using this exact section contract: Write `.agent-output/AGENT_REVIEW_REPORT.md` using this exact section contract.
Keep the section headings exactly as written below. Keep the Verdict token in English,
but write the section content and Suggested PR Comment in Chinese:
- Verdict: APPROVE, REQUEST_CHANGES, or NEEDS_HUMAN_DECISION - Verdict: APPROVE, REQUEST_CHANGES, or NEEDS_HUMAN_DECISION
- Summary - Summary
@@ -69,16 +72,16 @@ Write `AGENT_REVIEW_REPORT.md` in the workspace root using this exact section co
def render_pr_body(issue: IssueRecord, implementation_report: str) -> str: def render_pr_body(issue: IssueRecord, implementation_report: str) -> str:
return f"""Closes #{issue.issue_number} return f"""关联 Issue#{issue.issue_number}
## Agent Implementation Report ## 代理实现报告
{implementation_report.strip()} {implementation_report.strip()}
## Human Review Gate ## 人工审核
This PR was opened by the local agent manager. It has not been auto-merged. 此 PR 由本地 agent-manager 自动创建,但不会自动合并。
Human maintainers must review, decide, and merge manually. 请维护者人工审核、决策并手动合并。
""" """
@@ -102,13 +105,13 @@ def extract_section(raw: str, title: str) -> str:
def render_human_review_summary(review: ReviewReport) -> str: def render_human_review_summary(review: ReviewReport) -> str:
return f"""## Agent Review Summary return f"""## 代理评审摘要
Verdict: `{review.verdict}` 结论:`{review.verdict}`
{review.suggested_pr_comment.strip()} {review.suggested_pr_comment.strip()}
## Human Action Required ## 需要人工处理
Please review the PR manually. The agent manager will not merge, close, or request changes automatically. 请人工审核该 PR。agent-manager 不会自动合并、关闭 PR 或提交变更请求。
""" """

View File

@@ -132,12 +132,15 @@ class TaskRunner:
error_message="implementer produced no diff", error_message="implementer produced no diff",
clear_lease=True, clear_lease=True,
) )
commit_message = f"agent: implement issue #{issue.issue_number} - {issue.title}"
commit_id = self.workspace_manager.commit_changes(workspace, commit_message)
self.db.add_event(task.id, TaskState.TESTING, TaskState.TESTING, f"committed implementation {commit_id}")
self.workspace_manager.push_branch(workspace, branch_name) self.workspace_manager.push_branch(workspace, branch_name)
pr_body = render_pr_body(issue, implementation_report) pr_body = render_pr_body(issue, implementation_report)
pr = self.gitea.create_pull_request( pr = self.gitea.create_pull_request(
owner=repo.owner, owner=repo.owner,
name=repo.name, name=repo.name,
title=f"Agent: {issue.title}", title=f"代理实现:{issue.title}",
body=pr_body, body=pr_body,
head=branch_name, head=branch_name,
base=repo.default_branch, base=repo.default_branch,
@@ -191,7 +194,9 @@ class TaskRunner:
workspace: Path, workspace: Path,
) -> str: ) -> str:
prompt = render_implementer_prompt(repo, issue, branch_name) prompt = render_implementer_prompt(repo, issue, branch_name)
prompt_path = workspace / "AGENT_IMPLEMENTER_PROMPT.md" output_dir = workspace / ".agent-output"
output_dir.mkdir(exist_ok=True)
prompt_path = output_dir / "AGENT_IMPLEMENTER_PROMPT.md"
write_prompt(prompt_path, prompt) write_prompt(prompt_path, prompt)
command = render_command( command = render_command(
self.config.agents.implementer.command, self.config.agents.implementer.command,
@@ -202,7 +207,7 @@ class TaskRunner:
branch_name=branch_name, branch_name=branch_name,
) )
result = self.command_runner.run(command, workspace, stdin=prompt) result = self.command_runner.run(command, workspace, stdin=prompt)
report = read_report(workspace / "AGENT_IMPLEMENTATION_REPORT.md") report = read_report(output_dir / "AGENT_IMPLEMENTATION_REPORT.md")
self.db.add_agent_run( self.db.add_agent_run(
task_id=task.id, task_id=task.id,
role="implementer", role="implementer",
@@ -226,7 +231,9 @@ class TaskRunner:
workspace: Path, workspace: Path,
) -> str: ) -> str:
prompt = render_reviewer_prompt(repo, issue, pr_number) prompt = render_reviewer_prompt(repo, issue, pr_number)
prompt_path = workspace / "AGENT_REVIEWER_PROMPT.md" output_dir = workspace / ".agent-output"
output_dir.mkdir(exist_ok=True)
prompt_path = output_dir / "AGENT_REVIEWER_PROMPT.md"
write_prompt(prompt_path, prompt) write_prompt(prompt_path, prompt)
command = render_command( command = render_command(
self.config.agents.reviewer.command, self.config.agents.reviewer.command,
@@ -237,7 +244,7 @@ class TaskRunner:
pr_number=pr_number, pr_number=pr_number,
) )
result = self.command_runner.run(command, workspace, stdin=prompt) result = self.command_runner.run(command, workspace, stdin=prompt)
report = read_report(workspace / "AGENT_REVIEW_REPORT.md") report = read_report(output_dir / "AGENT_REVIEW_REPORT.md")
self.db.add_agent_run( self.db.add_agent_run(
task_id=task.id, task_id=task.id,
role="reviewer", role="reviewer",

View File

@@ -32,8 +32,22 @@ class WorkspaceManager:
self._git(["clone", repo.clone_url, str(path)], Path.cwd()) self._git(["clone", repo.clone_url, str(path)], Path.cwd())
self._git(["checkout", repo.default_branch], path) self._git(["checkout", repo.default_branch], path)
self._git(["checkout", "-B", branch_name], path) self._git(["checkout", "-B", branch_name], path)
self.exclude_runtime_artifacts(path)
return path return path
def exclude_runtime_artifacts(self, workspace: str | Path) -> None:
exclude_path = Path(workspace) / ".git" / "info" / "exclude"
entries = {
".agent-output/",
"AGENT_IMPLEMENTER_PROMPT.md",
"AGENT_REVIEWER_PROMPT.md",
}
existing = exclude_path.read_text(encoding="utf-8") if exclude_path.exists() else ""
with exclude_path.open("a", encoding="utf-8") as handle:
for entry in sorted(entries):
if entry not in existing:
handle.write(f"\n{entry}\n")
def has_diff(self, workspace: str | Path, base_ref: str = "origin/HEAD") -> bool: def has_diff(self, workspace: str | Path, base_ref: str = "origin/HEAD") -> bool:
result = self._git(["status", "--porcelain"], Path(workspace), check=False) result = self._git(["status", "--porcelain"], Path(workspace), check=False)
if result.stdout.strip(): if result.stdout.strip():
@@ -41,6 +55,16 @@ class WorkspaceManager:
diff = self._git(["diff", "--quiet", f"{base_ref}...HEAD"], Path(workspace), check=False) diff = self._git(["diff", "--quiet", f"{base_ref}...HEAD"], Path(workspace), check=False)
return diff.returncode == 1 return diff.returncode == 1
def commit_changes(self, workspace: str | Path, message: str) -> str:
workspace_path = Path(workspace)
self._git(["add", "-A"], workspace_path)
result = self._git(["diff", "--cached", "--quiet"], workspace_path, check=False)
if result.returncode == 0:
raise RuntimeError("no staged implementation changes to commit")
self._git(["commit", "-m", message], workspace_path)
commit = self._git(["rev-parse", "--short", "HEAD"], workspace_path)
return commit.stdout.strip()
def push_branch(self, workspace: str | Path, branch_name: str) -> None: def push_branch(self, workspace: str | Path, branch_name: str) -> None:
self._git(["push", "-u", "origin", branch_name], Path(workspace)) self._git(["push", "-u", "origin", branch_name], Path(workspace))

View File

@@ -136,6 +136,10 @@ class FakeWorkspaceManager:
def push_branch(self, workspace, branch_name): def push_branch(self, workspace, branch_name):
self.pushed.append(branch_name) self.pushed.append(branch_name)
def commit_changes(self, workspace, message):
self.commit_message = message
return "abc1234"
def cleanup(self, workspace): def cleanup(self, workspace):
pass pass
@@ -150,12 +154,16 @@ class FakeRunner:
if role == self.fail_role: if role == self.fail_role:
return AgentResult(exit_code=1, stdout="", stderr="failed") return AgentResult(exit_code=1, stdout="", stderr="failed")
if role == "implementer": if role == "implementer":
(Path(cwd) / "AGENT_IMPLEMENTATION_REPORT.md").write_text( output_dir = Path(cwd) / ".agent-output"
output_dir.mkdir(exist_ok=True)
(output_dir / "AGENT_IMPLEMENTATION_REPORT.md").write_text(
"## Summary\nImplemented\n\n## Test commands run\npytest\n", "## Summary\nImplemented\n\n## Test commands run\npytest\n",
encoding="utf-8", encoding="utf-8",
) )
if role == "reviewer": if role == "reviewer":
(Path(cwd) / "AGENT_REVIEW_REPORT.md").write_text( output_dir = Path(cwd) / ".agent-output"
output_dir.mkdir(exist_ok=True)
(output_dir / "AGENT_REVIEW_REPORT.md").write_text(
"## Verdict\nAPPROVE\n\n## Suggested PR Comment\nLooks good.\n", "## Verdict\nAPPROVE\n\n## Suggested PR Comment\nLooks good.\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -196,11 +204,12 @@ def test_run_task_success_posts_review_comments(db, tmp_path):
return httpx.Response(201, json={"id": 1}) return httpx.Response(201, json={"id": 1})
return httpx.Response(404) return httpx.Response(404)
workspace_manager = FakeWorkspaceManager(tmp_path / "work")
task = TaskRunner( task = TaskRunner(
db=db, db=db,
config=config, config=config,
gitea=make_client(handler), gitea=make_client(handler),
workspace_manager=FakeWorkspaceManager(tmp_path / "work"), workspace_manager=workspace_manager,
command_runner=FakeRunner(), command_runner=FakeRunner(),
worker_id="worker", worker_id="worker",
).run_once() ).run_once()
@@ -208,6 +217,11 @@ def test_run_task_success_posts_review_comments(db, tmp_path):
assert task is not None assert task is not None
assert task.state == TaskState.HUMAN_REVIEW_READY assert task.state == TaskState.HUMAN_REVIEW_READY
assert task.pr_number == 5 assert task.pr_number == 5
assert workspace_manager.pushed == ["agent/issue-1-ready-issue"]
assert workspace_manager.commit_message == "agent: implement issue #1 - Ready issue"
pull_requests = [payload for _, path, payload in requests if path == "/api/v1/repos/acme/service/pulls"]
assert pull_requests[0]["title"] == "代理实现Ready issue"
assert "代理实现报告" in pull_requests[0]["body"]
command = json.loads(db.list_agent_runs(task.id)[0]["command_json"]) command = json.loads(db.list_agent_runs(task.id)[0]["command_json"])
assert command[1] == "--cd" assert command[1] == "--cd"
assert Path(command[2]).is_absolute() assert Path(command[2]).is_absolute()

View File

@@ -38,9 +38,9 @@ def test_prompt_and_pr_body_include_contract_sections(db):
prompt = render_implementer_prompt(repo, issue, "agent/issue-7-add-thing") prompt = render_implementer_prompt(repo, issue, "agent/issue-7-add-thing")
body = render_pr_body(issue, "## Summary\nDone") body = render_pr_body(issue, "## Summary\nDone")
assert "AGENT_IMPLEMENTATION_REPORT.md" in prompt assert ".agent-output/AGENT_IMPLEMENTATION_REPORT.md" in prompt
assert "Closes #7" in body assert "关联 Issue#7" in body
assert "Human Review Gate" in body assert "人工审核" in body
def test_review_report_parsing_extracts_verdict_and_comment(): def test_review_report_parsing_extracts_verdict_and_comment():