diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index e10a897..73433f5 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -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, diff --git a/src/controllers/feedback.ts b/src/controllers/feedback.ts new file mode 100644 index 0000000..f63dda3 --- /dev/null +++ b/src/controllers/feedback.ts @@ -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 }; diff --git a/src/controllers/review.ts b/src/controllers/review.ts index f77dcb8..ac72c4f 100644 --- a/src/controllers/review.ts +++ b/src/controllers/review.ts @@ -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 // 继续执行代码审查流程,不因通知失败而中断 } - // 开始异步审查流程 + 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 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); diff --git a/src/index.ts b/src/index.ts index ce7332d..abde453 100644 --- a/src/index.ts +++ b/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, diff --git a/src/services/gitea.ts b/src/services/gitea.ts index 904d102..bab222c 100644 --- a/src/services/gitea.ts +++ b/src/services/gitea.ts @@ -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({