第1765篇:代码审查自动化——用AI在CI流程中检测代码质量
第1765篇:代码审查自动化——用AI在CI流程中检测代码质量
先聊一个真实的场景。
我们团队有段时间代码审查压力很大,主要工程师每天要处理十几个PR,每个PR少则几十行,多则几百行。时间一紧,有些PR就走形式了,眼睛扫一眼就过了。结果两个月后,生产环境出了一个性能问题——某个新加的接口在for循环里做了N+1查询,审查时没人仔细看,上线后在高并发下把数据库打挂了。
这件事之后,我们开始认真建设AI代码审查能力。
现有工具的局限
市面上已经有不少代码质量工具:SonarQube做静态分析,Checkstyle做格式规范,SpotBugs找常见Bug模式。这些工具各有价值,但有一个共同的局限:基于规则的检测,发现不了语义层面的问题。
N+1查询的问题,规则工具发现不了,因为它不理解"这个循环里的这个方法调用,最终会触发一条SQL"这种语义关系。代码可读性差、命名含义不清楚、业务逻辑有逻辑漏洞,这些都是规则工具的盲区。
大模型能做到语义理解,这是它的核心价值所在。
系统设计
AI审查不是替换现有工具,而是补充。我们把静态分析工具的结果也拉进来,让AI做综合评判。
代码差异提取与上下文构建
这一步是质量的基础。不能只把diff扔给AI,要给充分的上下文。
@Service
@Slf4j
public class CodeContextBuilder {
@Data
@Builder
public static class ReviewContext {
private String prTitle;
private String prDescription;
private List<FileChange> changedFiles;
private Map<String, String> relatedFiles; // 变更文件依赖的其他文件
private TestCoverageReport coverageReport;
private SonarQubeReport sonarReport;
}
@Data
public static class FileChange {
private String filePath;
private String language;
private String diff;
private String fullContent; // diff的上下文,扩展到完整方法体
private List<String> addedLines;
private List<String> removedLines;
private ChangeType changeType; // ADDED/MODIFIED/DELETED/RENAMED
}
@Autowired
private GitService gitService;
public ReviewContext buildContext(PullRequest pr) {
ReviewContext ctx = ReviewContext.builder()
.prTitle(pr.getTitle())
.prDescription(pr.getDescription())
.changedFiles(new ArrayList<>())
.relatedFiles(new HashMap<>())
.build();
// 获取PR的diff
List<GitDiff> diffs = gitService.getDiff(pr.getBaseCommit(), pr.getHeadCommit());
for (GitDiff diff : diffs) {
if (!shouldReview(diff.getFilePath())) continue;
FileChange change = new FileChange();
change.setFilePath(diff.getFilePath());
change.setLanguage(detectLanguage(diff.getFilePath()));
change.setDiff(diff.getDiff());
change.setChangeType(diff.getChangeType());
// 关键:不只传diff,要传完整的方法上下文
// 如果修改的是方法中间某几行,要把整个方法体都传过去
String fullMethodContext = extractMethodContext(
diff.getFilePath(),
diff.getChangedLineNumbers(),
pr.getHeadCommit()
);
change.setFullContent(fullMethodContext);
ctx.getChangedFiles().add(change);
}
// 拉取相关文件(调用方、接口定义等)
enrichWithRelatedFiles(ctx, pr);
return ctx;
}
private String extractMethodContext(String filePath,
List<Integer> changedLines,
String commitHash) {
String fullContent = gitService.getFileContent(filePath, commitHash);
// 解析Java文件,找到包含变更行的方法体
JavaFileParser parser = new JavaFileParser(fullContent);
Set<String> affectedMethods = new HashSet<>();
for (int lineNum : changedLines) {
String methodName = parser.getMethodAtLine(lineNum);
if (methodName != null) {
affectedMethods.add(methodName);
}
}
if (affectedMethods.isEmpty()) {
// 如果找不到方法,返回变更行的前后50行上下文
return extractLineContext(fullContent, changedLines, 50);
}
StringBuilder sb = new StringBuilder();
for (String method : affectedMethods) {
sb.append(parser.extractMethodBody(method)).append("\n\n");
}
return sb.toString();
}
private boolean shouldReview(String filePath) {
// 排除不需要审查的文件
List<String> excludePatterns = List.of(
".*\\.xml$", ".*\\.yaml$", ".*\\.yml$",
".*/test/.*", // 测试文件可以单独设置是否审查
".*/generated/.*", // 自动生成的代码
".*\\.lock$"
);
return excludePatterns.stream()
.noneMatch(pattern -> filePath.matches(pattern));
}
private void enrichWithRelatedFiles(ReviewContext ctx, PullRequest pr) {
// 对于每个修改的接口/抽象类,找到它的实现
// 对于修改的方法,找到调用它的地方(有助于判断改动的影响范围)
// 这里用AST分析,找出依赖关系
for (FileChange change : ctx.getChangedFiles()) {
if (change.getLanguage().equals("java")) {
List<String> dependencies = findDependencies(
change.getFilePath(), change.getFullContent());
for (String dep : dependencies.stream().limit(3).collect(Collectors.toList())) {
if (!ctx.getRelatedFiles().containsKey(dep)) {
String content = gitService.getFileContent(dep, pr.getHeadCommit());
if (content != null) {
ctx.getRelatedFiles().put(dep, content);
}
}
}
}
}
}
}AI审查引擎
@Service
@Slf4j
public class AICodeReviewEngine {
@Autowired
private OpenAiService openAiService;
private static final String SYSTEM_PROMPT = """
你是一位资深Java工程师,有10年以上代码审查经验。
你需要对提供的代码变更进行专业审查,重点关注:
1. **正确性问题(CRITICAL)**:
- 逻辑错误、边界条件处理
- 空指针异常风险
- 并发安全问题(线程安全、竞态条件)
- 事务处理是否正确
2. **性能问题(HIGH)**:
- N+1查询问题
- 不必要的循环嵌套
- 大集合操作缺少分页
- 缓存使用不当
3. **安全问题(HIGH)**:
- SQL注入风险
- 敏感信息泄露(日志中打印密码/token等)
- 未验证的用户输入
4. **代码质量问题(MEDIUM)**:
- 方法过长(超过50行建议拆分)
- 重复代码
- 魔法数字/字符串
- 异常处理是否合理
5. **可读性问题(LOW)**:
- 命名是否清晰
- 注释是否必要且准确
对于每个发现的问题,按以下JSON格式输出:
{
"issues": [
{
"severity": "CRITICAL|HIGH|MEDIUM|LOW",
"category": "问题类别",
"file": "文件路径",
"lineRange": "行号范围,如 45-52",
"title": "问题标题(一句话)",
"description": "详细描述",
"suggestion": "具体的修改建议或示例代码"
}
],
"summary": "整体评价(100字以内)",
"approvalRecommendation": "APPROVE|REQUEST_CHANGES|COMMENT"
}
只输出真实存在的问题,不要为了显示"审查认真"而杜撰问题。
""";
public ReviewResult review(ReviewContext ctx) {
List<IssueComment> allIssues = new ArrayList<>();
// 对每个文件分别审查(避免上下文过大)
for (FileChange fileChange : ctx.getChangedFiles()) {
if (fileChange.getFullContent() == null ||
fileChange.getFullContent().isEmpty()) {
continue;
}
// Token预算控制:每个文件最多4000 tokens
String content = truncateToTokenBudget(fileChange.getFullContent(), 4000);
String userMessage = buildFileReviewMessage(fileChange, content, ctx);
try {
FileReviewResult result = callLLM(userMessage);
allIssues.addAll(result.getIssues());
log.info("文件审查完成: file={}, issues={}",
fileChange.getFilePath(), result.getIssues().size());
} catch (Exception e) {
log.error("文件审查失败: file={}", fileChange.getFilePath(), e);
}
}
// 汇总评估
return buildFinalResult(allIssues, ctx);
}
private String buildFileReviewMessage(FileChange file, String content,
ReviewContext ctx) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("## PR信息\n标题: %s\n描述: %s\n\n",
ctx.getPrTitle(),
ctx.getPrDescription() != null ? ctx.getPrDescription() : "无"));
sb.append(String.format("## 文件: %s\n变更类型: %s\n\n",
file.getFilePath(), file.getChangeType()));
sb.append("## 完整代码(包含变更上下文):\n```java\n");
sb.append(content);
sb.append("\n```\n\n");
sb.append("## Git Diff(变更行标记):\n```diff\n");
sb.append(file.getDiff());
sb.append("\n```\n");
// 如果有SonarQube结果,一并告知AI(避免重复报告相同问题)
if (ctx.getSonarReport() != null) {
List<String> sonarIssues = ctx.getSonarReport()
.getIssuesForFile(file.getFilePath());
if (!sonarIssues.isEmpty()) {
sb.append("\n## SonarQube已发现的问题(无需重复报告这些):\n");
sonarIssues.forEach(issue -> sb.append("- ").append(issue).append("\n"));
}
}
return sb.toString();
}
private ReviewResult buildFinalResult(List<IssueComment> issues, ReviewContext ctx) {
// 统计各级别问题数量
long criticalCount = issues.stream()
.filter(i -> i.getSeverity() == Severity.CRITICAL).count();
long highCount = issues.stream()
.filter(i -> i.getSeverity() == Severity.HIGH).count();
// 质量门控逻辑:有CRITICAL问题就阻塞
boolean blocked = criticalCount > 0 || highCount >= 3;
ApprovalRecommendation recommendation;
if (criticalCount > 0) {
recommendation = ApprovalRecommendation.REQUEST_CHANGES;
} else if (highCount > 0) {
recommendation = ApprovalRecommendation.REQUEST_CHANGES;
} else if (issues.stream().anyMatch(i -> i.getSeverity() == Severity.MEDIUM)) {
recommendation = ApprovalRecommendation.COMMENT;
} else {
recommendation = ApprovalRecommendation.APPROVE;
}
return ReviewResult.builder()
.issues(issues)
.criticalCount(criticalCount)
.highCount(highCount)
.blocked(blocked)
.recommendation(recommendation)
.build();
}
}PR评论发布
把审查结果发布为PR上的Review Comments,这样工程师直接在PR页面就能看到。
@Service
@Slf4j
public class GitHubReviewPublisher {
@Autowired
private GitHubApiClient githubClient;
public void publishReview(String repoFullName, int prNumber,
ReviewResult result) {
List<ReviewComment> lineComments = new ArrayList<>();
// 将问题转换为行内评论
for (IssueComment issue : result.getIssues()) {
String commentBody = formatIssueComment(issue);
lineComments.add(ReviewComment.builder()
.path(issue.getFile())
.body(commentBody)
.position(parseStartLine(issue.getLineRange()))
.build());
}
// 构建整体Review摘要
String reviewBody = buildReviewSummary(result);
ReviewEvent event;
switch (result.getRecommendation()) {
case REQUEST_CHANGES -> event = ReviewEvent.REQUEST_CHANGES;
case APPROVE -> event = ReviewEvent.APPROVE;
default -> event = ReviewEvent.COMMENT;
}
// 发布Review
githubClient.createPullRequestReview(
repoFullName,
prNumber,
reviewBody,
event,
lineComments
);
// 如果有CRITICAL问题,加一个特殊标签
if (result.getCriticalCount() > 0) {
githubClient.addLabel(repoFullName, prNumber, "needs-critical-fix");
}
log.info("Review已发布: pr={}, issues={}, recommendation={}",
prNumber, result.getIssues().size(), result.getRecommendation());
}
private String formatIssueComment(IssueComment issue) {
String severityEmoji = switch (issue.getSeverity()) {
case CRITICAL -> "🚨";
case HIGH -> "⚠️";
case MEDIUM -> "💡";
case LOW -> "📝";
};
return String.format("""
%s **[%s] %s**
%s
**建议:**
%s
""",
severityEmoji,
issue.getSeverity(),
issue.getTitle(),
issue.getDescription(),
issue.getSuggestion()
);
}
private String buildReviewSummary(ReviewResult result) {
StringBuilder sb = new StringBuilder();
sb.append("## AI Code Review 结果\n\n");
if (result.getIssues().isEmpty()) {
sb.append("✅ 未发现明显问题,代码质量良好。\n");
} else {
sb.append("| 级别 | 数量 |\n|------|------|\n");
Map<Severity, Long> countBySeverity = result.getIssues().stream()
.collect(Collectors.groupingBy(IssueComment::getSeverity,
Collectors.counting()));
for (Severity s : Severity.values()) {
long count = countBySeverity.getOrDefault(s, 0L);
if (count > 0) {
sb.append(String.format("| %s | %d |\n", s, count));
}
}
}
if (result.isBlocked()) {
sb.append("\n🔴 **此PR存在需要修复的问题,请处理后重新提交。**\n");
}
sb.append("\n> 此Review由AI自动生成,仅供参考。人工审查员的意见优先。\n");
return sb.toString();
}
}踩过的坑
坑1:误报让工程师失去信任
早期版本误报率较高,每个PR平均有2-3个无效问题。工程师开始抱怨"AI乱报",逐渐养成了不看AI评论的习惯。
解决方案:
- 在System Prompt里加了一句:"只输出你有90%以上把握的问题,宁可漏报也不要误报。"
- 建立人工反馈机制,工程师可以对每条评论标记"有效"或"无效",定期用这些反馈来优化Prompt。
- 对于
LOW级别的问题,不在PR上发表评论,只在汇总报告中列出,避免噪音。
坑2:大文件Token超限
有些文件几千行,无法整文件传给大模型。
解决方案:只传变更相关的方法体,而不是整个文件。配合前面讲的extractMethodContext方法,把token用在刀刃上。
坑3:N+1检测准确率不高
N+1问题需要追踪方法调用链,单看一个文件的代码,AI没有足够上下文判断某个方法调用是否会触发SQL。
解决方案:在相关文件里把Repository/Mapper接口的方法签名也传进去。看到userRepository.findById(id)在循环里出现,AI能根据Repository方法名判断出这会触发SQL查询。
坑4:对框架代码的误解
AI有时候会误判Spring框架的特定用法,比如把@Transactional的传播行为误判为"事务未生效"。
解决方案:在Prompt里加入项目的技术栈说明(Spring Boot版本、使用的框架),让AI了解这些框架特性。
效果数据
上线6个月后:
| 指标 | 数值 |
|---|---|
| AI发现的真实问题中,被人工确认有效的比例 | 71% |
| CRITICAL级别问题的误报率 | 4.3%(人工审查确认的数据) |
| 每个PR平均节省人工审查时间 | 约12分钟 |
| 发布到生产后发现的因代码质量导致的问题 | 同期下降41% |
71%的有效率还不算高,但CRITICAL问题的精准度很重要,4.3%误报率在实际中已经可以接受了。
重要的是那41%的生产问题下降——N+1查询、空指针风险、并发问题这类系统性缺陷,在上线前就被拦住了。
