AI辅助代码审查工具:用Agent自动发现代码问题
AI辅助代码审查工具:用Agent自动发现代码问题
开篇故事:每天4小时的代码审查黑洞
刘洋是某金融科技公司的高级Java工程师,4年经验,团队8人。
2025年初,他做了一次统计,发现自己每天在代码审查上花4个多小时:
- 初级工程师提交的PR,经常有:变量名不规范、没有判空、不用try-with-resources、未关闭Stream
- 中级工程师提交的PR,经常有:性能问题(N+1查询、大对象在循环里初始化)、并发问题
- 高级工程师提交的PR,偶尔有:安全漏洞、架构设计问题
其中初级和中级问题占了他大约70%的审查时间。刘洋说:"我每天有将近3小时在给别人reviewif (list != null && list.size() > 0)应该改成!CollectionUtils.isEmpty(list),这完全是浪费时间。"
2025年3月,他用2周时间搭建了一套AI代码审查系统:
- 接入GitHub Webhook,PR一创建就自动触发
- AI分析diff,按严重程度分类:错误(必须修复)、警告(建议修复)、提示(可选)
- 发现问题直接评论到PR的具体代码行
- 高风险问题(SQL注入、密码硬编码)自动通知安全团队
上线3个月后:
- 人工代码审查时间:从4小时/天 → 1.5小时/天(减少62.5%)
- PR合并周期:从平均2.1天 → 0.8天(减少62%)
- 代码缺陷漏测逃逸率:从月均23个 → 8个(减少65%)
最让刘洋满意的一点:初级工程师的代码质量明显提升——因为AI每次都给出了清晰的改进建议,相当于给每个人配了一个随时在线的代码导师。
这篇文章,我把这套系统的完整实现讲清楚。
一、系统架构
1.1 整体架构图
二、pom.xml与配置
2.1 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
<groupId>com.company</groupId>
<artifactId>ai-code-review</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Redis(任务队列) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 数据库(审查历史) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- GitHub API客户端 -->
<dependency>
<groupId>org.kohsuke</groupId>
<artifactId>github-api</artifactId>
<version>1.321</version>
</dependency>
<!-- HTTP客户端 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>2.2 application.yml
spring:
application:
name: ai-code-review
datasource:
url: jdbc:mysql://localhost:3306/code_review?useUnicode=true&serverTimezone=Asia/Shanghai
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:password}
jpa:
hibernate:
ddl-auto: update
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.1
max-tokens: 4096
# GitHub配置
github:
token: ${GITHUB_TOKEN}
webhook-secret: ${GITHUB_WEBHOOK_SECRET}
# 目标仓库列表(白名单,只审查这些仓库的PR)
allowed-repos:
- company/backend-service
- company/payment-service
- company/user-service
# 审查配置
review:
# 是否在低风险时自动通过
auto-approve-on-low-risk: false
# 高风险问题必须修复才能合并
block-on-high-risk: true
# 安全团队通知邮件
security-alert-email: security@company.com
# 最大diff行数(超过则分块)
max-diff-lines-per-chunk: 150
# 忽略的文件模式
ignore-patterns:
- "*.md"
- "*.txt"
- "*.json"
- "package-lock.json"
- "*.lock"
- "test/**"
- "*Test.java"
- "*Tests.java"
server:
port: 8080
logging:
level:
com.company.review: DEBUG三、GitHub Webhook接入
3.1 Webhook Controller
package com.company.review.webhook;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
/**
* GitHub Webhook接收入口
* 负责签名验证、事件过滤、任务分发
*/
@Slf4j
@RestController
@RequestMapping("/webhook")
@RequiredArgsConstructor
public class GitHubWebhookController {
private final ReviewTaskDispatcher taskDispatcher;
private final ObjectMapper objectMapper;
@Value("${github.webhook-secret}")
private String webhookSecret;
/**
* 接收GitHub Webhook事件
*/
@PostMapping("/github")
public ResponseEntity<String> handleWebhook(
@RequestHeader("X-GitHub-Event") String eventType,
@RequestHeader("X-Hub-Signature-256") String signature,
@RequestBody String payload) {
log.info("收到GitHub Webhook事件: {}", eventType);
// 1. 验证签名(防止伪造请求)
if (!verifySignature(payload, signature)) {
log.warn("GitHub Webhook签名验证失败");
return ResponseEntity.status(403).body("Invalid signature");
}
// 2. 只处理PR相关事件
if (!"pull_request".equals(eventType)) {
return ResponseEntity.ok("Event ignored: " + eventType);
}
try {
JsonNode event = objectMapper.readTree(payload);
String action = event.path("action").asText();
// 只在PR创建或有新推送时触发审查
if (!"opened".equals(action) && !"synchronize".equals(action)) {
return ResponseEntity.ok("Action ignored: " + action);
}
// 提取PR信息
PullRequestInfo prInfo = extractPrInfo(event);
log.info("触发代码审查: repo={}, pr=#{}", prInfo.getRepoFullName(), prInfo.getPrNumber());
// 异步分发审查任务
taskDispatcher.dispatch(prInfo);
return ResponseEntity.ok("Review task submitted");
} catch (Exception e) {
log.error("处理Webhook事件失败", e);
return ResponseEntity.status(500).body("Internal error");
}
}
/**
* HMAC-SHA256签名验证
*/
private boolean verifySignature(String payload, String signature) {
try {
if (!signature.startsWith("sha256=")) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
webhookSecret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"));
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = "sha256=" + HexFormat.of().formatHex(hash);
return expectedSignature.equals(signature);
} catch (Exception e) {
log.error("签名验证异常", e);
return false;
}
}
private PullRequestInfo extractPrInfo(JsonNode event) {
JsonNode pr = event.path("pull_request");
JsonNode repo = event.path("repository");
return PullRequestInfo.builder()
.repoFullName(repo.path("full_name").asText())
.prNumber(pr.path("number").asInt())
.prTitle(pr.path("title").asText())
.headSha(pr.path("head").path("sha").asText())
.baseSha(pr.path("base").path("sha").asText())
.authorLogin(pr.path("user").path("login").asText())
.diffUrl(pr.path("diff_url").asText())
.build();
}
}3.2 Webhook数据模型
package com.company.review.webhook;
import lombok.Builder;
import lombok.Data;
/**
* Pull Request信息
*/
@Data
@Builder
public class PullRequestInfo {
private String repoFullName; // 如:company/backend-service
private int prNumber; // PR编号
private String prTitle; // PR标题
private String headSha; // 最新commit SHA
private String baseSha; // 基准分支 commit SHA
private String authorLogin; // 提交者GitHub用户名
private String diffUrl; // diff URL
}四、GitHub API集成
4.1 获取PR Diff和发表评论
package com.company.review.github;
import com.company.review.webhook.PullRequestInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kohsuke.github.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
/**
* GitHub API操作服务
* 封装获取diff、发表评论、更新PR状态等操作
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GitHubApiService {
@Value("${github.token}")
private String githubToken;
/**
* 获取PR的完整diff内容
*/
public String getPrDiff(PullRequestInfo prInfo) throws IOException {
GitHub github = new GitHubBuilder()
.withOAuthToken(githubToken)
.build();
GHRepository repo = github.getRepository(prInfo.getRepoFullName());
GHPullRequest pr = repo.getPullRequest(prInfo.getPrNumber());
// 获取PR的所有文件变更
StringBuilder diffBuilder = new StringBuilder();
PagedIterable<GHPullRequestFileDetail> files = pr.listFiles();
for (GHPullRequestFileDetail file : files) {
String filename = file.getFilename();
// 跳过忽略的文件
if (shouldIgnoreFile(filename)) {
continue;
}
diffBuilder.append("=== 文件: ").append(filename).append(" ===\n");
diffBuilder.append("状态: ").append(file.getStatus()).append("\n");
diffBuilder.append("新增: +").append(file.getAdditions()).append(" 行\n");
diffBuilder.append("删除: -").append(file.getDeletions()).append(" 行\n\n");
// 添加具体变更内容
String patch = file.getPatch();
if (patch != null && !patch.isBlank()) {
diffBuilder.append(patch).append("\n\n");
}
}
return diffBuilder.toString();
}
/**
* 发表行级代码评论
* 精确到具体的代码行
*/
public void postLineComment(PullRequestInfo prInfo, ReviewComment comment) {
try {
GitHub github = new GitHubBuilder()
.withOAuthToken(githubToken)
.build();
GHRepository repo = github.getRepository(prInfo.getRepoFullName());
GHPullRequest pr = repo.getPullRequest(prInfo.getPrNumber());
// 格式化评论内容(添加严重程度标识)
String formattedComment = formatComment(comment);
pr.createReviewComment(
formattedComment,
prInfo.getHeadSha(),
comment.getFilePath(),
comment.getLineNumber()
);
log.info("已发表行级评论: {}:{}", comment.getFilePath(), comment.getLineNumber());
} catch (Exception e) {
log.error("发表行级评论失败: {}", comment.getFilePath(), e);
}
}
/**
* 提交整体PR审查(通过/请求修改)
*/
public void submitReview(PullRequestInfo prInfo, PrReviewResult reviewResult) {
try {
GitHub github = new GitHubBuilder()
.withOAuthToken(githubToken)
.build();
GHRepository repo = github.getRepository(prInfo.getRepoFullName());
GHPullRequest pr = repo.getPullRequest(prInfo.getPrNumber());
GHPullRequestReviewBuilder reviewBuilder = pr.createReview();
reviewBuilder.body(reviewResult.getSummary());
// 根据审查结果决定PR状态
if (reviewResult.hasHighRiskIssues()) {
reviewBuilder.event(GHPullRequestReviewEvent.REQUEST_CHANGES);
} else if (reviewResult.isApproved()) {
reviewBuilder.event(GHPullRequestReviewEvent.APPROVE);
} else {
reviewBuilder.event(GHPullRequestReviewEvent.COMMENT);
}
reviewBuilder.create();
log.info("PR审查已提交: pr=#{}, hasHighRisk={}", prInfo.getPrNumber(),
reviewResult.hasHighRiskIssues());
} catch (Exception e) {
log.error("提交PR审查失败", e);
}
}
/**
* 添加PR标签
*/
public void addLabel(PullRequestInfo prInfo, String label) {
try {
GitHub github = new GitHubBuilder()
.withOAuthToken(githubToken)
.build();
GHRepository repo = github.getRepository(prInfo.getRepoFullName());
GHPullRequest pr = repo.getPullRequest(prInfo.getPrNumber());
// 确保标签存在
ensureLabelExists(repo, label);
pr.addLabels(label);
} catch (Exception e) {
log.error("添加PR标签失败: label={}", label, e);
}
}
private String formatComment(ReviewComment comment) {
String icon = switch (comment.getSeverity()) {
case "ERROR" -> "🔴 **[必须修复]**";
case "WARNING" -> "🟡 **[建议修复]**";
case "INFO" -> "🔵 **[仅供参考]**";
default -> "💬";
};
return String.format("""
%s %s
**问题描述:** %s
**建议修改:**
```java
%s
```
> *由AI代码审查自动生成,如有误报请忽略*
""", icon, comment.getCategory(),
comment.getDescription(), comment.getSuggestion());
}
private boolean shouldIgnoreFile(String filename) {
return filename.endsWith(".md") || filename.endsWith(".txt") ||
filename.endsWith(".json") || filename.equals("package-lock.json") ||
filename.contains("Test.java") || filename.contains("Tests.java");
}
private void ensureLabelExists(GHRepository repo, String labelName) {
try {
repo.getLabel(labelName);
} catch (Exception e) {
try {
String color = switch (labelName) {
case "ai-review: high-risk" -> "d73a4a";
case "ai-review: needs-work" -> "e4e669";
case "ai-review: approved" -> "0075ca";
default -> "ededed";
};
repo.createLabel(labelName, color);
} catch (Exception ex) {
log.warn("创建标签失败: {}", labelName);
}
}
}
}五、AI代码审查Agent
5.1 审查提示词设计
package com.company.review.agent;
/**
* 代码审查提示词
* 针对Java代码的专业审查规则
*/
public class ReviewPrompts {
public static final String SYSTEM_PROMPT = """
你是一位资深Java代码审查专家,有10年以上的企业级Java开发经验。
审查维度和优先级:
【P0 - 安全漏洞(必须修复,会触发安全告警)】
- SQL注入:字符串拼接SQL,而不是参数化查询
- 密码/密钥硬编码:代码中直接写密码、API Key、私钥
- 路径遍历:不验证用户输入的文件路径
- 反序列化漏洞:不安全的ObjectInputStream使用
- XSS:未经转义直接输出用户输入
【P1 - 严重Bug(必须修复)】
- 空指针:可能为null的对象未做空检查
- 资源泄露:Stream/Connection/InputStream未关闭(应用try-with-resources)
- 并发问题:非线程安全的集合在多线程环境使用
- 死锁风险:多锁嵌套顺序不一致
- 整数溢出:数值计算可能溢出
【P2 - 性能问题(建议修复)】
- N+1查询:循环中执行数据库查询
- 大对象循环创建:StringBuilder/DateFormat等在循环中new
- 低效集合操作:用List.contains()代替Set,时间复杂度O(n)
- 不必要的toString()、装箱/拆箱
【P3 - 代码规范(可选修复)】
- 命名不规范:变量名/方法名不符合Java命名规范
- 方法过长:超过50行的方法建议拆分
- 魔法数字:代码中直接写数字而不是常量
- 注释缺失:公共方法缺少JavaDoc
输出格式要求(严格遵守):
返回JSON数组,每个问题一个对象:
[
{
"severity": "ERROR|WARNING|INFO",
"category": "安全漏洞|严重Bug|性能问题|代码规范",
"filePath": "src/main/java/com/example/UserService.java",
"lineNumber": 42,
"description": "问题描述(简洁,不超过100字)",
"suggestion": "修改后的代码示例(关键代码片段,不超过5行)",
"isSecurityIssue": true|false
}
]
重要规则:
1. 只报告代码变更(diff中+号开头的新增行)中的问题
2. 误报比漏报成本更高,不确定的问题不要报
3. 不报告测试代码的规范问题(测试文件允许放宽要求)
4. 只返回JSON,不要任何说明文字
""";
public static final String USER_PROMPT_TEMPLATE = """
请审查以下Java代码变更(Git diff格式):
仓库:%s
PR标题:%s
代码变更:
%s
请按照系统提示的格式返回审查结果JSON。
""";
}5.2 代码分块处理
package com.company.review.agent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 代码Diff分块处理器
* 将超长diff分割为多个小块,避免超出AI上下文限制
*/
@Slf4j
@Component
public class DiffChunker {
@Value("${review.max-diff-lines-per-chunk:150}")
private int maxLinesPerChunk;
/**
* 将diff分割为多个块
* 以文件为单位分割,不会截断单个文件
*/
public List<String> chunk(String fullDiff) {
if (fullDiff == null || fullDiff.isBlank()) {
return List.of();
}
// 按文件分割("=== 文件:"是我们之前添加的分隔符)
String[] fileSections = fullDiff.split("(?=={3} 文件:)");
List<String> chunks = new ArrayList<>();
StringBuilder currentChunk = new StringBuilder();
int currentLineCount = 0;
for (String section : fileSections) {
if (section.isBlank()) continue;
int sectionLineCount = section.split("\n").length;
// 如果单个文件就超过限制,强制单独成块(超长文件单独处理)
if (sectionLineCount > maxLinesPerChunk) {
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
currentChunk = new StringBuilder();
currentLineCount = 0;
}
// 超长文件截取前N行
String truncated = truncateSection(section, maxLinesPerChunk);
chunks.add(truncated);
log.info("大文件截取处理: {}行 -> {}行", sectionLineCount, maxLinesPerChunk);
continue;
}
// 当前块加上这个文件会超限,先提交当前块
if (currentLineCount + sectionLineCount > maxLinesPerChunk && currentLineCount > 0) {
chunks.add(currentChunk.toString());
currentChunk = new StringBuilder();
currentLineCount = 0;
}
currentChunk.append(section);
currentLineCount += sectionLineCount;
}
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
}
log.info("Diff分块完成: 总行数={}, 分块数={}", countLines(fullDiff), chunks.size());
return chunks;
}
private String truncateSection(String section, int maxLines) {
String[] lines = section.split("\n");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(maxLines, lines.length); i++) {
sb.append(lines[i]).append("\n");
}
if (lines.length > maxLines) {
sb.append("\n[...文件过长,已截取前")
.append(maxLines).append("行,共").append(lines.length).append("行...]");
}
return sb.toString();
}
private int countLines(String text) {
return text.split("\n").length;
}
}5.3 代码审查Agent主体
package com.company.review.agent;
import com.company.review.github.GitHubApiService;
import com.company.review.model.ReviewComment;
import com.company.review.model.PrReviewResult;
import com.company.review.webhook.PullRequestInfo;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* AI代码审查Agent
* 核心审查逻辑:获取diff -> 分块 -> AI分析 -> 发表评论
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CodeReviewAgent {
private final ChatClient chatClient;
private final GitHubApiService githubApiService;
private final DiffChunker diffChunker;
private final ObjectMapper objectMapper;
private final ReviewResultProcessor resultProcessor;
private final SecurityAlertService securityAlertService;
/**
* 执行代码审查
* 异步执行,不阻塞Webhook响应
*/
@Async
public void review(PullRequestInfo prInfo) {
log.info("开始AI代码审查: repo={}, pr=#{}", prInfo.getRepoFullName(), prInfo.getPrNumber());
long startTime = System.currentTimeMillis();
try {
// Step 1: 获取PR diff
String fullDiff = githubApiService.getPrDiff(prInfo);
if (fullDiff == null || fullDiff.isBlank()) {
log.info("PR diff为空,跳过审查: pr=#{}", prInfo.getPrNumber());
return;
}
log.info("获取到diff: {}行", fullDiff.split("\n").length);
// Step 2: 分块处理
List<String> chunks = diffChunker.chunk(fullDiff);
// Step 3: 逐块AI审查
List<ReviewComment> allComments = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
String chunk = chunks.get(i);
log.info("审查第{}/{}块代码", i + 1, chunks.size());
List<ReviewComment> chunkComments = analyzeChunk(prInfo, chunk);
allComments.addAll(chunkComments);
}
log.info("AI审查完成,发现{}个问题", allComments.size());
// Step 4: 误报过滤(降低噪音)
List<ReviewComment> filteredComments = resultProcessor.filterFalsePositives(allComments);
log.info("过滤后剩余{}个问题", filteredComments.size());
// Step 5: 发表行级评论
for (ReviewComment comment : filteredComments) {
githubApiService.postLineComment(prInfo, comment);
}
// Step 6: 安全告警
List<ReviewComment> securityIssues = filteredComments.stream()
.filter(ReviewComment::isSecurityIssue)
.toList();
if (!securityIssues.isEmpty()) {
securityAlertService.alert(prInfo, securityIssues);
githubApiService.addLabel(prInfo, "ai-review: high-risk");
}
// Step 7: 提交整体PR审查结论
PrReviewResult reviewResult = resultProcessor.buildReviewResult(
filteredComments, prInfo);
githubApiService.submitReview(prInfo, reviewResult);
long duration = System.currentTimeMillis() - startTime;
log.info("代码审查完成: pr=#{}, 耗时={}秒, 问题数={}",
prInfo.getPrNumber(), duration / 1000, filteredComments.size());
} catch (Exception e) {
log.error("代码审查失败: pr=#{}", prInfo.getPrNumber(), e);
// 审查失败时发表一条说明评论
postErrorComment(prInfo, e.getMessage());
}
}
/**
* 分析单个代码块
*/
private List<ReviewComment> analyzeChunk(PullRequestInfo prInfo, String diffChunk) {
String userPrompt = String.format(
ReviewPrompts.USER_PROMPT_TEMPLATE,
prInfo.getRepoFullName(),
prInfo.getPrTitle(),
diffChunk);
try {
String response = chatClient.prompt()
.system(ReviewPrompts.SYSTEM_PROMPT)
.user(userPrompt)
.call()
.content();
return parseReviewComments(response);
} catch (Exception e) {
log.error("AI分析失败", e);
return List.of();
}
}
/**
* 解析AI返回的审查结果
*/
private List<ReviewComment> parseReviewComments(String response) {
try {
// 提取JSON数组
int start = response.indexOf('[');
int end = response.lastIndexOf(']');
if (start < 0 || end <= start) {
log.debug("AI返回内容中未找到JSON数组");
return List.of();
}
String json = response.substring(start, end + 1);
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.warn("解析AI审查结果失败: {}", e.getMessage());
return List.of();
}
}
private void postErrorComment(PullRequestInfo prInfo, String errorMsg) {
try {
githubApiService.postSummaryComment(prInfo,
"AI代码审查运行时出现异常,请手动进行代码审查。\n\n错误信息:" + errorMsg);
} catch (Exception ex) {
log.error("发表错误评论失败", ex);
}
}
}六、误报控制
package com.company.review.agent;
import com.company.review.model.ReviewComment;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* 审查结果处理器
* 过滤误报,构建最终审查结论
*/
@Slf4j
@Component
public class ReviewResultProcessor {
/**
* 过滤明显的误报
*/
public List<ReviewComment> filterFalsePositives(List<ReviewComment> comments) {
return comments.stream()
.filter(this::isLikelyTruePositive)
.collect(Collectors.toList());
}
private boolean isLikelyTruePositive(ReviewComment comment) {
// 1. 测试文件中的规范问题,宽松处理
if (isTestFile(comment.getFilePath()) &&
"INFO".equals(comment.getSeverity())) {
log.debug("过滤测试文件规范提示: {}", comment.getFilePath());
return false;
}
// 2. 行号无效,说明是AI理解偏差
if (comment.getLineNumber() <= 0) {
log.debug("过滤无效行号评论");
return false;
}
// 3. 描述太短(少于10字),可能是AI信息不足的情况
if (comment.getDescription() == null ||
comment.getDescription().length() < 10) {
log.debug("过滤描述过短的评论");
return false;
}
// 4. 过滤关于注释的重复报告(同一行多个问题只保留最高严重程度)
// 这里简化处理,实际可以按文件+行号去重
return true;
}
private boolean isTestFile(String filePath) {
if (filePath == null) return false;
return filePath.contains("Test.java") ||
filePath.contains("Tests.java") ||
filePath.contains("Spec.java") ||
filePath.contains("src/test/");
}
/**
* 构建整体PR审查结论
*/
public PrReviewResult buildReviewResult(List<ReviewComment> comments,
PullRequestInfo prInfo) {
long errorCount = comments.stream()
.filter(c -> "ERROR".equals(c.getSeverity())).count();
long warningCount = comments.stream()
.filter(c -> "WARNING".equals(c.getSeverity())).count();
long infoCount = comments.stream()
.filter(c -> "INFO".equals(c.getSeverity())).count();
boolean hasHighRisk = comments.stream()
.anyMatch(ReviewComment::isSecurityIssue) || errorCount > 0;
String summary = buildSummary(errorCount, warningCount, infoCount, hasHighRisk);
return PrReviewResult.builder()
.hasHighRiskIssues(hasHighRisk)
.approved(comments.isEmpty() || (errorCount == 0 && !hasHighRisk))
.summary(summary)
.totalIssues((int) (errorCount + warningCount + infoCount))
.build();
}
private String buildSummary(long errors, long warnings, long infos, boolean hasHighRisk) {
StringBuilder sb = new StringBuilder();
sb.append("## AI代码审查报告\n\n");
if (errors == 0 && warnings == 0 && infos == 0) {
sb.append("✅ **未发现明显问题**,代码质量良好。\n\n");
sb.append("*本审查由AI自动完成,建议同时进行人工复核。*");
return sb.toString();
}
sb.append("| 级别 | 数量 |\n");
sb.append("|------|------|\n");
if (errors > 0) sb.append("| 🔴 必须修复 | ").append(errors).append(" |\n");
if (warnings > 0) sb.append("| 🟡 建议修复 | ").append(warnings).append(" |\n");
if (infos > 0) sb.append("| 🔵 仅供参考 | ").append(infos).append(" |\n");
sb.append("\n");
if (hasHighRisk) {
sb.append("⚠️ **发现高风险问题(安全漏洞或严重Bug),已通知安全团队,合并前必须修复。**\n\n");
}
sb.append("请查看各行评论了解具体问题和修改建议。\n\n");
sb.append("*本审查由AI自动完成,如有误报请忽略或回复说明。*");
return sb.toString();
}
}七、审查规则配置:团队自定义规则
package com.company.review.rule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* 团队自定义规则引擎
* 基于正则表达式的快速检查,补充AI检查的盲点
*/
@Slf4j
@Component
public class CustomRuleEngine {
private final List<CustomRule> rules;
public CustomRuleEngine() {
this.rules = buildDefaultRules();
}
/**
* 检查代码差异,返回规则违规列表
*/
public List<RuleViolation> check(String filePath, String diffContent) {
List<RuleViolation> violations = new ArrayList<>();
String[] lines = diffContent.split("\n");
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
// 只检查新增的行(+开头)
if (!line.startsWith("+") || line.startsWith("+++")) continue;
String codeLine = line.substring(1); // 去掉+前缀
for (CustomRule rule : rules) {
if (rule.getPattern().matcher(codeLine).find()) {
violations.add(RuleViolation.builder()
.ruleId(rule.getId())
.filePath(filePath)
.lineNumber(i + 1)
.severity(rule.getSeverity())
.message(rule.getMessage())
.build());
}
}
}
return violations;
}
private List<CustomRule> buildDefaultRules() {
List<CustomRule> rules = new ArrayList<>();
// 安全规则
rules.add(CustomRule.builder()
.id("SEC001")
.pattern(Pattern.compile("password\\s*=\\s*[\"'][^\"']+[\"']",
Pattern.CASE_INSENSITIVE))
.severity("ERROR")
.message("疑似密码硬编码,请使用配置文件或Vault管理密码")
.build());
rules.add(CustomRule.builder()
.id("SEC002")
.pattern(Pattern.compile("(api.?key|apikey|api_key)\\s*=\\s*[\"'][^\"']+[\"']",
Pattern.CASE_INSENSITIVE))
.severity("ERROR")
.message("疑似API Key硬编码,请使用环境变量或配置中心")
.build());
// 公司内部规范
rules.add(CustomRule.builder()
.id("COMP001")
.pattern(Pattern.compile("System\\.out\\.println"))
.severity("WARNING")
.message("生产代码不应使用System.out.println,请使用SLF4J Logger")
.build());
rules.add(CustomRule.builder()
.id("COMP002")
.pattern(Pattern.compile("e\\.printStackTrace\\(\\)"))
.severity("WARNING")
.message("不要使用e.printStackTrace(),应使用log.error()记录异常")
.build());
rules.add(CustomRule.builder()
.id("COMP003")
.pattern(Pattern.compile("catch\\s*\\(Exception\\s+e\\)\\s*\\{\\s*\\}"))
.severity("ERROR")
.message("不允许空的catch块,必须记录或处理异常")
.build());
rules.add(CustomRule.builder()
.id("COMP004")
.pattern(Pattern.compile("new\\s+Date\\(\\)"))
.severity("INFO")
.message("建议使用LocalDateTime替代Date,后者线程不安全且API设计不佳")
.build());
// 性能规则
rules.add(CustomRule.builder()
.id("PERF001")
.pattern(Pattern.compile("new\\s+ArrayList\\(\\).*\\.add\\("))
.severity("INFO")
.message("在循环外创建集合时,建议指定初始容量以避免扩容")
.build());
return rules;
}
}八、与CI/CD集成
8.1 CI检查状态更新
package com.company.review.cicd;
import com.company.review.model.PrReviewResult;
import com.company.review.webhook.PullRequestInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kohsuke.github.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* CI/CD集成服务
* 将AI审查结果同步到GitHub Checks,控制PR是否允许合并
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CiIntegrationService {
@Value("${github.token}")
private String githubToken;
/**
* 创建或更新GitHub Check Run
* 当审查发现高风险问题时,将Check标记为FAILURE,阻止PR合并
*/
public void updateCheckRun(PullRequestInfo prInfo, PrReviewResult reviewResult) {
try {
GitHub github = new GitHubBuilder()
.withOAuthToken(githubToken)
.build();
GHRepository repo = github.getRepository(prInfo.getRepoFullName());
GHCheckRunBuilder checkRunBuilder = repo.createCheckRun(
"AI Code Review",
prInfo.getHeadSha());
checkRunBuilder.withStartedAt(java.util.Date.from(
java.time.Instant.now()));
checkRunBuilder.withStatus(GHCheckRun.Status.COMPLETED);
if (reviewResult.hasHighRiskIssues()) {
// 高风险:标记为FAILURE,阻止合并
checkRunBuilder.withConclusion(GHCheckRun.Conclusion.FAILURE);
checkRunBuilder.add(new GHCheckRunBuilder.Output(
"AI代码审查:发现高风险问题",
"发现安全漏洞或严重Bug,请修复后重新提交。\n\n" +
reviewResult.getSummary()
));
} else {
// 无高风险:标记为SUCCESS,允许合并
checkRunBuilder.withConclusion(GHCheckRun.Conclusion.SUCCESS);
checkRunBuilder.add(new GHCheckRunBuilder.Output(
"AI代码审查:通过",
reviewResult.getSummary()
));
}
GHCheckRun checkRun = checkRunBuilder.create();
log.info("GitHub Check Run已更新: id={}, conclusion={}",
checkRun.getId(), reviewResult.hasHighRiskIssues() ? "FAILURE" : "SUCCESS");
} catch (Exception e) {
log.error("更新Check Run失败", e);
}
}
}九、审查历史与统计
package com.company.review.stats;
import com.company.review.model.ReviewRecord;
import com.company.review.repository.ReviewRecordRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 审查统计API
* 展示AI代码审查的效果数据
*/
@RestController
@RequestMapping("/api/review/stats")
@RequiredArgsConstructor
public class ReviewStatsController {
private final ReviewRecordRepository reviewRecordRepository;
/**
* 获取指定时间段的审查统计数据
*/
@GetMapping("/summary")
public Map<String, Object> getSummary(
@RequestParam(defaultValue = "30") int days) {
LocalDate startDate = LocalDate.now().minusDays(days);
List<ReviewRecord> records = reviewRecordRepository
.findByCreatedAtAfter(startDate.atStartOfDay());
// 统计各维度数据
long totalPRs = records.size();
long totalIssues = records.stream().mapToLong(ReviewRecord::getTotalIssues).sum();
long highRiskIssues = records.stream().mapToLong(ReviewRecord::getHighRiskIssues).sum();
long autoApproved = records.stream()
.filter(r -> r.getHighRiskIssues() == 0 && r.getErrorCount() == 0).count();
double avgIssuesPerPR = totalPRs > 0 ? (double) totalIssues / totalPRs : 0;
// 按类型统计问题
Map<String, Long> issuesByCategory = records.stream()
.flatMap(r -> r.getIssueCategories().stream())
.collect(Collectors.groupingBy(c -> c, Collectors.counting()));
// 审查耗时统计
double avgReviewSeconds = records.stream()
.mapToLong(ReviewRecord::getReviewDurationSeconds)
.average()
.orElse(0);
return Map.of(
"period", days + "天",
"totalPRsReviewed", totalPRs,
"totalIssuesFound", totalIssues,
"highRiskIssues", highRiskIssues,
"autoApprovedPRs", autoApproved,
"avgIssuesPerPR", String.format("%.1f", avgIssuesPerPR),
"avgReviewTimeSeconds", String.format("%.0f", avgReviewSeconds),
"topIssueCategories", issuesByCategory,
"humanTimeSaved", String.format("约%.0f小时",
totalPRs * 0.5 * (1 - 0.375)) // 假设每PR平均节省30分钟
);
}
}十、性能数据与效果
10.1 系统性能数据
| 指标 | 数据 |
|---|---|
| Webhook响应时间 | < 200ms(异步处理,快速返回) |
| 单PR平均审查时间 | 45秒(含GitHub API调用) |
| 最长审查时间(大PR) | 180秒 |
| AI Token消耗/PR | 平均2,800 tokens |
| 系统可用性 | 99.2%(3个月统计) |
10.2 业务效果数据(上线3个月)
| 指标 | 上线前 | 上线后 | 变化 |
|---|---|---|---|
| 人工审查时间/天 | 4小时 | 1.5小时 | -62.5% |
| PR合并周期 | 2.1天 | 0.8天 | -62% |
| 发现代码问题数/月 | 人工发现87个 | AI+人工发现156个 | +79% |
| 安全漏洞漏测数 | 月均3.2个 | 月均0.4个 | -87.5% |
| 初级工程师代码返修率 | 68% | 31% | -54% |
10.3 最有价值的发现
3个月内AI发现的最关键Bug(人工可能未检测到的):
- 3处SQL注入漏洞(拼接用户输入构建SQL)
- 2处密钥硬编码(测试环境密钥混入生产代码)
- 7处资源泄露(InputStream未关闭)
- 12处并发隐患(HashMap在多线程环境误用)
FAQ
Q1:AI审查的误报率高吗?会不会干扰开发?
实测误报率约12%。主要误报场景:①框架生成的代码被误判;②测试代码的"System.out.println";③注解里的字符串被误判为SQL。解决方案:测试文件关闭P3规范检查,自定义规则引擎补充框架特定规则。开发者反映误报可接受,因为AI评论有明确的"如有误报请忽略"说明。
Q2:超大PR(几千行diff)怎么处理?
分块处理是关键。150行/块是经验值,保证既不超出AI上下文窗口,也不会让AI分析范围太小(丢失上下文)。超大文件单独成块,超长文件截取最关键的部分(通常问题集中在文件前半部分的主要逻辑)。
Q3:GitHub API有调用限制,会不会触发?
GitHub API默认5000次/小时(使用Token)。按每个PR调用5次API计算,每小时最多处理1000个PR,对绝大多数团队完全够用。如果并发PR很多,可以加请求队列+延迟处理。
Q4:如何避免AI审查结果影响开发者信心?
关键是分级展示:ERROR和WARNING用显眼标识,INFO用低调的蓝色标识。整体审查结论要中性("发现X个问题"而不是"代码很烂")。允许开发者回复说明,告知AI某个问题是已知情况或误报。
Q5:可以对接Gitlab或Gitea吗?
可以,核心逻辑(AI审查)与代码托管平台无关。只需替换GitHub API调用层:实现DiffFetcher和CommentPoster接口,对接对应平台的API即可。GitLab有类似的MR(Merge Request)事件和API。
Q6:审查提示词如何持续优化?
建立"误报/漏报"反馈循环:
- 开发者回复AI评论,标注"误报"或"漏报"
- 定期收集标注数据
- 在提示词中加入具体的例子(Few-shot)
- 每月迭代一次提示词版本 这套流程3个月内把误报率从25%降到12%。
总结
AI辅助代码审查系统的核心价值在于解放高级工程师的时间,让他们专注于架构和复杂逻辑审查,把低级错误和规范问题交给AI处理。
实现要点:
- GitHub Webhook + HMAC签名验证:安全可靠的事件接入
- 分块处理大型diff:解决上下文限制问题
- 分级评论(ERROR/WARNING/INFO):降低噪音,聚焦重要问题
- 误报过滤机制:提高信噪比
- 自定义规则引擎:覆盖AI检测不到的团队特定规范
- CI/CD集成:高风险问题自动阻止合并
建议从小范围试点开始(1-2个非核心仓库),收集数据,迭代提示词,再逐步推广到全团队。
