Add issue review

This commit is contained in:
Pinghua
2026-06-11 19:41:48 +08:00
parent a81266e12e
commit c6d72b425c
3 changed files with 225 additions and 109 deletions
+35 -103
View File
@@ -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__":