diff --git a/.github/scripts/ai_review.py b/.github/scripts/ai_review.py new file mode 100644 index 0000000..2c532cd --- /dev/null +++ b/.github/scripts/ai_review.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +""" +AI code review script used by GitHub Actions PR Review workflow. +""" +import json +import os +import subprocess +import traceback + + +MAX_DIFF_LENGTH = 18000 +REVIEW_PATHS = [ + '*.py', + '*.md', + 'README.md', + 'AGENTS.md', + 'requirements.txt', + 'pyproject.toml', + 'setup.cfg', + '.github/workflows/*.yml', + '.github/scripts/*.py', +] + + +def run_git(args): + result = subprocess.run(args, capture_output=True, text=True) + if result.returncode != 0: + print(f"⚠️ git command failed: {' '.join(args)}") + print(result.stderr.strip()) + return '' + return result.stdout.strip() + + +def get_diff(): + """Get PR diff content for review-relevant files.""" + base_ref = os.environ.get('GITHUB_BASE_REF', 'main') + diff = run_git(['git', 'diff', f'origin/{base_ref}...HEAD', '--', *REVIEW_PATHS]) + truncated = len(diff) > MAX_DIFF_LENGTH + return diff[:MAX_DIFF_LENGTH], truncated + + +def get_changed_files(): + """Get changed file list for review-relevant files.""" + base_ref = os.environ.get('GITHUB_BASE_REF', 'main') + output = run_git(['git', 'diff', '--name-only', f'origin/{base_ref}...HEAD', '--', *REVIEW_PATHS]) + return output.split('\n') if output else [] + + +def get_pr_context(): + """Read PR title/body from GitHub event payload when available.""" + event_path = os.environ.get('GITHUB_EVENT_PATH') + if not event_path or not os.path.exists(event_path): + return '', '' + try: + with open(event_path, 'r', encoding='utf-8') as f: + payload = json.load(f) + pr = payload.get('pull_request', {}) + return (pr.get('title') or '').strip(), (pr.get('body') or '').strip() + except Exception: + return '', '' + + +def classify_files(files): + py_files = [f for f in files if f.endswith('.py')] + doc_files = [f for f in files if f.endswith('.md') or f in ('README.md', 'AGENTS.md')] + config_files = [ + f for f in files if f in ('requirements.txt', 'pyproject.toml', 'setup.cfg') + ] + workflow_files = [f for f in files if f.startswith('.github/workflows/')] + return py_files, doc_files, config_files, workflow_files + + +def _build_ci_context(): + """Build CI context section from environment variables set by the workflow.""" + auto_check_result = os.environ.get('CI_AUTO_CHECK_RESULT', '') + syntax_ok = os.environ.get('CI_SYNTAX_OK', '') + has_py = os.environ.get('CI_HAS_PY_CHANGES', 'false') + + if not auto_check_result: + return """ +## CI 检查状态 +> ⚠️ 未获取到 CI 检查结果。审查时不得假设 CI 已通过,验证相关判断应标注为"无法确认"。 +""" + + lines = ["\n## CI 检查状态(来自本次 PR 的自动化流水线)"] + lines.append(f"- 静态检查总体结果: **{'✅ 通过' if auto_check_result == 'success' else '❌ 失败'}**") + if has_py == 'true': + lines.append(f"- Python 语法检查 (py_compile): **{'✅ 通过' if syntax_ok == 'true' else '❌ 失败' if syntax_ok == 'false' else '⏭️ 未执行'}**") + lines.append("- Flake8 严重错误检查 (E9/F63/F7/F82): **✅ 通过**(若未通过则静态检查总体会失败)") + else: + lines.append("- Python 文件: 无变更,语法检查已跳过") + lines.append("") + lines.append("> 以上 CI 仅覆盖语法正确性(py_compile)和致命 lint 错误(flake8 E9/F63/F7/F82)。") + lines.append("") + return '\n'.join(lines) + + +def build_prompt(diff_content, files, truncated, pr_title, pr_body): + """Build AI review prompt aligned with project requirements.""" + truncate_notice = '' + if truncated: + truncate_notice = "\n\n> ⚠️ 注意:diff 过长已截断,请基于可见内容审查并标注不确定点。\n" + + py_files, doc_files, config_files, workflow_files = classify_files(files) + ci_context = _build_ci_context() + return f"""你是 AI-Account-Toolkit 仓库的 PR 审查助手。请根据变更内容和 PR 描述,执行代码审查。 + +## PR 信息 +- 标题: {pr_title or '(empty)'} +- 描述: +{pr_body or '(empty)'} + +## 修改文件统计 +- Python: {len(py_files)} +- Docs/Markdown: {len(doc_files)} +- Config: {len(config_files)} +- Workflow: {len(workflow_files)} + +修改文件列表: +{', '.join(files)}{truncate_notice} + +## 代码变更 (diff) +```diff +{diff_content} +``` +{ci_context} + +## 审查规则 +1. 必要性(Necessity):是否有明确问题/业务价值,避免无效重构。 +2. 关联性(Traceability):是否有关联 Issue(Fixes/Refs);自然语言关联也可接受。 +3. 类型判定(Type):fix/feat/refactor/docs/chore/test 是否匹配。 +4. 描述完整性(Description Completeness):是否包含背景、范围、验证命令与结果。 +5. 合入判定(Merge Readiness):给出 Ready / Not Ready,并列出阻断项。 + +## 阻断 vs 建议的判定标准 +仅以下问题可判定为 Not Ready(阻断项/必改项): +- 代码存在正确性或安全性问题(逻辑错误、异常吞没,安全漏洞等) +- CI 检查未通过 +- PR 描述与实际改动内容存在实质性矛盾 +- 缺少回滚方案 + +以下问题仅放入建议项,不影响合入判定: +- issue 关联格式不规范 +- 描述中非关键性措辞或格式问题 + +## 审查输出要求 +- 使用中文。 +- 先给"结论":`Ready to Merge` 或 `Not Ready`。 +- 再给结构化结果: + - 必要性:通过/不通过 + 理由 + - 关联性:通过/不通过 + 证据 + - 类型:建议类型 + - 描述完整性:完整/不完整(缺失项) + - 风险级别:低/中/高 + 关键风险 + - 必改项(最多 5 条,仅限阻断条件,按优先级) + - 建议项(最多 5 条) +- 对发现的问题,尽量定位到文件路径并说明影响。 +- 如果信息不足,明确写"基于当前 diff/PR 描述无法确认"。 +""" + + +def review_with_openai(prompt): + """Run review with OpenAI-compatible API.""" + api_key = os.environ.get('OPENAI_API_KEY') + base_url = os.environ.get('OPENAI_BASE_URL', 'https://api.openai.com/v1') + model = os.environ.get('OPENAI_MODEL', 'gpt-4o-mini') + + if not api_key: + print("❌ OpenAI API Key 未配置(检查 GitHub Secrets: OPENAI_API_KEY)") + return None + + print(f"🌐 Base URL: {base_url}") + print(f"🤖 使用模型: {model}") + + try: + from openai import OpenAI + client = OpenAI(api_key=api_key, base_url=base_url) + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + max_tokens=2000, + temperature=0.3 + ) + print(f"✅ OpenAI 兼容接口 ({model}) 审查成功") + return response.choices[0].message.content + except ImportError as e: + print(f"❌ OpenAI 依赖未安装: {e}") + print(" 请确保安装了 openai: pip install openai") + return None + except Exception as e: + print(f"❌ OpenAI 兼容接口审查失败: {e}") + traceback.print_exc() + return None + + +def review_with_gemini(prompt): + """Run review with Gemini API as fallback.""" + api_key = os.environ.get('GEMINI_API_KEY') + model = os.environ.get('GEMINI_MODEL') or 'gemini-2.5-flash' + + if not api_key: + print("❌ Gemini API Key 未配置(检查 GitHub Secrets: GEMINI_API_KEY)") + return None + + print(f"🤖 使用模型: {model}") + + try: + from google import genai + client = genai.Client(api_key=api_key) + response = client.models.generate_content( + model=model, + contents=prompt + ) + print(f"✅ Gemini ({model}) 审查成功") + return response.text + except ImportError as e: + print(f"❌ Gemini 依赖未安装: {e}") + return None + except Exception as e: + print(f"❌ Gemini 审查失败: {e}") + return None + + +def ai_review(diff_content, files, truncated): + """Run AI review: OpenAI first, then Gemini fallback.""" + pr_title, pr_body = get_pr_context() + prompt = build_prompt(diff_content, files, truncated, pr_title, pr_body) + + # Try OpenAI first + result = review_with_openai(prompt) + if result: + return result + + # Fallback to Gemini + print("尝试使用 Gemini...") + result = review_with_gemini(prompt) + if result: + return result + + return None + + +def main(): + diff, truncated = get_diff() + files = get_changed_files() + + if not diff or not files: + print("没有可审查的代码/文档/配置变更,跳过 AI 审查") + summary_file = os.environ.get('GITHUB_STEP_SUMMARY') + if summary_file: + with open(summary_file, 'a', encoding='utf-8') as f: + f.write("## 🤖 AI 代码审查\n\n✅ 没有可审查变更\n") + return + + print(f"审查文件: {files}") + if truncated: + print(f"⚠️ Diff 内容已截断至 {MAX_DIFF_LENGTH} 字符") + + review = ai_review(diff, files, truncated) + + summary_file = os.environ.get('GITHUB_STEP_SUMMARY') + + strict_mode = os.environ.get('AI_REVIEW_STRICT', 'false').lower() == 'true' + + if review: + if summary_file: + with open(summary_file, 'a', encoding='utf-8') as f: + f.write(f"## 🤖 AI 代码审查\n\n{review}\n") + + with open('ai_review_result.txt', 'w', encoding='utf-8') as f: + f.write(review) + + print("AI 审查完成") + else: + print("⚠️ 所有 AI 接口都不可用") + if summary_file: + with open(summary_file, 'a', encoding='utf-8') as f: + f.write("## 🤖 AI 代码审查\n\n⚠️ AI 接口不可用,请检查配置\n") + if strict_mode: + raise SystemExit("AI_REVIEW_STRICT=true and no AI review result is available") + + +if __name__ == '__main__': + main()