diff --git a/.claude/agents/code-review-agent.md b/.claude/agents/code-review-agent.md index 461511d..a7222e4 100644 --- a/.claude/agents/code-review-agent.md +++ b/.claude/agents/code-review-agent.md @@ -10,12 +10,14 @@ description: "Code Review Agent: 通过 Gitea API 获取 PR diff,分析代码 ## 工作方式 1. 读取提供的 PR diff 文件(路径在 prompt 中) -2. **(如提供)读取全局影响报告,了解变更的影响范围** -3. 逐文件分析代码变更 -4. **使用搜索工具(Grep)追踪变更函数/类的调用方,验证接口兼容性** -5. **检查相关测试文件是否覆盖了变更逻辑** -6. 识别问题,按严重程度分类 -7. 输出结构化 JSON 供 CI 脚本解析并发布到 Gitea +2. **(如提供)读取关联 Issue 文件,了解原始需求/缺陷描述** +3. **(如提供)读取全局影响报告,了解变更的影响范围** +4. 逐文件分析代码变更 +5. **使用搜索工具(Grep)追踪变更函数/类的调用方,验证接口兼容性** +6. **检查相关测试文件是否覆盖了变更逻辑** +7. **对比 Issue 验收条件,验证改动是否完整满足需求** +8. 识别问题,按严重程度分类 +9. 输出结构化 JSON 供 CI 脚本解析并发布到 Gitea ## 审查标准 @@ -26,6 +28,8 @@ description: "Code Review Agent: 通过 Gitea API 获取 PR diff,分析代码 - 功能缺陷:明显与 PR 描述不符的实现 - **接口兼容性破坏:函数签名变更导致已有调用方编译/运行失败** - **行为语义变更:返回值、异常、副作用的行为变化影响依赖方** +- **业务不符:改动未满足关联 Issue 的需求描述** +- **遗漏场景:Issue 中描述的边界情况或验收条件未被代码覆盖** ### 中等(建议修复) - 错误处理不完整、异常被吞没 diff --git a/scripts/gitea_api.py b/scripts/gitea_api.py new file mode 100644 index 0000000..be56423 --- /dev/null +++ b/scripts/gitea_api.py @@ -0,0 +1,180 @@ +"""Gitea API helpers for the code-review-agent. + +Fetches PR data (metadata, diff, files), linked issues, and posts reviews. +""" + +import json +import re +import sys +import urllib.error +import urllib.request + + +# ── Low-level helper ──────────────────────────────────────────────────── + +def _api_req(gitea_url, api_token, method, path, body=None): + """Send a Gitea API request, return parsed JSON or raw bytes.""" + url = f"{gitea_url}/api/v1/repos{path}" + data = json.dumps(body).encode("utf-8") if body else None + req = urllib.request.Request(url, method=method, data=data) + req.add_header("Authorization", f"token {api_token}") + if data: + 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: + err_body = e.read().decode(errors="replace") + print(f"API Error {e.code} on {method} {path}: {err_body}", file=sys.stderr) + sys.exit(1) + + +# ── PR endpoints ──────────────────────────────────────────────────────── + +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 post_review(gitea_url, api_token, target_repo, pr_num, review): + """Post a PR review to Gitea.""" + comments = review.get("comments", []) + 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 = { + "body": review["body"], + "event": review.get("event", "COMMENT"), + } + if gitea_comments: + payload["comments"] = gitea_comments + + _api_req(gitea_url, api_token, "POST", + f"/{target_repo}/pulls/{pr_num}/reviews", body=payload) + print(f"Review posted to PR #{pr_num}") + + +# ── Issue endpoints ───────────────────────────────────────────────────── + +_ISSUE_REF_RE = re.compile( + r"(?:fixe?s?|close?s?|resolve?s?|refs?|see)\s+" + r"((?:[\w.-]+/[\w.-]+)?#\d+)", + re.IGNORECASE, +) +_BARE_ISSUE_RE = re.compile(r"(?:^|\s)(#\d+)") + + +def _extract_issue_refs(pr_body): + """Extract issue references from PR description. + + Returns list of ``(owner, repo, issue_num)`` tuples. + ``owner/repo`` is ``None`` when using shorthand ``#123``. + """ + refs = [] + for m in _ISSUE_REF_RE.finditer(pr_body): + ref = m.group(1) + match = re.match(r"(?:([\w.-]+)/([\w.-]+))?#(\d+)", ref) + if match: + refs.append((match.group(1), match.group(2), int(match.group(3)))) + + # Add bare #123 refs not already captured + for m in _BARE_ISSUE_RE.finditer(pr_body): + num = int(m.group(1)[1:]) + if not any(r[2] == num for r in refs): + refs.append((None, None, num)) + + return refs + + +def fetch_issue(gitea_url, api_token, target_repo, issue_num): + """Fetch a single issue from Gitea. + + Returns dict with keys: number, title, body, state, labels, html_url. + """ + data = _api_req(gitea_url, api_token, "GET", + f"/{target_repo}/issues/{issue_num}") + return { + "number": data["number"], + "title": data["title"], + "body": data.get("body", ""), + "state": data.get("state", "open"), + "labels": [lb["name"] for lb in data.get("labels", [])], + "html_url": data.get("html_url", ""), + } + + +def fetch_linked_issues(gitea_url, api_token, target_repo, pr_body): + """Extract issue refs from PR body and fetch each issue. + + Returns list of issue dicts (see ``fetch_issue``). + """ + refs = _extract_issue_refs(pr_body) + issues = [] + for owner, repo, num in refs: + if owner and repo: + repo_path = f"{owner}/{repo}" + else: + repo_path = target_repo + try: + issues.append(fetch_issue(gitea_url, api_token, repo_path, num)) + except SystemExit: + # issue fetch failed (404, etc.) — skip and continue + print(f"[gitea] Warning: could not fetch issue #{num}, skipping", + file=sys.stderr) + return issues + + +def format_issues_markdown(issues): + """Format a list of issue dicts as a Markdown string.""" + if not issues: + return "" + parts = ["# Linked Issues\n"] + for iss in issues: + labels = ", ".join(iss["labels"]) if iss["labels"] else "none" + parts.append( + f"## #{iss['number']}: {iss['title']}\n" + f"- **State**: {iss['state']}\n" + f"- **Labels**: {labels}\n" + f"\n{iss['body']}\n" + ) + return "\n".join(parts) diff --git a/scripts/review_pr.py b/scripts/review_pr.py index 7b1f7e6..98e9290 100644 --- a/scripts/review_pr.py +++ b/scripts/review_pr.py @@ -21,9 +21,8 @@ import subprocess import sys import threading import time -import urllib.error -import urllib.request +import gitea_api from monitor_session import SessionMonitor MAX_DIFF_CHARS = 200_000 # ~50k words, safe for Claude context @@ -101,60 +100,6 @@ def _check_claude(): 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 = [] @@ -279,7 +224,8 @@ def _strip_frontmatter(md_text): return md_text -def build_prompt(meta, files, diff_path, agent_md, impact_report_path=None): +def build_prompt(meta, files, diff_path, agent_md, impact_report_path=None, + issue_context_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) @@ -297,6 +243,13 @@ def build_prompt(meta, files, diff_path, agent_md, impact_report_path=None): f"Read that file to see every line changed.\n\n" ) + if issue_context_path: + prompt += ( + f"Linked issue(s) saved to: {issue_context_path}\n" + f"Read that file to see the original requirements / bug report.\n" + f"Verify that the code changes address the issue requirements completely.\n\n" + ) + if impact_report_path: prompt += ( f"A global impact report has been saved to: {impact_report_path}\n" @@ -312,8 +265,9 @@ def build_prompt(meta, files, diff_path, agent_md, impact_report_path=None): ) prompt += ( - f"Analyze the diff thoroughly. Focus on: security, correctness, " - f"error handling, performance, and code quality — in that order.\n\n" + f"Analyze the diff thoroughly. Focus on: business correctness (vs linked " + f"issues), security, correctness, error handling, performance, " + f"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." ) @@ -447,45 +401,6 @@ def validate_review(review): 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" @@ -517,12 +432,17 @@ def main(): # ── 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) + meta = gitea_api.fetch_pr_metadata(args.gitea_url, args.api_token, args.target_repo, args.pr) print(f" Title: {meta['title']}") + # ── 1b. Fetch linked issues ─────────────────────────────────────────── + issues = gitea_api.fetch_linked_issues( + args.gitea_url, args.api_token, args.target_repo, meta.get("body", "")) + print(f" {len(issues)} linked issue(s) found") + # ── 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) + files = gitea_api.fetch_pr_files(args.gitea_url, args.api_token, args.target_repo, args.pr) print(f" {len(files)} file(s) changed") if not files: @@ -531,7 +451,7 @@ def main(): # ── 3. Fetch diff ──────────────────────────────────────────────────── print("[review_pr] Fetching diff...") - diff_text = fetch_pr_diff(args.gitea_url, args.api_token, args.target_repo, args.pr) + diff_text = gitea_api.fetch_pr_diff(args.gitea_url, args.api_token, args.target_repo, args.pr) if not diff_text.strip(): print("Empty diff — skipping review.") @@ -575,11 +495,21 @@ def main(): else: print("[review_pr] No --repo-dir provided — skipping global impact analysis") + # ── 4c. Write linked issues to file ────────────────────────────────── + issue_context_path = None + if issues: + issue_md = gitea_api.format_issues_markdown(issues) + issue_context_path = os.path.join(diff_dir, f"{prefix}_issue.md") + with open(issue_context_path, "w", encoding="utf-8") as f: + f.write(issue_md) + print(f" Issues saved to: {issue_context_path}") + mon = None prompt_file = os.path.join(diff_dir, f"{prefix}_prompt.txt") try: # ── 5. Build prompt & run Claude ───────────────────────────────── - prompt = build_prompt(meta, files, diff_path, agent_md, impact_report_path) + prompt = build_prompt(meta, files, diff_path, agent_md, + impact_report_path, issue_context_path) print("[review_pr] Invoking Claude for analysis...") mon = SessionMonitor(project_root) @@ -596,7 +526,7 @@ def main(): # ── 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) + gitea_api.post_review(args.gitea_url, args.api_token, args.target_repo, args.pr, review) print("[review_pr] Done.") finally: @@ -609,6 +539,8 @@ def main(): os.unlink(prompt_file) if impact_report_path and os.path.exists(impact_report_path): os.unlink(impact_report_path) + if issue_context_path and os.path.exists(issue_context_path): + os.unlink(issue_context_path) if __name__ == "__main__":