feat(ai-review 對話收斂): 讀 PR review 留言判斷解決狀態並收斂 findings #42

Merged
jiantw83 merged 68 commits from ai-review-resolve/20260623-110950 into develop 2026-06-23 08:29:38 +00:00
4 changed files with 89 additions and 121 deletions
Showing only changes of commit 374bb4ec75 - Show all commits
+7 -28
View File
@@ -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 headcommit 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
View File
@@ -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
View File
@@ -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.jsonworkspace 與 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
View File
@@ -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;
}