edd4b6b1ea
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m44s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m26s
CI/CD Pipeline / Unit Tests (push) Failing after 5m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m52s
CI/CD Pipeline / Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
781 lines
28 KiB
Python
781 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
CI Code Review Script
|
||
- 从 Gitea 获取 PR diff
|
||
- 调用 LLM 进行代码审查
|
||
- 将审查结果写回 PR 评论
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import sys
|
||
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 上已有的 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()
|
||
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
|
||
|
||
def create_commit_status(
|
||
self, sha: str, state: str, context: str, description: str = "", target_url: str = ""
|
||
) -> bool:
|
||
"""
|
||
给指定 commit 打 status。
|
||
state: pending / success / failure / error / warning
|
||
Gitea API: POST /repos/{owner}/{repo}/statuses/{sha}
|
||
"""
|
||
url = self._api_url(f"statuses/{sha}")
|
||
logger.info(f"设置 commit status: sha={sha[:12]}..., state={state}, context={context}")
|
||
|
||
payload = {
|
||
"state": state,
|
||
"context": context,
|
||
"description": description[:200] if description else "",
|
||
}
|
||
if target_url:
|
||
payload["target_url"] = target_url
|
||
|
||
resp = self.session.post(
|
||
url,
|
||
data=json.dumps(payload),
|
||
timeout=GITEA_TIMEOUT,
|
||
)
|
||
if resp.status_code not in (200, 201):
|
||
logger.error(f"设置 status 失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||
return False
|
||
|
||
logger.info(f"Status 设置成功: {context} = {state}")
|
||
return True
|
||
|
||
|
||
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 调用(支持异步轮询)"""
|
||
import time
|
||
|
||
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()
|
||
chat_data = data.get("data", {})
|
||
chat_id = chat_data.get("id", "")
|
||
conversation_id = chat_data.get("conversation_id", "")
|
||
status = chat_data.get("status", "")
|
||
|
||
# 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
|
||
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
|
||
|
||
if not content:
|
||
content = chat_data.get("content") or data.get("content")
|
||
|
||
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 build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
|
||
"""
|
||
构建代码审查的 Prompt。
|
||
包含:PR 基本信息、修改文件列表、diff 内容、审查要求。
|
||
"""
|
||
# 提取文件名列表
|
||
file_names = [f.get("filename", "") for f in file_list] if file_list else []
|
||
file_list_str = "\n".join(f" - {fn}" for fn in file_names) if file_names else " (未获取到文件列表)"
|
||
|
||
prompt = f"""请作为资深代码审查专家,对以下 Pull Request 的代码变更进行严格审查。
|
||
|
||
## PR 基本信息
|
||
- PR 编号: #{pr_number}
|
||
- 修改文件数: {len(file_list) if file_list else '未知'}
|
||
|
||
## 修改文件列表
|
||
{file_list_str}
|
||
|
||
## 代码变更(diff)
|
||
```diff
|
||
{diff_text}
|
||
```
|
||
|
||
## 审查要求
|
||
请从以下维度进行审查,重点关注**阻塞级问题**:
|
||
|
||
### 问题分级标准
|
||
- **🔴 阻塞级(BLOCKER)**:必须修复,否则不允许合并。包括:
|
||
1. **明显逻辑bug**:条件判断错误、死循环、返回值错误、空指针/None引用未处理、边界条件遗漏导致功能异常
|
||
2. **安全漏洞**:SQL注入、XSS、命令注入、敏感信息明文存储/泄露、权限绕过、认证缺失
|
||
3. **语法错误**:代码存在语法层面的错误,无法运行
|
||
4. **数据损坏风险**:可能导致数据丢失、数据不一致、脏数据写入的问题
|
||
|
||
- **💡 建议级(SUGGESTION)**:不阻塞合并,仅供参考改进。包括:
|
||
1. 命名不规范、代码风格问题
|
||
2. 最佳实践建议、设计模式优化
|
||
3. 格式问题(缩进、空行、import顺序等)
|
||
4. 代码可读性改进、注释补充
|
||
5. 非关键路径的轻微性能优化建议
|
||
6. 重复代码、过长函数等代码质量问题
|
||
|
||
1. **逻辑正确性**:是否有明显的逻辑错误、边界条件遗漏、空指针/None引用风险
|
||
2. **异常处理**:异常捕获是否合理,是否有裸except,错误处理是否完善
|
||
3. **参数校验**:函数入参、返回值是否有必要的校验
|
||
4. **代码质量**:是否有重复代码、命名不清晰、过于复杂的函数
|
||
5. **性能问题**:是否有明显的性能隐患(如循环内重复计算、不必要的数据库查询)
|
||
6. **安全问题**:是否有注入风险、敏感信息泄露、权限控制问题
|
||
|
||
## 输出格式
|
||
请使用以下格式输出,语言为中文。**必须严格按照格式输出,尤其是【阻塞级判定】部分**:
|
||
|
||
### 【阻塞级判定】
|
||
- 是否存在阻塞级问题:(是 / 否)
|
||
- 阻塞级问题数量:X 个
|
||
|
||
### 📊 审查概览
|
||
- 整体评价:(通过 / 有建议 / 需修改)
|
||
- 建议级问题数量:X 个
|
||
|
||
### 🔴 阻塞级问题(必须修复)
|
||
(如果没有阻塞级问题,写"无")
|
||
1. **[文件: 行号] 问题标题**
|
||
- 问题类型:(逻辑bug / 安全漏洞 / 语法错误 / 数据损坏风险)
|
||
- 问题描述:...
|
||
- 修改建议:...
|
||
|
||
### 💡 改进建议(不阻塞合并)
|
||
(如果没有建议,写"无")
|
||
1. **[文件: 行号] 建议标题**
|
||
- 具体内容:...
|
||
|
||
### ✅ 良好实践
|
||
(可选,列出值得肯定的地方)
|
||
|
||
请务必基于代码实际内容审查,不要编造不存在的问题。如果代码质量良好,直接给出通过结论即可。
|
||
**重要:【阻塞级判定】必须准确,只有确实存在严重问题时才写"是"。**
|
||
"""
|
||
return prompt
|
||
|
||
|
||
def parse_blocker_result(review_text: str) -> Tuple[bool, int]:
|
||
"""
|
||
从审查结果中解析是否存在阻塞级问题。
|
||
返回 (has_blocker, blocker_count)
|
||
"""
|
||
# 先找【阻塞级判定】部分的明确标记
|
||
pattern = r"【阻塞级判定】[\s\S]*?是否存在阻塞级问题[::]\s*(是|否)"
|
||
match = re.search(pattern, review_text)
|
||
if match:
|
||
has_blocker = match.group(1) == "是"
|
||
else:
|
||
# fallback 1: 找"阻塞级问题数量"
|
||
count_pattern = r"阻塞级问题数量[::]\s*(\d+)"
|
||
count_match = re.search(count_pattern, review_text)
|
||
if count_match:
|
||
has_blocker = int(count_match.group(1)) > 0
|
||
else:
|
||
# fallback 2: 检查是否有"阻塞级问题"section且内容不是"无"
|
||
has_blocker = False
|
||
blocker_section = re.search(r"### 🔴 阻塞级问题[\s\S]*?(?=### |\Z)", review_text)
|
||
if blocker_section:
|
||
section_text = blocker_section.group(0)
|
||
# 如果有编号列表项,说明有问题
|
||
if re.search(r"\d+\.\s*\*\*", section_text):
|
||
has_blocker = True
|
||
|
||
# 提取数量
|
||
count_pattern = r"阻塞级问题数量[::]\s*(\d+)"
|
||
count_match = re.search(count_pattern, review_text)
|
||
blocker_count = int(count_match.group(1)) if count_match else (1 if has_blocker else 0)
|
||
|
||
logger.info(f"阻塞级问题解析: 存在={has_blocker}, 数量={blocker_count}")
|
||
return has_blocker, blocker_count
|
||
|
||
|
||
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)}")
|
||
sys.exit(1)
|
||
|
||
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}")
|
||
sys.exit(1)
|
||
|
||
# 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_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)
|
||
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 审查失败")
|
||
sys.exit(0) # fail-open: LLM调用失败不阻塞合并
|
||
|
||
# 7. 加上审查时间和标识(便于识别是自动审查)
|
||
from datetime import datetime
|
||
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
marker = "<!-- AI_CODE_REVIEW_AUTO_COMMENT -->"
|
||
full_comment = f"""{review_result}
|
||
|
||
---
|
||
<sub>🤖 由 AI 代码审查机器人自动生成 | {timestamp} | 模型: {llm_model}</sub>
|
||
|
||
{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:
|
||
# 去重:删除之前的 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.error("评论发布失败")
|
||
sys.exit(1)
|
||
|
||
# 10. 解析阻塞级问题,用退出码决定 job 状态
|
||
# 有阻塞级问题 → exit 1 → job失败 → Gitea自动打failure status → 门禁拦截
|
||
# 无阻塞级问题 → exit 0 → job成功 → Gitea自动打success status
|
||
# LLM调用失败等异常 → exit 0 → fail-open,不阻塞正常开发
|
||
has_blocker, blocker_count = parse_blocker_result(review_result)
|
||
|
||
if has_blocker:
|
||
logger.error(f"检测到 {blocker_count} 个阻塞级问题,审查不通过")
|
||
logger.info("代码审查完成(失败)")
|
||
sys.exit(1)
|
||
else:
|
||
logger.info("无阻塞级问题,审查通过")
|
||
logger.info("代码审查完成(通过)")
|
||
sys.exit(0)
|
||
|
||
except Exception as e:
|
||
logger.exception(f"审查脚本发生未预期的异常: {e}")
|
||
sys.exit(0) # fail-open: 异常不阻塞正常开发
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|