205 lines
8.7 KiB
JavaScript
205 lines
8.7 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 } 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, 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: [], excludedFindings: [], carriedFindings: [], resolvedCount: 0, falsePositiveCount: 0, openCount: 0, closedCount: 0 };
|
||
try {
|
||
reconcile = await reconcileConversations();
|
||
ok(`Step2 完成: 關閉=${reconcile.closedCount} 已修復=${reconcile.resolvedCount} 誤報=${reconcile.falsePositiveCount} 加回=${reconcile.carriedFindings.length}`);
|
||
} catch (e) {
|
||
warn(`Step2 對話收斂失敗(繼續執行): ${e.message}`);
|
||
}
|
||
|
||
const { provider, apiKeys, 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;
|
||
const reconcileDropped = [...reconcile.resolvedFindings, ...reconcile.excludedFindings];
|
||
oldFindings = dropResolvedFindings(oldFindings, reconcileDropped);
|
||
oldFindings = addCarriedFindings(oldFindings, reconcile.carriedFindings);
|
||
line(`Step4 對話收斂套用: ${beforeReconcile} -> ${oldFindings.length} 筆(移除已修復 ${reconcile.resolvedFindings.length}、移除誤報 ${reconcile.excludedFindings.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 排除問題過濾');
|
||
// 先把對話收斂判定的誤報寫入 exclusions.json(workspace 與 cloned repo 各一份),供本次過濾與後續 commit
|
||
if (reconcile.excludedFindings.length > 0) {
|
||
appendExclusions(WORKSPACE, reconcile.excludedFindings, repoDir || WORKSPACE);
|
||
}
|
||
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);
|
||
|
||
// 蒐集 AI 助理使用量:本次 token 消耗 + 剩餘可用百分比(帳號額度優先,否則用回應 header 的速率配額;皆失敗時降級為「無法計算」,不中斷流程)
|
||
const runUsage = getRunUsage();
|
||
const quota = await fetchAccountQuota(provider, { apiKeys, baseURL });
|
||
const rate = getRateLimit();
|
||
const usageSection = formatUsageStats(provider, model, runUsage, quota, rate);
|
||
line(`使用量統計: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`);
|
||
|
||
try {
|
||
logFindingsStats('Step6 儲存 findings 統計', filtered);
|
||
logFindingsStats('Step6 review summary 統計', filtered);
|
||
logFindingsStats('Step6 review comments 統計', filtered);
|
||
await postFindingsReview(filtered, {
|
||
summaryFindings: filtered,
|
||
commentFindings: filtered,
|
||
usageSection,
|
||
});
|
||
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);
|
||
});
|