Files
code-review/app/main.js
T

200 lines
8.2 KiB
JavaScript
Raw 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 } 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: [], 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, 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;
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);
// 蒐集 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);
});