refactor(pipeline 日誌): pipeline 日誌可讀性優化(步驟連號、輸入輸出、成敗明確) #43
+7
-28
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import https from 'https';
|
||||
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js';
|
||||
import { line, ok, warn } from './log.js';
|
||||
import { line, warn } from './log.js';
|
||||
|
||||
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
||||
@@ -40,11 +40,9 @@ export async function getCommitMessageBySha(sha) {
|
||||
timeout: 30000,
|
||||
httpsAgent,
|
||||
});
|
||||
const message = extractCommitMessage(resp.data);
|
||||
line(`bot-check commit api: sha=${sha} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} message=${message ? 'found' : 'empty'}`);
|
||||
return message;
|
||||
return extractCommitMessage(resp.data);
|
||||
} catch (e) {
|
||||
warn(`bot-check commit api 失敗: sha=${sha} error=${e.message}`);
|
||||
warn(`取得 commit 訊息失敗: sha=${sha} error=${e.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -58,40 +56,21 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
|
||||
httpsAgent,
|
||||
});
|
||||
const sha = resp.data?.commit?.id || resp.data?.commit?.sha || '';
|
||||
line(`bot-check branch api: branch=${branch} keys=${Object.keys(resp.data || {}).join(',') || 'empty'} sha=${sha || 'empty'} message=${extractCommitMessage(resp.data?.commit) ? 'found' : 'empty'}`);
|
||||
return await getCommitMessageBySha(sha);
|
||||
} catch (e) {
|
||||
warn(`bot-check branch api 失敗: branch=${branch} error=${e.message}`);
|
||||
warn(`取得分支 head 訊息失敗: branch=${branch} error=${e.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 檢查 PR head(commit sha 或分支 head)的訊息是否帶 [ai-review-bot] 標記,是則代表本次是自動提交、應跳過審查。 */
|
||||
export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) {
|
||||
line(`bot-check start: PR_HEAD_SHA=${PR_HEAD_SHA || 'empty'} GITHUB_SHA=${process.env.GITHUB_SHA || 'empty'} sha=${sha || 'empty'} branch=${branch || 'empty'}`);
|
||||
|
||||
const shaMessage = await getCommitMessageBySha(sha);
|
||||
if (sha) {
|
||||
line(`bot-check sha: sha=${sha} message=${shaMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(shaMessage)}`);
|
||||
if (shaMessage.includes('[ai-review-bot]')) {
|
||||
ok('bot-check matched commit sha marker');
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
line('bot-check skip sha lookup because sha is empty');
|
||||
}
|
||||
if (sha && shaMessage.includes('[ai-review-bot]')) return true;
|
||||
|
||||
const branchMessage = await getBranchHeadCommitMessage(branch);
|
||||
if (branch) {
|
||||
line(`bot-check branch: branch=${branch} head_message=${branchMessage ? 'found' : 'empty'} outcome=${getBotReviewOutcome(branchMessage)}`);
|
||||
if (branchMessage.includes('[ai-review-bot]')) {
|
||||
ok('bot-check matched branch head marker');
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
line('bot-check skip branch lookup because branch is empty');
|
||||
}
|
||||
if (branch && branchMessage.includes('[ai-review-bot]')) return true;
|
||||
|
||||
line('bot-check no [ai-review-bot] marker found');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -10,6 +10,21 @@ export function line(message) {
|
||||
console.log(` - ${message}`);
|
||||
}
|
||||
|
||||
/** 階段輸入:這個階段吃進什麼。 */
|
||||
export function input(message) {
|
||||
console.log(` ← 輸入:${message}`);
|
||||
}
|
||||
|
||||
/** 階段輸出:這個階段產出什麼。 */
|
||||
export function output(message) {
|
||||
console.log(` → 輸出:${message}`);
|
||||
}
|
||||
|
||||
/** 檢查/把關結果:明確標示成功或失敗。 */
|
||||
export function result(passed, message) {
|
||||
console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`);
|
||||
}
|
||||
|
||||
export function ok(message) {
|
||||
console.log(` ✓ ${message}`);
|
||||
}
|
||||
|
||||
+65
-91
@@ -9,97 +9,89 @@ import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatU
|
||||
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';
|
||||
import { section, step, line, input, output, result, 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}`);
|
||||
|
||||
// Step1 啟動
|
||||
step('Step1', '啟動');
|
||||
input(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER} ${PR_HEAD_BRANCH} → ${PR_BASE_BRANCH}`);
|
||||
output('參數讀取完成');
|
||||
|
||||
// Step2 前置驗證(step 標題與逐項檢查由 runPreflight 內部輸出)
|
||||
if (!(await runPreflight(WORKSPACE))) {
|
||||
error('前置驗證未通過,終止流程');
|
||||
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);
|
||||
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 失敗');
|
||||
if (headMessage.includes('[ai-review-bot]') && getBotReviewOutcome(headMessage) === 'failure') {
|
||||
result(false, '偵測到 [ai-review-bot][failure],讓 workflow 失敗');
|
||||
section('Pipeline 結束');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (await shouldSkipBotCommit()) {
|
||||
ok('偵測到 [ai-review-bot] 自動提交,直接完成 action');
|
||||
result(true, '本次為 [ai-review-bot] 自動提交,跳過審查並結束');
|
||||
section('Pipeline 結束');
|
||||
process.exit(0);
|
||||
}
|
||||
output('非自動提交,繼續審查');
|
||||
|
||||
step('Step2', 'PR 對話收斂');
|
||||
// Step4 PR 對話收斂:關閉所有未解決 comment,並把對應 finding 分流
|
||||
step('Step4', '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}`);
|
||||
output(`關閉 comment ${reconcile.closedCount};findings 已修復 ${reconcile.resolvedCount}、誤報 ${reconcile.falsePositiveCount}、加回仍成立 ${reconcile.carriedFindings.length}`);
|
||||
} catch (e) {
|
||||
warn(`Step2 對話收斂失敗(繼續執行): ${e.message}`);
|
||||
warn(`對話收斂失敗(繼續執行): ${e.message}`);
|
||||
}
|
||||
|
||||
// Step5 角色分析:載入角色、取 diff,讓各角色平行產生 findings
|
||||
step('Step5', '角色分析產生 findings');
|
||||
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
||||
if (!provider) {
|
||||
error('未設定任何 LLM API Key,請檢查 action inputs');
|
||||
result(false, '未設定任何 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}`);
|
||||
result(false, `取得 PR diff 失敗: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!diff.trim()) {
|
||||
warn('diff 為空,無需審查');
|
||||
result(true, 'diff 為空,無需審查');
|
||||
section('Pipeline 結束');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
input(`LLM=${provider}/${model};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`);
|
||||
try {
|
||||
const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`;
|
||||
await postComment(intro);
|
||||
ok('角色介紹 comment 發布成功');
|
||||
await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`);
|
||||
line('角色介紹 comment 已發布');
|
||||
} catch (e) {
|
||||
warn(`comment 發布失敗(繼續執行): ${e.message}`);
|
||||
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
||||
}
|
||||
|
||||
step('Step3', 'Findings 產生');
|
||||
const results = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
|
||||
const analyses = 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}`);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
ok(`Step3 完成: 新 findings 總計 ${newFindings.length} 筆`);
|
||||
logFindingsStats('Step3 統計', newFindings);
|
||||
output(`新 findings ${newFindings.length} 筆(${formatFindingsStatsLine(newFindings)})`);
|
||||
|
||||
step('Step4', 'Findings 合併與語意去重');
|
||||
// Step6 合併與去重:舊問題 + 對話收斂結果 + 新問題 → 語意去重
|
||||
step('Step6', 'Findings 合併與語意去重');
|
||||
let repoDir;
|
||||
try {
|
||||
repoDir = cloneRepo(WORKSPACE);
|
||||
@@ -107,94 +99,76 @@ async function main() {
|
||||
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}`);
|
||||
}
|
||||
if (repoState) line(`repo: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'}`);
|
||||
let oldFindings = loadOldFindings(repoDir || WORKSPACE);
|
||||
logFindingsStats('Step4 舊 findings 統計', oldFindings);
|
||||
const beforeReconcile = oldFindings.length;
|
||||
const reconcileDropped = [...reconcile.resolvedFindings, ...reconcile.excludedFindings];
|
||||
oldFindings = dropResolvedFindings(oldFindings, reconcileDropped);
|
||||
oldFindings = dropResolvedFindings(oldFindings, [...reconcile.resolvedFindings, ...reconcile.excludedFindings]);
|
||||
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);
|
||||
input(`舊 findings ${beforeReconcile} 筆(套用對話收斂後 ${oldFindings.length})+新 findings ${newFindings.length} 筆`);
|
||||
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);
|
||||
output(`合併 ${mergedFindings.length} → 去重後 ${sorted.length} 筆(${formatFindingsStatsLine(sorted)})`);
|
||||
|
||||
step('Step5', 'AI 排除問題過濾');
|
||||
// 先把對話收斂判定的誤報寫入 exclusions.json(workspace 與 cloned repo 各一份),供本次過濾與後續 commit
|
||||
// Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決
|
||||
step('Step7', '排除規則與誤報過濾');
|
||||
if (reconcile.excludedFindings.length > 0) {
|
||||
appendExclusions(WORKSPACE, reconcile.excludedFindings, repoDir || WORKSPACE);
|
||||
}
|
||||
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
|
||||
input(`待過濾 ${sorted.length} 筆;排除規則 ${exclusions.length} 條`);
|
||||
const ruleFiltered = applyExclusions(sorted, exclusions);
|
||||
logFindingsStats('Step5 規則排除後統計', ruleFiltered);
|
||||
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
|
||||
logFindingsStats('Step5 AI 誤報過濾後統計', filtered);
|
||||
ok(`Step5 完成: findings total=${filtered.length}`);
|
||||
output(`保留 ${filtered.length} 筆(規則排除 ${sorted.length - ruleFiltered.length}、誤報剔除 ${ruleFiltered.length - filtered.length})`);
|
||||
|
||||
step('Step6', 'Findings 寫入與 Review 發布');
|
||||
// Step8 寫入 findings 並發布 Gitea Review(附使用量)
|
||||
step('Step8', '寫入 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)}`);
|
||||
|
||||
input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`);
|
||||
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 完成');
|
||||
await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection });
|
||||
output('Gitea Review 已發布');
|
||||
} catch (e) {
|
||||
warn(`review 發布失敗(繼續執行): ${e.message}`);
|
||||
warn(`Review 發布失敗(繼續執行): ${e.message}`);
|
||||
}
|
||||
|
||||
step('Step7', 'JSON 格式驗證');
|
||||
// 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 result = await validateJSONArrayFile(fullPath, relPath);
|
||||
if (!result.exists) missingPaths.push({ fullPath, relPath });
|
||||
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 格式皆正確');
|
||||
|
||||
for (const { fullPath, relPath } of missingPaths) {
|
||||
ensureJSONArrayFileExists(fullPath, relPath);
|
||||
}
|
||||
|
||||
step('Step8', '記憶區 Commit/Push');
|
||||
// Step10 記憶區 Commit/Push
|
||||
step('Step10', '記憶區 Commit/Push');
|
||||
const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success';
|
||||
line(`review outcome=${reviewOutcome}`);
|
||||
input(`review outcome=${reviewOutcome}`);
|
||||
await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome);
|
||||
|
||||
step('Step9', '嚴重問題檢查');
|
||||
// Step11 嚴重問題把關
|
||||
step('Step11', '嚴重問題把關');
|
||||
const criticalCount = filtered.filter(f => f.level === 'critical').length;
|
||||
if (criticalCount > 0) {
|
||||
error(`發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1)`);
|
||||
result(false, `發現 ${criticalCount} 個嚴重問題,workflow 失敗(exit 1)`);
|
||||
section('Pipeline 結束');
|
||||
process.exit(1);
|
||||
}
|
||||
ok('無嚴重問題');
|
||||
ok('Pipeline 完成');
|
||||
result(true, '無嚴重問題,審查通過');
|
||||
section('Pipeline 結束');
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import {
|
||||
getLLMConfig,
|
||||
} from './config.js';
|
||||
import { verifyRemoteAccess } from './git.js';
|
||||
import { step, line, ok, error } from './log.js';
|
||||
import { step, line, ok, error, result } from './log.js';
|
||||
|
||||
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||
@@ -175,6 +175,6 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
|
||||
if (llm.keyIndex) ok(`LLM provider=${llm.provider} 驗證通過(key ${llm.keyIndex}/${llm.total})`);
|
||||
else ok(`LLM provider=${llm.provider} 連線正常`);
|
||||
|
||||
ok('前置驗證通過');
|
||||
result(true, '前置驗證通過');
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user