305 lines
17 KiB
JavaScript
305 lines
17 KiB
JavaScript
'use strict';
|
||
|
||
// action 啟動橫幅:輸出名稱/用途/更新時間(此區塊由 code-action-node 維護)
|
||
console.log('================================================');
|
||
console.log('Action : AI Code Review');
|
||
console.log('用途 : AI 多角色 code review:攻擊方找問題、防守方裁決誤報,結果留言到 PR 並保存 findings');
|
||
console.log('更新時間: 2026/07/17 18:49:58');
|
||
console.log('================================================');
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const { log, taipeiNow, taipeiFileStamp } = require('./lib/log');
|
||
const { loadContext } = require('./lib/context');
|
||
const gitrepo = require('./lib/gitrepo');
|
||
const gitea = require('./lib/gitea');
|
||
const agents = require('./lib/agents');
|
||
const { loadRoles, attackersOf, defendersOf } = require('./lib/roles');
|
||
const review = require('./lib/review');
|
||
const templates = require('./lib/templates');
|
||
|
||
// ai-review-bot 的 commit 訊息前綴:步驟 1 依此判斷是否為上一回合審查的結果 commit。
|
||
const BOT_COMMIT_PREFIX = 'chore: update ai-review findings [ai-review-bot]';
|
||
|
||
/**
|
||
* 保存本回合 AI review 的 findings 為 JSON 檔,並回傳 repo 相對路徑(供 commit 使用)。
|
||
*
|
||
* 檔案寫入 `<cwd>/.gitea/ai-review/findings/<台北時區時間戳>.json`,
|
||
* 內容含產生時間、受審 commit、PR 編號、使用工具與模型、保留及排除的問題清單。
|
||
* 每回合產生一個新檔,不覆蓋歷史紀錄。
|
||
*
|
||
* @param {Object} params - 解構參數。
|
||
* @param {string} params.cwd - repo 根目錄(workspace)絕對路徑,findings 目錄與相對路徑皆以此為基準。
|
||
* @param {Object} params.ctx - 由 `loadContext()` 載入的執行環境 context。
|
||
* @param {string} params.ctx.headSha - 受審的 head commit SHA,寫入 payload 的 `commitSha`。
|
||
* @param {number|string} params.ctx.prNumber - PR 編號,寫入 payload 的 `prNumber`。
|
||
* @param {string} [params.ctx.model] - 指定的 AI 模型名稱;未指定時以「(工具預設)」記錄。
|
||
* @param {Object} params.tool - `agents.detectTool()` 偵測到的 AI 工具。
|
||
* @param {string} params.tool.name - 工具名稱(antigravity/codex/claude)。
|
||
* @param {string} params.tool.version - 工具版本字串。
|
||
* @param {Array<Object>} params.kept - 防守方裁決後保留的問題(findings)清單;無可審查變更時為空陣列。
|
||
* @param {Array<Object>} params.excluded - 被裁決為誤報而排除的問題清單。
|
||
* @returns {string} findings JSON 檔相對於 repo 根目錄的路徑(例如 `.gitea/ai-review/findings/xxx.json`)。
|
||
* @remarks
|
||
* 使用情境:`main()` 步驟 7 於防守方裁決、`review.sortFindings(kept)` 排序後呼叫本函式保存結果,
|
||
* 再將回傳的相對路徑交給 `commitFindings` commit 並 push 回 PR 來源分支;
|
||
* 另在步驟 3 判定無可審查變更時,也會以空清單保存一份空 findings 後以 success 收場。
|
||
* 本函式無 try/catch,檔案系統錯誤會往上拋出,由 `main().catch` 以 exit code 1 收場。
|
||
*/
|
||
function saveFindings({ cwd, ctx, tool, kept, excluded }) {
|
||
const findingsDir = path.join(cwd, '.gitea', 'ai-review', 'findings');
|
||
fs.mkdirSync(findingsDir, { recursive: true });
|
||
const findingsPath = path.join(findingsDir, `${taipeiFileStamp()}.json`);
|
||
const payload = {
|
||
generatedAt: taipeiNow(),
|
||
commitSha: ctx.headSha,
|
||
prNumber: ctx.prNumber,
|
||
tool: { name: tool.name, version: tool.version, model: ctx.model || '(工具預設)' },
|
||
findings: kept,
|
||
excluded,
|
||
};
|
||
fs.writeFileSync(findingsPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||
const relativePath = path.relative(cwd, findingsPath);
|
||
log('步驟7', 'INF', `findings 已保存:${relativePath}(保留 ${kept.length} 條、排除 ${excluded.length} 條)。`);
|
||
return relativePath;
|
||
}
|
||
|
||
/**
|
||
* 收尾:將本回合的審查結果檔(findings 檔與/或 exclusions.json)commit 並 push 回 PR 來源分支。
|
||
*
|
||
* commit 訊息固定為「chore: update ai-review findings [ai-review-bot][success|failure]」,
|
||
* 供下一回合 `main()` 步驟 1 比對辨識、直接回報結果而不重複審查。
|
||
* 依 `commitAndPushFindings` 的回傳值記錄不同日誌:true=已 commit/push;
|
||
* false=檔案無實際變更(空 commit 防護),記「略過 commit/push」。
|
||
* commit/push 失敗(例如與開發者新 commit 競態)時僅記 WRN log,不拋出例外、不改變審查結果。
|
||
*
|
||
* @param {Object} params - 解構參數。
|
||
* @param {string} params.cwd - repo 根目錄(workspace)絕對路徑,git 操作在此目錄執行。
|
||
* @param {Object} params.ctx - 由 `loadContext()` 載入的執行環境 context。
|
||
* @param {string} params.ctx.headRef - PR 來源分支名稱(push 目標分支)。
|
||
* @param {string} params.ctx.headSha - 受審的 head commit SHA。
|
||
* @param {string} params.ctx.token - push 用的 Gitea token(必填 input)。
|
||
* @param {string} params.ctx.serverUrl - Gitea 伺服器 URL。
|
||
* @param {string} params.ctx.repository - `owner/repo` 形式的 repo 名稱。
|
||
* @param {string[]} params.files - 要 commit 的檔案 repo 相對路徑陣列(如 findings 檔、`.gitea/ai-review/exclusions.json`);全數無變更時只記 INF 略過。
|
||
* @param {'success'|'failure'} params.result - 本回合審查結果:success=無嚴重問題、failure=有嚴重問題;會拼進 commit 訊息尾端。
|
||
* @returns {void} 無回傳值;成敗僅反映在 log 上。
|
||
* @remarks
|
||
* 使用情境:`main()` 於流程尾端依 `severe.length === 0 ? 'success' : 'failure'` 決定 result、
|
||
* 依模式組出 filesToCommit(一般模式:findings 檔+有變更時的 exclusions.json;
|
||
* 建問題模式:只有 exclusions.json)後呼叫本函式;另在步驟 3 判定無可審查變更且非建問題模式時,
|
||
* 也會以 result: 'success' 提交空 findings。
|
||
* 注意 commit 訊息與模組常數 `BOT_COMMIT_PREFIX` 耦合,修改前綴會使步驟 1 的快速回報失效。
|
||
*/
|
||
function commitFindings({ cwd, ctx, files, result }) {
|
||
try {
|
||
const committed = gitrepo.commitAndPushFindings(cwd, {
|
||
headRef: ctx.headRef,
|
||
headSha: ctx.headSha,
|
||
message: `${BOT_COMMIT_PREFIX}[${result}]`,
|
||
files,
|
||
token: ctx.token,
|
||
serverUrl: ctx.serverUrl,
|
||
repository: ctx.repository,
|
||
});
|
||
if (committed) {
|
||
log('收尾', 'INF', `審查結果檔已 commit 並 push 回 ${ctx.headRef}(結果:${result})。`);
|
||
} else {
|
||
log('收尾', 'INF', '審查結果檔無實際變更,略過 commit/push。');
|
||
}
|
||
} catch (err) {
|
||
// push 失敗(例如與開發者新 commit 競態)時只記錄,不改變審查結果。
|
||
log('收尾', 'WRN', `commit/push 審查結果檔失敗:${err.message}。`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* AI code review 主流程:依固定 10 步驟執行多角色審查,回傳 process exit code。
|
||
*
|
||
* 流程概要:
|
||
* 1. 快速回報 — 最新 commit 若為 ai-review-bot 的結果 commit([success]/[failure]),直接回報 0/1 不重審;
|
||
* 2. 偵測 AI 工具(antigravity/codex/claude)並留言;
|
||
* 3. 讀 .reviewignore、整理 git diff 並留言(無可審查變更時:留言+保存空 findings,
|
||
* 一般模式 commit success、建問題模式略過 commit,回傳 0);
|
||
* 4–5. 攻擊方登場留言、每位攻擊方一個 sub agent 並行找問題;
|
||
* 6–7. 防守方登場留言、裁決誤報後排序並保存 findings JSON,
|
||
* 並以 appendExclusions 把誤判/重複問題回寫 .gitea/ai-review/exclusions.json;
|
||
* 8. 將 PR 既有舊留言標記為解決(跳過本回合留言);
|
||
* 9. 嚴重問題逐條掛在程式碼行上留言;
|
||
* 10. 警告+建議彙整為單一表格留言;
|
||
* 建問題模式(input: create-issue):保留問題另建 issue(createIssueWithFindings)逐條留言明細;
|
||
* 收尾:組 filesToCommit —— 一般模式 commit findings 檔(+有變更的 exclusions.json)、
|
||
* 建問題模式只 commit exclusions.json、無檔案可 commit 時略過;
|
||
* commit 訊息帶結果標記(success=無嚴重問題、failure=有嚴重問題)。
|
||
*
|
||
* @returns {Promise<number>} process exit code:0=成功(無嚴重問題或無可審查變更、或偵測到 success 標記);
|
||
* 1=失敗(有嚴重問題、缺 PR 編號/token、找不到 AI 工具、或偵測到 failure 標記)。
|
||
* @remarks
|
||
* 使用情境:由本檔尾端的頂層呼叫端執行 —— `main().then((code) => process.exit(code))`;
|
||
* 非預期例外由頂層 `catch` 記 ERR log 後以 exit code 1 收場,且刻意不 commit 結果標記,
|
||
* 讓下一次 workflow 觸發時重新完整審查。警告+建議等級的問題不影響成敗,只有「嚴重」會使結果為 failure;
|
||
* 建問題模式只改變問題明細的落地方式(issue 留言取代 findings 進版控),不改變成敗判定。
|
||
*/
|
||
async function main() {
|
||
const ctx = loadContext();
|
||
const cwd = ctx.workspace;
|
||
|
||
// ── 步驟 1:ai-review-bot 結果 commit 快速回報 ─────────────────────────
|
||
const subject = gitrepo.latestCommitSubject(cwd);
|
||
if (subject === `${BOT_COMMIT_PREFIX}[success]`) {
|
||
log('步驟1', 'INF', '偵測到 ai-review-bot 的 success commit,直接回報成功。');
|
||
return 0;
|
||
}
|
||
if (subject === `${BOT_COMMIT_PREFIX}[failure]`) {
|
||
log('步驟1', 'ERR', '偵測到 ai-review-bot 的 failure commit,直接回報失敗。');
|
||
return 1;
|
||
}
|
||
log('步驟1', 'INF', '最新 commit 非 ai-review-bot 標記,開始審查流程。');
|
||
|
||
// ── 前置檢查:PR 事件與必填 input ──────────────────────────────────────
|
||
if (!ctx.prNumber) {
|
||
log('前置', 'ERR', '無法取得 PR 編號(本 action 僅支援 pull_request 事件)。');
|
||
return 1;
|
||
}
|
||
if (!ctx.token) {
|
||
log('前置', 'ERR', '缺少必填 input:token。');
|
||
return 1;
|
||
}
|
||
|
||
// 本回合發出的一般留言 id:步驟 8 標註過時時要跳過這些。
|
||
const currentRunCommentIds = new Set();
|
||
/**
|
||
* 建立本回合 PR 一般留言並記錄留言 id,供後續舊留言處理排除。
|
||
*
|
||
* @param {string} body 要發布到 PR 的 Markdown 留言內容。
|
||
* @returns {Promise<Object>} Gitea API 建立的留言物件;至少預期包含 `id`。
|
||
* @remarks
|
||
* 使用情境:只在 `main()` 內部使用,處理工具資訊、diff 摘要、角色登場與
|
||
* 警告/建議彙整等一般留言。若 Gitea API 失敗,例外會往上拋出並由
|
||
* 主流程頂層 catch 收斂。
|
||
*/
|
||
const postComment = async (body) => {
|
||
const created = await gitea.createIssueComment(ctx, body);
|
||
currentRunCommentIds.add(created.id);
|
||
return created;
|
||
};
|
||
|
||
// ── 步驟 2:偵測 AI agent 工具並留言 ──────────────────────────────────
|
||
const tool = agents.detectTool();
|
||
if (!tool) {
|
||
log('步驟2', 'ERR', '找不到可用的 AI 工具(antigravity/codex/claude)。');
|
||
return 1;
|
||
}
|
||
log('步驟2', 'INF', `選用工具:${tool.name}(${tool.version})。`);
|
||
const runLink = `${ctx.serverUrl}/${ctx.repository}/actions/runs/${ctx.runId}`;
|
||
await postComment(
|
||
templates.toolComment({
|
||
toolName: tool.name,
|
||
version: tool.version,
|
||
model: ctx.model,
|
||
sha: ctx.headSha,
|
||
runNumber: ctx.runNumber,
|
||
runLink,
|
||
}),
|
||
);
|
||
|
||
// ── 步驟 3:讀取 .reviewignore、整理 git diff 並留言 ───────────────────
|
||
const ignores = review.loadReviewIgnore(cwd);
|
||
const base = gitrepo.resolveMergeBase(cwd, ctx.baseRef);
|
||
const allFiles = gitrepo.changedFiles(cwd, base);
|
||
const files = allFiles.filter((file) => !review.isIgnored(file, ignores));
|
||
const ignoredCount = allFiles.length - files.length;
|
||
log('步驟3', 'INF', `變更檔案 ${allFiles.length} 個,套用 .reviewignore 後送審 ${files.length} 個(排除 ${ignoredCount} 個)。`);
|
||
|
||
if (files.length === 0) {
|
||
// 沒有可審查的變更:留言說明、保存空 findings、以 success 收場。
|
||
await postComment(templates.nothingToReviewComment(ignoredCount));
|
||
const relativePath = saveFindings({ cwd, ctx, tool, kept: [], excluded: [] });
|
||
if (ctx.createIssue) {
|
||
// 建問題模式下 findings 不進版控,且 exclusions.json 無變更 → 沒東西可提交。
|
||
log('收尾', 'INF', '建問題模式且無可審查變更,略過 commit/push。');
|
||
} else {
|
||
commitFindings({ cwd, ctx, files: [relativePath], result: 'success' });
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const diffRows = review.collectDiffRows({ cwd, files, base, gitrepo });
|
||
await review.fillPurposes({ tool, model: ctx.model, cwd, diffRows });
|
||
await postComment(templates.diffComment(diffRows, ignoredCount));
|
||
|
||
// ── 步驟 4:攻擊方角色登場留言 ─────────────────────────────────────────
|
||
const roles = loadRoles(path.join(ctx.actionPath, 'src', 'prompts', 'roles'));
|
||
const attackers = attackersOf(roles);
|
||
const defenders = defendersOf(roles);
|
||
log('步驟4', 'INF', `攻擊方 ${attackers.length} 位、防守方 ${defenders.length} 位。`);
|
||
await postComment(templates.rolesComment({ title: '⚔️ 攻擊方登場', roles: attackers }));
|
||
|
||
// ── 步驟 5:每個攻擊方一個 sub agent 並行分析,合併問題列表 ────────────
|
||
const findings = await review.runAttackers({ tool, model: ctx.model, cwd, attackers, diffRows });
|
||
|
||
// ── 步驟 6:防守方角色登場留言 ─────────────────────────────────────────
|
||
await postComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders }));
|
||
|
||
// ── 步驟 7:防守方裁決 → 排除 → 排序 → 保存 findings ──────────────────
|
||
const { kept, excluded } = await review.runDefenders({ tool, model: ctx.model, cwd, defenders, findings });
|
||
review.sortFindings(kept);
|
||
const relativePath = saveFindings({ cwd, ctx, tool, kept, excluded });
|
||
|
||
// 誤判/重複的問題附加到 exclusions.json(之後與審查結果一起 commit)。
|
||
const exclusionsChanged = review.appendExclusions({ cwd, excluded, prNumber: ctx.prNumber });
|
||
|
||
// ── 步驟 7(分組):依嚴重等級分組(嚴重/警告+建議),組內已依檔案與行數排序 ─
|
||
const severe = kept.filter((finding) => finding.severity === '嚴重');
|
||
const others = kept.filter((finding) => finding.severity !== '嚴重');
|
||
log('步驟7', 'INF', `分組結果:嚴重 ${severe.length} 條、警告+建議 ${others.length} 條。`);
|
||
|
||
// ── 步驟 8:將 PR 既有留言標記為解決(本回合留言除外)───────────────────
|
||
await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });
|
||
|
||
// ── 步驟 9:嚴重問題逐條掛在程式碼行上留言(開發者可回覆)──────────────
|
||
if (severe.length > 0) {
|
||
await review.postSevereComments({ ctx, gitea, severe, cwd });
|
||
}
|
||
|
||
// ── 步驟 10:警告+建議彙整為單一表格留言 ──────────────────────────────
|
||
if (others.length > 0) {
|
||
await postComment(templates.othersComment(others));
|
||
log('步驟10', 'INF', `警告+建議表格留言已發布(${others.length} 條)。`);
|
||
}
|
||
|
||
// ── 建問題模式(input: create-issue):另建 issue 逐條留言問題明細 ──────
|
||
if (ctx.createIssue) {
|
||
if (kept.length > 0) {
|
||
await review.createIssueWithFindings({ ctx, gitea, tool, model: ctx.model, cwd, findings: kept });
|
||
} else {
|
||
log('建問題', 'INF', '沒有保留的問題,略過建立 issue。');
|
||
}
|
||
}
|
||
|
||
// ── 收尾:commit 並 push(success=無嚴重問題、failure=有嚴重問題)───────
|
||
// 一般模式:findings+exclusions.json;建問題模式:問題明細已在 issue 留言,只 commit exclusions.json。
|
||
const result = severe.length === 0 ? 'success' : 'failure';
|
||
const filesToCommit = ctx.createIssue ? [] : [relativePath];
|
||
if (exclusionsChanged) {
|
||
filesToCommit.push(path.join('.gitea', 'ai-review', 'exclusions.json'));
|
||
}
|
||
if (filesToCommit.length > 0) {
|
||
commitFindings({ cwd, ctx, files: filesToCommit, result });
|
||
} else {
|
||
log('收尾', 'INF', '建問題模式且 exclusions.json 無變更,略過 commit/push。');
|
||
}
|
||
return result === 'success' ? 0 : 1;
|
||
}
|
||
|
||
main()
|
||
.then((code) => {
|
||
process.exit(code);
|
||
})
|
||
.catch((err) => {
|
||
// 非預期錯誤:不 commit 結果標記(讓下次觸發重新審查),以失敗收場。
|
||
log('main', 'ERR', `審查流程發生非預期錯誤:${err.stack || err.message || err}`);
|
||
process.exit(1);
|
||
});
|