Files
ai-code-review/app/main.js
T
2026-06-26 09:36:33 +00:00

230 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/**
* AI Code Review Pipeline 的總指揮(orchestrator)。
*
* 依序串接 Step1~Step11:啟動參數讀取、前置驗證、自動提交檢查、PR 對話收斂、
* 角色平行分析產生 findings、新舊 findings 合併與語意去重、排除規則與誤報過濾、
* 寫入 findings 並發布 Gitea Review、findings/exclusions JSON 格式驗證、
* 記憶區 commit/push,以及嚴重問題把關。
*
* 結果主要透過 `process.exit()` 決定 workflow 成敗,而非以回傳值傳遞。
*
* @async
* @returns {Promise<void>} 流程正常走完(無嚴重問題)時 resolve;多數結束路徑會直接
* 呼叫 `process.exit()` 結束程序,函式不會以回傳值回報審查結果。
* @throws {Error} 內部未被個別 try/catch 攔截的未預期例外會向上拋出,
* 由頂層 `main().catch(...)` 接住並以 `process.exit(1)` 結束。
*
* @remarks
* 流程階段(Step1~Step11):
* - Step1 啟動:讀取 repo / PR / 分支等基本參數。
* - Step2 前置驗證:`runPreflight`,未通過則 exit 1。
* - Step3 自動提交檢查:偵測上輪 bot `[failure]`exit 1)或本次為 bot 自動提交(exit 0 跳過)。
* - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。
* - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號;
* 未設定 API Key 或取 diff 失敗 exit 1diff 為空 exit 0。
* - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。
* - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。
* - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。
* - Step9 JSON 驗證:驗證 findings/exclusions 檔,格式錯誤 exit 1,缺檔則建立空陣列檔。
* - Step10 記憶區 commit/push:依是否有 critical 計算 reviewOutcome 後推回來源分支。
* - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。
*
* 退出行為:
* - exit 1:前置驗證未過、上輪 bot failure、未設定 LLM Key、取 diff 失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。
* - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。
*
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
*/
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 newFindings = [];
let fulfilledAnalyses = 0;
for (const role of roles) {
try {
const findings = await analyzeWithRole(role, diff);
fulfilledAnalyses += 1;
newFindings.push(...findings);
} catch (e) {
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
}
}
if (fulfilledAnalyses === 0) {
result(false, '所有角色分析皆失敗,終止流程以避免誤判為審查通過');
process.exit(1);
}
// 對只有檔名、缺行號的問題,反問原角色補上行號(最多重試數次),確保後續能行內標註
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);
});