From 143d11fe078fd1276e82519302cc71f62859f624 Mon Sep 17 00:00:00 2001 From: auto-approve-bot Date: Thu, 16 Jul 2026 23:13:53 +0800 Subject: [PATCH 1/9] feat(ci): add AI code review script --- scripts/ci_code_review.py | 566 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 scripts/ci_code_review.py diff --git a/scripts/ci_code_review.py b/scripts/ci_code_review.py new file mode 100644 index 000000000..518510d57 --- /dev/null +++ b/scripts/ci_code_review.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +""" +CI Code Review Script +- 从 Gitea 获取 PR diff +- 调用 LLM 进行代码审查 +- 将审查结果写回 PR 评论 +""" + +import os +import sys +import json +import logging +import argparse +from typing import Optional, Tuple + +import requests + + +# ============== 日志配置 ============== +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("ci_code_review") + + +# ============== 常量配置 ============== +# diff 最大字符数(超过则截断) +MAX_DIFF_CHARS = int(os.getenv("MAX_DIFF_CHARS", "30000")) +# LLM 调用超时时间(秒) +LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120")) +# Gitea API 超时时间(秒) +GITEA_TIMEOUT = int(os.getenv("GITEA_TIMEOUT", "30")) +# 最大重试次数 +MAX_RETRIES = int(os.getenv("MAX_RETRIES", "2")) +# LLM 提供商: openai (OpenAI兼容) / coze (扣子原生Bot API) +LLM_PROVIDER = os.getenv("LLM_PROVIDER", "coze").lower() + + +# ============== 工具函数 ============== +def truncate_diff(diff_text: str, max_chars: int) -> Tuple[str, bool]: + """ + 截断过大的 diff 内容,避免超出 LLM 上下文限制。 + 优先保留文件头和前面的变更,末尾加提示。 + """ + if len(diff_text) <= max_chars: + return diff_text, False + + # 找到一个合适的截断位置(尽量在文件边界) + truncated = diff_text[:max_chars] + # 尝试在最后一个 "diff --git" 处截断,避免截断到一半 + last_file_boundary = truncated.rfind("\ndiff --git ") + if last_file_boundary > max_chars // 2: + truncated = truncated[:last_file_boundary] + + truncated += ( + f"\n\n... [DIFF TRUNCATED] 原始 diff 共 {len(diff_text)} 字符," + f"已截断至 {len(truncated)} 字符,仅审查前半部分。\n" + ) + return truncated, True + + +def get_env_or_fail(name: str) -> str: + """从环境变量获取值,不存在则报错退出。""" + value = os.getenv(name) + if not value: + logger.error(f"环境变量 {name} 未设置") + sys.exit(1) + return value + + +# ============== Gitea API 相关 ============== +class GiteaClient: + """Gitea API 客户端""" + + def __init__(self, base_url: str, token: str, repo: str): + # 确保 base_url 以 / 结尾 + self.base_url = base_url.rstrip("/") + "/" + self.token = token + self.repo = repo # 格式: owner/repo + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"token {token}", + "Accept": "application/json", + "Content-Type": "application/json", + }) + + def _api_url(self, path: str) -> str: + """拼接 API 路径""" + return f"{self.base_url}api/v1/repos/{self.repo}/{path.lstrip('/')}" + + def get_pr_diff(self, pr_number: int) -> str: + """ + 获取 PR 的 diff 内容。 + Gitea API: GET /repos/{owner}/{repo}/pulls/{index}.diff + """ + url = self._api_url(f"pulls/{pr_number}.diff") + logger.info(f"获取 PR #{pr_number} diff: {url}") + + resp = self.session.get(url, timeout=GITEA_TIMEOUT, headers={ + "Accept": "text/plain", + }) + if resp.status_code != 200: + logger.error(f"获取 diff 失败: HTTP {resp.status_code} - {resp.text[:200]}") + raise RuntimeError(f"Failed to get PR diff: HTTP {resp.status_code}") + + diff_text = resp.text + logger.info(f"获取到 diff,共 {len(diff_text)} 字符") + return diff_text + + def get_pr_files(self, pr_number: int) -> list: + """ + 获取 PR 修改的文件列表。 + Gitea API: GET /repos/{owner}/{repo}/pulls/{index}/files + """ + url = self._api_url(f"pulls/{pr_number}/files") + logger.info(f"获取 PR #{pr_number} 文件列表") + + resp = self.session.get(url, timeout=GITEA_TIMEOUT) + if resp.status_code != 200: + logger.warning(f"获取文件列表失败: HTTP {resp.status_code}") + return [] + + files = resp.json() + logger.info(f"PR 修改了 {len(files)} 个文件") + return files + + def post_pr_comment(self, pr_number: int, body: str) -> bool: + """ + 在 PR 上发布评论。 + Gitea API: POST /repos/{owner}/{repo}/issues/{index}/comments + (Gitea 中 PR 评论走 issues 接口) + """ + url = self._api_url(f"issues/{pr_number}/comments") + logger.info(f"发布 PR 评论: {url}") + + payload = {"body": body} + resp = self.session.post( + url, + data=json.dumps(payload), + timeout=GITEA_TIMEOUT, + ) + if resp.status_code not in (200, 201): + logger.error(f"发布评论失败: HTTP {resp.status_code} - {resp.text[:200]}") + return False + + logger.info(f"评论发布成功,评论 ID: {resp.json().get('id', 'unknown')}") + return True + + def get_existing_review_comments(self, pr_number: int, marker: str) -> list: + """ + 获取 PR 上已有的审查评论(带标识),用于后续更新或删除旧评论。 + """ + url = self._api_url(f"issues/{pr_number}/comments") + resp = self.session.get(url, timeout=GITEA_TIMEOUT) + if resp.status_code != 200: + return [] + + comments = resp.json() + return [c for c in comments if marker in c.get("body", "")] + + +# ============== LLM 调用 ============== +def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str: + """构建代码审查的 Prompt""" + file_names = [f.get("filename", "") for f in file_list] if file_list else [] + files_summary = ", ".join(file_names[:10]) if file_names else "未知" + if len(file_names) > 10: + files_summary += f" 等 {len(file_names)} 个文件" + + prompt = f"""你是一位资深代码审查专家,请对以下 Pull Request 的代码变更进行严格审查。 + +**PR 信息:** +- PR 编号:#{pr_number} +- 修改文件:{files_summary} + +**审查重点:** +1. **严重问题**:逻辑错误、潜在 Bug、安全漏洞、数据不一致、空指针、资源泄漏、并发问题等 +2. **代码质量**:边界条件处理、错误处理是否完善、异常场景覆盖 +3. **性能隐患**:明显的性能问题、低效算法、不必要的重复计算 +4. **最佳实践**:代码规范、可读性、可维护性、命名是否清晰 + +**审查原则:** +- 只针对变更的代码(diff)进行审查,不要审查未改动的代码 +- 严重问题必须指出具体文件名和大致行号(根据 diff 中的行号推断) +- 给出明确、可操作的建议,不要空泛 +- 如果代码质量很好、没有明显问题,也请如实说明 +- 用中文回复 + +**输出格式要求(严格遵守,不要输出格式以外的内容):** + +## 代码审查结果 - PR #{pr_number} + +### ⚠️ 问题(N个需要修改) +1. **文件名 第X行**:问题描述(说明原因和可能的影响) +2. **文件名 第X行**:问题描述 + +### 💡 建议(N个可选) +1. 建议描述(可选优化、代码风格等) + +--- +✅ 格式检查通过 | ❌ 逻辑审查需修改 | ⚠️ 建议关注性能 + +**说明:** 底部的三个状态标签,根据审查结果勾选或取消对应标记(用 ✅/❌/⚠️ 表示): +- 格式检查:代码格式、命名规范等是否达标 +- 逻辑审查:是否存在必须修改的逻辑问题 +- 性能:是否存在需要关注的性能问题 + +**以下是代码 diff 内容:** + +```diff +{diff_text} +``` +""" + return prompt + + +def call_llm_openai( + prompt: str, + llm_base_url: str, + llm_api_key: str, + llm_model: str, +) -> Optional[str]: + """OpenAI 兼容模式调用""" + base_url = llm_base_url.rstrip("/") + "/" + api_url = f"{base_url}chat/completions" + + headers = { + "Authorization": f"Bearer {llm_api_key}", + "Content-Type": "application/json", + } + + payload = { + "model": llm_model, + "messages": [ + { + "role": "system", + "content": "你是一位严谨的资深代码审查专家,擅长发现代码中的逻辑错误、安全隐患和性能问题。", + }, + { + "role": "user", + "content": prompt, + }, + ], + "temperature": 0.3, + "max_tokens": 2048, + } + + logger.info(f"调用 LLM (OpenAI兼容): {api_url}, model={llm_model}") + + last_error = None + for attempt in range(MAX_RETRIES + 1): + try: + resp = requests.post( + api_url, + headers=headers, + json=payload, + timeout=LLM_TIMEOUT, + ) + if resp.status_code != 200: + logger.warning( + f"LLM 调用失败 (第 {attempt + 1} 次): " + f"HTTP {resp.status_code} - {resp.text[:200]}" + ) + last_error = f"HTTP {resp.status_code}" + continue + + data = resp.json() + choices = data.get("choices", []) + if not choices: + logger.warning(f"LLM 返回空结果 (第 {attempt + 1} 次)") + last_error = "empty choices" + continue + + content = choices[0].get("message", {}).get("content", "") + if not content.strip(): + logger.warning(f"LLM 返回空内容 (第 {attempt + 1} 次)") + last_error = "empty content" + continue + + logger.info(f"LLM 审查完成,结果长度: {len(content)} 字符") + return content + + except requests.Timeout: + logger.warning(f"LLM 调用超时 (第 {attempt + 1} 次)") + last_error = "timeout" + except requests.RequestException as e: + logger.warning(f"LLM 调用异常 (第 {attempt + 1} 次): {e}") + last_error = str(e) + + logger.error(f"LLM 调用最终失败: {last_error}") + return None + + +def call_llm_coze( + prompt: str, + llm_base_url: str, + llm_api_key: str, + llm_model: str, + coze_bot_id: str, +) -> Optional[str]: + """扣子(Coze)原生 Bot API 调用""" + base_url = llm_base_url.rstrip("/") + "/" + api_url = f"{base_url}v3/chat" + + headers = { + "Authorization": f"Bearer {llm_api_key}", + "Content-Type": "application/json", + } + + payload = { + "bot_id": coze_bot_id, + "user_id": "ci-code-review-bot", + "stream": False, + "additional_messages": [ + { + "role": "user", + "content": prompt, + "content_type": "text", + } + ], + } + + logger.info(f"调用 LLM (Coze): {api_url}, bot_id={coze_bot_id}") + + last_error = None + for attempt in range(MAX_RETRIES + 1): + try: + resp = requests.post( + api_url, + headers=headers, + json=payload, + timeout=LLM_TIMEOUT, + ) + if resp.status_code != 200: + logger.warning( + f"Coze 调用失败 (第 {attempt + 1} 次): " + f"HTTP {resp.status_code} - {resp.text[:300]}" + ) + last_error = f"HTTP {resp.status_code}" + continue + + data = resp.json() + + # 解析 Coze 返回格式,兼容多种可能的返回结构 + content = None + # 方式1: data.messages 数组 + messages = data.get("data", {}).get("messages", []) or data.get("messages", []) + for msg in messages: + if msg.get("role") == "assistant" and msg.get("type") == "answer": + content = msg.get("content", "") + break + + # 方式2: 直接 content 字段 + if not content: + content = data.get("data", {}).get("content") or data.get("content") + + # 方式3: choices 格式(兼容) + if not content: + choices = data.get("choices", []) + if choices: + content = choices[0].get("message", {}).get("content", "") + + if not content or not content.strip(): + logger.warning(f"Coze 返回空内容 (第 {attempt + 1} 次): {str(data)[:200]}") + last_error = "empty content" + continue + + logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符") + return content + + except requests.Timeout: + logger.warning(f"Coze 调用超时 (第 {attempt + 1} 次)") + last_error = "timeout" + except requests.RequestException as e: + logger.warning(f"Coze 调用异常 (第 {attempt + 1} 次): {e}") + last_error = str(e) + + logger.error(f"Coze 调用最终失败: {last_error}") + return None + + +def call_llm_for_review( + diff_text: str, + pr_number: int, + file_list: list, + llm_base_url: str, + llm_api_key: str, + llm_model: str, + coze_bot_id: str = "", +) -> Optional[str]: + """ + 调用 LLM 进行代码审查,返回审查结果文本。 + 失败时返回 None。 + 根据 LLM_PROVIDER 环境变量选择调用方式。 + """ + prompt = build_review_prompt(diff_text, pr_number, file_list) + logger.info(f"Prompt 长度: {len(prompt)} 字符") + + provider = LLM_PROVIDER + + if provider == "coze": + return call_llm_coze(prompt, llm_base_url, llm_api_key, llm_model, coze_bot_id) + else: + # 默认 OpenAI 兼容 + return call_llm_openai(prompt, llm_base_url, llm_api_key, llm_model) + + +# ============== 主流程 ============== +def main(): + parser = argparse.ArgumentParser(description="CI AI 代码审查脚本") + parser.add_argument("--pr", type=int, help="PR 编号(也可通过 PR_NUMBER 环境变量)") + parser.add_argument("--repo", type=str, help="仓库名 owner/repo(也可通过 REPO_NAME 环境变量)") + parser.add_argument("--gitea-url", type=str, help="Gitea 地址(也可通过 GITEA_API_URL 环境变量)") + parser.add_argument("--gitea-token", type=str, help="Gitea Token(也可通过 GITEA_TOKEN 环境变量)") + parser.add_argument("--dry-run", action="store_true", help="只输出审查结果,不发表评论") + args = parser.parse_args() + + # 读取配置 + gitea_url = args.gitea_url or os.getenv("GITEA_API_URL") or os.getenv("GITEA_SERVER_URL") + gitea_token = args.gitea_token or os.getenv("GITEA_TOKEN") + repo_name = args.repo or os.getenv("REPO_NAME") or os.getenv("GITEA_REPO") + pr_number = args.pr or int(os.getenv("PR_NUMBER") or os.getenv("GITEA_PR_NUMBER") or 0) + + llm_base_url = os.getenv("LLM_BASE_URL") + llm_api_key = os.getenv("LLM_API_KEY") + llm_model = os.getenv("LLM_MODEL", "") + coze_bot_id = os.getenv("COZE_BOT_ID", os.getenv("COZE_BOTID", "")) + + # 根据 provider 设置默认值 + provider = LLM_PROVIDER + if provider == "coze": + # 扣子模式:默认国内站,key 兼容多种环境变量名 + if not llm_base_url: + llm_base_url = "https://api.coze.cn" + if not llm_api_key: + llm_api_key = os.getenv("COZE_API_KEY", "") or os.getenv("COZE_PAT", "") + else: + # OpenAI兼容模式:默认模型 + if not llm_model: + llm_model = "gpt-4o-mini" + + # 必要参数校验 + missing = [] + if not gitea_url: + missing.append("GITEA_API_URL") + if not gitea_token: + missing.append("GITEA_TOKEN") + if not repo_name: + missing.append("REPO_NAME") + if not pr_number: + missing.append("PR_NUMBER") + if not llm_base_url: + missing.append("LLM_BASE_URL") + if not llm_api_key: + missing.append("LLM_API_KEY") + if provider == "coze" and not coze_bot_id: + missing.append("COZE_BOT_ID (扣子模式需要)") + + if missing: + logger.error(f"缺少必要配置: {', '.join(missing)}") + # 审查失败不阻断 CI,返回 0 + logger.info("审查脚本因配置缺失而跳过,退出码 0") + sys.exit(0) + + logger.info(f"开始审查 PR #{pr_number},仓库: {repo_name}") + logger.info(f"Gitea: {gitea_url}") + logger.info(f"LLM: {llm_base_url} (model={llm_model})") + + try: + # 1. 初始化 Gitea 客户端 + gitea = GiteaClient(gitea_url, gitea_token, repo_name) + + # 2. 获取 PR diff 和文件列表 + try: + diff_text = gitea.get_pr_diff(pr_number) + file_list = gitea.get_pr_files(pr_number) + except Exception as e: + logger.error(f"获取 PR 信息失败: {e}") + logger.info("审查脚本异常退出,退出码 0(不阻断 CI)") + sys.exit(0) + + # 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件等) + skip_extensions = (".lock", ".sum", ".min.js", ".min.css", ".map", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot") + if file_list: + skipped = [f.get("filename") for f in file_list + if f.get("filename", "").endswith(skip_extensions) + or f.get("status") == "removed"] + if skipped: + logger.info(f"跳过 {len(skipped)} 个非文本/已删除文件: {', '.join(skipped[:5])}...") + + # 4. 截断过大的 diff + diff_text, was_truncated = truncate_diff(diff_text, MAX_DIFF_CHARS) + if was_truncated: + logger.warning(f"Diff 过大,已截断至 {len(diff_text)} 字符") + + # 5. 如果 diff 为空,直接跳过 + if not diff_text.strip(): + logger.info("Diff 为空,无需审查") + sys.exit(0) + + # 6. 调用 LLM 审查 + review_result = call_llm_for_review( + diff_text=diff_text, + pr_number=pr_number, + file_list=file_list, + llm_base_url=llm_base_url, + llm_api_key=llm_api_key, + llm_model=llm_model, + coze_bot_id=coze_bot_id, + ) + + if not review_result: + logger.error("LLM 审查失败,跳过发布评论") + logger.info("审查脚本异常退出,退出码 0(不阻断 CI)") + sys.exit(0) + + # 7. 加上审查时间和标识(便于识别是自动审查) + from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + marker = "" + full_comment = f"""{review_result} + +--- +🤖 由 AI 代码审查机器人自动生成 | {timestamp} | 模型: {llm_model} + +{marker} +""" + + # 8. 输出审查结果到日志 + logger.info("=" * 60) + logger.info("审查结果:") + for line in review_result.split("\n")[:30]: + logger.info(line) + if len(review_result.split("\n")) > 30: + logger.info(f"... 共 {len(review_result.split(chr(10)))} 行") + logger.info("=" * 60) + + # 9. 发布评论 + if args.dry_run: + logger.info("--dry-run 模式,跳过发布评论") + print(full_comment) + else: + success = gitea.post_pr_comment(pr_number, full_comment) + if not success: + logger.warning("评论发布失败,但不影响 CI 通过") + + # 10. 判断是否有严重问题(可选阻断) + # 目前只做建议,不阻断合并,始终返回 0 + has_critical = "问题" in review_result and ("❌" in review_result or "需修改" in review_result) + if has_critical: + logger.warning("检测到需修改的问题,但当前配置为仅建议,不阻断合并") + + logger.info("代码审查完成") + sys.exit(0) + + except Exception as e: + logger.exception(f"审查脚本发生未预期的异常: {e}") + # 任何异常都不阻断 CI + logger.info("审查脚本异常退出,退出码 0(不阻断 CI)") + sys.exit(0) + + +if __name__ == "__main__": + main() -- 2.54.0 From 6b1b2e710e18b00e40d8f1778717c5bd4dcbca02 Mon Sep 17 00:00:00 2001 From: auto-approve-bot Date: Thu, 16 Jul 2026 23:14:00 +0800 Subject: [PATCH 2/9] feat(ci): add code review workflow --- .gitea/workflows/code-review.yml | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .gitea/workflows/code-review.yml diff --git a/.gitea/workflows/code-review.yml b/.gitea/workflows/code-review.yml new file mode 100644 index 000000000..d1b969449 --- /dev/null +++ b/.gitea/workflows/code-review.yml @@ -0,0 +1,58 @@ +name: AI Code Review + +on: + pull_request: + types: + - opened + - synchronize + - reopened + +# 同一个 PR 只跑一个 review,新的取消旧的 +concurrency: + group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }} + cancel-in-progress: true + +jobs: + code-review: + name: AI Code Review + runs-on: ubuntu-latest + # 跳过草稿 PR + if: ${{ !gitea.event.pull_request.draft }} + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + + - name: Run AI Code Review + env: + # Gitea 配置(自动从运行环境获取) + GITEA_API_URL: ${{ gitea.server_url }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REPO_NAME: ${{ gitea.repository }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + # LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容) + LLM_PROVIDER: "coze" + # 扣子模式配置(默认国内站 api.coze.cn) + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }} + LLM_MODEL: ${{ secrets.LLM_MODEL }} + # 可选参数 + MAX_DIFF_CHARS: "30000" + LLM_TIMEOUT: "120" + run: | + python scripts/ci_code_review.py + # 审查脚本异常不影响 CI 通过 + continue-on-error: true -- 2.54.0 From e1db127123a9c9383c005ebc77b7edaff8f30e96 Mon Sep 17 00:00:00 2001 From: auto-approve-bot Date: Fri, 17 Jul 2026 00:22:59 +0800 Subject: [PATCH 3/9] fix: rename GITEA_TOKEN to GITEA_API_TOKEN to avoid conflict with built-in variable --- .gitea/workflows/code-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/code-review.yml b/.gitea/workflows/code-review.yml index d1b969449..d516c36b6 100644 --- a/.gitea/workflows/code-review.yml +++ b/.gitea/workflows/code-review.yml @@ -39,7 +39,7 @@ jobs: env: # Gitea 配置(自动从运行环境获取) GITEA_API_URL: ${{ gitea.server_url }} - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_API_TOKEN }} REPO_NAME: ${{ gitea.repository }} PR_NUMBER: ${{ gitea.event.pull_request.number }} # LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容) -- 2.54.0 From 5abc6552f42be87c263876447e59e7f11fde4f0a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 00:29:40 +0800 Subject: [PATCH 4/9] fix: rename secret to REVIEW_GITEA_TOKEN (GITEA_ prefix is reserved) --- .gitea/workflows/code-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/code-review.yml b/.gitea/workflows/code-review.yml index d516c36b6..c835a1be8 100644 --- a/.gitea/workflows/code-review.yml +++ b/.gitea/workflows/code-review.yml @@ -39,7 +39,7 @@ jobs: env: # Gitea 配置(自动从运行环境获取) GITEA_API_URL: ${{ gitea.server_url }} - GITEA_TOKEN: ${{ secrets.GITEA_API_TOKEN }} + GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} REPO_NAME: ${{ gitea.repository }} PR_NUMBER: ${{ gitea.event.pull_request.number }} # LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容) -- 2.54.0 From aeef4011de1d6f4d1ca7c8e5cbe4644ed249b99a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 00:36:23 +0800 Subject: [PATCH 5/9] fix: remove setup-python action, use system python3 (not available in runner cache) --- .gitea/workflows/code-review.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/code-review.yml b/.gitea/workflows/code-review.yml index c835a1be8..23f3c1eb0 100644 --- a/.gitea/workflows/code-review.yml +++ b/.gitea/workflows/code-review.yml @@ -25,15 +25,10 @@ jobs: with: fetch-depth: 0 - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install requests + python3 -m pip install --upgrade pip + python3 -m pip install requests - name: Run AI Code Review env: @@ -53,6 +48,6 @@ jobs: MAX_DIFF_CHARS: "30000" LLM_TIMEOUT: "120" run: | - python scripts/ci_code_review.py + python3 scripts/ci_code_review.py # 审查脚本异常不影响 CI 通过 continue-on-error: true -- 2.54.0 From ac1ae892acbbd122dd2f1c14924f4bdcf4736116 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 00:44:59 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20Coze=20v3=20API=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=20-=20GET=E6=96=B9=E6=B3=95+query=E5=8F=82?= =?UTF-8?q?=E6=95=B0=EF=BC=8C=E7=AD=89=E5=BE=85answer=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_code_review.py | 63 ++++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/scripts/ci_code_review.py b/scripts/ci_code_review.py index 518510d57..006e8220b 100644 --- a/scripts/ci_code_review.py +++ b/scripts/ci_code_review.py @@ -300,7 +300,9 @@ def call_llm_coze( llm_model: str, coze_bot_id: str, ) -> Optional[str]: - """扣子(Coze)原生 Bot API 调用""" + """扣子(Coze)原生 Bot API 调用(支持异步轮询)""" + import time + base_url = llm_base_url.rstrip("/") + "/" api_url = f"{base_url}v3/chat" @@ -342,21 +344,68 @@ def call_llm_coze( continue data = resp.json() + chat_data = data.get("data", {}) + chat_id = chat_data.get("id", "") + conversation_id = chat_data.get("conversation_id", "") + status = chat_data.get("status", "") - # 解析 Coze 返回格式,兼容多种可能的返回结构 + # Coze v3 API 异步:先返回 in_progress,需要轮询 + if status == "in_progress" and conversation_id and chat_id: + logger.info(f"Coze 异步处理中,开始轮询... (chat_id={chat_id[:12]}...)") + # 轮询 message 列表接口(GET + query参数),最多等 LLM_TIMEOUT 秒 + poll_url = f"{base_url}v3/chat/message/list" + poll_start = time.time() + poll_interval = 3 # 每3秒轮询一次 + + while time.time() - poll_start < LLM_TIMEOUT: + time.sleep(poll_interval) + poll_params = { + "chat_id": chat_id, + "conversation_id": conversation_id, + } + poll_resp = requests.get( + poll_url, + headers=headers, + params=poll_params, + timeout=GITEA_TIMEOUT, + ) + if poll_resp.status_code != 200: + logger.debug(f"轮询返回 HTTP {poll_resp.status_code}: {poll_resp.text[:100]}") + continue + + poll_data = poll_resp.json() + if poll_data.get("code", 0) != 0: + logger.debug(f"轮询返回错误: {poll_data.get('msg', '')}") + continue + + messages = poll_data.get("data", []) or [] + + # 找assistant的answer消息 + content = None + for msg in messages: + if msg.get("role") == "assistant" and msg.get("type") == "answer": + content = msg.get("content", "") + break + + if content and content.strip(): + logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符") + return content + + logger.warning(f"Coze 轮询超时 ({LLM_TIMEOUT}s),未拿到结果") + last_error = "poll timeout" + continue + + # 同步返回的情况(兼容) content = None - # 方式1: data.messages 数组 - messages = data.get("data", {}).get("messages", []) or data.get("messages", []) + messages = chat_data.get("messages", []) or data.get("messages", []) for msg in messages: if msg.get("role") == "assistant" and msg.get("type") == "answer": content = msg.get("content", "") break - # 方式2: 直接 content 字段 if not content: - content = data.get("data", {}).get("content") or data.get("content") + content = chat_data.get("content") or data.get("content") - # 方式3: choices 格式(兼容) if not content: choices = data.get("choices", []) if choices: -- 2.54.0 From cdb68636bff29e95c1e45e43d1d4f4bca9ecd17f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 01:01:23 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8DAI=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A53=E4=B8=AAbug=20-=20=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E7=94=9F=E6=95=88/=E8=AF=84=E8=AE=BA=E5=8E=BB?= =?UTF-8?q?=E9=87=8D/=E5=BC=82=E5=B8=B8=E9=80=80=E5=87=BA=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug1: 过滤逻辑实际从diff中移除跳过的文件(lock/图片等),节省token - Bug2: 评论去重接上,每次新审查先删除旧的AI评论,避免刷屏 - Bug3: 异常情况sys.exit(1)替代sys.exit(0),配合continue-on-error也能看到失败 --- scripts/ci_code_review.py | 143 ++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 74 deletions(-) diff --git a/scripts/ci_code_review.py b/scripts/ci_code_review.py index 006e8220b..6bb625f35 100644 --- a/scripts/ci_code_review.py +++ b/scripts/ci_code_review.py @@ -150,70 +150,34 @@ class GiteaClient: def get_existing_review_comments(self, pr_number: int, marker: str) -> list: """ - 获取 PR 上已有的审查评论(带标识),用于后续更新或删除旧评论。 + 获取 PR 上已有的 AI 审查评论 ID 列表(带标识 marker)。 """ url = self._api_url(f"issues/{pr_number}/comments") resp = self.session.get(url, timeout=GITEA_TIMEOUT) if resp.status_code != 200: + logger.warning(f"获取评论列表失败: HTTP {resp.status_code}") return [] comments = resp.json() - return [c for c in comments if marker in c.get("body", "")] + review_comment_ids = [] + for c in comments: + body = c.get("body", "") + if marker in body: + review_comment_ids.append(c.get("id")) + logger.info(f"找到 {len(review_comment_ids)} 条旧的 AI 审查评论") + return review_comment_ids + def delete_pr_comment(self, pr_number: int, comment_id: int) -> bool: + """ + 删除 PR 上的指定评论。 + """ + url = self._api_url(f"issues/comments/{comment_id}") + resp = self.session.delete(url, timeout=GITEA_TIMEOUT) + if resp.status_code not in (200, 204): + logger.warning(f"删除评论 {comment_id} 失败: HTTP {resp.status_code}") + return False + return True -# ============== LLM 调用 ============== -def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str: - """构建代码审查的 Prompt""" - file_names = [f.get("filename", "") for f in file_list] if file_list else [] - files_summary = ", ".join(file_names[:10]) if file_names else "未知" - if len(file_names) > 10: - files_summary += f" 等 {len(file_names)} 个文件" - - prompt = f"""你是一位资深代码审查专家,请对以下 Pull Request 的代码变更进行严格审查。 - -**PR 信息:** -- PR 编号:#{pr_number} -- 修改文件:{files_summary} - -**审查重点:** -1. **严重问题**:逻辑错误、潜在 Bug、安全漏洞、数据不一致、空指针、资源泄漏、并发问题等 -2. **代码质量**:边界条件处理、错误处理是否完善、异常场景覆盖 -3. **性能隐患**:明显的性能问题、低效算法、不必要的重复计算 -4. **最佳实践**:代码规范、可读性、可维护性、命名是否清晰 - -**审查原则:** -- 只针对变更的代码(diff)进行审查,不要审查未改动的代码 -- 严重问题必须指出具体文件名和大致行号(根据 diff 中的行号推断) -- 给出明确、可操作的建议,不要空泛 -- 如果代码质量很好、没有明显问题,也请如实说明 -- 用中文回复 - -**输出格式要求(严格遵守,不要输出格式以外的内容):** - -## 代码审查结果 - PR #{pr_number} - -### ⚠️ 问题(N个需要修改) -1. **文件名 第X行**:问题描述(说明原因和可能的影响) -2. **文件名 第X行**:问题描述 - -### 💡 建议(N个可选) -1. 建议描述(可选优化、代码风格等) - ---- -✅ 格式检查通过 | ❌ 逻辑审查需修改 | ⚠️ 建议关注性能 - -**说明:** 底部的三个状态标签,根据审查结果勾选或取消对应标记(用 ✅/❌/⚠️ 表示): -- 格式检查:代码格式、命名规范等是否达标 -- 逻辑审查:是否存在必须修改的逻辑问题 -- 性能:是否存在需要关注的性能问题 - -**以下是代码 diff 内容:** - -```diff -{diff_text} -``` -""" - return prompt def call_llm_openai( @@ -509,9 +473,7 @@ def main(): if missing: logger.error(f"缺少必要配置: {', '.join(missing)}") - # 审查失败不阻断 CI,返回 0 - logger.info("审查脚本因配置缺失而跳过,退出码 0") - sys.exit(0) + sys.exit(1) logger.info(f"开始审查 PR #{pr_number},仓库: {repo_name}") logger.info(f"Gitea: {gitea_url}") @@ -527,17 +489,45 @@ def main(): file_list = gitea.get_pr_files(pr_number) except Exception as e: logger.error(f"获取 PR 信息失败: {e}") - logger.info("审查脚本异常退出,退出码 0(不阻断 CI)") - sys.exit(0) + sys.exit(1) - # 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件等) + # 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件、二进制文件等) skip_extensions = (".lock", ".sum", ".min.js", ".min.css", ".map", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot") + skipped_files = [] if file_list: - skipped = [f.get("filename") for f in file_list - if f.get("filename", "").endswith(skip_extensions) - or f.get("status") == "removed"] - if skipped: - logger.info(f"跳过 {len(skipped)} 个非文本/已删除文件: {', '.join(skipped[:5])}...") + skipped_files = [f.get("filename") for f in file_list + if f.get("filename", "").endswith(skip_extensions) + or f.get("status") == "removed"] + if skipped_files: + logger.info(f"跳过 {len(skipped_files)} 个非文本/已删除文件: {', '.join(skipped_files[:5])}...") + + # 实际从 diff 中移除跳过的文件(按文件边界切割) + if skipped_files: + diff_lines = diff_text.split("\n") + filtered_lines = [] + current_file = None + skip_current = False + i = 0 + while i < len(diff_lines): + line = diff_lines[i] + # 检测新文件开始: diff --git a/xxx b/xxx + if line.startswith("diff --git "): + # 提取文件名 + parts = line.split(" ") + if len(parts) >= 4: + # b/ 后面的是目标文件名 + current_file = parts[3][2:] if parts[3].startswith("b/") else parts[3] + skip_current = any(current_file == sf for sf in skipped_files) or any( + current_file.endswith(ext) for ext in skip_extensions + ) + else: + skip_current = False + if not skip_current: + filtered_lines.append(line) + i += 1 + original_len = len(diff_text) + diff_text = "\n".join(filtered_lines) + logger.info(f"Diff 过滤后: {original_len} -> {len(diff_text)} 字符 (减少 {original_len - len(diff_text)})") # 4. 截断过大的 diff diff_text, was_truncated = truncate_diff(diff_text, MAX_DIFF_CHARS) @@ -561,9 +551,8 @@ def main(): ) if not review_result: - logger.error("LLM 审查失败,跳过发布评论") - logger.info("审查脚本异常退出,退出码 0(不阻断 CI)") - sys.exit(0) + logger.error("LLM 审查失败") + sys.exit(1) # 7. 加上审查时间和标识(便于识别是自动审查) from datetime import datetime @@ -586,14 +575,22 @@ def main(): logger.info(f"... 共 {len(review_result.split(chr(10)))} 行") logger.info("=" * 60) - # 9. 发布评论 + # 9. 发布评论(先删除旧的审查评论,避免刷屏) if args.dry_run: logger.info("--dry-run 模式,跳过发布评论") print(full_comment) else: + # 去重:删除之前的 AI 审查评论 + old_comments = gitea.get_existing_review_comments(pr_number, marker) + if old_comments: + logger.info(f"找到 {len(old_comments)} 条旧的 AI 审查评论,先删除") + for cid in old_comments: + gitea.delete_pr_comment(pr_number, cid) + # 发布新评论 success = gitea.post_pr_comment(pr_number, full_comment) if not success: - logger.warning("评论发布失败,但不影响 CI 通过") + logger.error("评论发布失败") + sys.exit(1) # 10. 判断是否有严重问题(可选阻断) # 目前只做建议,不阻断合并,始终返回 0 @@ -606,9 +603,7 @@ def main(): except Exception as e: logger.exception(f"审查脚本发生未预期的异常: {e}") - # 任何异常都不阻断 CI - logger.info("审查脚本异常退出,退出码 0(不阻断 CI)") - sys.exit(0) + sys.exit(1) if __name__ == "__main__": -- 2.54.0 From d83d0a5d69a550e435ae74d81d51636ef5a66ef9 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 01:13:20 +0800 Subject: [PATCH 8/9] style: black format ci_code_review.py --- scripts/ci_code_review.py | 61 +++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/scripts/ci_code_review.py b/scripts/ci_code_review.py index 6bb625f35..d37ecdd93 100644 --- a/scripts/ci_code_review.py +++ b/scripts/ci_code_review.py @@ -15,7 +15,6 @@ from typing import Optional, Tuple import requests - # ============== 日志配置 ============== logging.basicConfig( level=logging.INFO, @@ -80,11 +79,13 @@ class GiteaClient: self.token = token self.repo = repo # 格式: owner/repo self.session = requests.Session() - self.session.headers.update({ - "Authorization": f"token {token}", - "Accept": "application/json", - "Content-Type": "application/json", - }) + self.session.headers.update( + { + "Authorization": f"token {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + ) def _api_url(self, path: str) -> str: """拼接 API 路径""" @@ -98,9 +99,13 @@ class GiteaClient: url = self._api_url(f"pulls/{pr_number}.diff") logger.info(f"获取 PR #{pr_number} diff: {url}") - resp = self.session.get(url, timeout=GITEA_TIMEOUT, headers={ - "Accept": "text/plain", - }) + resp = self.session.get( + url, + timeout=GITEA_TIMEOUT, + headers={ + "Accept": "text/plain", + }, + ) if resp.status_code != 200: logger.error(f"获取 diff 失败: HTTP {resp.status_code} - {resp.text[:200]}") raise RuntimeError(f"Failed to get PR diff: HTTP {resp.status_code}") @@ -179,7 +184,6 @@ class GiteaClient: return True - def call_llm_openai( prompt: str, llm_base_url: str, @@ -223,10 +227,7 @@ def call_llm_openai( timeout=LLM_TIMEOUT, ) if resp.status_code != 200: - logger.warning( - f"LLM 调用失败 (第 {attempt + 1} 次): " - f"HTTP {resp.status_code} - {resp.text[:200]}" - ) + logger.warning(f"LLM 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:200]}") last_error = f"HTTP {resp.status_code}" continue @@ -300,10 +301,7 @@ def call_llm_coze( timeout=LLM_TIMEOUT, ) if resp.status_code != 200: - logger.warning( - f"Coze 调用失败 (第 {attempt + 1} 次): " - f"HTTP {resp.status_code} - {resp.text[:300]}" - ) + logger.warning(f"Coze 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:300]}") last_error = f"HTTP {resp.status_code}" continue @@ -492,12 +490,30 @@ def main(): sys.exit(1) # 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件、二进制文件等) - skip_extensions = (".lock", ".sum", ".min.js", ".min.css", ".map", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot") + skip_extensions = ( + ".lock", + ".sum", + ".min.js", + ".min.css", + ".map", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".eot", + ) skipped_files = [] if file_list: - skipped_files = [f.get("filename") for f in file_list - if f.get("filename", "").endswith(skip_extensions) - or f.get("status") == "removed"] + skipped_files = [ + f.get("filename") + for f in file_list + if f.get("filename", "").endswith(skip_extensions) or f.get("status") == "removed" + ] if skipped_files: logger.info(f"跳过 {len(skipped_files)} 个非文本/已删除文件: {', '.join(skipped_files[:5])}...") @@ -556,6 +572,7 @@ def main(): # 7. 加上审查时间和标识(便于识别是自动审查) from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") marker = "" full_comment = f"""{review_result} -- 2.54.0 From d3c8b5f1a26c790f69d9542e760ac41e57ed7053 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 01:17:44 +0800 Subject: [PATCH 9/9] style: isort fix imports --- scripts/ci_code_review.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/ci_code_review.py b/scripts/ci_code_review.py index d37ecdd93..685467007 100644 --- a/scripts/ci_code_review.py +++ b/scripts/ci_code_review.py @@ -6,11 +6,11 @@ CI Code Review Script - 将审查结果写回 PR 评论 """ -import os -import sys +import argparse import json import logging -import argparse +import os +import sys from typing import Optional, Tuple import requests -- 2.54.0