From 374bb4ec75fab19d9bfa18d367af3e9a23d6c8a1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 23 Jun 2026 15:13:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor(pipeline=20=E6=97=A5=E8=AA=8C):=20?= =?UTF-8?q?=E6=AD=A5=E9=A9=9F=E9=80=A3=E8=99=9F=E5=96=AE=E4=B8=80=E4=BB=BB?= =?UTF-8?q?=E5=8B=99=E3=80=81=E8=BC=B8=E5=85=A5=E8=BC=B8=E5=87=BA=E8=88=87?= =?UTF-8?q?=E6=88=90=E6=95=97=E8=A8=8A=E6=81=AF=E6=9B=B4=E6=98=8E=E7=A2=BA?= =?UTF-8?q?=E3=80=81=E6=B8=85=E9=99=A4=20bot-check=20=E9=9B=9C=E8=A8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/gitea.js | 35 +++-------- app/log.js | 15 +++++ app/main.js | 156 ++++++++++++++++++++--------------------------- app/preflight.js | 4 +- 4 files changed, 89 insertions(+), 121 deletions(-) diff --git a/app/gitea.js b/app/gitea.js index 42c2b81..7e5dd88 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -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; } diff --git a/app/log.js b/app/log.js index a2155bc..bf3b52d 100644 --- a/app/log.js +++ b/app/log.js @@ -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}`); } diff --git a/app/main.js b/app/main.js index 32be207..23a70f9 100644 --- a/app/main.js +++ b/app/main.js @@ -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 結束'); } diff --git a/app/preflight.js b/app/preflight.js index 301b715..bf912c4 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -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; }