import path from 'path'; import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js'; import { loadRoles, getRoleIntro } from './roles.js'; import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js'; import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers } from './findings.js'; import { reconcileConversations, dropResolvedFindings, addCarriedFindings } from './resolve.js'; import { saveFindings, postFindingsReview, formatFindingsStatsLine } from './comments.js'; import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatUsageStatsLine } from './usage.js'; import { cloneRepo, commitAndPush, getRepoState } from './git.js'; import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js'; import { runPreflight } from './preflight.js'; import { section, step, line, input, output, result, warn, error } from './log.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; async function main() { section('AI Code Review Pipeline'); // Step1 啟動 step('Step1', '啟動'); input(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER} ${PR_HEAD_BRANCH} → ${PR_BASE_BRANCH}`); output('參數讀取完成'); // Step2 前置驗證(step 標題與逐項檢查由 runPreflight 內部輸出) if (!(await runPreflight(WORKSPACE))) { result(false, '前置驗證未通過,終止流程'); section('Pipeline 結束'); process.exit(1); } // Step3 自動提交檢查:判斷本次 PR head 是否為 bot 自動提交 step('Step3', '自動提交檢查'); const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || ''; input(`PR head sha=${headSha ? headSha.slice(0, 7) : 'empty'}`); const headMessage = await getCommitMessageBySha(headSha); if (headMessage.includes('[ai-review-bot]') && getBotReviewOutcome(headMessage) === 'failure') { result(false, '偵測到 [ai-review-bot][failure],讓 workflow 失敗'); section('Pipeline 結束'); process.exit(1); } if (await shouldSkipBotCommit()) { result(true, '本次為 [ai-review-bot] 自動提交,跳過審查並結束'); section('Pipeline 結束'); process.exit(0); } output('非自動提交,繼續審查'); // Step4 PR 對話收斂:關閉所有未解決 comment,並把對應 finding 分流 step('Step4', 'PR 對話收斂'); let reconcile = { resolvedFindings: [], excludedFindings: [], carriedFindings: [], resolvedCount: 0, falsePositiveCount: 0, openCount: 0, closedCount: 0 }; try { reconcile = await reconcileConversations(); output(`關閉 comment ${reconcile.closedCount};findings 已修復 ${reconcile.resolvedCount}、誤報 ${reconcile.falsePositiveCount}、加回仍成立 ${reconcile.carriedFindings.length}`); } catch (e) { warn(`對話收斂失敗(繼續執行): ${e.message}`); } // Step5 角色分析:載入角色、取 diff,讓各角色平行產生 findings step('Step5', '角色分析產生 findings'); const { provider, apiKeys, baseURL, model } = getLLMConfig(); if (!provider) { result(false, '未設定任何 LLM API Key,請檢查 action inputs'); process.exit(1); } const roles = loadRoles(); let diff; try { diff = await getPRDiff(); } catch (e) { result(false, `取得 PR diff 失敗: ${e.message}`); process.exit(1); } if (!diff.trim()) { result(true, 'diff 為空,無需審查'); section('Pipeline 結束'); process.exit(0); } input(`LLM=${provider}/${model};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`); try { await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`); line('角色介紹 comment 已發布'); } catch (e) { warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); } const analyses = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const newFindings = []; for (let i = 0; i < analyses.length; i++) { if (analyses[i].status === 'fulfilled') newFindings.push(...analyses[i].value); else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`); } // 對只有檔名、缺行號的問題,反問原角色補上行號(最多重試數次),確保後續能行內標註 await resolveMissingLineNumbers(newFindings, diff); output(`新 findings ${newFindings.length} 筆(${formatFindingsStatsLine(newFindings)})`); // Step6 合併與去重:舊問題 + 對話收斂結果 + 新問題 → 語意去重 step('Step6', 'Findings 合併與語意去重'); let repoDir; try { repoDir = cloneRepo(WORKSPACE); } catch (e) { warn(`clone repo 失敗(繼續執行): ${e.message}`); } const repoState = repoDir ? getRepoState(repoDir) : null; if (repoState) line(`repo: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'}`); let oldFindings = loadOldFindings(repoDir || WORKSPACE); const beforeReconcile = oldFindings.length; oldFindings = dropResolvedFindings(oldFindings, [...reconcile.resolvedFindings, ...reconcile.excludedFindings]); oldFindings = addCarriedFindings(oldFindings, reconcile.carriedFindings); input(`舊 findings ${beforeReconcile} 筆(套用對話收斂後 ${oldFindings.length})+新 findings ${newFindings.length} 筆`); const mergedFindings = mergeFindings(oldFindings, newFindings); const deduped = await deduplicateWithAI(mergedFindings); const sorted = sortByLevel(deduped); output(`合併 ${mergedFindings.length} → 去重後 ${sorted.length} 筆(${formatFindingsStatsLine(sorted)})`); // Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決 step('Step7', '排除規則與誤報過濾'); if (reconcile.excludedFindings.length > 0) { // 以 repoDir 為主(即將提交回去的來源分支副本),WORKSPACE 為鏡像; // 順序須與下方 loadExclusions 一致,否則會讀到空的 WORKSPACE 而把既有排除規則覆蓋掉。 appendExclusions(repoDir || WORKSPACE, reconcile.excludedFindings, WORKSPACE); } const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE); input(`待過濾 ${sorted.length} 筆;排除規則 ${exclusions.length} 條`); const ruleFiltered = applyExclusions(sorted, exclusions); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); output(`保留 ${filtered.length} 筆(規則排除 ${sorted.length - ruleFiltered.length}、誤報剔除 ${ruleFiltered.length - filtered.length})`); // Step8 寫入 findings 並發布 Gitea Review(附使用量) step('Step8', '寫入 findings 與發布 Review'); const reviewDir = repoDir || WORKSPACE; saveFindings(WORKSPACE, filtered, reviewDir); const runUsage = getRunUsage(); const quota = await fetchAccountQuota(provider, { apiKeys, baseURL }); const rate = getRateLimit(); const usageSection = formatUsageStats(provider, model, runUsage, quota, rate); input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`); line(`使用量: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`); try { await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection }); output('Gitea Review 已發布'); } catch (e) { warn(`Review 發布失敗(繼續執行): ${e.message}`); } // Step9 JSON 格式驗證 step('Step9', 'findings/exclusions JSON 格式驗證'); const missingPaths = []; for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { const fullPath = path.join(reviewDir, relPath); try { const r = await validateJSONArrayFile(fullPath, relPath); if (!r.exists) missingPaths.push({ fullPath, relPath }); } catch { result(false, `${relPath} JSON 格式錯誤,終止流程`); process.exit(1); } } for (const { fullPath, relPath } of missingPaths) ensureJSONArrayFileExists(fullPath, relPath); result(true, '兩個檔案 JSON 格式皆正確'); // Step10 記憶區 Commit/Push step('Step10', '記憶區 Commit/Push'); const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; input(`review outcome=${reviewOutcome}`); await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); // Step11 嚴重問題把關 step('Step11', '嚴重問題把關'); const criticalCount = filtered.filter(f => f.level === 'critical').length; if (criticalCount > 0) { result(false, `發現 ${criticalCount} 個嚴重問題,workflow 失敗(exit 1)`); section('Pipeline 結束'); process.exit(1); } result(true, '無嚴重問題,審查通過'); section('Pipeline 結束'); } main().catch(e => { error(`Runner failed: ${e.message}`); process.exit(1); });