mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
@@ -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
292
src/controllers/feedback.ts
Normal 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 };
|
||||
@@ -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(保证有baseSha),headCloneUrl作为额外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 PR:head.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);
|
||||
|
||||
24
src/index.ts
24
src/index.ts
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user