444 lines
25 KiB
JavaScript
444 lines
25 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/21 17:19:15');
|
||
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()` 步驟 8 於防守方裁決、`review.sortFindings(kept)` 排序後呼叫本函式保存結果,
|
||
* 再將回傳的相對路徑交給 `commitFindings` commit 並 push 回 PR 來源分支;
|
||
* 另在步驟 4 判定無可審查變更時,也會以空清單保存一份空 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('步驟8', '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 {boolean} true=已 commit/push;false=無變更或 commit/push 失敗。
|
||
* @remarks
|
||
* 使用情境:`main()` 於流程尾端依 `severe.length === 0 ? 'success' : 'failure'` 決定 result、
|
||
* 依模式組出 filesToCommit(一般模式:findings 檔+有變更時的 exclusions.json;
|
||
* 建問題模式:只有 exclusions.json)後呼叫本函式;另在步驟 4 判定無可審查變更且非建問題模式時,
|
||
* 也會以 result: 'success' 提交空 findings。
|
||
* 推送一律以 `ctx.token` 的身分進行(不走 runner 的 origin 自動 token);只要 token 是能觸發 CI 的
|
||
* PAT,結果 commit 就會再觸發 CI、由步驟 1 快速回報;
|
||
* 注意 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})。`);
|
||
return true;
|
||
} else {
|
||
log('收尾', 'INF', '審查結果檔無實際變更,略過 commit/push。');
|
||
return false;
|
||
}
|
||
} catch (err) {
|
||
// push 失敗(例如與開發者新 commit 競態)時只記錄,交由呼叫端依嚴重度決定是否阻擋。
|
||
log('收尾', 'WRN', `commit/push 審查結果檔失敗:${err.message}。`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* AI code review 主流程:編排多角色審查、發布審查結果,並回傳 process exit code。
|
||
*
|
||
* 一般模式會把審查情境、嚴重問題與警告/建議發布到 PR,並在成功產生本回合結果後才把舊留言標為過時。
|
||
* 建問題模式會把審查情境與每條 finding 發到追蹤 issue;沒有保留 finding 時不建立 issue、PR 也不留言。
|
||
* 嚴重 finding 會寫入 failure 結果 commit,警告與建議只建立追蹤資訊,不直接阻擋合併。
|
||
*
|
||
* @returns {Promise<number>} process exit code:沒有嚴重問題且結果 commit 已成功推送時回傳 0;
|
||
* 若步驟 1 偵測到 `[ai-review-bot][failure]`、前置條件不足,或需要推送結果檔卻未成功推送,
|
||
* 則回傳 1,避免 token 權限不足或遠端競態讓 workflow 靜默通過。
|
||
* @remarks
|
||
* 使用情境:由本檔尾端的頂層呼叫端執行 —— `main().then((code) => process.exit(code))`;
|
||
* 非預期例外由頂層 `catch` 記 ERR log 後以 exit code 1 收場,且刻意不 commit 結果標記,
|
||
* 讓下一次 workflow 觸發時重新完整審查。警告+建議等級不影響結果標記,只有「嚴重」會使結果 commit
|
||
* 標記為 failure;若結果檔需要 push 卻失敗,本輪直接 exit 1,避免缺權限時無聲放行。
|
||
* 建問題模式只改變問題明細的落地方式(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;
|
||
}
|
||
|
||
// 本回合(一般模式)發出的 PR 留言 id:resolveOldComments 標註過時時要跳過這些。
|
||
const currentRunCommentIds = new Set();
|
||
// 建問題模式:追蹤 issue 於「確定有保留問題」後才建立;在那之前的情境留言(工具/diff/角色)
|
||
// 先暫存於 pendingIssueCommentBodies,建立 issue 後一次寫入。
|
||
const pendingIssueCommentBodies = [];
|
||
let issueModeActive = ctx.createIssue;
|
||
let trackingIssue = null;
|
||
/**
|
||
* 發布一則審查留言。依模式決定去向:
|
||
* - 一般模式:發到 PR,並記錄留言 id 供 `resolveOldComments` 排除。
|
||
* - 建問題模式:追蹤 issue 已建立時發到 issue;尚未建立時先暫存到 `pendingIssueCommentBodies`。
|
||
*
|
||
* @param {string} body 要發布的 Markdown 留言內容。
|
||
* @returns {Promise<Object|null>} 一般模式、或建問題模式且 issue 已建立時回傳 Gitea 留言物件;
|
||
* 建問題模式尚未建立 issue 而先暫存時回傳 null。
|
||
* @remarks
|
||
* 使用情境:只在 `main()` 內部使用,處理工具資訊、diff 摘要、角色登場與
|
||
* 警告/建議彙整等留言。若 Gitea API 失敗,例外會往上拋出並由主流程頂層 catch 收斂。
|
||
*/
|
||
const queueOrPostComment = async (body) => {
|
||
if (issueModeActive) {
|
||
if (trackingIssue) return gitea.createCommentOnIssue(ctx, trackingIssue.number, body);
|
||
pendingIssueCommentBodies.push(body);
|
||
return null;
|
||
}
|
||
const created = await gitea.createIssueComment(ctx, body);
|
||
currentRunCommentIds.add(created.id);
|
||
return created;
|
||
};
|
||
/**
|
||
* 建問題模式:開啟追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立),
|
||
* 並把 `pendingIssueCommentBodies` 內暫存的情境留言依流程順序寫入 issue;
|
||
* 設定閉包變數 `trackingIssue` 供後續留言直接發到 issue。
|
||
* 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。
|
||
*
|
||
* @param {number[]} [labelIds] - 建立 issue 時要一併掛上的標籤 id 陣列(由 `review.selectLabels` 事先挑選);
|
||
* 空陣列或省略時不掛任何標籤(`gitea.createIssue` 對空陣列不帶 labels 欄位)。
|
||
* @returns {Promise<void>} 無回傳值;結果反映在閉包變數 `trackingIssue` 與 issue 留言。
|
||
*/
|
||
const openTrackingIssue = async (labelIds = []) => {
|
||
trackingIssue = await gitea.createIssue(ctx, {
|
||
title: ctx.prTitle || `AI Code Review:PR #${ctx.prNumber}`,
|
||
body: templates.issueBody({ prNumber: ctx.prNumber, prBody: ctx.prBody }),
|
||
labels: labelIds,
|
||
});
|
||
log('建問題', 'INF', `已建立追蹤 issue #${trackingIssue.number},寫入 ${pendingIssueCommentBodies.length} 則情境留言。`);
|
||
while (pendingIssueCommentBodies.length > 0) {
|
||
const body = pendingIssueCommentBodies[0];
|
||
await gitea.createCommentOnIssue(ctx, trackingIssue.number, body);
|
||
pendingIssueCommentBodies.shift();
|
||
}
|
||
};
|
||
/**
|
||
* 建問題模式降級:追蹤 issue 無法建立或寫入時,改把已暫存的情境留言發回 PR,後續沿用一般模式。
|
||
*
|
||
* @returns {Promise<void>} 無回傳值;會關閉建問題模式並把 PR 留言 id 登錄到 `currentRunCommentIds`。
|
||
*/
|
||
const fallbackToPrComments = async () => {
|
||
issueModeActive = false;
|
||
trackingIssue = null;
|
||
for (const body of pendingIssueCommentBodies) {
|
||
const created = await gitea.createIssueComment(ctx, body);
|
||
currentRunCommentIds.add(created.id);
|
||
}
|
||
pendingIssueCommentBodies.length = 0;
|
||
};
|
||
|
||
// ── 步驟 2:延後執行 ───────────────────────────────────────────────────
|
||
// 「將 PR 既有留言標記為解決」原本在此執行,但若工具偵測/diff/攻防裁決任一失敗,
|
||
// 舊結果會先被清掉卻沒有新結果。故延後到「本回合審查已成功產生結果、發布問題留言前」
|
||
// 才呼叫 review.resolveOldComments(見下方步驟 4 空變更路徑與步驟 9 前);
|
||
// 屆時本回合的工具/diff/角色留言已登錄於 currentRunCommentIds,不會被誤標為過時。
|
||
// 建問題模式不清理 PR 既有審查內容,只在收束時標記舊追蹤 issue 連結。
|
||
|
||
// ── 步驟 3:偵測 AI agent 工具並留言 ──────────────────────────────────
|
||
const tool = agents.detectTool();
|
||
if (!tool) {
|
||
log('步驟3', 'ERR', '找不到可用的 AI 工具(antigravity/codex/claude)。');
|
||
return 1;
|
||
}
|
||
log('步驟3', 'INF', `選用工具:${tool.name}(${tool.version})。`);
|
||
const runLink = `${ctx.serverUrl}/${ctx.repository}/actions/runs/${ctx.runId}`;
|
||
await queueOrPostComment(
|
||
templates.toolComment({
|
||
toolName: tool.name,
|
||
version: tool.version,
|
||
model: ctx.model,
|
||
sha: ctx.headSha,
|
||
runNumber: ctx.runNumber,
|
||
runLink,
|
||
}),
|
||
);
|
||
|
||
// ── 步驟 4:讀取 .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('步驟4', 'INF', `變更檔案 ${allFiles.length} 個,套用 .reviewignore 後送審 ${files.length} 個(排除 ${ignoredCount} 個)。`);
|
||
|
||
if (files.length === 0) {
|
||
// 沒有可審查的變更:保存空 findings、以 success 收場。
|
||
// 一般模式在 PR 留言告知;建問題模式靜默通過(不建 issue、PR 也不留言,暫存的情境留言捨棄)。
|
||
if (issueModeActive) {
|
||
log('步驟4', 'INF', '建問題模式且無可審查變更:靜默通過(不建 issue、PR 不留言)。');
|
||
} else {
|
||
await queueOrPostComment(templates.nothingToReviewComment(ignoredCount));
|
||
// 已成功產生本回合結果留言(無可審查變更),此時才把舊留言標為過時(本回合留言已排除)。
|
||
await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });
|
||
}
|
||
const relativePath = saveFindings({ cwd, ctx, tool, kept: [], excluded: [] });
|
||
if (issueModeActive) {
|
||
// 建問題模式下 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 queueOrPostComment(templates.diffComment(diffRows, ignoredCount));
|
||
|
||
// ── 步驟 5:攻擊方角色登場留言 ─────────────────────────────────────────
|
||
const roles = loadRoles(path.join(ctx.actionPath, 'src', 'prompts', 'roles'));
|
||
const attackers = attackersOf(roles);
|
||
const defenders = defendersOf(roles);
|
||
log('步驟5', 'INF', `攻擊方 ${attackers.length} 位、防守方 ${defenders.length} 位。`);
|
||
await queueOrPostComment(templates.rolesComment({ title: '⚔️ 攻擊方登場', roles: attackers }));
|
||
|
||
// ── 步驟 6:每個攻擊方一個 sub agent 並行分析,合併問題列表 ────────────
|
||
const findings = await review.runAttackers({ tool, model: ctx.model, cwd, attackers, diffRows });
|
||
|
||
// ── 步驟 7:防守方角色登場留言 ─────────────────────────────────────────
|
||
await queueOrPostComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders }));
|
||
|
||
// ── 步驟 8:防守方裁決 → 排除 → 排序 → 保存 findings ──────────────────
|
||
const { kept, excluded } = findings.length === 0
|
||
? { 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 });
|
||
|
||
// ── 步驟 8(分組):依嚴重等級分組(嚴重/警告+建議),組內已依檔案與行數排序 ─
|
||
const severe = kept.filter((finding) => finding.severity === '嚴重');
|
||
const others = kept.filter((finding) => finding.severity !== '嚴重');
|
||
log('步驟8', 'INF', `分組結果:嚴重 ${severe.length} 條、警告+建議 ${others.length} 條。`);
|
||
|
||
// ── 建問題模式:確定有保留問題才建立 issue,並把暫存的情境留言一次寫入;
|
||
// 無保留問題則不建 issue、PR 也完全不留言(靜默通過,暫存的情境留言捨棄)。 ──────
|
||
if (issueModeActive) {
|
||
if (kept.length > 0) {
|
||
// 先依保留問題挑好標籤,於建立 issue 時一次帶入(省去「先建空標籤 issue 再補掛」的多餘 API 往返);
|
||
// 標籤挑選失敗一律降級為不掛標籤,不阻斷建 issue 流程。
|
||
let labelIds = [];
|
||
try {
|
||
const labels = await gitea.listLabels(ctx);
|
||
labelIds = await review.selectLabels({
|
||
tool,
|
||
model: ctx.model,
|
||
cwd,
|
||
labels,
|
||
prTitle: ctx.prTitle,
|
||
prBody: ctx.prBody,
|
||
findings: kept,
|
||
});
|
||
} catch (err) {
|
||
log('建問題', 'WRN', `標籤挑選失敗(${err.message}),issue 不掛標籤。`);
|
||
}
|
||
try {
|
||
await openTrackingIssue(labelIds);
|
||
} catch (err) {
|
||
log('建問題', 'WRN', `建立或寫入追蹤 issue 失敗(${err.message}),改用 PR 留言與 findings 檔流程。`);
|
||
await fallbackToPrComments();
|
||
}
|
||
} else {
|
||
// 無保留問題 → 不建 issue、PR 也不留言(靜默通過,暫存的情境留言捨棄)。
|
||
log('建問題', 'INF', '沒有保留的問題:靜默通過(不建 issue、PR 不留言)。');
|
||
}
|
||
}
|
||
|
||
// ── 步驟 2(延後執行,一般模式):審查已成功產生結果,發布問題留言前才把舊留言標為過時 ─
|
||
// 延後到此可避免工具偵測/diff/攻防裁決任一失敗時舊結果先被清掉卻無新結果;
|
||
// 本回合的工具/diff/角色留言已登錄於 currentRunCommentIds,不會被誤標為過時;
|
||
// 嚴重/其他問題留言於本步驟之後才發布,同樣不受影響。
|
||
if (!issueModeActive) {
|
||
await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });
|
||
}
|
||
|
||
// ── 步驟 9:嚴重問題留言(一般模式掛在 PR 程式碼行上;建問題模式逐條發到 issue)─
|
||
if (severe.length > 0) {
|
||
if (issueModeActive && trackingIssue) {
|
||
await review.postSevereToIssue({ ctx, gitea, issueNumber: trackingIssue.number, severe });
|
||
} else {
|
||
await review.postSevereComments({ ctx, gitea, severe, cwd });
|
||
}
|
||
}
|
||
|
||
// ── 步驟 10:警告+建議——一般模式彙整為單一表格留言到 PR;
|
||
// 建問題模式逐條發到 issue,讓每條問題都能被個別回覆。 ──
|
||
if (others.length > 0) {
|
||
if (issueModeActive && trackingIssue) {
|
||
await review.postOthersToIssue({ ctx, gitea, issueNumber: trackingIssue.number, others });
|
||
} else {
|
||
await queueOrPostComment(templates.othersComment(others));
|
||
log('步驟10', 'INF', `警告+建議表格留言已發布(${others.length} 條)。`);
|
||
}
|
||
}
|
||
|
||
// ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─
|
||
// 標籤已於建立 issue 時一次帶入(見上方 selectLabels → openTrackingIssue),此處不再補掛。
|
||
if (issueModeActive && trackingIssue) {
|
||
await review.resolveOldIssueLinkComments({ ctx, gitea });
|
||
await gitea.createIssueComment(
|
||
ctx,
|
||
templates.prIssueLinkComment({
|
||
issueNumber: trackingIssue.number,
|
||
issueUrl: trackingIssue.html_url,
|
||
severeCount: severe.length,
|
||
otherCount: others.length,
|
||
}),
|
||
);
|
||
// 只有「嚴重」問題才讓 PR 相依於追蹤 issue(issue 關閉前無法合併,需 repo 啟用「問題相依」功能);
|
||
// 僅有警告/建議時,issue 仍建立供追蹤,但不掛相依、不阻擋 PR 合併。
|
||
if (severe.length > 0) {
|
||
try {
|
||
await gitea.addIssueDependency(ctx, ctx.prNumber, trackingIssue.number);
|
||
log('建問題', 'INF', `有嚴重問題:已將 PR #${ctx.prNumber} 設為相依於 issue #${trackingIssue.number},issue 關閉前無法合併。`);
|
||
} catch (err) {
|
||
log('建問題', 'WRN', `設定 PR 相依失敗(可能未啟用「問題相依」功能):${err.message}。`);
|
||
}
|
||
} else {
|
||
log('建問題', 'INF', `無嚴重問題(僅警告/建議):issue #${trackingIssue.number} 僅供追蹤,不阻擋 PR 合併。`);
|
||
}
|
||
log('建問題', 'INF', `issue #${trackingIssue.number} 已寫入審查內容,並在 PR 回貼連結。`);
|
||
}
|
||
|
||
// ── 收尾:commit 並 push(success=無嚴重問題、failure=有嚴重問題)───────
|
||
// 一般模式:findings+exclusions.json;建問題模式通常只 commit exclusions.json。
|
||
// 若有嚴重問題,仍 commit findings 檔產生 [failure] 結果 commit,避免相依 API 不支援時 fail-open。
|
||
const result = severe.length === 0 ? 'success' : 'failure';
|
||
const filesToCommit = review.resultFilesToCommit({
|
||
createIssue: issueModeActive,
|
||
severeCount: severe.length,
|
||
relativePath,
|
||
exclusionsChanged,
|
||
});
|
||
let resultCommitted = false;
|
||
if (filesToCommit.length > 0) {
|
||
resultCommitted = commitFindings({ cwd, ctx, files: filesToCommit, result });
|
||
} else {
|
||
log('收尾', 'INF', '建問題模式且 exclusions.json 無變更,略過 commit/push。');
|
||
}
|
||
// 需要推送結果檔卻沒成功時直接失敗;這通常代表 token 權限不足、非快轉或分支保護阻擋。
|
||
if (review.shouldFailMissingResultCommit({ filesToCommit, resultCommitted })) {
|
||
log('收尾', 'ERR', '審查結果檔需要 push 但未成功產生結果 commit;直接回報失敗避免缺權限時靜默通過。');
|
||
return 1;
|
||
}
|
||
// 嚴重問題已推出 [failure] 結果 commit 時,由它再觸發的下一輪在步驟 1 讀 commit 訊息回報失敗。
|
||
if (result === 'failure') {
|
||
log('收尾', 'INF', '本輪有嚴重問題:已標記結果 commit 為 [failure],失敗檢查由下一輪步驟 1 讀 commit 訊息回報。');
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
main()
|
||
.then((code) => {
|
||
process.exit(code);
|
||
})
|
||
.catch((err) => {
|
||
// 非預期錯誤:不 commit 結果標記(讓下次觸發重新審查),以失敗收場。
|
||
log('main', 'ERR', `審查流程發生非預期錯誤:${err.stack || err.message || err}`);
|
||
process.exit(1);
|
||
});
|