AI 辅助 Code Review——让 AI 在 CI/CD 里做第一道质量门禁
AI 辅助 Code Review——让 AI 在 CI/CD 里做第一道质量门禁
我所在的团队有一段时期 Code Review 的状态非常令人担忧。
每个 PR 提上来,Reviewer 的压力很大——代码量多、业务复杂、又要赶进度。所以很多 Review 的结果是:"看起来没问题,合了吧。" 然后两周后生产上发现一个低级 Bug,回头一看,代码里写着明显的问题,PR 的时候根本没发现。
我们统计过,大约 60% 的线上 Bug,事后复盘都能在代码里找到人工 Review 本该发现的问题。不是大家能力不行,是注意力有限。
后来我们引入了 AI Code Review,接在 CI/CD 里,每次提 PR 自动跑,先把明显的问题过滤掉,让人工 Review 只聚焦在真正需要人类判断的部分。
效果:人工 Review 的平均时间从 35 分钟降到了 18 分钟,同时漏掉的低级问题减少了 73%。
这篇文章讲的就是这套机制怎么建。
一、AI 最擅长发现什么类型的问题
在设计之前,先搞清楚 AI 的能力边界。不是所有 Code Review 的工作都适合 AI 来做。
AI 擅长的问题类型
1. 重复代码和可复用逻辑
// 这种代码,AI 能轻易发现
if (user.getAge() != null && user.getAge() > 0) {
// 处理年龄
}
// ...50 行后
if (product.getWeight() != null && product.getWeight() > 0) {
// 处理重量
}
// ...又50 行后,同样的模式出现了 5 次2. 明显的潜在 Bug
// NPE 风险
String name = user.getName().toUpperCase(); // user.getName() 可能是 null
// 整数溢出
int total = price * quantity; // price 和 quantity 都是 int,相乘可能溢出
// 资源未关闭
FileInputStream fis = new FileInputStream(path);
// ... 没有 try-with-resources,出异常时文件流不会被关闭3. 不规范的命名和编码风格
// 魔法数字
if (status == 3) { ... } // 3 是什么意思?
// 含义不明的变量名
int d = user.getRegisterDate().until(LocalDate.now(), ChronoUnit.DAYS);
// 过长的方法
public void process() {
// 200 行的方法体...
}4. 安全问题
// SQL 注入风险
String query = "SELECT * FROM users WHERE name = '" + userName + "'";
// 敏感信息打印到日志
log.info("用户密码:{}", user.getPassword());
// 硬编码的密钥
String secretKey = "hardcoded_secret_abc123";AI 不擅长(需要人工)的问题类型
- 业务逻辑正确性:AI 不了解你的业务规则
- 架构设计决策:是否应该引入新依赖、是否违反了某个设计原则
- 性能权衡:这段代码在特定业务场景下的性能是否可接受
- 产品需求理解:代码是否真的实现了 PRD 的要求
明白了边界,才能设计出好用的 AI Review 系统。
二、架构设计
整体思路:GitHub Actions 触发 → 获取 PR Diff → AI 分析 → 评论到 PR
PR 提交
↓
GitHub Actions 触发(on: pull_request)
↓
获取 PR 的代码变更(git diff)
↓
对变更代码进行 AI 分析
- 分批次处理大 diff(防止超出 Token 限制)
- 针对不同文件类型用不同的分析提示词
↓
解析 AI 输出,格式化评论
↓
通过 GitHub API 发评论到 PR
↓
如果发现高严重度问题,标记 Check 失败(可选)三、Java 实现:PR Review Bot
核心是一个 Spring Boot 应用,作为 GitHub Webhook 的接收端,或者直接在 GitHub Actions 里运行。
/**
* PR Review 分析器
* 核心功能:分析代码变更,生成评审意见
*/
@Service
@Slf4j
public class PrReviewAnalyzer {
@Autowired
private ChatLanguageModel reviewModel;
/**
* 分析一个文件的代码变更
*/
public ReviewResult analyzeFileDiff(FileDiff fileDiff) {
// 跳过不需要 Review 的文件
if (shouldSkipFile(fileDiff.getFileName())) {
return ReviewResult.skipped(fileDiff.getFileName());
}
// 构建 Review Prompt
String prompt = buildReviewPrompt(fileDiff);
// 调用 LLM
String response = reviewModel.generate(prompt).content().text();
// 解析结构化输出
return parseReviewResult(fileDiff.getFileName(), response);
}
private String buildReviewPrompt(FileDiff diff) {
return String.format("""
你是一位资深 Java 工程师,正在做 Code Review。
请审查以下代码变更(+ 是新增,- 是删除):
文件:%s
```diff
%s
```
请检查以下几类问题(按重要性排序):
1. 【高严重度】潜在 Bug:NPE、并发问题、资源泄露、逻辑错误
2. 【高严重度】安全问题:SQL 注入、XSS、硬编码密钥、敏感信息泄露
3. 【中严重度】代码质量:重复代码、过长方法(>50行)、复杂条件
4. 【中严重度】可维护性:魔法数字、不明含义的变量名、缺少注释
5. 【低严重度】规范问题:命名不符合规范、空指针保护不完整
输出格式(JSON):
{
"issues": [
{
"severity": "HIGH|MEDIUM|LOW",
"type": "BUG|SECURITY|QUALITY|MAINTAINABILITY|STYLE",
"line": 行号(针对新增的代码),
"description": "问题描述(简洁,50字以内)",
"suggestion": "建议修改方式(具体,附代码示例)"
}
],
"overall": "APPROVE|REQUEST_CHANGES|COMMENT",
"summary": "整体评价(2-3句话)"
}
如果代码变更很小或只是格式调整,不需要强行找问题,返回 issues 为空列表即可。
只返回 JSON,不要有其他文字。
""",
diff.getFileName(),
diff.getDiffContent()
);
}
private boolean shouldSkipFile(String fileName) {
return fileName.endsWith(".md") ||
fileName.endsWith(".txt") ||
fileName.endsWith(".json") ||
fileName.endsWith(".yml") ||
fileName.endsWith(".yaml") ||
fileName.contains("test") && fileName.endsWith(".xml") ||
fileName.contains(".generated.");
}
private ReviewResult parseReviewResult(String fileName, String llmResponse) {
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(llmResponse);
List<ReviewIssue> issues = new ArrayList<>();
JsonNode issuesNode = root.get("issues");
if (issuesNode != null && issuesNode.isArray()) {
for (JsonNode issueNode : issuesNode) {
issues.add(ReviewIssue.builder()
.severity(issueNode.get("severity").asText())
.type(issueNode.get("type").asText())
.line(issueNode.has("line") ? issueNode.get("line").asInt() : 0)
.description(issueNode.get("description").asText())
.suggestion(issueNode.get("suggestion").asText())
.build());
}
}
return ReviewResult.builder()
.fileName(fileName)
.issues(issues)
.overallDecision(root.get("overall").asText("COMMENT"))
.summary(root.get("summary").asText(""))
.success(true)
.build();
} catch (Exception e) {
log.error("Review 结果解析失败,file={}", fileName, e);
return ReviewResult.failure(fileName, "AI 响应解析失败");
}
}
}GitHub API 集成:把评论发到 PR
/**
* GitHub PR 评论发布器
*/
@Service
@Slf4j
public class GitHubPrCommenter {
@Value("${github.token}")
private String githubToken;
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build();
/**
* 发布整体 Review 评论
*/
public void postReviewComment(
String owner,
String repo,
int prNumber,
List<ReviewResult> allResults) {
String commentBody = formatReviewComment(allResults);
// 发 PR 整体评论
String url = String.format(
"https://api.github.com/repos/%s/%s/issues/%d/comments",
owner, repo, prNumber
);
String body = String.format("{\"body\": %s}",
new ObjectMapper().valueToTree(commentBody).toString());
Request request = new Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer " + githubToken)
.addHeader("Accept", "application/vnd.github+json")
.post(RequestBody.create(body, MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
log.error("发评论失败:{}", response.code());
} else {
log.info("PR #{} 评论发布成功", prNumber);
}
} catch (IOException e) {
log.error("发评论时网络异常", e);
}
}
/**
* 对特定代码行发评论(行级别评论)
*/
public void postLineComment(
String owner,
String repo,
int prNumber,
String commitId,
String path,
int line,
String comment) {
String url = String.format(
"https://api.github.com/repos/%s/%s/pulls/%d/comments",
owner, repo, prNumber
);
Map<String, Object> payload = Map.of(
"body", comment,
"commit_id", commitId,
"path", path,
"line", line,
"side", "RIGHT"
);
try {
String body = new ObjectMapper().writeValueAsString(payload);
Request request = new Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer " + githubToken)
.addHeader("Accept", "application/vnd.github+json")
.post(RequestBody.create(body, MediaType.parse("application/json")))
.build();
httpClient.newCall(request).execute().close();
} catch (Exception e) {
log.error("发行级评论失败", e);
}
}
/**
* 格式化整体 Review 评论(Markdown 格式)
*/
private String formatReviewComment(List<ReviewResult> results) {
long highCount = countIssuesBySeverity(results, "HIGH");
long mediumCount = countIssuesBySeverity(results, "MEDIUM");
long lowCount = countIssuesBySeverity(results, "LOW");
StringBuilder sb = new StringBuilder();
sb.append("## 🤖 AI Code Review 报告\n\n");
// 摘要
sb.append("### 问题汇总\n\n");
sb.append(String.format("| 严重度 | 数量 |\n|--------|------|\n"));
sb.append(String.format("| 🔴 高 | %d |\n", highCount));
sb.append(String.format("| 🟡 中 | %d |\n", mediumCount));
sb.append(String.format("| 🟢 低 | %d |\n\n", lowCount));
// 高严重度问题详情
if (highCount > 0) {
sb.append("### 🔴 需要关注的问题\n\n");
for (ReviewResult result : results) {
result.getIssues().stream()
.filter(i -> "HIGH".equals(i.getSeverity()))
.forEach(issue -> {
sb.append(String.format("**[%s] %s** (第 %d 行)\n\n",
issue.getType(), result.getFileName(), issue.getLine()));
sb.append(issue.getDescription()).append("\n\n");
sb.append("> 建议:").append(issue.getSuggestion()).append("\n\n");
sb.append("---\n\n");
});
}
}
// 中严重度问题
if (mediumCount > 0 && mediumCount <= 10) { // 太多就不展开
sb.append("### 🟡 值得改进的地方\n\n");
// ... 类似处理
}
// 免责声明
sb.append("\n---\n");
sb.append("*此 Review 由 AI 自动生成,作为人工 Review 的前置补充。");
sb.append("AI 发现的问题可能存在误判,最终决策以人工 Reviewer 为准。*\n");
return sb.toString();
}
private long countIssuesBySeverity(List<ReviewResult> results, String severity) {
return results.stream()
.flatMap(r -> r.getIssues().stream())
.filter(i -> severity.equals(i.getSeverity()))
.count();
}
}四、GitHub Actions 配置
# .github/workflows/ai-code-review.yml
name: AI Code Review
on:
pull_request:
types: [opened, synchronize]
# 只对主要代码变更触发,忽略纯文档 PR
paths:
- 'src/**/*.java'
jobs:
ai-review:
runs-on: ubuntu-latest
# 只在非 Draft PR 上运行
if: github.event.pull_request.draft == false
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # 获取完整历史,用于 diff 计算
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Get PR diff
id: diff
run: |
# 获取 PR 的代码变更
git diff origin/${{ github.base_ref }}...HEAD -- '*.java' > /tmp/pr.diff
echo "diff_size=$(wc -c < /tmp/pr.diff)" >> $GITHUB_OUTPUT
- name: Run AI Review
if: steps.diff.outputs.diff_size != '0'
run: |
./mvnw -pl tools/ai-reviewer \
spring-boot:run \
-Dspring-boot.run.arguments="
--diff.file=/tmp/pr.diff
--github.token=${{ secrets.GITHUB_TOKEN }}
--github.owner=${{ github.repository_owner }}
--github.repo=${{ github.event.repository.name }}
--github.pr.number=${{ github.event.number }}
--github.commit.id=${{ github.event.pull_request.head.sha }}
"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SPRING_PROFILES_ACTIVE: github-actions
- name: Fail on critical issues
if: steps.ai-review.outputs.has_critical == 'true'
run: |
echo "发现高严重度问题,请修复后重新提交"
# 注意:是否阻断 PR 合并,需要根据团队策略决定
# 我们的做法是:初期只 Warning,不 Fail;稳定后才 Fail
exit 0 # 改成 exit 1 会阻断 PR 合并五、大 Diff 的分批处理
当 PR 改动量大时,整个 diff 可能超过 LLM 的 Context 限制,需要分批处理:
/**
* 大 Diff 分批处理器
*/
@Service
public class DiffSplitter {
// 每批最多处理的字符数(约等于 Token 数的 4 倍)
private static final int MAX_CHARS_PER_BATCH = 8000;
/**
* 把大 diff 按文件拆分,避免超出 Token 限制
*/
public List<FileDiff> splitDiff(String fullDiff) {
List<FileDiff> fileDiffs = new ArrayList<>();
String[] lines = fullDiff.split("\n");
String currentFile = null;
StringBuilder currentDiff = new StringBuilder();
for (String line : lines) {
if (line.startsWith("diff --git")) {
// 保存上一个文件
if (currentFile != null) {
addFileDiff(fileDiffs, currentFile, currentDiff.toString());
}
// 开始新文件
currentFile = extractFileName(line);
currentDiff = new StringBuilder();
}
currentDiff.append(line).append("\n");
}
// 保存最后一个文件
if (currentFile != null) {
addFileDiff(fileDiffs, currentFile, currentDiff.toString());
}
return fileDiffs;
}
private void addFileDiff(List<FileDiff> diffs, String fileName, String diffContent) {
// 如果单个文件的 diff 太大,截取前半部分(保留最重要的改动)
if (diffContent.length() > MAX_CHARS_PER_BATCH) {
log.warn("文件 {} 的 diff 过大({}字符),截取前{}字符分析",
fileName, diffContent.length(), MAX_CHARS_PER_BATCH);
diffContent = diffContent.substring(0, MAX_CHARS_PER_BATCH) +
"\n... (以下内容因篇幅截断,请人工 Review)";
}
diffs.add(FileDiff.builder()
.fileName(fileName)
.diffContent(diffContent)
.build());
}
private String extractFileName(String diffGitLine) {
// "diff --git a/src/main/java/Foo.java b/src/main/java/Foo.java"
String[] parts = diffGitLine.split(" ");
return parts[parts.length - 1].replaceFirst("^b/", "");
}
}六、Mermaid:AI Review 工作流
七、真实数据:引入后效果
这是我们团队引入 AI Review 后的数据(3 个月统计):
| 指标 | 引入前 | 引入后 | 变化 |
|---|---|---|---|
| 人工 Review 平均时间 | 35 分钟 | 18 分钟 | -49% |
| PR 从提交到合并平均时间 | 26 小时 | 18 小时 | -31% |
| Review 漏过的低级 Bug 数量/月 | 11 个 | 3 个 | -73% |
| AI 误报率(无效评论比例) | - | 28% | - |
值得注意的是 28% 的误报率。AI 有时候会对正常代码发出错误告警,这会打扰开发者。所以我们做了一个配置:只有"高严重度"的问题会在 PR 里生成通知,中低严重度的问题只展示在可折叠的摘要里。
八、几个实践建议
建议一:先 Comment,不要立即 Block
刚引入时,让 AI Review 只发评论,不阻断 PR 合并。等团队建立信任,误报率降低之后,再考虑对高严重度问题设置质量门禁。
建议二:区分强制检查和建议性检查
安全问题(SQL 注入、硬编码密钥)设为强制,必须处理;代码风格问题设为建议,开发者自己判断。
建议三:维护 Review 规则的黑白名单
有些代码你知道是"刻意"这样写的,比如测试代码里的魔法数字。可以在注释里加 // ai-review-ignore 让 Bot 跳过特定代码段。
建议四:用比主模型便宜的模型
Code Review 用 GPT-4o-mini 而不是 GPT-4o,质量差不多,但成本只有 1/20。我们团队每月的 AI Review 成本不到 15 美元。
