mirror of
https://github.com/adminlove520/AI-Account-Toolkit.git
synced 2026-05-16 09:26:46 +08:00
feat: 添加 AI 审查脚本
This commit is contained in:
284
.github/scripts/ai_review.py
vendored
Normal file
284
.github/scripts/ai_review.py
vendored
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user