diff --git a/app/gitea.js b/app/gitea.js index 24cccbc..42c2b81 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -153,3 +153,76 @@ export async function postPullReview({ body, comments = [] }) { ); return resp.data; } + +/** + * 取得 PR 上所有的 review(每個 review 可含多個行內 comment)。 + */ +export async function listPullReviews() { + const resp = await axios.get( + api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`), + { headers: headers(), timeout: 30000, httpsAgent }, + ); + return Array.isArray(resp.data) ? resp.data : []; +} + +/** + * 取得單一 review 底下的所有行內 comment。 + */ +export async function getPullReviewComments(reviewId) { + const resp = await axios.get( + api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${reviewId}/comments`), + { headers: headers(), timeout: 30000, httpsAgent }, + ); + return Array.isArray(resp.data) ? resp.data : []; +} + +/** + * 取得 PR 上所有 review 的行內 comment,展平成單一陣列。 + * 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程。 + */ +export async function listAllReviewComments() { + const reviews = await listPullReviews(); + const all = []; + for (const review of reviews) { + if (!review?.id) continue; + try { + all.push(...await getPullReviewComments(review.id)); + } catch (e) { + warn(`取得 review #${review.id} 的 comments 失敗(略過): ${e.message}`); + } + } + line(`取得 PR review comments: reviews=${reviews.length} comments=${all.length}`); + return all; +} + +/** + * 解決(resolve)一個 review comment 所屬的對話。 + * 對應 Gitea 官方 API:POST /repos/{repo}/pulls/comments/{id}/resolve。 + */ +export async function resolvePullReviewComment(commentId) { + const resp = await axios.post( + api(`/repos/${GITEA_REPOSITORY}/pulls/comments/${commentId}/resolve`), + {}, + { headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent }, + ); + return resp.data; +} + +/** + * 取得指定 ref(預設 PR head)下某檔案的最新文字內容; + * Gitea contents API 回傳 base64,這裡解碼成字串。檔案不存在或非文字時回傳空字串。 + */ +export async function getFileContentAtRef(filePath, ref = PR_HEAD_SHA || PR_HEAD_BRANCH) { + try { + const resp = await axios.get( + api(`/repos/${GITEA_REPOSITORY}/contents/${encodeURIComponent(filePath).replace(/%2F/g, '/')}`), + { headers: headers(), params: ref ? { ref } : undefined, timeout: 30000, httpsAgent }, + ); + const { content, encoding } = resp.data || {}; + if (typeof content !== 'string') return ''; + return encoding === 'base64' ? Buffer.from(content, 'base64').toString('utf8') : content; + } catch (e) { + warn(`取得檔案內容失敗(視為空): ${filePath}@${ref || 'head'} error=${e.message}`); + return ''; + } +} diff --git a/app/main.js b/app/main.js index 156fdb2..f5caad4 100644 --- a/app/main.js +++ b/app/main.js @@ -3,6 +3,7 @@ import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConf import { loadRoles, getRoleIntro } from './roles.js'; import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js'; import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js'; +import { reconcileConversations, dropResolvedFindings, addCarriedFindings } from './resolve.js'; import { saveFindings, postFindingsReview, formatFindingsStatsLine } from './comments.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; @@ -43,6 +44,15 @@ async function main() { process.exit(0); } + step('Step2', 'PR 對話收斂'); + let reconcile = { resolvedFindings: [], carriedFindings: [], resolvedCount: 0, unresolvedCount: 0 }; + try { + reconcile = await reconcileConversations(); + ok(`Step2 完成: resolved=${reconcile.resolvedCount} unresolved=${reconcile.unresolvedCount} 加回=${reconcile.carriedFindings.length}`); + } catch (e) { + warn(`Step2 對話收斂失敗(繼續執行): ${e.message}`); + } + const { provider, baseURL, model } = getLLMConfig(); if (!provider) { error('未設定任何 LLM API Key,請檢查 action inputs'); @@ -99,8 +109,13 @@ async function main() { if (repoState) { line(`repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`); } - const oldFindings = loadOldFindings(repoDir || WORKSPACE); + let oldFindings = loadOldFindings(repoDir || WORKSPACE); logFindingsStats('Step4 舊 findings 統計', oldFindings); + const beforeReconcile = oldFindings.length; + oldFindings = dropResolvedFindings(oldFindings, reconcile.resolvedFindings); + oldFindings = addCarriedFindings(oldFindings, reconcile.carriedFindings); + line(`Step4 對話收斂套用: ${beforeReconcile} -> ${oldFindings.length} 筆(移除已解決 ${reconcile.resolvedFindings.length}、加回未解決 ${reconcile.carriedFindings.length})`); + logFindingsStats('Step4 收斂後舊 findings 統計', oldFindings); logFindingsStats('Step4 新 findings 統計', newFindings); const mergedFindings = mergeFindings(oldFindings, newFindings); ok(`Step4 merged findings total=${mergedFindings.length}`); diff --git a/app/resolve.js b/app/resolve.js new file mode 100644 index 0000000..43e1c7d --- /dev/null +++ b/app/resolve.js @@ -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]; +}