diff --git a/.github/code_review/prompts/findings.md b/.github/code_review/prompts/findings.md new file mode 100644 index 00000000..15aa0c90 --- /dev/null +++ b/.github/code_review/prompts/findings.md @@ -0,0 +1,64 @@ +你现在的任务不是重新做代码审查,而是将已经生成的 `review.md` 提取为结构化结果,供后续脚本用于行内评论、阻断判断和其他自动化处理。 + +输入文件: +1. 仓库根目录下的 `review.md`:这是主依据,包含第一阶段已经确认的问题。 +2. 仓库根目录下的 `pr.diff`:仅用于辅助定位文件路径和行号,必要时可用于补全位置;不要基于 `pr.diff` 新增问题。 + +任务要求: +1. 只提取 `review.md` 中已经明确列出的问题,不要新增、扩展或重写任何新问题。 +2. 不要重新判断代码是否有问题;第一阶段 review 的结论已经成立,你的任务只是标准化提取。 +3. 如果 `review.md` 中某个问题缺少明确的文件路径或行号,不要编造;但不要跳过该问题,仍应保留该 finding,并将 `inline_candidate` 设为 `false`。在这种情况下,`path`、`start_line`、`end_line` 可留空字符串、0 或与定位失败一致的保守值,但不能让该问题从结果中消失。 +4. 允许参考 `pr.diff` 来确认问题对应的目标文件路径、起始行和结束行,但不要因此新增新的发现。 +5. 输出必须是严格合法的 JSON,且只能输出 JSON,不要输出 Markdown、解释文字、代码块、前后缀或额外说明。 +6. 输出必须直接以 `{` 开始、以 `}` 结束;不要使用 ```json、``` 或任何其他 Markdown 代码块围栏包裹 JSON。 + +严重级别映射: +- `Critical` -> `critical` +- `Warning` -> `warning` +- `Suggestion` -> `suggestion` + +输出 JSON 格式: +```json +{ + "findings": [ + { + "severity": "critical", + "path": "relative/path/to/file.py", + "start_line": 12, + "end_line": 12, + "title": "问题标题", + "body": "1-3 句简洁说明,适合直接用于 GitHub 行内评论。", + "inline_candidate": true + } + ] +} +``` + +字段要求: +1. `severity`:必须是 `critical`、`warning`、`suggestion` 之一。 +2. `path`:仓库相对路径,例如 `tests/ai_test.py`;如果确实无法稳定定位,可为空字符串,但该 finding 仍需保留。 +3. `start_line`:问题起始行号,必须优先使用变更后文件的目标行号;如果确实无法稳定定位,可为 `0`,但该 finding 仍需保留。 +4. `end_line`: + - 单行问题时,填写与 `start_line` 相同的值; + - 范围问题时,填写结束行号。 +5. `title`:简短问题标题,适合作为行内评论标题,不要过长。 +6. `body`:1-3 句简洁说明,说明风险和建议修复方向,适合直接显示在代码旁边。 +7. `inline_candidate`: + - `true`:适合做行内评论,问题能明确归因到一个文件的一行或一小段范围,且适合直接挂在代码旁解释。 + - `false`:更适合放在总评论中,例如跨文件、跨逻辑链路、整体性结论、位置不稳定或无法明确锚定到 diff 行的问题。 + +提取规则: +1. 如果 `review.md` 中同一个问题覆盖多个独立位置,只有在这些位置都适合单独挂行内评论时,才拆成多条 findings;否则保持为一条并将 `inline_candidate` 设为 `false`。 +2. 如果一个问题已经在第一阶段被合并表述为一个综合问题(例如“命令注入 + 路径穿越”),不要在这里再次拆分成多个新问题,除非 `review.md` 本身已经明确拆开。 +3. `body` 不要照抄整段 review 原文;应提炼成适合行内评论展示的短说明。 +4. 不允许因为定位失败、信息缺失或不适合行内评论而省略第一阶段已经明确列出的问题;这类问题必须保留在 `findings` 中,并通过 `inline_candidate=false` 与保守字段值表达。 +5. 如果 `review.md` 中没有任何问题,输出: +```json +{"findings":[]} +``` + +校验要求: +1. 输出必须能被标准 JSON 解析器直接解析。 +2. 所有字符串必须使用双引号。 +3. 不要在 JSON 外再输出任何额外字符。 +4. 不要输出 ```json 或 ```,也不要输出任何注释。 diff --git a/.github/ai-review-prompt.md b/.github/code_review/prompts/review.md similarity index 99% rename from .github/ai-review-prompt.md rename to .github/code_review/prompts/review.md index d05928fd..67eb19f4 100644 --- a/.github/ai-review-prompt.md +++ b/.github/code_review/prompts/review.md @@ -75,4 +75,4 @@ ## 测试建议 -如果需要补充测试,用 1-2 条说明建议覆盖的场景;如果不需要,请说明“暂无额外测试建议”。 \ No newline at end of file +如果需要补充测试,用 1-2 条说明建议覆盖的场景;如果不需要,请说明“暂无额外测试建议”。 diff --git a/.github/code_review/scripts/evaluate_gate.py b/.github/code_review/scripts/evaluate_gate.py new file mode 100644 index 00000000..c89ea29e --- /dev/null +++ b/.github/code_review/scripts/evaluate_gate.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +import json +import os +import sys + + +def main(): + findings_path = os.environ.get("FINDINGS_PATH", "findings.json") + with open(findings_path, "r", encoding="utf-8") as f: + data = json.load(f) + + findings = data.get("findings", []) + for finding in findings: + if finding.get("severity") == "critical": + print("Critical issues found in AI Code Review, blocking pipeline") + return 1 + + print("No critical findings, pipeline can continue") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/code_review/scripts/post_inline_comments.py b/.github/code_review/scripts/post_inline_comments.py new file mode 100644 index 00000000..5a6d0324 --- /dev/null +++ b/.github/code_review/scripts/post_inline_comments.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import sys +import urllib.error +import urllib.request + + +def read_findings(path): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("findings", []) + + +def build_position_map(diff_path): + with open(diff_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + position_map = {} + current_path = None + new_line_no = None + position = 0 + in_hunk = False + + for raw in lines: + line = raw.rstrip("\n") + + if line.startswith("diff --git "): + current_path = None + new_line_no = None + position = 0 + in_hunk = False + continue + + if line.startswith("+++ b/"): + current_path = line[6:] + if current_path not in position_map: + position_map[current_path] = {} + continue + + if line.startswith("@@ "): + match = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line) + if match: + new_line_no = int(match.group(1)) + in_hunk = True + continue + + if not in_hunk or current_path is None: + continue + + if not line: + continue + + prefix = line[0] + if prefix not in (" ", "+", "-"): + continue + + position += 1 + + if prefix == " ": + position_map[current_path][new_line_no] = position + new_line_no += 1 + elif prefix == "+": + position_map[current_path][new_line_no] = position + new_line_no += 1 + elif prefix == "-": + continue + + return position_map + + +def build_payload(commit_id, path, position, title, body): + comment_body = "**%s**" % title + if body: + comment_body += "\n\n%s" % body + return { + "body": comment_body, + "commit_id": commit_id, + "path": path, + "position": position, + } + + +def post_inline_comment(api_url, token, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + api_url, + data=data, + method="POST", + headers={ + "Authorization": "Bearer %s" % token, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + return True, "status=%s" % resp.status + except urllib.error.HTTPError as e: + try: + err = e.read().decode("utf-8", errors="ignore") + except Exception: + err = "" + return False, "status=%s body=%s" % (e.code, err) + + +def main(): + findings_path = os.environ.get("FINDINGS_PATH", "findings.json") + diff_path = os.environ.get("PR_DIFF_PATH", "pr.diff") + api_url = os.environ["INLINE_COMMENT_API_URL"] + token = os.environ["GITHUB_TOKEN"] + commit_id = os.environ["GITHUB_HEAD_SHA"] + + findings = read_findings(findings_path) + if not findings: + print("no findings found, skip inline comments") + return 0 + + position_map = build_position_map(diff_path) + created = 0 + + for finding in findings: + if finding.get("severity") != "critical": + continue + if not finding.get("inline_candidate"): + continue + + path = finding.get("path") + start_line = finding.get("start_line") + end_line = finding.get("end_line") + title = finding.get("title", "").strip() + body = finding.get("body", "").strip() + + if not path or not start_line or not title: + print("skip finding without required fields: %s" % json.dumps(finding, ensure_ascii=False)) + continue + + start_line = int(start_line) + end_line = int(end_line) if end_line else None + target_line = start_line + + file_positions = position_map.get(path, {}) + position = file_positions.get(target_line) + if position is None and end_line and end_line > start_line: + position = file_positions.get(end_line) + if position is not None: + target_line = end_line + + target = "%s:%s-%s" % (path, start_line, end_line) if end_line and end_line > start_line else "%s:%s" % (path, start_line) + + if position is None: + print("skip finding without diff position: %s" % target) + continue + + payload = build_payload(commit_id, path, position, title, body) + ok, msg = post_inline_comment(api_url, token, payload) + if ok: + created += 1 + print("inline comment created: %s position=%s %s" % (target, position, msg)) + else: + print("inline comment failed: %s position=%s %s" % (target, position, msg)) + + print("inline comments created: %s" % created) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/code_review/scripts/post_review_comment.py b/.github/code_review/scripts/post_review_comment.py new file mode 100644 index 00000000..89bcfa03 --- /dev/null +++ b/.github/code_review/scripts/post_review_comment.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import sys +import urllib.error +import urllib.request + + +def load_review(path): + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + + +def add_github_links(body, repo_url, head_sha): + pattern = re.compile(r"`((?:\.?[\w.-]+/)*[\w.-]+\.[\w.+-]+):(\d+)(?:-(\d+))?`") + + def repl(match): + file_path = match.group(1) + start = match.group(2) + end = match.group(3) + label = "%s:%s%s" % (file_path, start, ("-%s" % end) if end else "") + anchor = "#L%s%s" % (start, ("-L%s" % end) if end else "") + url = "%s/blob/%s/%s%s" % (repo_url, head_sha, file_path, anchor) + return "[`%s`](%s)" % (label, url) + + return pattern.sub(repl, body) + + +def post_comment(api_url, token, body): + payload = json.dumps({"body": body}).encode("utf-8") + req = urllib.request.Request( + api_url, + data=payload, + method="POST", + headers={ + "Authorization": "Bearer %s" % token, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + return resp.status, resp.read().decode("utf-8", errors="ignore") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", errors="ignore") + + +def main(): + review_path = os.environ.get("REVIEW_PATH", "review.md") + api_url = os.environ["COMMENT_API_URL"] + repo_url = os.environ["GITHUB_WEB_REPO_URL"].rstrip("/") + head_sha = os.environ["GITHUB_HEAD_SHA"] + token = os.environ["GITHUB_TOKEN"] + + body = load_review(review_path) + if not body: + body = "AI Code Review 未生成有效结果,请检查流水线日志。" + + body = add_github_links(body, repo_url, head_sha) + status, response = post_comment(api_url, token, "## AI Code Review\n\n%s" % body) + + print("comment http code: %s" % status) + if status < 200 or status >= 300: + print("failed to create github comment") + print(response) + return 1 + + print("comment created successfully") + return 0 + + +if __name__ == "__main__": + sys.exit(main())