AI代码审查助手:用Spring AI构建自动代码审查工具
AI代码审查助手:用Spring AI构建自动代码审查工具
date: 2026-09-20 tags: [代码审查, GitHub Actions, Spring AI, DevOps, Java]
开篇故事:从2天到4小时的PR审查
张华是某金融科技公司的Lead,团队15人,每天提交10-15个PR。
2025年6月之前,他们的代码审查是这样的:
- PR提交 → 在Slack里@对应的Reviewer
- Reviewer看到消息,先把手头的活停下来
- 打开PR,花15-30分钟读代码,写评论
- 来来回回几轮,最快也要2天
- 还经常漏掉问题:SQL注入漏洞、忘加索引、异常没处理
核心问题:
- 每个PR平均等待Reviewer 8.4小时(人在开会/午休/下班)
- Reviewer每天要看5-6个PR,到后来开始"敷衍审查"(草草看两眼就过了)
- 生产事故里,有37%的问题是代码审查没发现的
2025年7月,张华用Spring AI搭了一个AI代码审查助手,接入GitHub Actions,每个PR提交后自动触发:
上线后的变化:
- AI在PR提交后3分钟内完成初步审查,自动发评论
- 人工Reviewer只需要关注AI标记的问题,其余快速浏览
- PR平均等待时间:从2天降到4.1小时
- AI发现的安全漏洞:上线3个月,拦截SQL注入2次、未授权接口3次、敏感信息泄露1次
- AI建议接受率:64%(人工Reviewer认为有价值的评论占64%)
一、AI代码审查的价值定位:辅助不是替代
1.1 AI能做什么,不能做什么
AI擅长的审查维度:
- 安全漏洞:SQL注入、XSS、SSRF、硬编码密钥
- 代码规范:命名规范、注释完整性、方法过长
- 常见Bug模式:空指针风险、资源未关闭、线程安全问题
- 性能问题:N+1查询、大循环中的数据库调用、不必要的全表扫描
- 测试覆盖:是否有对应的单元测试
AI不擅长的:
- 业务逻辑正确性(AI不了解你的业务)
- 架构合理性判断(需要全局视角)
- 性能问题的根本原因(需要运行数据)
- 代码的"感觉"(有些代码技术上正确,但读起来很别扭)
1.2 效益预估
团队10人,每天10个PR:
优化前:
- 每个PR等待时间:8.4小时(Reviewer响应时间)
- 每个PR Review耗时:35分钟(人工)
- 每天总审查时间:10 × 35min = 350分钟 ≈ 5.8小时
优化后:
- AI审查:10 × 3分钟 = 30分钟(自动)
- 人工复审(AI标记后):10 × 12分钟 = 120分钟
- 每天节省:350 - 120 = 230分钟 ≈ 3.8小时
- 按人力成本¥500/小时:每天节省¥1,900,每年节省¥57万二、GitHub Actions集成:PR触发AI审查
2.1 完整的GitHub Actions Workflow配置
创建 .github/workflows/ai-code-review.yml:
name: AI Code Review
on:
pull_request:
types: [opened, synchronize, reopened]
# 只对源代码变更触发,配置文件变更不触发
paths:
- 'src/**/*.java'
- 'src/**/*.kt'
# 防止同一PR的多个推送并发触发
concurrency:
group: ai-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
ai-review:
name: AI Code Review
runs-on: ubuntu-latest
# 权限配置:读取PR内容、写评论
permissions:
contents: read
pull-requests: write
issues: write
steps:
# Step 1: Checkout代码(只取必要的历史)
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来计算diff
# Step 2: 获取PR变更的文件列表
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files: |
**/*.java
separator: ","
# Step 3: 生成代码Diff(只取变更的行)
- name: Generate code diff
id: diff
run: |
# 获取与base branch的diff
git diff origin/${{ github.base_ref }}...HEAD -- "*.java" > /tmp/code.diff
# 统计变更行数
ADDED=$(grep '^+' /tmp/code.diff | grep -v '^+++' | wc -l)
DELETED=$(grep '^-' /tmp/code.diff | grep -v '^---' | wc -l)
echo "added_lines=$ADDED" >> $GITHUB_OUTPUT
echo "deleted_lines=$DELETED" >> $GITHUB_OUTPUT
# 如果变更行数超过1000,截断(避免超出Token限制)
if [ "$ADDED" -gt 1000 ]; then
head -n 3000 /tmp/code.diff > /tmp/code_truncated.diff
echo "truncated=true" >> $GITHUB_OUTPUT
else
cp /tmp/code.diff /tmp/code_truncated.diff
echo "truncated=false" >> $GITHUB_OUTPUT
fi
# Step 4: 调用AI审查服务
- name: Call AI Review Service
id: ai-review
if: steps.changed-files.outputs.any_changed == 'true'
env:
AI_REVIEW_SERVICE_URL: ${{ secrets.AI_REVIEW_SERVICE_URL }}
AI_REVIEW_API_KEY: ${{ secrets.AI_REVIEW_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
BASE_BRANCH: ${{ github.base_ref }}
HEAD_SHA: ${{ github.sha }}
run: |
# 读取diff内容
DIFF_CONTENT=$(cat /tmp/code_truncated.diff | base64 -w 0)
# 调用AI审查服务API
RESPONSE=$(curl -s -X POST "$AI_REVIEW_SERVICE_URL/api/review" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AI_REVIEW_API_KEY" \
-d "{
\"prNumber\": \"$PR_NUMBER\",
\"prTitle\": \"$PR_TITLE\",
\"author\": \"$PR_AUTHOR\",
\"baseBranch\": \"$BASE_BRANCH\",
\"headSha\": \"$HEAD_SHA\",
\"diffContent\": \"$DIFF_CONTENT\",
\"truncated\": ${{ steps.diff.outputs.truncated }},
\"changedFiles\": \"${{ steps.changed-files.outputs.all_changed_files }}\"
}" \
--max-time 120) # 最长等待2分钟
echo "review_result=$RESPONSE" >> $GITHUB_OUTPUT
echo "Review API Response: $RESPONSE"
# Step 5: 将AI审查结果发布为PR评论
- name: Post review comment
if: steps.ai-review.outcome == 'success'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const reviewResult = JSON.parse('${{ steps.ai-review.outputs.review_result }}');
// 格式化评论内容
let comment = `## 🤖 AI代码审查报告\n\n`;
comment += `> 由 AI 助手自动生成,仅供参考。请以人工审查为准。\n\n`;
if (reviewResult.summary) {
comment += `### 📋 总体评估\n${reviewResult.summary}\n\n`;
}
if (reviewResult.issues && reviewResult.issues.length > 0) {
comment += `### ⚠️ 发现问题 (${reviewResult.issues.length})\n\n`;
// 按严重程度排序
const critical = reviewResult.issues.filter(i => i.severity === 'CRITICAL');
const major = reviewResult.issues.filter(i => i.severity === 'MAJOR');
const minor = reviewResult.issues.filter(i => i.severity === 'MINOR');
if (critical.length > 0) {
comment += `#### 🔴 严重问题\n`;
critical.forEach(issue => {
comment += `- **[${issue.type}]** ${issue.file}:${issue.line}\n`;
comment += ` - 问题: ${issue.description}\n`;
comment += ` - 建议: ${issue.suggestion}\n\n`;
});
}
if (major.length > 0) {
comment += `#### 🟡 重要问题\n`;
major.forEach(issue => {
comment += `- **[${issue.type}]** \`${issue.file}:${issue.line}\`\n`;
comment += ` - ${issue.description}\n\n`;
});
}
if (minor.length > 0) {
comment += `#### 🟢 建议优化\n`;
minor.forEach(issue => {
comment += `- \`${issue.file}:${issue.line}\`: ${issue.description}\n`;
});
}
} else {
comment += `### ✅ 未发现明显问题\n\nAI审查未发现严重问题,请人工复核业务逻辑。\n\n`;
}
comment += `---\n`;
comment += `*审查时间: ${new Date().toISOString()} | 变更行数: +${{ steps.diff.outputs.added_lines }}/-${{ steps.diff.outputs.deleted_lines }}*`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }},
body: comment
});三、Spring AI代码审查服务:核心逻辑实现
3.1 项目结构
ai-code-reviewer/
├── src/main/java/com/laozhang/reviewer/
│ ├── ReviewController.java # REST入口
│ ├── service/
│ │ ├── CodeReviewService.java # 核心审查逻辑
│ │ ├── DiffParser.java # 解析Git Diff
│ │ ├── ReviewPromptBuilder.java # 构建审查Prompt
│ │ └── GitHubCommentService.java # 发布GitHub评论
│ ├── model/
│ │ ├── ReviewRequest.java
│ │ ├── ReviewResult.java
│ │ └── CodeIssue.java
│ └── config/
│ └── SpringAiConfig.java3.2 完整的审查服务实现
@RestController
@RequestMapping("/api")
@Slf4j
public class ReviewController {
private final CodeReviewService reviewService;
@PostMapping("/review")
public ResponseEntity<ReviewResult> review(@RequestBody ReviewRequest request) {
log.info("Received review request for PR #{}: {}",
request.getPrNumber(), request.getPrTitle());
try {
ReviewResult result = reviewService.review(request);
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("Review failed for PR #{}", request.getPrNumber(), e);
return ResponseEntity.ok(ReviewResult.failed(
"AI审查服务暂时不可用,请进行人工审查"
));
}
}
@GetMapping("/health")
public ResponseEntity<String> health() {
return ResponseEntity.ok("OK");
}
}@Service
@Slf4j
public class CodeReviewService {
private final ChatClient chatClient;
private final DiffParser diffParser;
private final ReviewPromptBuilder promptBuilder;
public ReviewResult review(ReviewRequest request) {
// Step 1: 解析Diff
String rawDiff = new String(Base64.getDecoder().decode(request.getDiffContent()));
List<ChangedFile> changedFiles = diffParser.parse(rawDiff);
log.info("Parsed {} changed files from diff", changedFiles.size());
// Step 2: 过滤掉不需要审查的文件
List<ChangedFile> reviewableFiles = changedFiles.stream()
.filter(f -> shouldReview(f.getFileName()))
.collect(Collectors.toList());
if (reviewableFiles.isEmpty()) {
return ReviewResult.noIssues("无需审查的文件变更");
}
// Step 3: 分批审查(每批最多3个文件,避免超出上下文窗口)
List<CodeIssue> allIssues = new ArrayList<>();
String overallSummary = "";
List<List<ChangedFile>> batches = partition(reviewableFiles, 3);
for (int i = 0; i < batches.size(); i++) {
List<ChangedFile> batch = batches.get(i);
log.debug("Reviewing batch {}/{}: {} files", i + 1, batches.size(), batch.size());
ReviewBatchResult batchResult = reviewBatch(batch, request);
allIssues.addAll(batchResult.getIssues());
if (i == 0) {
overallSummary = batchResult.getSummary();
}
}
// Step 4: 对问题去重(防止同一问题在多个批次中重复出现)
List<CodeIssue> deduplicatedIssues = deduplicateIssues(allIssues);
// Step 5: 按严重程度排序
deduplicatedIssues.sort(Comparator.comparing(CodeIssue::getSeverity).reversed());
return ReviewResult.builder()
.prNumber(request.getPrNumber())
.summary(overallSummary)
.issues(deduplicatedIssues)
.reviewedAt(Instant.now())
.totalFilesReviewed(reviewableFiles.size())
.build();
}
private ReviewBatchResult reviewBatch(List<ChangedFile> files, ReviewRequest request) {
String prompt = promptBuilder.buildBatchReviewPrompt(files, request);
// 使用结构化输出,让LLM直接返回JSON
String response = chatClient.prompt()
.system(getSystemPrompt())
.user(prompt)
.call()
.content();
// 解析LLM响应
return parseReviewResponse(response);
}
private String getSystemPrompt() {
return """
你是一位资深Java工程师,专门进行代码安全审查和质量审查。
你的职责:
1. 识别安全漏洞(SQL注入、XSS、SSRF、权限绕过、敏感信息泄露)
2. 发现常见Bug(空指针、资源泄露、线程安全、异常处理不当)
3. 指出性能问题(N+1查询、大循环中的IO操作、不合理的缓存使用)
4. 检查代码规范(命名、注释、方法长度、圈复杂度)
5. 评估测试覆盖
你必须:
- 只针对变更的代码行(以+开头的行)进行审查
- 每个问题给出具体的文件名、行号
- 提供可操作的修改建议,而不只是指出问题
- 严重程度分为:CRITICAL(安全漏洞/数据丢失风险)、MAJOR(功能Bug/性能问题)、MINOR(规范/优化建议)
输出格式:严格的JSON,不要添加任何说明文字
{
"summary": "总体评估(2-3句话)",
"issues": [
{
"severity": "CRITICAL|MAJOR|MINOR",
"type": "SECURITY|BUG|PERFORMANCE|STYLE|TEST",
"file": "文件名",
"line": 行号,
"description": "问题描述",
"suggestion": "修改建议(可以包含代码片段)",
"codeSnippet": "问题代码片段"
}
]
}
""";
}
private boolean shouldReview(String fileName) {
// 跳过自动生成的文件
if (fileName.contains("generated") ||
fileName.contains("Generated") ||
fileName.endsWith("_pb.java") ||
fileName.contains("target/")) {
return false;
}
// 只审查Java文件
return fileName.endsWith(".java");
}
private ReviewBatchResult parseReviewResponse(String response) {
try {
// 提取JSON部分(LLM可能会多输出一些文字)
String json = extractJson(response);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
String summary = root.path("summary").asText("");
List<CodeIssue> issues = new ArrayList<>();
JsonNode issuesNode = root.path("issues");
if (issuesNode.isArray()) {
for (JsonNode issueNode : issuesNode) {
CodeIssue issue = CodeIssue.builder()
.severity(IssueSeverity.valueOf(
issueNode.path("severity").asText("MINOR")))
.type(IssueType.valueOf(
issueNode.path("type").asText("STYLE")))
.file(issueNode.path("file").asText())
.line(issueNode.path("line").asInt(0))
.description(issueNode.path("description").asText())
.suggestion(issueNode.path("suggestion").asText())
.codeSnippet(issueNode.path("codeSnippet").asText())
.build();
issues.add(issue);
}
}
return ReviewBatchResult.builder()
.summary(summary)
.issues(issues)
.build();
} catch (Exception e) {
log.error("Failed to parse review response: {}", response, e);
return ReviewBatchResult.empty();
}
}
private String extractJson(String text) {
// 找到第一个{和最后一个}
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end >= 0 && end > start) {
return text.substring(start, end + 1);
}
return "{}";
}
private List<CodeIssue> deduplicateIssues(List<CodeIssue> issues) {
// 按file+line去重
Map<String, CodeIssue> seen = new LinkedHashMap<>();
for (CodeIssue issue : issues) {
String key = issue.getFile() + ":" + issue.getLine() + ":" + issue.getType();
seen.putIfAbsent(key, issue);
}
return new ArrayList<>(seen.values());
}
private <T> List<List<T>> partition(List<T> list, int size) {
List<List<T>> result = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
result.add(list.subList(i, Math.min(i + size, list.size())));
}
return result;
}
}3.3 Git Diff解析器
@Component
@Slf4j
public class DiffParser {
/**
* 解析Git diff输出,提取每个文件的变更信息
*/
public List<ChangedFile> parse(String diffContent) {
List<ChangedFile> files = new ArrayList<>();
ChangedFile currentFile = null;
StringBuilder currentHunk = new StringBuilder();
int currentLine = 0;
for (String line : diffContent.split("\n")) {
if (line.startsWith("diff --git")) {
// 保存上一个文件
if (currentFile != null) {
currentFile.addHunk(currentHunk.toString());
files.add(currentFile);
currentHunk = new StringBuilder();
}
// 提取文件名:diff --git a/src/xxx.java b/src/xxx.java
String fileName = line.replaceAll("diff --git a/.+ b/", "");
currentFile = new ChangedFile(fileName);
} else if (line.startsWith("@@")) {
// 保存上一个hunk
if (currentHunk.length() > 0 && currentFile != null) {
currentFile.addHunk(currentHunk.toString());
currentHunk = new StringBuilder();
}
// 解析行号:@@ -10,7 +10,9 @@
currentLine = parseStartLine(line);
currentHunk.append(line).append("\n");
} else if (line.startsWith("+") && !line.startsWith("+++")) {
// 新增的行
if (currentFile != null) {
currentFile.addAddedLine(currentLine, line.substring(1));
}
currentHunk.append(line).append("\n");
currentLine++;
} else if (line.startsWith("-") && !line.startsWith("---")) {
// 删除的行
currentHunk.append(line).append("\n");
// 删除行不增加行号
} else if (!line.startsWith("\\")) {
// 上下文行
currentHunk.append(line).append("\n");
currentLine++;
}
}
// 保存最后一个文件
if (currentFile != null && currentHunk.length() > 0) {
currentFile.addHunk(currentHunk.toString());
files.add(currentFile);
}
log.debug("Parsed {} files from diff", files.size());
return files;
}
private int parseStartLine(String hunkHeader) {
// @@ -10,7 +10,9 @@ → 提取 +后的行号
try {
String plus = hunkHeader.split("\\+")[1];
return Integer.parseInt(plus.split(",|\\s")[0]);
} catch (Exception e) {
return 0;
}
}
}@Data
@AllArgsConstructor
public class ChangedFile {
private final String fileName;
private final List<String> hunks = new ArrayList<>();
private final Map<Integer, String> addedLines = new LinkedHashMap<>();
public void addHunk(String hunk) {
if (!hunk.isBlank()) {
hunks.add(hunk);
}
}
public void addAddedLine(int lineNumber, String content) {
addedLines.put(lineNumber, content);
}
/**
* 获取完整的diff内容(用于LLM审查)
*/
public String getDiffContent() {
return String.join("\n", hunks);
}
/**
* 获取新增行摘要(用于统计)
*/
public int getAddedLineCount() {
return addedLines.size();
}
}3.4 Prompt构建器
@Component
public class ReviewPromptBuilder {
public String buildBatchReviewPrompt(List<ChangedFile> files, ReviewRequest request) {
StringBuilder prompt = new StringBuilder();
prompt.append(String.format("""
## PR信息
- PR标题:%s
- 作者:%s
- 目标分支:%s
## 待审查的代码变更
""",
request.getPrTitle(),
request.getAuthor(),
request.getBaseBranch()
));
for (ChangedFile file : files) {
prompt.append(String.format("### 文件:%s\n", file.getFileName()));
prompt.append("```diff\n");
prompt.append(file.getDiffContent());
prompt.append("\n```\n\n");
}
prompt.append("""
## 审查要求
请重点检查以上代码变更中的:
1. 安全漏洞(特别是SQL注入、权限控制、敏感数据处理)
2. 潜在的NullPointerException
3. 资源泄露(数据库连接、文件句柄等)
4. 并发安全问题
5. 不合理的异常处理
6. 测试覆盖是否充分
注意:
- 只分析以+开头的新增代码行
- 行号对应新文件中的行号
- 如果代码完全没有问题,issues数组返回空数组
""");
return prompt.toString();
}
}四、审查维度设计:安全/性能/规范/测试
4.1 安全审查提示词(精细化)
@Component
public class SecurityReviewPrompts {
// 专门针对安全漏洞的Prompt
public static final String SECURITY_PROMPT = """
作为安全审查专家,检查以下Java代码中的安全漏洞:
重点检查:
1. SQL注入
- 是否使用字符串拼接构建SQL?
- PreparedStatement参数是否正确绑定?
- JPA/MyBatis的${}是否被滥用?
2. 敏感信息泄露
- 日志中是否打印密码、Token、身份证等敏感信息?
- 异常信息是否直接返回给前端?
- 配置文件是否硬编码密钥?
3. 权限控制
- 接口是否缺少鉴权注解(@PreAuthorize/@Secured)?
- 是否存在越权访问风险(用userId做过滤了吗)?
- 文件上传是否校验文件类型?
4. 反序列化安全
- 是否使用不安全的反序列化(ObjectInputStream)?
- JSON反序列化是否开启了多态类型(activateDefaultTyping)?
5. SSRF(服务端请求伪造)
- URL是否由用户输入控制?
- 是否有域名白名单校验?
""";
// 常见安全漏洞的代码示例(用于Few-Shot)
public static final String SECURITY_EXAMPLES = """
漏洞示例1 - SQL注入:
```java
// 错误
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
// 正确
String sql = "SELECT * FROM users WHERE name = ?";
preparedStatement.setString(1, name);
```
漏洞示例2 - 敏感信息泄露:
```java
// 错误
log.info("User login: username={}, password={}", username, password);
// 正确
log.info("User login: username={}", username);
```
漏洞示例3 - 越权访问:
```java
// 错误 - 直接用用户传入的orderId查询
Order order = orderService.getById(orderId);
// 正确 - 加入userId过滤
Order order = orderService.getByIdAndUserId(orderId, currentUserId);
```
""";
}4.2 性能审查规则
@Component
public class PerformanceReviewRules {
// N+1查询检测规则(通过静态分析)
public List<CodeIssue> checkNPlusOnePattern(ChangedFile file) {
List<CodeIssue> issues = new ArrayList<>();
Map<Integer, String> addedLines = file.getAddedLines();
// 检测模式:循环内出现数据库调用
List<Integer> loopLines = new ArrayList<>();
boolean inLoop = false;
int loopDepth = 0;
for (Map.Entry<Integer, String> entry : addedLines.entrySet()) {
String line = entry.getValue().trim();
int lineNum = entry.getKey();
// 检测循环开始
if (line.contains("for (") || line.contains("forEach(") ||
line.contains("while (") || line.contains(".stream()")) {
inLoop = true;
loopDepth++;
loopLines.add(lineNum);
}
// 检测循环内的数据库调用
if (inLoop && (
line.contains("Repository.find") ||
line.contains("Repository.get") ||
line.contains(".findById(") ||
line.contains("jdbcTemplate.query") ||
line.contains("entityManager.find")
)) {
issues.add(CodeIssue.builder()
.severity(IssueSeverity.MAJOR)
.type(IssueType.PERFORMANCE)
.file(file.getFileName())
.line(lineNum)
.description("疑似N+1查询:在循环中调用数据库")
.suggestion("考虑使用批量查询(IN查询)或JOIN查询替代循环中的单条查询")
.build()
);
}
// 检测循环结束(简单的花括号计数)
if (inLoop) {
loopDepth += (int) line.chars().filter(c -> c == '{').count();
loopDepth -= (int) line.chars().filter(c -> c == '}').count();
if (loopDepth <= 0) {
inLoop = false;
loopDepth = 0;
}
}
}
return issues;
}
}五、增量分析:只分析changed lines
5.1 Token消耗优化策略
@Service
@Slf4j
public class IncrementalAnalysisService {
private static final int MAX_LINES_PER_REQUEST = 500; // 每次LLM调用最多500行
private static final int CONTEXT_LINES = 10; // 每个变更前后各保留10行上下文
/**
* 只提取变更行 + 上下文,大幅减少Token消耗
*/
public String extractRelevantCode(String fullDiff) {
StringBuilder relevant = new StringBuilder();
String[] lines = fullDiff.split("\n");
// 找出所有新增行的行号
Set<Integer> addedLineNums = new HashSet<>();
for (int i = 0; i < lines.length; i++) {
if (lines[i].startsWith("+") && !lines[i].startsWith("+++")) {
addedLineNums.add(i);
}
}
// 对每个新增行,提取前后CONTEXT_LINES行
Set<Integer> linesToInclude = new HashSet<>();
for (int lineNum : addedLineNums) {
for (int j = Math.max(0, lineNum - CONTEXT_LINES);
j <= Math.min(lines.length - 1, lineNum + CONTEXT_LINES);
j++) {
linesToInclude.add(j);
}
}
// 按顺序组装(跳过的行用"..."表示)
int lastIncluded = -1;
List<Integer> sortedLines = new ArrayList<>(linesToInclude);
Collections.sort(sortedLines);
int totalLines = 0;
for (int lineNum : sortedLines) {
if (totalLines >= MAX_LINES_PER_REQUEST) {
relevant.append("\n... (代码过长,已截断) ...\n");
break;
}
if (lastIncluded >= 0 && lineNum > lastIncluded + 1) {
relevant.append("\n... (省略 ").append(lineNum - lastIncluded - 1)
.append(" 行未变更代码) ...\n");
}
relevant.append(lines[lineNum]).append("\n");
lastIncluded = lineNum;
totalLines++;
}
log.info("Extracted {} lines from diff (total {} lines)",
totalLines, lines.length);
return relevant.toString();
}
/**
* 估算Token数(粗略估计:4个字符=1个token)
*/
public int estimateTokens(String text) {
// 中文字符1个约2-3个token
long chineseChars = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
long otherChars = text.length() - chineseChars;
return (int) (chineseChars * 2 + otherChars / 4);
}
}六、审查结果发布:自动在PR上添加评论
6.1 GitHub API客户端
@Service
@Slf4j
public class GitHubCommentService {
private final RestTemplate restTemplate;
@Value("${github.token}")
private String githubToken;
@Value("${github.api.base-url:https://api.github.com}")
private String apiBaseUrl;
/**
* 在PR上发布总体审查评论
*/
public void postReviewComment(String owner, String repo,
int prNumber, ReviewResult result) {
String url = String.format("%s/repos/%s/%s/issues/%d/comments",
apiBaseUrl, owner, repo, prNumber);
String body = formatReviewComment(result);
HttpHeaders headers = createHeaders();
Map<String, String> requestBody = Map.of("body", body);
HttpEntity<Map<String, String>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<Map> response = restTemplate.postForEntity(url, entity, Map.class);
log.info("Posted review comment, comment_id: {}",
response.getBody() != null ? response.getBody().get("id") : "unknown");
} catch (Exception e) {
log.error("Failed to post review comment", e);
}
}
/**
* 在具体的代码行上发布行内评论(更精准的审查体验)
*/
public void postInlineComment(String owner, String repo, int prNumber,
String commitSha, String path,
int line, String body) {
String url = String.format("%s/repos/%s/%s/pulls/%d/comments",
apiBaseUrl, owner, repo, prNumber);
Map<String, Object> requestBody = Map.of(
"body", body,
"commit_id", commitSha,
"path", path,
"line", line,
"side", "RIGHT" // RIGHT = 新文件的行
);
HttpHeaders headers = createHeaders();
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
restTemplate.postForEntity(url, entity, Map.class);
log.debug("Posted inline comment at {}:{}", path, line);
} catch (HttpClientErrorException e) {
// 行内评论可能因为行号不匹配而失败,降级为PR评论
log.warn("Failed to post inline comment at {}:{}, falling back to PR comment",
path, line);
postReviewComment(owner, repo, prNumber,
ReviewResult.singleIssue(path, line, body));
}
}
/**
* 为PR添加Review(可以是APPROVE/REQUEST_CHANGES/COMMENT)
*/
public void submitReview(String owner, String repo, int prNumber,
String commitSha, ReviewResult result) {
String url = String.format("%s/repos/%s/%s/pulls/%d/reviews",
apiBaseUrl, owner, repo, prNumber);
// 根据问题严重程度决定Review类型
String event = determineReviewEvent(result);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("commit_id", commitSha);
requestBody.put("body", formatSummary(result));
requestBody.put("event", event);
// 添加行内评论
List<Map<String, Object>> comments = result.getIssues().stream()
.filter(issue -> issue.getLine() > 0)
.map(issue -> Map.of(
"path", (Object) issue.getFile(),
"line", issue.getLine(),
"side", "RIGHT",
"body", formatIssueComment(issue)
))
.collect(Collectors.toList());
if (!comments.isEmpty()) {
requestBody.put("comments", comments);
}
HttpHeaders headers = createHeaders();
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
restTemplate.postForEntity(url, entity, Map.class);
}
private String determineReviewEvent(ReviewResult result) {
boolean hasCritical = result.getIssues().stream()
.anyMatch(i -> i.getSeverity() == IssueSeverity.CRITICAL);
if (hasCritical) {
return "REQUEST_CHANGES"; // 有严重问题,要求修改
} else {
return "COMMENT"; // 只是评论,不阻止合并
}
}
private String formatReviewComment(ReviewResult result) {
StringBuilder sb = new StringBuilder();
sb.append("## 🤖 AI代码审查报告\n\n");
if (result.getSummary() != null && !result.getSummary().isBlank()) {
sb.append("### 📋 总体评估\n").append(result.getSummary()).append("\n\n");
}
if (result.getIssues().isEmpty()) {
sb.append("### ✅ 未发现明显问题\n");
sb.append("AI审查未发现严重问题,请人工复核业务逻辑。\n\n");
} else {
sb.append(String.format("### ⚠️ 发现 %d 个问题\n\n", result.getIssues().size()));
// CRITICAL问题
List<CodeIssue> criticals = result.getIssues().stream()
.filter(i -> i.getSeverity() == IssueSeverity.CRITICAL)
.collect(Collectors.toList());
if (!criticals.isEmpty()) {
sb.append("#### 🔴 严重问题(需修复后才能合并)\n\n");
criticals.forEach(issue -> {
sb.append(String.format("**`%s:%d`** - %s\n\n",
issue.getFile(), issue.getLine(), issue.getDescription()));
sb.append(String.format("> 💡 建议:%s\n\n", issue.getSuggestion()));
});
}
// MAJOR问题
List<CodeIssue> majors = result.getIssues().stream()
.filter(i -> i.getSeverity() == IssueSeverity.MAJOR)
.collect(Collectors.toList());
if (!majors.isEmpty()) {
sb.append("#### 🟡 重要问题\n\n");
majors.forEach(issue -> {
sb.append(String.format("- `%s:%d`: %s\n",
issue.getFile(), issue.getLine(), issue.getDescription()));
});
sb.append("\n");
}
}
sb.append("---\n");
sb.append(String.format("*AI审查完成 | 审查文件数:%d | 审查时间:%s*\n",
result.getTotalFilesReviewed(),
result.getReviewedAt().toString()
));
return sb.toString();
}
private String formatIssueComment(CodeIssue issue) {
return String.format("**[%s] %s**\n\n%s\n\n💡 建议:%s",
issue.getSeverity().name(),
issue.getType().name(),
issue.getDescription(),
issue.getSuggestion()
);
}
private HttpHeaders createHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + githubToken);
headers.set("Accept", "application/vnd.github+json");
headers.set("X-GitHub-Api-Version", "2022-11-28");
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
}七、自定义规则:团队特定编码规范
7.1 规范配置文件设计
# review-rules.yml - 团队自定义审查规则
rules:
# 命名规范
naming:
- name: "Controller方法命名"
pattern: "public.*Controller.*\\b(get|list|create|update|delete|query)\\b"
message: "Controller方法应使用get/list/create/update/delete命名规范"
severity: MINOR
- name: "常量命名"
pattern: "static final.*[a-z]"
message: "常量应使用大写字母+下划线命名(如MAX_RETRY_COUNT)"
severity: MINOR
# 强制规范
mandatory:
- name: "日志打印禁止System.out"
pattern: "System\\.out\\.print"
message: "禁止使用System.out,请使用Slf4j日志"
severity: MAJOR
- name: "禁止捕获并忽略异常"
pattern: "catch.*\\{\\s*\\}"
message: "禁止空的catch块,至少要打印日志"
severity: MAJOR
- name: "数据库操作必须在事务内"
pattern: "Repository\\.save|Repository\\.delete|Repository\\.update"
requiresContext: "@Transactional"
message: "数据库写操作应在@Transactional方法内"
severity: MAJOR
# 团队约定
conventions:
- name: "AI调用必须有超时"
pattern: "chatClient\\.prompt\\(\\)"
requiresContext: "timeout|withTimeout|TimeUnit"
message: "AI服务调用必须设置超时,防止请求挂起"
severity: MAJOR
# 审查跳过规则(某些文件不需要审查)
skip:
patterns:
- "**/*Test.java"
- "**/*Config.java"
- "**/generated/**"7.2 规则引擎加载
@Component
@Slf4j
public class CustomRulesEngine {
private List<ReviewRule> rules;
@PostConstruct
public void loadRules() {
try {
ClassPathResource resource = new ClassPathResource("review-rules.yml");
ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
RulesConfig config = mapper.readValue(resource.getInputStream(), RulesConfig.class);
rules = new ArrayList<>();
rules.addAll(config.getNaming().stream()
.map(r -> new ReviewRule(r.getName(), r.getPattern(),
r.getMessage(), r.getSeverity()))
.collect(Collectors.toList()));
rules.addAll(config.getMandatory().stream()
.map(r -> new ReviewRule(r.getName(), r.getPattern(),
r.getMessage(), r.getSeverity()))
.collect(Collectors.toList()));
log.info("Loaded {} custom review rules", rules.size());
} catch (IOException e) {
log.error("Failed to load review rules", e);
rules = Collections.emptyList();
}
}
/**
* 用自定义规则预过滤问题(在调用LLM之前)
* 减少LLM的工作量,提高准确性
*/
public List<CodeIssue> applyRules(ChangedFile file) {
List<CodeIssue> issues = new ArrayList<>();
for (Map.Entry<Integer, String> entry : file.getAddedLines().entrySet()) {
int lineNum = entry.getKey();
String line = entry.getValue();
for (ReviewRule rule : rules) {
if (rule.matches(line)) {
issues.add(CodeIssue.builder()
.severity(rule.getSeverity())
.type(IssueType.STYLE)
.file(file.getFileName())
.line(lineNum)
.description("[规范检查] " + rule.getMessage())
.suggestion(rule.getName())
.build()
);
}
}
}
return issues;
}
}八、误报处理:开发者忽略AI建议的机制
8.1 忽略标记设计
开发者可以在代码注释中标记忽略:
// 方案1:行内忽略
String sql = "SELECT * FROM " + tableName; // ai-review-ignore: SECURITY - tableName来自枚举,安全
// 方案2:块忽略
// ai-review-ignore-start
public void legacyMethod() {
// 历史遗留代码,暂时不改
}
// ai-review-ignore-end
// 方案3:PR描述中忽略
// 在PR body里添加:
// ## AI Review Ignores
// - SECURITY:UserController.java:42 - 已做白名单验证
// - MAJOR:OrderService.java:156 - 历史遗留问题,下次重构时统一处理8.2 忽略记录追踪
@Entity
@Table(name = "ai_review_ignores")
@Data
@Builder
public class ReviewIgnore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String repository;
private int prNumber;
private String issueType;
private String file;
private Integer line;
private String reason; // 忽略原因
private String ignoredBy; // 谁忽略的
private Instant ignoredAt;
// 后续追踪:这个忽略的问题有没有在生产上造成问题
private Boolean causedIncident;
private String incidentId;
}@Service
public class ReviewIgnoreTracker {
private final ReviewIgnoreRepository ignoreRepository;
/**
* 记录开发者忽略的AI建议
*/
public void recordIgnore(String repo, int prNumber,
String issueType, String file, int line,
String reason, String ignoredBy) {
ReviewIgnore ignore = ReviewIgnore.builder()
.repository(repo)
.prNumber(prNumber)
.issueType(issueType)
.file(file)
.line(line)
.reason(reason)
.ignoredBy(ignoredBy)
.ignoredAt(Instant.now())
.build();
ignoreRepository.save(ignore);
}
/**
* 统计忽略率(用于评估AI审查质量)
*/
public ReviewStats getStats(String repo, LocalDate startDate, LocalDate endDate) {
List<ReviewIgnore> ignores = ignoreRepository
.findByRepositoryAndIgnoredAtBetween(repo,
startDate.atStartOfDay().toInstant(ZoneOffset.UTC),
endDate.atStartOfDay().toInstant(ZoneOffset.UTC));
long totalIssues = ignoreRepository.countTotalIssues(repo, startDate, endDate);
long totalIgnored = ignores.size();
// 按类型统计
Map<String, Long> ignoreByType = ignores.stream()
.collect(Collectors.groupingBy(ReviewIgnore::getIssueType, Collectors.counting()));
return ReviewStats.builder()
.totalIssues(totalIssues)
.acceptedIssues(totalIssues - totalIgnored)
.ignoredIssues(totalIgnored)
.acceptanceRate((double)(totalIssues - totalIgnored) / totalIssues * 100)
.ignoreByType(ignoreByType)
.build();
}
}九、效果统计:追踪AI审查的接受率和价值
9.1 审查效果数据模型
@Entity
@Table(name = "review_effectiveness")
@Data
public class ReviewEffectiveness {
@Id
private String id;
private String repository;
private int prNumber;
private int totalIssuesFound;
private int criticalIssues;
private int majorIssues;
private int minorIssues;
private int acceptedIssues; // 开发者接受并修复的
private int ignoredIssues; // 开发者忽略的
private long reviewDurationMs; // AI审查耗时
private int tokensCost; // 消耗Token数
private Instant reviewedAt;
}9.2 效果统计Dashboard查询
@Service
public class ReviewAnalyticsService {
private final ReviewEffectivenessRepository effectivenessRepo;
/**
* 生成月度报告
*/
public MonthlyReport generateMonthlyReport(String repo, YearMonth month) {
LocalDate start = month.atDay(1);
LocalDate end = month.atEndOfMonth();
List<ReviewEffectiveness> records = effectivenessRepo
.findByRepositoryAndDateRange(repo, start, end);
if (records.isEmpty()) {
return MonthlyReport.empty(month);
}
// 计算关键指标
int totalPRs = records.size();
int totalIssues = records.stream().mapToInt(ReviewEffectiveness::getTotalIssuesFound).sum();
int acceptedIssues = records.stream().mapToInt(ReviewEffectiveness::getAcceptedIssues).sum();
int criticalBlocked = records.stream().mapToInt(ReviewEffectiveness::getCriticalIssues).sum();
double acceptanceRate = totalIssues > 0
? (double) acceptedIssues / totalIssues * 100 : 0;
long avgReviewTime = (long) records.stream()
.mapToLong(ReviewEffectiveness::getReviewDurationMs)
.average()
.orElse(0);
int totalTokens = records.stream().mapToInt(ReviewEffectiveness::getTokensCost).sum();
return MonthlyReport.builder()
.month(month)
.totalPRsReviewed(totalPRs)
.totalIssuesFound(totalIssues)
.acceptedIssues(acceptedIssues)
.acceptanceRate(acceptanceRate)
.criticalIssuesBlocked(criticalBlocked)
.avgReviewDurationMs(avgReviewTime)
.totalTokensCost(totalTokens)
// 成本收益:节省的人工时间 × 人力成本 - AI费用
.estimatedTimeSavedMinutes(totalPRs * 23) // 每PR节省23分钟
.estimatedCostSaved(totalPRs * 23 * 500 / 60.0) // 按¥500/小时
.build();
}
}常见问题 FAQ
Q1:AI审查会不会阻塞开发流程?
A:不会,我们推荐的配置是"评论"而不是"阻止合并"。只有发现CRITICAL级别(安全漏洞/数据丢失风险)才触发REQUEST_CHANGES。其余问题只作为评论供人工参考,不阻止合并。
Q2:审查结果的准确率如何保证?
A:通过三个机制:1)自定义规则预过滤(正则匹配,100%准确);2)LLM使用Few-Shot示例提高准确率;3)开发者反馈机制,误报可以忽略,同时把忽略原因记录下来,后续优化Prompt。我们团队的CRITICAL问题准确率约85%,MINOR建议准确率约65%。
Q3:Token消耗量怎么控制?
A:主要策略:只发送changed lines + 上下文(减少80%Token),设置最大文件数限制(大PR拆成多批),跳过测试文件和生成代码。张华团队每PR平均消耗约2000个input Token,按GPT-4o价格约0.03元,一年1000个PR总成本约30元。
Q4:私有代码会不会泄露给OpenAI?
A:可以选择本地部署的LLM(如Ollama + CodeLlama),不把代码发到外部服务。对于代码保密要求高的团队,这是推荐方案。代码审查场景对LLM能力要求不如对话高,7B-13B的本地模型基本够用。
Q5:多个AI评论会不会刷屏PR评论区?
A:通过两个机制控制:1)每个PR只发一条汇总评论 + 行内评论;2)如果PR有多次push,会先删除上次的AI评论再发新的(通过记录comment_id实现)。
总结
AI代码审查的核心价值不是替代人,而是:
- 24小时不下班的第一道防线:每个PR提交后立即扫描,人工审查时已有初步结论
- 标准化规范执行:团队规范不再靠人记,AI全自动检查
- 释放Reviewer精力:从"检查所有问题"变成"验证AI标记的问题是否合理"
- 建立知识积累:每次误报反馈都在优化审查质量
张华团队的教训:第一个月先把CRITICAL安全检查做好,别一开始就想做全面的代码审查。安全问题误报成本低、价值高,是最适合AI审查的起点。
