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 } 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'; import { runPreflight } from './preflight.js'; import { section, step, line, ok, warn, error } from './log.js'; const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; function logFindingsStats(label, findings) { line(`${label}: ${formatFindingsStatsLine(findings)}`); } async function main() { section('AI Code Review Pipeline'); step('Step1', 'Pipeline 啟動'); line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); if (!(await runPreflight(WORKSPACE))) { error('前置驗證未通過,終止流程'); section('Pipeline 結束'); process.exit(1); } const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || ''; const headMessage = await getCommitMessageBySha(headSha); const headOutcome = getBotReviewOutcome(headMessage); line(`head check: sha=${headSha || 'empty'} outcome=${headOutcome}`); if (headMessage.includes('[ai-review-bot]') && headOutcome === 'failure') { error('偵測到 [ai-review-bot][failure],直接讓 workflow 失敗'); section('Pipeline 結束'); process.exit(1); } if (await shouldSkipBotCommit()) { ok('偵測到 [ai-review-bot] 自動提交,直接完成 action'); section('Pipeline 結束'); 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'); process.exit(1); } line(`LLM: provider=${provider} model=${model} base_url=${baseURL}`); const roles = loadRoles(); line(`已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); let diff; try { diff = await getPRDiff(); line(`diff 長度: ${diff.length} 字元`); } catch (e) { error(`取得 diff 失敗: ${e.message}`); process.exit(1); } if (!diff.trim()) { warn('diff 為空,無需審查'); process.exit(0); } try { const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; await postComment(intro); ok('角色介紹 comment 發布成功'); } catch (e) { warn(`comment 發布失敗(繼續執行): ${e.message}`); } step('Step3', 'Findings 產生'); const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const newFindings = []; for (let i = 0; i < results.length; i++) { if (results[i].status === 'fulfilled') { newFindings.push(...results[i].value); } else { warn(`[${roles[i].name}] 分析失敗(跳過): ${results[i].reason?.message}`); } } ok(`Step3 完成: 新 findings 總計 ${newFindings.length} 筆`); logFindingsStats('Step3 統計', newFindings); step('Step4', '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'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`); } 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}`); logFindingsStats('Step4 合併後統計', mergedFindings); const deduped = await deduplicateWithAI(mergedFindings); logFindingsStats('Step4 AI 去重後統計', deduped); const sorted = sortByLevel(deduped); ok(`Step4 去重完成: ${mergedFindings.length} -> ${sorted.length} 筆`); logFindingsStats('Step4 排序後統計', sorted); step('Step5', 'AI 排除問題過濾'); const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE); const ruleFiltered = applyExclusions(sorted, exclusions); logFindingsStats('Step5 規則排除後統計', ruleFiltered); const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions); logFindingsStats('Step5 AI 誤報過濾後統計', filtered); ok(`Step5 完成: findings total=${filtered.length}`); step('Step6', 'Findings 寫入與 Review 發布'); const reviewDir = repoDir || WORKSPACE; saveFindings(WORKSPACE, filtered, reviewDir); try { logFindingsStats('Step6 儲存 findings 統計', filtered); logFindingsStats('Step6 review summary 統計', filtered); logFindingsStats('Step6 review comments 統計', filtered); await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, }); ok('Step6 完成'); } catch (e) { warn(`review 發布失敗(繼續執行): ${e.message}`); } step('Step7', 'JSON 格式驗證'); const missingPaths = []; for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { const fullPath = path.join(reviewDir, relPath); try { const result = await validateJSONArrayFile(fullPath, relPath); if (!result.exists) missingPaths.push({ fullPath, relPath }); } catch { process.exit(1); } } for (const { fullPath, relPath } of missingPaths) { ensureJSONArrayFileExists(fullPath, relPath); } step('Step8', '記憶區 Commit/Push'); const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; line(`review outcome=${reviewOutcome}`); await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); step('Step9', '嚴重問題檢查'); const criticalCount = filtered.filter(f => f.level === 'critical').length; if (criticalCount > 0) { error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`); section('Pipeline 結束'); process.exit(1); } ok('無嚴重問題'); ok('Pipeline 完成'); section('Pipeline 結束'); } main().catch(e => { error(`Runner failed: ${e.message}`); process.exit(1); });