feat(ai-review 對話收斂): 讀 PR review 留言判斷解決狀態並收斂 findings

This commit is contained in:
Jeffery
2026-06-23 11:09:50 +08:00
parent 5e2ee59cca
commit a99163468b
3 changed files with 307 additions and 1 deletions
+218
View File
@@ -0,0 +1,218 @@
import { chatJSON } from './llm.js';
import { listAllReviewComments, resolvePullReviewComment, getFileContentAtRef } from './gitea.js';
import { line, ok, warn } from './log.js';
const EMPTY = { resolvedFindings: [], carriedFindings: [], resolvedCount: 0, unresolvedCount: 0 };
/** 取出 "**label**value" 這一行的 value(單行)。 */
function fieldValue(body, label) {
const m = body.match(new RegExp(`\\*\\*${label}\\*\\*[:]\\s*(.+)`));
return m ? m[1].trim() : '';
}
function levelToKey(raw) {
if (!raw) return null;
if (raw.includes('嚴重')) return 'critical';
if (raw.includes('警告')) return 'warning';
if (raw.includes('建議')) return 'info';
return null;
}
/**
* 嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。
* 同時支援 review comment(嚴重等級/審查員/問題/建議)與行內 critical comment(等級/審查員/建議)格式。
* 不符合格式(例如人工自由留言)時回傳 null。
*/
export function parseBotReviewComment(body) {
if (typeof body !== 'string' || !body.includes('**')) return null;
const normalized = body.replace(/\r\n/g, '\n');
const levelRaw = fieldValue(normalized, '嚴重等級') || fieldValue(normalized, '等級');
const role = fieldValue(normalized, '審查員');
const problem = fieldValue(normalized, '問題');
const suggestion = fieldValue(normalized, '建議');
const level = levelToKey(levelRaw);
if (!level && !role) return null;
if (!suggestion && !problem) return null;
return {
level: level || 'warning',
role: role || 'AI Review',
problem: problem || '',
suggestion: suggestion || problem || '',
};
}
/**
* 把 PR 上的行內 review comment 依「檔案路徑 + 行號」收斂成對話(同一處的留言與回覆視為一段對話)。
* 對話只要任一則 comment 帶有 resolver 即視為已解決;同時嘗試解析出該對話對應的 bot finding。
*/
export function groupConversations(comments) {
const groups = new Map();
for (const c of comments || []) {
const filePath = typeof c?.path === 'string' ? c.path : '';
const lineNum = Number(c?.position) || Number(c?.original_position) || 0;
const key = `${filePath}|${lineNum}`;
if (!groups.has(key)) {
groups.set(key, { key, path: filePath, line: lineNum, commentIds: [], bodies: [], resolved: false, botFinding: null });
}
const g = groups.get(key);
if (c?.id != null) g.commentIds.push(c.id);
const body = typeof c?.body === 'string' ? c.body : '';
if (body) g.bodies.push(body);
if (c?.resolver) g.resolved = true;
if (!g.botFinding) {
const finding = parseBotReviewComment(body);
if (finding) g.botFinding = { ...finding, location: lineNum ? `${filePath}:${lineNum}` : filePath };
}
}
return [...groups.values()].map(g => ({ ...g, thread: g.bodies.join('\n---\n') }));
}
/** 取目標行附近的程式碼片段(含行號),讓 AI 對照判斷問題是否已解決。 */
export function codeWindow(content, lineNum, radius = 20) {
if (!content) return '';
const lines = content.split('\n');
const center = Number.isFinite(lineNum) && lineNum > 0 ? lineNum - 1 : 0;
const start = Math.max(0, center - radius);
const end = Math.min(lines.length, center + radius + 1);
return lines.slice(start, end).map((text, i) => `${start + i + 1}: ${text}`).join('\n');
}
/**
* 批次請 AI 判斷每個對話指出的問題在最新程式碼中是否已解決。
* 回傳與輸入等長、依 idx 對齊的 [{ idx, resolved }];無法判斷一律視為未解決(寧可保留)。
*/
export async function judgeConversationsResolved(items, chatFn = chatJSON) {
if (!items || items.length === 0) return [];
const systemPrompt = `你是 🛡️ Paladin(聖騎士),公正的裁判。下面是一批 PR review 對話(JSON 陣列),每個對話包含:曾被指出的問題(thread)、問題所在檔案 path 與行號 line、以及該位置最新的程式碼片段 code。請逐一判斷「該對話指出的問題在最新程式碼中是否已被解決」。只回傳 JSON 陣列,每個元素為 {"idx": 數字, "resolved": true 或 false},不要有其他文字。若資訊不足以判斷,resolved 一律填 false。`;
const payload = items.map(it => ({ idx: it.idx, path: it.path, line: it.line, thread: it.thread, code: it.code }));
const result = await chatFn(systemPrompt, JSON.stringify(payload));
const byIdx = new Map(
(Array.isArray(result) ? result : [])
.filter(r => Number.isInteger(r?.idx))
.map(r => [r.idx, r.resolved === true]),
);
return items.map(it => ({ idx: it.idx, resolved: byIdx.get(it.idx) === true }));
}
function pushCarried(target, conversation) {
if (!conversation.botFinding) return;
target.push({ ...conversation.botFinding, is_new: false });
}
/**
* 對話收斂主流程:
* 1. 取得 PR 所有行內 review comment,收斂成對話,跳過已 resolve 的;
* 2. 取每個對話所在檔案的最新內容,請 AI 判斷問題是否已解決;
* 3. 已解決者呼叫 Gitea resolve API 解決對話,並記錄其 finding(供移除舊問題);
* 4. 未解決且可解析為 bot finding 者,收集為「加回問題列表」清單。
* 任一外部呼叫失敗都降級處理(保守視為未解決),不中斷整體 pipeline。
*/
export async function reconcileConversations(deps = {}) {
const {
listComments = listAllReviewComments,
resolveComment = resolvePullReviewComment,
getFileContent = getFileContentAtRef,
judge = judgeConversationsResolved,
} = deps;
let comments;
try {
comments = await listComments();
} catch (e) {
warn(`取得 PR review comments 失敗,跳過對話收斂: ${e.message}`);
return { ...EMPTY };
}
const conversations = groupConversations(comments);
const open = conversations.filter(c => !c.resolved && c.commentIds.length > 0);
const alreadyResolved = conversations.length - open.length;
line(`對話收斂: 對話總數=${conversations.length} 已解決/不可處理=${alreadyResolved} 待判斷=${open.length}`);
if (open.length === 0) return { ...EMPTY };
const fileCache = new Map();
for (const filePath of [...new Set(open.map(c => c.path).filter(Boolean))]) {
fileCache.set(filePath, await getFileContent(filePath));
}
const items = open.map((c, idx) => ({
idx,
path: c.path,
line: c.line,
thread: c.thread,
code: codeWindow(fileCache.get(c.path) || '', c.line),
}));
let verdicts;
try {
verdicts = await judge(items);
} catch (e) {
warn(`AI 判斷對話解決狀態失敗,全部視為未解決: ${e.message}`);
verdicts = items.map(it => ({ idx: it.idx, resolved: false }));
}
const resolvedSet = new Set(verdicts.filter(v => v.resolved).map(v => v.idx));
const resolvedFindings = [];
const carriedFindings = [];
let resolvedCount = 0;
for (let i = 0; i < open.length; i++) {
const c = open[i];
if (resolvedSet.has(i)) {
try {
await resolveComment(c.commentIds[0]);
resolvedCount += 1;
if (c.botFinding) resolvedFindings.push({ ...c.botFinding, is_new: false });
ok(`對話已解決並 resolve: ${c.path}:${c.line}`);
continue;
} catch (e) {
warn(`resolve 對話失敗(保留為未解決): ${c.path}:${c.line} error=${e.message}`);
}
}
pushCarried(carriedFindings, c);
}
const unresolvedCount = open.length - resolvedCount;
ok(`對話收斂完成: resolved=${resolvedCount} unresolved=${unresolvedCount} 加回 findings=${carriedFindings.length}`);
return { resolvedFindings, carriedFindings, resolvedCount, unresolvedCount };
}
function fileOf(location) {
return String(location || '').split(':')[0].trim();
}
function normalizeKey(text) {
return String(text || '')
.normalize('NFKC')
.replace(/[\p{P}\p{S}\s]+/gu, '')
.trim()
.toLowerCase();
}
/** 以「檔案路徑 + 正規化建議內容」為簽章,對 line 漂移與標點差異穩定。 */
function findingSig(f) {
return `${fileOf(f?.location)}|${normalizeKey(f?.suggestion)}`;
}
/**
* 從 findings 中移除「已解決對話」對應的問題(以檔案路徑+建議內容比對,避免行號漂移誤判)。
*/
export function dropResolvedFindings(findings, resolvedFindings = []) {
if (!resolvedFindings || resolvedFindings.length === 0) return findings;
const resolved = new Set(resolvedFindings.map(findingSig));
return findings.filter(f => !resolved.has(findingSig(f)));
}
/**
* 把「未解決對話」對應、但目前 findings 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準)。
*/
export function addCarriedFindings(findings, carriedFindings = []) {
if (!carriedFindings || carriedFindings.length === 0) return findings;
const seen = new Set(findings.map(findingSig));
const additions = carriedFindings.filter(f => {
const sig = findingSig(f);
if (seen.has(sig)) return false;
seen.add(sig);
return true;
});
if (additions.length > 0) ok(`加回未解決問題: ${additions.length}`);
return [...findings, ...additions];
}