mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
- Add @biomejs/biome as dev dependency - Remove deprecated tslint dependency - Add biome.json with project-specific rules - Update lint script to use Biome - Apply Biome auto-fixes across codebase
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import { zValidator } from '@hono/zod-validator';
|
||
import { Hono } from 'hono';
|
||
import OpenAI from 'openai';
|
||
import { z } from 'zod';
|
||
import config from '../config';
|
||
import { LearningSystem } from '../review/learning/learning-system';
|
||
import { VectorMemoryStore } from '../review/memory/vector-store';
|
||
import { FileReviewStore } from '../review/store/file-review-store';
|
||
import { giteaService } from '../services/gitea';
|
||
|
||
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,
|
||
});
|
||
}
|
||
// 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 };
|