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
[中文说明](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.
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.
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
- Files changed
@@ -55,7 +56,9 @@ Issue: #{issue.issue_number} {issue.title}
Review the implementation currently checked out in this workspace. Focus on correctness,
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
- 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:
return f"""Closes #{issue.issue_number}
return f"""关联 Issue#{issue.issue_number}
## Agent Implementation Report
## 代理实现报告
{implementation_report.strip()}
## Human Review Gate
## 人工审核
This PR was opened by the local agent manager. It has not been auto-merged.
Human maintainers must review, decide, and merge manually.
此 PR 由本地 agent-manager 自动创建,但不会自动合并。
请维护者人工审核、决策并手动合并。
"""
@@ -102,13 +105,13 @@ def extract_section(raw: str, title: str) -> 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()}
## 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",
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)
pr_body = render_pr_body(issue, implementation_report)
pr = self.gitea.create_pull_request(
owner=repo.owner,
name=repo.name,
title=f"Agent: {issue.title}",
title=f"代理实现:{issue.title}",
body=pr_body,
head=branch_name,
base=repo.default_branch,
@@ -191,7 +194,9 @@ class TaskRunner:
workspace: Path,
) -> str:
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)
command = render_command(
self.config.agents.implementer.command,
@@ -202,7 +207,7 @@ class TaskRunner:
branch_name=branch_name,
)
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(
task_id=task.id,
role="implementer",
@@ -226,7 +231,9 @@ class TaskRunner:
workspace: Path,
) -> str:
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)
command = render_command(
self.config.agents.reviewer.command,
@@ -237,7 +244,7 @@ class TaskRunner:
pr_number=pr_number,
)
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(
task_id=task.id,
role="reviewer",

View File

@@ -32,8 +32,22 @@ class WorkspaceManager:
self._git(["clone", repo.clone_url, str(path)], Path.cwd())
self._git(["checkout", repo.default_branch], path)
self._git(["checkout", "-B", branch_name], path)
self.exclude_runtime_artifacts(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:
result = self._git(["status", "--porcelain"], Path(workspace), check=False)
if result.stdout.strip():
@@ -41,6 +55,16 @@ class WorkspaceManager:
diff = self._git(["diff", "--quiet", f"{base_ref}...HEAD"], Path(workspace), check=False)
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:
self._git(["push", "-u", "origin", branch_name], Path(workspace))

View File

@@ -136,6 +136,10 @@ class FakeWorkspaceManager:
def push_branch(self, workspace, branch_name):
self.pushed.append(branch_name)
def commit_changes(self, workspace, message):
self.commit_message = message
return "abc1234"
def cleanup(self, workspace):
pass
@@ -150,12 +154,16 @@ class FakeRunner:
if role == self.fail_role:
return AgentResult(exit_code=1, stdout="", stderr="failed")
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",
encoding="utf-8",
)
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",
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(404)
workspace_manager = FakeWorkspaceManager(tmp_path / "work")
task = TaskRunner(
db=db,
config=config,
gitea=make_client(handler),
workspace_manager=FakeWorkspaceManager(tmp_path / "work"),
workspace_manager=workspace_manager,
command_runner=FakeRunner(),
worker_id="worker",
).run_once()
@@ -208,6 +217,11 @@ def test_run_task_success_posts_review_comments(db, tmp_path):
assert task is not None
assert task.state == TaskState.HUMAN_REVIEW_READY
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"])
assert command[1] == "--cd"
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")
body = render_pr_body(issue, "## Summary\nDone")
assert "AGENT_IMPLEMENTATION_REPORT.md" in prompt
assert "Closes #7" in body
assert "Human Review Gate" in body
assert ".agent-output/AGENT_IMPLEMENTATION_REPORT.md" in prompt
assert "关联 Issue#7" in body
assert "人工审核" in body
def test_review_report_parsing_extracts_verdict_and_comment():