appendExclusions 原以 WORKSPACE 為主、repoDir 為鏡像,與 loadExclusions 相反, 會讀到空的 WORKSPACE 後把僅含本次新增的結果鏡像覆蓋掉 repoDir 既有規則, 改為以 repoDir 為主、WORKSPACE 為鏡像,順序與 loadExclusions 一致。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
8.9 KiB
JavaScript
183 lines
8.9 KiB
JavaScript
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);
|
||
});
|