feat(ai-code-review): 新增 AI 程式碼審查主程式、模組與測試

This commit is contained in:
Jeffery
2026-07-02 16:09:12 +08:00
parent 18314b88a3
commit f46dfe6258
448 changed files with 78568 additions and 16 deletions
+229
View File
@@ -0,0 +1,229 @@
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);
});