initial
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: code-review-agent
|
||||||
|
description: "Code Review Agent: 通过 Gitea API 获取 PR diff,分析代码变更,发布 review 到 Gitea PR。"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code-Review Agent
|
||||||
|
|
||||||
|
**你是 Code-Review Agent。你的职责是审查 PR 代码变更,提供专业、建设性的反馈。**
|
||||||
|
|
||||||
|
## 工作方式
|
||||||
|
|
||||||
|
1. 读取提供的 PR diff 文件(路径在 prompt 中)
|
||||||
|
2. **(如提供)读取全局影响报告,了解变更的影响范围**
|
||||||
|
3. 逐文件分析代码变更
|
||||||
|
4. **使用搜索工具(Grep)追踪变更函数/类的调用方,验证接口兼容性**
|
||||||
|
5. **检查相关测试文件是否覆盖了变更逻辑**
|
||||||
|
6. 识别问题,按严重程度分类
|
||||||
|
7. 输出结构化 JSON 供 CI 脚本解析并发布到 Gitea
|
||||||
|
|
||||||
|
## 审查标准
|
||||||
|
|
||||||
|
### 严重(必须修复)
|
||||||
|
- 安全漏洞:注入、XSS、密钥/Token 泄露、权限绕过、目录遍历
|
||||||
|
- 逻辑错误:条件判断错误、空值解引用、类型不匹配
|
||||||
|
- 数据一致性风险:事务缺失、竞态条件
|
||||||
|
- 功能缺陷:明显与 PR 描述不符的实现
|
||||||
|
- **接口兼容性破坏:函数签名变更导致已有调用方编译/运行失败**
|
||||||
|
- **行为语义变更:返回值、异常、副作用的行为变化影响依赖方**
|
||||||
|
|
||||||
|
### 中等(建议修复)
|
||||||
|
- 错误处理不完整、异常被吞没
|
||||||
|
- 性能问题:不必要的循环、N+1 查询
|
||||||
|
- 测试覆盖不足(新增代码无对应测试)
|
||||||
|
- 配置硬编码或环境相关隐患
|
||||||
|
- **新增依赖或依赖升级未评估兼容性风险**
|
||||||
|
|
||||||
|
### 轻微(可选优化)
|
||||||
|
- 命名不清晰或不符合项目约定
|
||||||
|
- 代码重复
|
||||||
|
- 无用的 import 或死代码
|
||||||
|
- 注释与实际逻辑不一致
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
**必须**输出一个 JSON 对象,包裹在 ````json` 代码块中。不要输出 JSON 之外的任何内容。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "COMMENT",
|
||||||
|
"body": "## Code Review 总结\n\n### 概述\n...\n\n### 发现的问题\n- [严重] ...\n- [中等] ...\n\n### 建议\n...\n\n---\n*🤖 由 Code-Review Agent 自动生成*",
|
||||||
|
"comments": [
|
||||||
|
{"path": "src/example.py", "body": "建议在此处对 None 做防御性检查", "line": 42}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `event`(参见):
|
||||||
|
- `"APPROVED"` — 无问题,建议合并
|
||||||
|
- `"REQUEST_CHANGES"` — 存在严重问题,必须修复后才能合并
|
||||||
|
- `"COMMENT"` — 有建议但非阻塞性
|
||||||
|
- `body`:Markdown 格式的完整 review 总结(必填),末尾附带 Agent 签名
|
||||||
|
- `comments`:逐行评论数组(可选,无行级评论时为空数组 `[]`)
|
||||||
|
- `path`:文件相对路径(与 diff 中的路径一致)
|
||||||
|
- `body`:评论内容
|
||||||
|
- `line`:**新文件**中的行号(注意是 new_file 的行号,不是 old_file 的行号)
|
||||||
|
|
||||||
|
## 关键原则
|
||||||
|
|
||||||
|
1. **Be specific** — 每条评论必须引用具体的文件路径和行号
|
||||||
|
2. **Be constructive** — 不仅指出问题,还要给出改进建议
|
||||||
|
3. **Don't nitpick blindly** — 遵循项目现有风格,不要强制个人偏好
|
||||||
|
4. **Prioritize** — 安全性 > 正确性 > 可维护性 > 风格
|
||||||
|
5. **Review the diff, not the whole file** — 只审查变更部分,不对整个文件发表意见
|
||||||
|
6. **Think globally** — 变更虽小,影响可能广泛。追踪调用链,检查边界效应
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"permissionMode": "bypass",
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git *)",
|
||||||
|
"Bash(python *)",
|
||||||
|
"Bash(claude *)",
|
||||||
|
"Bash(ls *)",
|
||||||
|
"Bash(mkdir *)",
|
||||||
|
"Bash(rm *)",
|
||||||
|
"Bash(cp *)",
|
||||||
|
"Bash(mv *)",
|
||||||
|
"Bash(cat *)",
|
||||||
|
"Bash(echo *)",
|
||||||
|
"Bash(which *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"autoMode": {
|
||||||
|
"allow": [
|
||||||
|
"$defaults",
|
||||||
|
"Reading and analyzing PR diff files for code review",
|
||||||
|
"Producing structured JSON review output"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
"""CI orchestrator for code-review-agent.
|
||||||
|
|
||||||
|
Fetches a PR diff from Gitea, runs Claude Code to analyze it,
|
||||||
|
and posts the review back to the Gitea PR.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/review_pr.py \
|
||||||
|
--target-repo owner/repo \
|
||||||
|
--pr 42 \
|
||||||
|
--gitea-url https://gitea.example.com \
|
||||||
|
--api-token <token>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MAX_DIFF_CHARS = 200_000 # ~50k words, safe for Claude context
|
||||||
|
|
||||||
|
|
||||||
|
def _find_claude():
|
||||||
|
"""Resolve the claude CLI binary path.
|
||||||
|
|
||||||
|
Checks (in order):
|
||||||
|
1. CLAUDE_BIN env var (explicit override)
|
||||||
|
2. ``claude`` on PATH
|
||||||
|
3. Common install locations (npm global, homebrew, etc.)
|
||||||
|
Returns the path string, or None.
|
||||||
|
"""
|
||||||
|
# 1. Explicit override
|
||||||
|
env_bin = os.environ.get("CLAUDE_BIN", "")
|
||||||
|
if env_bin:
|
||||||
|
if os.path.isfile(env_bin) and os.access(env_bin, os.X_OK):
|
||||||
|
return env_bin
|
||||||
|
print(f"WARNING: CLAUDE_BIN={env_bin} is not executable, falling back.",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
# 2. On PATH
|
||||||
|
path_bin = shutil.which("claude")
|
||||||
|
if path_bin:
|
||||||
|
return path_bin
|
||||||
|
|
||||||
|
# 3. Common install locations
|
||||||
|
candidates = [
|
||||||
|
os.path.expanduser("~/.npm-global/bin/claude"),
|
||||||
|
"/usr/local/bin/claude",
|
||||||
|
"/usr/bin/claude",
|
||||||
|
"/home/linuxbrew/.linuxbrew/bin/claude",
|
||||||
|
]
|
||||||
|
# Also try npm global prefix
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["npm", "config", "get", "prefix"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
npm_prefix = result.stdout.strip()
|
||||||
|
if npm_prefix:
|
||||||
|
candidates.append(os.path.join(npm_prefix, "bin", "claude"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_claude():
|
||||||
|
claude_bin = _find_claude()
|
||||||
|
if not claude_bin:
|
||||||
|
print(
|
||||||
|
"ERROR: claude CLI not found. Install Claude Code or set CLAUDE_BIN env var.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for var in ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"):
|
||||||
|
if not os.environ.get(var):
|
||||||
|
missing.append(var)
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
f"ERROR: Required env vars not set: {', '.join(missing)}. "
|
||||||
|
f"Set them in the CI workflow or shell environment.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return claude_bin
|
||||||
|
|
||||||
|
|
||||||
|
def _api_req(gitea_url, api_token, method, path):
|
||||||
|
"""Send a Gitea API request, return parsed JSON or raw bytes."""
|
||||||
|
url = f"{gitea_url}/api/v1/repos{path}"
|
||||||
|
req = urllib.request.Request(url, method=method)
|
||||||
|
req.add_header("Authorization", f"token {api_token}")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
if not raw:
|
||||||
|
return {} if path.endswith("/merge") else None
|
||||||
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
|
if "application/json" in content_type:
|
||||||
|
return json.loads(raw)
|
||||||
|
return raw # raw bytes for .diff endpoints
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode(errors="replace")
|
||||||
|
print(f"API Error {e.code} on {method} {path}: {body}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_pr_metadata(gitea_url, api_token, target_repo, pr_num):
|
||||||
|
"""Fetch PR details: title, description, head SHA."""
|
||||||
|
pr = _api_req(gitea_url, api_token, "GET", f"/{target_repo}/pulls/{pr_num}")
|
||||||
|
return {
|
||||||
|
"number": pr["number"],
|
||||||
|
"title": pr["title"],
|
||||||
|
"body": pr.get("body", ""),
|
||||||
|
"head_sha": pr.get("head", {}).get("sha", ""),
|
||||||
|
"html_url": pr.get("html_url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_pr_diff(gitea_url, api_token, target_repo, pr_num):
|
||||||
|
"""Fetch raw unified diff for a PR."""
|
||||||
|
raw = _api_req(gitea_url, api_token, "GET", f"/{target_repo}/pulls/{pr_num}.diff")
|
||||||
|
return raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_pr_files(gitea_url, api_token, target_repo, pr_num):
|
||||||
|
"""Fetch changed file list with stats."""
|
||||||
|
files = _api_req(gitea_url, api_token, "GET", f"/{target_repo}/pulls/{pr_num}/files")
|
||||||
|
result = []
|
||||||
|
for f in files:
|
||||||
|
result.append({
|
||||||
|
"filename": f["filename"],
|
||||||
|
"status": f["status"],
|
||||||
|
"additions": f.get("additions", 0),
|
||||||
|
"deletions": f.get("deletions", 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_files_summary(files):
|
||||||
|
"""Build a one-line-per-file summary for the Claude prompt."""
|
||||||
|
lines = []
|
||||||
|
for f in files:
|
||||||
|
lines.append(
|
||||||
|
f" {f['status']:7} {f['filename']} "
|
||||||
|
f"(+{f['additions']} -{f['deletions']})"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_symbols(diff_text):
|
||||||
|
"""Extract changed function/class/method names from a unified diff."""
|
||||||
|
symbols = set()
|
||||||
|
# Match lines added by the PR: "+def name" or "+class name"
|
||||||
|
for m in re.finditer(r'^\+ *(?:async\s+)?def\s+(\w+)', diff_text, re.MULTILINE):
|
||||||
|
symbols.add(m.group(1))
|
||||||
|
for m in re.finditer(r'^\+ *class\s+(\w+)', diff_text, re.MULTILINE):
|
||||||
|
symbols.add(m.group(1))
|
||||||
|
return sorted(symbols)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_impact_report(repo_dir, changed_files, symbols):
|
||||||
|
"""Generate an impact-analysis report for the changed code.
|
||||||
|
|
||||||
|
Uses ``git grep`` in *repo_dir* to find:
|
||||||
|
- files that import the changed modules
|
||||||
|
- call-sites of changed functions / classes
|
||||||
|
- related test files
|
||||||
|
"""
|
||||||
|
changed_modules = set()
|
||||||
|
for f in changed_files:
|
||||||
|
if f.endswith(".py"):
|
||||||
|
# Convert path like "skills/utils/LLM.py" → "skills.utils.LLM"
|
||||||
|
mod = f.replace("/", ".").replace(".py", "")
|
||||||
|
changed_modules.add(mod)
|
||||||
|
|
||||||
|
lines = ["# Global Impact Report\n"]
|
||||||
|
|
||||||
|
# ── Importers ──
|
||||||
|
if changed_modules:
|
||||||
|
lines.append("## Importers (files that import changed modules)\n")
|
||||||
|
for mod in sorted(changed_modules):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "grep", "-l", f"from {mod} import|import {mod}"],
|
||||||
|
cwd=repo_dir, capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
found = [l.strip() for l in result.stdout.splitlines() if l.strip()]
|
||||||
|
# Exclude the changed file itself
|
||||||
|
found = [f for f in found if f not in changed_files]
|
||||||
|
if found:
|
||||||
|
lines.append(f"- `{mod}` — used by: {', '.join(f'`{f}`' for f in found[:10])}")
|
||||||
|
if len(found) > 10:
|
||||||
|
lines.append(f" *(and {len(found) - 10} more)*")
|
||||||
|
else:
|
||||||
|
lines.append(f"- `{mod}` — no external importers found")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ── Callers ──
|
||||||
|
if symbols:
|
||||||
|
lines.append("## Callers (files that reference changed symbols)\n")
|
||||||
|
for sym in sorted(symbols):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "grep", "-l", f"\\b{sym}\\b"],
|
||||||
|
cwd=repo_dir, capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
found = [l.strip() for l in result.stdout.splitlines() if l.strip()]
|
||||||
|
# Exclude the changed file itself
|
||||||
|
found = [f for f in found if f not in changed_files]
|
||||||
|
if found:
|
||||||
|
lines.append(f"- `{sym}()` — referenced in: {', '.join(f'`{f}`' for f in found[:10])}")
|
||||||
|
if len(found) > 10:
|
||||||
|
lines.append(f" *(and {len(found) - 10} more)*")
|
||||||
|
else:
|
||||||
|
lines.append(f"- `{sym}()` — no external callers found")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ── Related tests ──
|
||||||
|
if changed_modules:
|
||||||
|
lines.append("## Related test files\n")
|
||||||
|
for mod in sorted(changed_modules):
|
||||||
|
# Try common test naming patterns
|
||||||
|
parts = mod.split(".")
|
||||||
|
test_patterns = [
|
||||||
|
f"test_{parts[-1]}.py",
|
||||||
|
f"{parts[-1]}_test.py",
|
||||||
|
]
|
||||||
|
for pat in test_patterns:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["find", ".", "-name", pat, "-not", "-path", "*/.git/*"],
|
||||||
|
cwd=repo_dir, capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
found = [l.strip() for l in result.stdout.splitlines() if l.strip()]
|
||||||
|
if found:
|
||||||
|
lines.append(f"- {', '.join(f'`{f}`' for f in found)}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- `{mod}` — no test files found (pattern `{pat}`)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break # Only try first matching pattern
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if len(lines) == 2: # Only title + one blank line
|
||||||
|
lines.append("*(No importers, callers, or related tests found.)*\n")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_frontmatter(md_text):
|
||||||
|
"""Remove YAML frontmatter (``--- ... ---``) from markdown text."""
|
||||||
|
if md_text.startswith("---\n"):
|
||||||
|
end = md_text.find("---\n", 4)
|
||||||
|
if end != -1:
|
||||||
|
return md_text[end + 4:].lstrip("\n")
|
||||||
|
return md_text
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(meta, files, diff_path, agent_md, impact_report_path=None):
|
||||||
|
"""Build the prompt string for Claude, embedding the agent definition."""
|
||||||
|
files_summary = format_files_summary(files)
|
||||||
|
total_additions = sum(f["additions"] for f in files)
|
||||||
|
total_deletions = sum(f["deletions"] for f in files)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"{_strip_frontmatter(agent_md)}\n\n"
|
||||||
|
f"---\n\n"
|
||||||
|
f"PR #{meta['number']} in repository.\n\n"
|
||||||
|
f"PR Title: {meta['title']}\n"
|
||||||
|
f"PR Description:\n{meta.get('body', '(no description)')}\n\n"
|
||||||
|
f"Changed Files ({len(files)} files, +{total_additions} -{total_deletions}):\n"
|
||||||
|
f"{files_summary}\n\n"
|
||||||
|
f"The complete diff has been saved to: {diff_path}\n"
|
||||||
|
f"Read that file to see every line changed.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if impact_report_path:
|
||||||
|
prompt += (
|
||||||
|
f"A global impact report has been saved to: {impact_report_path}\n"
|
||||||
|
f"Read that file to see which files import, call, or test the changed code.\n"
|
||||||
|
f"You have access to the target repository. Use Grep and Glob tools to "
|
||||||
|
f"trace callers, check interface compatibility, and verify that the "
|
||||||
|
f"changes do not break existing dependents.\n\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prompt += (
|
||||||
|
f"Note: no global impact report is available (--repo-dir not provided). "
|
||||||
|
f"Review the diff in isolation.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt += (
|
||||||
|
f"Analyze the diff thoroughly. Focus on: security, correctness, "
|
||||||
|
f"error handling, performance, and code quality — in that order.\n\n"
|
||||||
|
f"Output your review as a single JSON object inside a ```json code block. "
|
||||||
|
f"Do NOT output anything else."
|
||||||
|
)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def run_claude(claude_bin, project_root, prompt, extra_dir=None):
|
||||||
|
"""Run claude -p, return stdout."""
|
||||||
|
cmd = [
|
||||||
|
claude_bin, "-p",
|
||||||
|
"--permission-mode", "acceptEdits",
|
||||||
|
"--verbose",
|
||||||
|
]
|
||||||
|
if extra_dir:
|
||||||
|
cmd += ["--add-dir", extra_dir]
|
||||||
|
cmd.append(prompt)
|
||||||
|
|
||||||
|
print(f"[review_pr] Running: {' '.join(cmd[:4])} ...", file=sys.stderr)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=project_root,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
stdout_lines = []
|
||||||
|
for line in proc.stdout:
|
||||||
|
line = line.rstrip("\n")
|
||||||
|
stdout_lines.append(line)
|
||||||
|
if line:
|
||||||
|
print(f"[claude] {line}", file=sys.stderr)
|
||||||
|
|
||||||
|
proc.wait(timeout=600)
|
||||||
|
# Drain remaining stderr
|
||||||
|
stderr_output = proc.stderr.read()
|
||||||
|
if stderr_output:
|
||||||
|
print(stderr_output, file=sys.stderr)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f"Claude exited with code {proc.returncode}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return "\n".join(stdout_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json_from_output(stdout):
|
||||||
|
"""Extract the review JSON from Claude's output."""
|
||||||
|
match = re.search(r"```json\s*([\s\S]*)\s*```", stdout)
|
||||||
|
if not match:
|
||||||
|
print("ERROR: No ```json block found in Claude output.", file=sys.stderr)
|
||||||
|
print(stdout[:3000], file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
obj = json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"ERROR: Invalid JSON in Claude output: {e}", file=sys.stderr)
|
||||||
|
print(match.group(1)[:2000], file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Unwrap {review: {...}} envelope if present
|
||||||
|
if isinstance(obj, dict) and "review" in obj and isinstance(obj["review"], dict):
|
||||||
|
obj = obj["review"]
|
||||||
|
|
||||||
|
if not isinstance(obj, dict) or "body" not in obj:
|
||||||
|
print(f"ERROR: JSON missing required 'body' field. "
|
||||||
|
f"Keys: {list(obj.keys()) if isinstance(obj, dict) else type(obj).__name__}",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def validate_review(review):
|
||||||
|
"""Validate the review JSON structure."""
|
||||||
|
if not isinstance(review, dict):
|
||||||
|
print(f"ERROR: review is not a JSON object, got {type(review).__name__}", file=sys.stderr)
|
||||||
|
print(f"Raw: {json.dumps(review, ensure_ascii=False)[:2000]}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
event = review.get("event", "")
|
||||||
|
if event not in ("APPROVED", "REQUEST_CHANGES", "COMMENT", ""):
|
||||||
|
print(f"ERROR: invalid event '{event}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
body = review.get("body", "")
|
||||||
|
if not body or not isinstance(body, str):
|
||||||
|
print(f"ERROR: review.body is required and must be a string.", file=sys.stderr)
|
||||||
|
print(f"Got type: {type(body).__name__}, value: {json.dumps(body, ensure_ascii=False)[:500]}", file=sys.stderr)
|
||||||
|
print(f"Full review keys: {list(review.keys())}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
comments = review.get("comments", [])
|
||||||
|
if not isinstance(comments, list):
|
||||||
|
print("ERROR: review.comments must be an array", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
for i, c in enumerate(comments):
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
print(f"ERROR: comment[{i}] is not an object", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if "path" not in c or "body" not in c:
|
||||||
|
print(f"ERROR: comment[{i}] missing 'path' or 'body'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def post_review(gitea_url, api_token, target_repo, pr_num, review):
|
||||||
|
"""Post a PR review to Gitea."""
|
||||||
|
payload = {
|
||||||
|
"body": review["body"],
|
||||||
|
"event": review.get("event", "COMMENT"),
|
||||||
|
}
|
||||||
|
comments = review.get("comments", [])
|
||||||
|
if comments:
|
||||||
|
# Translate {line} -> {new_line} for Gitea API
|
||||||
|
gitea_comments = []
|
||||||
|
for c in comments:
|
||||||
|
gc = {
|
||||||
|
"path": c["path"],
|
||||||
|
"body": c["body"],
|
||||||
|
}
|
||||||
|
if c.get("line"):
|
||||||
|
gc["new_line"] = c["line"]
|
||||||
|
if c.get("old_line"):
|
||||||
|
gc["old_line"] = c["old_line"]
|
||||||
|
gitea_comments.append(gc)
|
||||||
|
payload["comments"] = gitea_comments
|
||||||
|
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
url = f"{gitea_url}/api/v1/repos/{target_repo}/pulls/{pr_num}/reviews"
|
||||||
|
req = urllib.request.Request(url, data=body, method="POST")
|
||||||
|
req.add_header("Authorization", f"token {api_token}")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
print(f"Review posted: {result.get('html_url', result.get('url', 'unknown'))}")
|
||||||
|
return result
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err_body = e.read().decode(errors="replace")
|
||||||
|
print(f"Failed to post review: {e.code} - {err_body}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Code Review Agent — fetch PR, analyze with Claude, post review"
|
||||||
|
)
|
||||||
|
parser.add_argument("--target-repo", required=True,
|
||||||
|
help="Target repository path (e.g. owner/repo)")
|
||||||
|
parser.add_argument("--pr", type=int, required=True,
|
||||||
|
help="PR number to review")
|
||||||
|
parser.add_argument("--gitea-url", required=True,
|
||||||
|
help="Gitea instance URL")
|
||||||
|
parser.add_argument("--api-token", required=True,
|
||||||
|
help="Gitea API token with read:repository + write:repository")
|
||||||
|
parser.add_argument("--repo-dir", default=None,
|
||||||
|
help="Path to local clone of the target repo (enables global impact analysis)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
claude_bin = _check_claude()
|
||||||
|
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
agent_path = os.path.join(project_root, ".claude", "agents", "code-review-agent.md")
|
||||||
|
|
||||||
|
if not os.path.exists(agent_path):
|
||||||
|
print(f"ERROR: agent definition not found at {agent_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(agent_path, "r", encoding="utf-8") as f:
|
||||||
|
agent_md = f.read()
|
||||||
|
print(f"[review_pr] Loaded agent definition ({len(agent_md)} chars)")
|
||||||
|
|
||||||
|
# ── 1. Fetch PR metadata ─────────────────────────────────────────────
|
||||||
|
print(f"[review_pr] Fetching PR #{args.pr} metadata from {args.target_repo}...")
|
||||||
|
meta = fetch_pr_metadata(args.gitea_url, args.api_token, args.target_repo, args.pr)
|
||||||
|
print(f" Title: {meta['title']}")
|
||||||
|
|
||||||
|
# ── 2. Fetch changed files ───────────────────────────────────────────
|
||||||
|
print("[review_pr] Fetching changed files...")
|
||||||
|
files = fetch_pr_files(args.gitea_url, args.api_token, args.target_repo, args.pr)
|
||||||
|
print(f" {len(files)} file(s) changed")
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
print("No files changed — skipping review.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── 3. Fetch diff ────────────────────────────────────────────────────
|
||||||
|
print("[review_pr] Fetching diff...")
|
||||||
|
diff_text = fetch_pr_diff(args.gitea_url, args.api_token, args.target_repo, args.pr)
|
||||||
|
|
||||||
|
if not diff_text.strip():
|
||||||
|
print("Empty diff — skipping review.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(diff_text) > MAX_DIFF_CHARS:
|
||||||
|
print(
|
||||||
|
f"Warning: diff is {len(diff_text)} chars "
|
||||||
|
f"(>{MAX_DIFF_CHARS}). Review may be incomplete.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
diff_text = diff_text[:MAX_DIFF_CHARS]
|
||||||
|
|
||||||
|
print(f" Diff: {len(diff_text)} chars, ~{diff_text.count(chr(10))} lines")
|
||||||
|
|
||||||
|
# ── 4. Write diff inside project_root so claude CLI can read it ─────
|
||||||
|
diff_dir = os.path.join(project_root, ".reviews")
|
||||||
|
os.makedirs(diff_dir, exist_ok=True)
|
||||||
|
diff_path = os.path.join(diff_dir, f"pr{args.pr}.diff")
|
||||||
|
with open(diff_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(diff_text)
|
||||||
|
print(f" Diff saved to: {diff_path}")
|
||||||
|
|
||||||
|
# ── 4b. Build impact report (if repo-dir provided) ──────────────────
|
||||||
|
impact_report_path = None
|
||||||
|
repo_dir = args.repo_dir
|
||||||
|
if repo_dir:
|
||||||
|
if not os.path.isdir(repo_dir):
|
||||||
|
print(f"ERROR: --repo-dir is not a directory: {repo_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
changed_file_names = [f["filename"] for f in files]
|
||||||
|
symbols = _extract_symbols(diff_text)
|
||||||
|
print(f"[review_pr] Extracted {len(symbols)} changed symbol(s): {', '.join(symbols) if symbols else '(none)'}")
|
||||||
|
report = _build_impact_report(repo_dir, changed_file_names, symbols)
|
||||||
|
impact_report_path = os.path.join(diff_dir, f"pr{args.pr}_impact.md")
|
||||||
|
with open(impact_report_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(report)
|
||||||
|
print(f" Impact report saved to: {impact_report_path}")
|
||||||
|
else:
|
||||||
|
print("[review_pr] No --repo-dir provided — skipping global impact analysis")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── 5. Build prompt & run Claude ─────────────────────────────────
|
||||||
|
prompt = build_prompt(meta, files, diff_path, agent_md, impact_report_path)
|
||||||
|
print("[review_pr] Invoking Claude for analysis...")
|
||||||
|
stdout = run_claude(claude_bin, project_root, prompt, extra_dir=repo_dir)
|
||||||
|
|
||||||
|
# ── 6. Parse Claude output ───────────────────────────────────────
|
||||||
|
review = extract_json_from_output(stdout)
|
||||||
|
validate_review(review)
|
||||||
|
print(f" Review event: {review.get('event', 'COMMENT')}")
|
||||||
|
print(f" Body length: {len(review['body'])} chars")
|
||||||
|
print(f" Inline comments: {len(review.get('comments', []))}")
|
||||||
|
|
||||||
|
# ── 7. Post review to Gitea ──────────────────────────────────────
|
||||||
|
print("[review_pr] Posting review to Gitea...")
|
||||||
|
post_review(args.gitea_url, args.api_token, args.target_repo, args.pr, review)
|
||||||
|
|
||||||
|
print("[review_pr] Done.")
|
||||||
|
finally:
|
||||||
|
# Clean up temp files
|
||||||
|
if os.path.exists(diff_path):
|
||||||
|
os.unlink(diff_path)
|
||||||
|
if impact_report_path and os.path.exists(impact_report_path):
|
||||||
|
os.unlink(impact_report_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user