feat: 集成Agent审查引擎到应用入口和控制器

webhook控制器支持agent模式的PR/commit入队;admin API新增review runs查询;feedback控制器支持人工审批反馈;Gitea服务扩展commit评论接口

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
accelerator
2026-03-01 03:36:01 +00:00
parent 25d4f56bde
commit 2ce2a5f6a6
5 changed files with 434 additions and 11 deletions

View File

@@ -1,8 +1,9 @@
import { Hono } from 'hono';
import { sign } from 'hono/jwt';
import config from '@/config';
import { giteaService } from '@/services/gitea';
import { logger } from '@/utils/logger';
import config from '../config';
import { giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
import { reviewEngine } from '../review/engine';
const publicRoutes = new Hono();
const protectedRoutes = new Hono();
@@ -90,6 +91,33 @@ protectedRoutes.delete('/repositories/:owner/:repo/webhook/:hookId', async (c) =
}
});
// 查询审查任务
protectedRoutes.get('/review/runs', async (c) => {
try {
const limit = parseInt(c.req.query('limit') || '50', 10);
const runs = await reviewEngine.listRuns(limit);
return c.json({ data: runs });
} catch (error: any) {
logger.error('获取审查任务列表失败:', error);
return c.json({ message: 'Failed to fetch review runs', error: error.message }, 500);
}
});
// 查询审查任务详情
protectedRoutes.get('/review/runs/:runId', async (c) => {
try {
const { runId } = c.req.param();
const result = await reviewEngine.getRunDetails(runId);
if (!result) {
return c.json({ message: 'Run not found' }, 404);
}
return c.json(result);
} catch (error: any) {
logger.error('获取审查任务详情失败:', error);
return c.json({ message: 'Failed to fetch review run details', error: error.message }, 500);
}
});
export const adminController = {
publicRoutes,
protectedRoutes,

292
src/controllers/feedback.ts Normal file
View File

@@ -0,0 +1,292 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { FileReviewStore } from '../review/store/file-review-store';
import { VectorMemoryStore } from '../review/memory/vector-store';
import { LearningSystem } from '../review/learning/learning-system';
import { giteaService } from '../services/gitea';
import config from '../config';
import OpenAI from 'openai';
const feedbackRouter = new Hono();
// 全局实例
let memoryStore: VectorMemoryStore | null = null;
let learningSystem: LearningSystem | null = null;
let reviewStore: FileReviewStore | null = null;
// 初始化反馈系统(记忆系统可选)
export function initializeFeedbackSystem(openaiClient: OpenAI, store: FileReviewStore): void {
// 保存store实例以供handlers重用避免多实例状态不同步
reviewStore = store;
// 记忆系统为可选功能
if (config.review.qdrantUrl && config.review.enableMemory) {
memoryStore = new VectorMemoryStore(config.review.qdrantUrl, openaiClient);
learningSystem = new LearningSystem(memoryStore, reviewStore);
memoryStore.initialize().catch((err) => {
console.error('Failed to initialize memory store:', err);
});
}
}
// 提交人工反馈
feedbackRouter.post(
'/finding/:findingId',
zValidator(
'json',
z.object({
approved: z.boolean().describe('是否批准该finding'),
reason: z.string().optional().describe('反馈原因'),
reviewer: z.string().optional().describe('审查者'),
})
),
async (c) => {
const { findingId } = c.req.param();
const { approved, reason } = c.req.valid('json');
if (!reviewStore) {
return c.json({ error: 'Feedback system not initialized' }, 503);
}
// 重用已初始化的store实例避免多实例状态不同步
const finding = await reviewStore.getFinding(findingId);
if (!finding) {
return c.json({ error: 'Finding not found' }, 404);
}
// 获取run信息以获取owner和repo
const runDetails = await reviewStore.getRunDetails(finding.runId);
if (!runDetails) {
return c.json({ error: 'Run not found' }, 404);
}
const { owner, repo } = runDetails.run;
// 原子幂等性保护先标记finding为published原子check-and-set
// 只有第一个请求会得到true后续并发/重试请求会得到false
// 这解决了read-check-write竞态两个并发请求不会都发布评论
const wasUnpublished = await reviewStore.markFindingPublished(finding.runId, finding.fingerprint);
if (!wasUnpublished) {
// finding已被标记为published但需验证是否真的发布成功
// 场景并发请求A正在发布时请求B到达或请求A发布失败回滚后请求B重试
// 检查是否存在已发布的comment记录来确认真实状态
// 关键必须通过fingerprint匹配而非仅path+line以区分同一位置的不同findings
const publishedComment = runDetails.comments.find(
c => c.status === 'published' && c.fingerprint === finding.fingerprint
);
if (publishedComment) {
// 确认已成功发布到Gitea存在published comment record返回幂等成功
return c.json({
success: true,
message: '该finding已处理过',
alreadyProcessed: true,
learningApplied: false,
published: true,
});
} else {
// published标记存在但无published comment记录
// 可能原因1) 并发请求正在发布中 2) 之前发布失败并回滚
// 不能声称成功,返回错误让用户稍后重试
return c.json({
error: 'Finding approval in progress or previously failed. Please retry in a moment.',
inProgress: true,
}, 409); // 409 Conflict
}
}
// 以下代码只会被第一个请求执行wasUnpublished=true
let learningApplied = false;
// 如果记忆系统启用,尝试执行学习和向量存储(可选功能,失败不阻止审批流程)
if (memoryStore && learningSystem) {
try {
await memoryStore.storeFeedback(findingId, approved, reason || '', owner, repo);
if (approved) {
await learningSystem.learnFromApproval(finding, owner, repo);
} else {
await learningSystem.learnFromFalsePositive(finding, reason || '人工标记为误报', owner, repo);
}
learningApplied = true;
} catch (memoryError) {
// 记忆系统故障不应阻止人工审批操作
console.error('Memory system operation failed (non-fatal):', memoryError);
learningApplied = false;
}
}
try {
// 如果批准发布到Gitea人工审批通过的问题应该通知开发者
if (approved) {
const comment = `## 🔍 AI代码审查问题人工确认
**${finding.title}**
严重程度: ${finding.severity}
置信度: ${(finding.confidence * 100).toFixed(0)}%
${finding.detail}
${finding.evidence ? `**证据:**\n\`\`\`\n${finding.evidence}\n\`\`\`` : ''}
${finding.suggestion ? `**建议:**\n${finding.suggestion}` : ''}
---
_此问题已通过人工审批确认_`;
// 关键区分Gitea发布失败和本地store失败避免重复发布
// 1. 先发布到Gitea失败则回滚published标记
// 2. 再写本地record失败不回滚因为Gitea已成功重试不应重复发布
try {
if (runDetails.run.eventType === 'pull_request' && runDetails.run.prNumber) {
await giteaService.addPullRequestComment(
owner,
repo,
runDetails.run.prNumber,
comment
);
} else if (runDetails.run.commitSha) {
await giteaService.addCommitComment(
owner,
repo,
runDetails.run.commitSha,
comment
);
}
} catch (giteaError) {
// Gitea API失败回滚published状态允许用户重试发布
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
throw giteaError;
}
// Gitea发布成功写入本地record
// 关键权衡如果record写入失败必须回滚published标记以保持可恢复性
// 代价立即重试可能导致重复Gitea评论罕见边缘情况优于永久卡死
try {
await reviewStore.addCommentRecord({
runId: finding.runId,
status: 'published',
body: comment,
path: finding.path,
line: finding.line,
fingerprint: finding.fingerprint,
});
} catch (storeError) {
// 本地store失败回滚published标记允许用户重试
// 如果用户立即重试可能导致重复Gitea评论可接受的权衡以避免永久卡死
console.error('Failed to persist comment record after successful Gitea publish, rolling back:', storeError);
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
throw new Error(
'Comment published to Gitea but failed to save locally. State rolled back, you may retry. Note: immediate retry may create duplicate comments.'
);
}
} else {
// 拒绝标记为误报创建comment record以标记处理完成
// 不发布到Gitea但需要记录以使重试请求能识别已处理
// 如果写入失败回滚published标记以允许重试
try {
await reviewStore.addCommentRecord({
runId: finding.runId,
status: 'published',
body: `REJECTED: ${finding.title} - ${reason || '人工标记为误报'}`,
path: finding.path,
line: finding.line,
fingerprint: finding.fingerprint,
});
} catch (storeError) {
// 拒绝record写入失败回滚published标记允许用户重试
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
throw storeError;
}
}
// finding已在开头原子标记为published处理成功则保持published状态
return c.json({
success: true,
message: approved ? '已标记为有效问题并发布到Gitea' : '已标记为误报',
learningApplied,
published: approved,
});
} catch (error) {
console.error('Failed to process feedback:', error);
return c.json(
{
error: 'Failed to process feedback',
details: error instanceof Error ? error.message : String(error),
},
500
);
}
}
);
// 获取待审批的findings
feedbackRouter.get('/pending', async (c) => {
if (!reviewStore) {
return c.json({ error: 'Feedback system not initialized' }, 503);
}
const limit = Number(c.req.query('limit') || '50');
try {
const pendingFindings = await reviewStore.getPendingFindings(limit);
return c.json({
findings: pendingFindings,
total: pendingFindings.length,
});
} catch (error) {
console.error('Failed to fetch pending findings:', error);
return c.json(
{
error: 'Failed to fetch pending findings',
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
// 获取finding详情
feedbackRouter.get('/finding/:findingId', async (c) => {
if (!reviewStore) {
return c.json({ error: 'Feedback system not initialized' }, 503);
}
const { findingId } = c.req.param();
try {
const finding = await reviewStore.getFinding(findingId);
if (!finding) {
return c.json({ error: 'Finding not found' }, 404);
}
// 获取run详情以提供更多上下文
const runDetails = await reviewStore.getRunDetails(finding.runId);
return c.json({
finding,
run: runDetails?.run,
});
} catch (error) {
console.error('Failed to fetch finding:', error);
return c.json(
{
error: 'Failed to fetch finding',
details: error instanceof Error ? error.message : String(error),
},
500
);
}
});
export { feedbackRouter };

View File

@@ -1,9 +1,10 @@
import { Context } from 'hono';
import { map } from 'lodash-es'
import { map } from 'lodash-es';
import { giteaService, PullRequestFile, PullRequestDetails } from '../services/gitea';
import { aiReviewService } from '../services/ai-review';
import { feishuService } from '../services/feishu';
import config from '../config';
import { reviewEngine } from '../review/engine';
import * as crypto from 'crypto';
import { logger } from '../utils/logger';
@@ -78,6 +79,19 @@ function determineEventType(c: Context, body: any): GiteaEventType {
return GiteaEventType.Unknown;
}
function resolveCloneUrl(repo: any): string | null {
if (repo?.clone_url && typeof repo.clone_url === 'string') {
return repo.clone_url;
}
if (repo?.ssh_url && typeof repo.ssh_url === 'string') {
return repo.ssh_url;
}
if (repo?.html_url && typeof repo.html_url === 'string') {
return `${repo.html_url}.git`;
}
return null;
}
/**
* 处理Pull Request事件
*/
@@ -141,7 +155,44 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
// 继续执行代码审查流程,不因通知失败而中断
}
// 开始异步审查流程
if (config.review.engine === 'agent') {
// Fork PR策略始终clone base repo保证有baseShaheadCloneUrl作为额外remote保证有headSha
const baseCloneUrl = resolveCloneUrl(repo);
const headSha = pullRequest.head?.sha;
const baseSha = pullRequest.base?.sha;
if (!baseCloneUrl || !headSha || !baseSha) {
return c.json({ error: '缺少Agent审查所需字段(clone_url/base sha/head sha)' }, 400);
}
// 检测fork PRhead.repo存在且与base repo不同
const headCloneUrl = pullRequest.head?.repo ? resolveCloneUrl(pullRequest.head.repo) : undefined;
const isForkPR = headCloneUrl && headCloneUrl !== baseCloneUrl;
// 包含baseSha以支持retarget场景相同headSha但baseSha变化时需要重新审查
const idempotencyKey = `${owner}/${repoName}#${prNumber}:${baseSha}...${headSha}`;
const { run, reused } = await reviewEngine.enqueuePullRequest({
eventType: 'pull_request',
idempotencyKey,
owner,
repo: repoName,
cloneUrl: baseCloneUrl,
headCloneUrl: isForkPR ? headCloneUrl : undefined,
prNumber,
baseSha,
headSha,
});
return c.json(
{
status: reused ? 'deduplicated' : 'accepted',
message: reused ? '审查任务已存在,已去重' : 'Agent代码审查任务已入队',
runId: run.id,
},
202
);
}
// Legacy模式开始异步审查流程
reviewPullRequest(owner, repoName, prNumber).catch(error => {
logger.error(`审查PR ${owner}/${repoName}#${prNumber} 失败:`, error);
});
@@ -213,7 +264,36 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
removed: commitInfo.removed.length
});
// 如果没有文件变更信息,则忽略
// Agent模式优先处理从本地仓库派生diff不依赖webhook文件列表
if (config.review.engine === 'agent') {
const cloneUrl = resolveCloneUrl(body.repository);
if (!cloneUrl) {
return c.json({ error: '缺少Agent审查所需字段(clone_url)' }, 400);
}
const idempotencyKey = `${owner}/${repoName}@${commitSha}`;
const { run, reused } = await reviewEngine.enqueueCommit({
eventType: 'commit_status',
idempotencyKey,
owner,
repo: repoName,
cloneUrl,
commitSha,
commitMessage: commitInfo.message,
relatedPrNumber: relatedPR?.number,
});
return c.json(
{
status: reused ? 'deduplicated' : 'accepted',
message: reused ? '审查任务已存在,已去重' : 'Agent提交审查任务已入队',
runId: run.id,
},
202
);
}
// Legacy模式需要webhook文件列表
if (commitInfo.added.length === 0 && commitInfo.modified.length === 0 && commitInfo.removed.length === 0) {
logger.warn('提交没有文件变更信息,忽略审查', { commitSha });
return c.json({ status: 'ignored', message: '提交没有文件变更信息' }, 200);

View File

@@ -3,7 +3,10 @@ import { jwt } from 'hono/jwt';
import { serveStatic } from 'hono/bun';
import { handleGiteaWebhook } from './controllers/review';
import { adminController } from './controllers/admin';
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
import config from './config';
import { reviewEngine } from './review/engine';
import OpenAI from 'openai';
// 创建Hono应用实例
const app = new Hono();
@@ -39,8 +42,9 @@ app.route('/admin/api', adminController.publicRoutes);
// 受保护的路由
const adminProtected = new Hono();
adminProtected.use('/*', jwt({ secret: config.admin.jwtSecret }));
adminProtected.use('/*', jwt({ secret: config.admin.jwtSecret, alg: 'HS256' }));
adminProtected.route('/', adminController.protectedRoutes);
adminProtected.route('/feedback', feedbackRouter);
app.route('/admin/api', adminProtected);
@@ -57,6 +61,24 @@ app.get('*', serveStatic({ path: './public/index.html' }));
const port = config.app.port;
console.log(`⚡️ 服务启动在 http://localhost:${port}`);
reviewEngine.start().catch((error) => {
console.error('❌ 启动Agent Review Engine失败', error);
});
// 初始化反馈系统(总是初始化,记忆系统可选)
const openaiClient = new OpenAI({
baseURL: config.openai.baseUrl,
apiKey: config.openai.apiKey,
});
const reviewStore = reviewEngine.getStore();
initializeFeedbackSystem(openaiClient, reviewStore);
if (config.review.enableMemory) {
console.log('✅ 反馈系统已初始化(含向量记忆)');
} else {
console.log('✅ 反馈系统已初始化(不含向量记忆)');
}
export default {
port,
fetch: app.fetch,

View File

@@ -1,11 +1,12 @@
import axios from 'axios';
import config from '../config';
import { logger } from '../utils/logger';
import { LineComment } from './ai-review';
// 打印将要使用的 Admin Token用于调试
logger.info(`Gitea Admin Token used: [${config.admin.giteaAdminToken}]`);
logger.info(`Gitea Access Token (fallback): [${config.gitea.accessToken}]`);
export interface LineComment {
path: string;
line: number;
comment: string;
}
// 创建API客户端
const giteaClient = axios.create({