diff --git a/action.yml b/action.yml index 68d0977..859e653 100644 --- a/action.yml +++ b/action.yml @@ -19,9 +19,7 @@ inputs: # Gitea API token:用於對 PR/issue 留言審查結果,以及 push 審查結果檔(findings/exclusions)回 repo。 token: # 參數用途說明:secrets/vars context 在 action 內不可用,故由呼叫端 workflow 以 secrets 傳入。 - # 建議傳入「能觸發 CI 的 PAT」:以自動 token(gitea.token / GITHUB_TOKEN)推送的結果 commit 不會 - # 再觸發 CI,導致新 head 缺檢查而卡合併;改用 PAT 推送會讓 PR 的 synchronize 事件再觸發 CI, - # 由主程式步驟 1 快速回報([success]/[failure])廉價地把結果蓋到新 head。 + # 建議傳入能觸發 CI 的 PAT;自動 token 推送結果 commit 時可能不會再觸發 workflow。 description: 'Gitea API token(PR/issue 留言與 push findings 用;建議以能觸發 CI 的 PAT 由 secrets 傳入)' # 必填:缺少 token 無法呼叫 Gitea API,action 無法運作。 required: true diff --git a/readme.md b/readme.md index 68799df..daf0b4f 100644 --- a/readme.md +++ b/readme.md @@ -38,16 +38,16 @@ jobs: ```mermaid flowchart TD S1[1 判斷 bot commit 標記] -->|命中| E0[直接回報 success/failure] - S1 -->|未命中| S3[3 偵測 AI 工具並留言] - S3 --> S4[4 讀 .reviewignore 整理 diff 並留言] - S4 --> S5[5 攻擊方登場留言] - S5 --> S6[6 攻擊方 sub agent 並行找問題] - S6 --> S7[7 防守方登場留言] - S7 --> S8[8 防守方裁決 → 保存 findings + 誤判回寫 exclusions.json] - S8 --> S2[2 延後將舊留言標記解決(成功產生結果後才執行)] - S2 --> S9[9 嚴重問題逐條掛行留言] - S9 --> S10[10 警告+建議彙整表格留言] - S10 --> E1[收尾 commit/push + exit code] + S1 -->|未命中| N3[3 偵測 AI 工具並留言] + N3 --> N4[4 讀 .reviewignore 整理 diff 並留言] + N4 --> N5[5 攻擊方登場留言] + N5 --> N6[6 攻擊方 sub agent 並行找問題] + N6 --> N7[7 防守方登場留言] + N7 --> N8[8 防守方裁決 → 保存 findings + 誤判回寫 exclusions.json] + N8 --> N2[2 延後將舊留言標記解決(成功產生結果後才執行)] + N2 --> N9[9 嚴重問題逐條掛行留言] + N9 --> N10[10 警告+建議彙整表格留言] + N10 --> E1[收尾 commit/push + exit code] ``` ## 專案列表 diff --git a/src/index.js b/src/index.js index 4e5cafd..1b63e3b 100644 --- a/src/index.js +++ b/src/index.js @@ -117,31 +117,11 @@ function commitFindings({ cwd, ctx, files, result }) { } /** - * AI code review 主流程:依固定 10 步驟執行多角色審查,回傳 process exit code。 + * AI code review 主流程:編排多角色審查、發布審查結果,並回傳 process exit code。 * - * 流程概要(步驟 2~10 描述一般模式;建問題模式差異見末段): - * 1. 快速回報 — 最新 commit 若為 ai-review-bot 的結果 commit([success]/[failure]),直接回報 0/1 不重審; - * 2. 將 PR 既有舊留言標記為解決(跳過本回合留言;建問題模式不執行此步)—— - * 此步延後到「本回合審查已成功產生結果、即將發布問題留言前」才執行,避免工具偵測/diff/ - * 攻防裁決任一失敗時舊結果先被清掉卻沒有新結果(一般模式); - * 3. 偵測 AI 工具(antigravity/codex/claude)並留言; - * 4. 讀 .reviewignore、整理 git diff 並留言(無可審查變更時:留言+保存空 findings, - * 一般模式 commit success、建問題模式略過 commit,回傳 0); - * 5–6. 攻擊方登場留言、每位攻擊方一個 sub agent 並行找問題; - * 7–8. 防守方登場留言、裁決誤報後排序並保存 findings JSON, - * 並以 appendExclusions 把誤判/重複問題回寫 .gitea/ai-review/exclusions.json; - * 9. 嚴重問題逐條掛在程式碼行上留言; - * 10. 警告+建議彙整為單一表格留言; - * 建問題模式(input: create-issue):不執行步驟 2、不觸碰 PR 既有留言;步驟 3~10 的所有留言 - * 改發到追蹤 issue(工具/diff/角色留言先暫存,確定有保留問題後先挑好標籤、連同標籤一次建立 issue - * 並寫入暫存留言,嚴重問題與警告+建議再逐條發到該 issue,讓每條問題都能被個別回覆); - * 無保留問題或無可審查變更則不建 issue、PR 也完全不留言(靜默通過); - * 收束時在 PR 回貼 issue 連結形成雙向關聯, - * 並「僅在有嚴重問題時」讓 PR 相依於該 issue(addIssueDependency,issue 關閉前 PR 無法合併; - * 需 repo 啟用問題相依功能)——僅有警告/建議時 issue 仍建立供追蹤,但不阻擋合併; - * 收尾:組 filesToCommit —— 一般模式 commit findings 檔(+有變更的 exclusions.json)、 - * 建問題模式只 commit exclusions.json、無檔案可 commit 時略過; - * commit 訊息帶結果標記(success=無嚴重問題、failure=有嚴重問題)。 + * 一般模式會把審查情境、嚴重問題與警告/建議發布到 PR,並在成功產生本回合結果後才把舊留言標為過時。 + * 建問題模式會把審查情境與每條 finding 發到追蹤 issue;沒有保留 finding 時不建立 issue、PR 也不留言。 + * 嚴重 finding 會寫入 failure 結果 commit,警告與建議只建立追蹤資訊,不直接阻擋合併。 * * @returns {Promise} process exit code:本輪「審查」一律回傳 0(不因嚴重問題直接讓檢查失敗—— * 失敗改由推出的 `[ai-review-bot][failure]` 結果 commit,於下一輪在步驟 1 讀 commit 訊息時回報); @@ -182,14 +162,14 @@ async function main() { // 本回合(一般模式)發出的 PR 留言 id:resolveOldComments 標註過時時要跳過這些。 const currentRunCommentIds = new Set(); - // 建問題模式:issue 於「確定有保留問題」後才建立;在那之前的情境留言(工具/diff/角色) - // 先暫存於 issueBuffer,建立 issue 後一次寫入。 - const issueBuffer = []; - let issue = null; + // 建問題模式:追蹤 issue 於「確定有保留問題」後才建立;在那之前的情境留言(工具/diff/角色) + // 先暫存於 pendingIssueCommentBodies,建立 issue 後一次寫入。 + const pendingIssueCommentBodies = []; + let trackingIssue = null; /** * 發布一則審查留言。依模式決定去向: * - 一般模式:發到 PR,並記錄留言 id 供 `resolveOldComments` 排除。 - * - 建問題模式:issue 已建立時發到 issue;尚未建立時先暫存到 `issueBuffer`。 + * - 建問題模式:追蹤 issue 已建立時發到 issue;尚未建立時先暫存到 `pendingIssueCommentBodies`。 * * @param {string} body 要發布的 Markdown 留言內容。 * @returns {Promise} 一般模式、或建問題模式且 issue 已建立時回傳 Gitea 留言物件; @@ -200,8 +180,8 @@ async function main() { */ const queueOrPostComment = async (body) => { if (ctx.createIssue) { - if (issue) return gitea.createCommentOnIssue(ctx, issue.number, body); - issueBuffer.push(body); + if (trackingIssue) return gitea.createCommentOnIssue(ctx, trackingIssue.number, body); + pendingIssueCommentBodies.push(body); return null; } const created = await gitea.createIssueComment(ctx, body); @@ -210,24 +190,25 @@ async function main() { }; /** * 建問題模式:建立追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立), - * 並把 `issueBuffer` 內暫存的情境留言依流程順序寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。 + * 並把 `pendingIssueCommentBodies` 內暫存的情境留言依流程順序寫入 issue; + * 設定閉包變數 `trackingIssue` 供後續留言直接發到 issue。 * 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。 * * @param {number[]} [labelIds] - 建立 issue 時要一併掛上的標籤 id 陣列(由 `review.selectLabels` 事先挑選); * 空陣列或省略時不掛任何標籤(`gitea.createIssue` 對空陣列不帶 labels 欄位)。 - * @returns {Promise} 無回傳值;結果反映在閉包變數 `issue` 與 issue 留言。 + * @returns {Promise} 無回傳值;結果反映在閉包變數 `trackingIssue` 與 issue 留言。 */ const createIssueAndFlushBufferedComments = async (labelIds = []) => { - issue = await gitea.createIssue(ctx, { + 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 #${issue.number},寫入 ${issueBuffer.length} 則情境留言。`); - for (const body of issueBuffer) { - await gitea.createCommentOnIssue(ctx, issue.number, body); + log('建問題', 'INF', `已建立追蹤 issue #${trackingIssue.number},寫入 ${pendingIssueCommentBodies.length} 則情境留言。`); + for (const body of pendingIssueCommentBodies) { + await gitea.createCommentOnIssue(ctx, trackingIssue.number, body); } - issueBuffer.length = 0; + pendingIssueCommentBodies.length = 0; }; // ── 步驟 2:延後執行 ─────────────────────────────────────────────────── @@ -355,7 +336,7 @@ async function main() { // ── 步驟 9:嚴重問題留言(一般模式掛在 PR 程式碼行上;建問題模式逐條發到 issue)─ if (severe.length > 0) { if (ctx.createIssue) { - await review.postSevereToIssue({ ctx, gitea, issueNumber: issue.number, severe }); + await review.postSevereToIssue({ ctx, gitea, issueNumber: trackingIssue.number, severe }); } else { await review.postSevereComments({ ctx, gitea, severe, cwd }); } @@ -365,7 +346,7 @@ async function main() { // 建問題模式逐條發到 issue,讓每條問題都能被個別回覆。 ── if (others.length > 0) { if (ctx.createIssue) { - await review.postOthersToIssue({ ctx, gitea, issueNumber: issue.number, others }); + await review.postOthersToIssue({ ctx, gitea, issueNumber: trackingIssue.number, others }); } else { await queueOrPostComment(templates.othersComment(others)); log('步驟10', 'INF', `警告+建議表格留言已發布(${others.length} 條)。`); @@ -374,12 +355,12 @@ async function main() { // ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─ // 標籤已於建立 issue 時一次帶入(見上方 selectLabels → createIssueAndFlushBufferedComments),此處不再補掛。 - if (ctx.createIssue && issue) { + if (ctx.createIssue && trackingIssue) { await gitea.createIssueComment( ctx, - templates.issueLinkComment({ - issueNumber: issue.number, - issueUrl: issue.html_url, + templates.prIssueLinkComment({ + issueNumber: trackingIssue.number, + issueUrl: trackingIssue.html_url, severeCount: severe.length, otherCount: others.length, }), @@ -388,15 +369,15 @@ async function main() { // 僅有警告/建議時,issue 仍建立供追蹤,但不掛相依、不阻擋 PR 合併。 if (severe.length > 0) { try { - await gitea.addIssueDependency(ctx, ctx.prNumber, issue.number); - log('建問題', 'INF', `有嚴重問題:已將 PR #${ctx.prNumber} 設為相依於 issue #${issue.number},issue 關閉前無法合併。`); + 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 #${issue.number} 僅供追蹤,不阻擋 PR 合併。`); + log('建問題', 'INF', `無嚴重問題(僅警告/建議):issue #${trackingIssue.number} 僅供追蹤,不阻擋 PR 合併。`); } - log('建問題', 'INF', `issue #${issue.number} 已寫入審查內容,並在 PR 回貼連結。`); + log('建問題', 'INF', `issue #${trackingIssue.number} 已寫入審查內容,並在 PR 回貼連結。`); } // ── 收尾:commit 並 push(success=無嚴重問題、failure=有嚴重問題)─────── diff --git a/src/lib/diagnostics.js b/src/lib/diagnostics.js index 011cd28..d39e9ee 100644 --- a/src/lib/diagnostics.js +++ b/src/lib/diagnostics.js @@ -37,13 +37,13 @@ function redactSecrets(text) { * 訊號與逾時狀態;只有 `ACTIONS_STEP_DEBUG=true` 時才附上經 {@link redactSecrets} * 遮罩且去除控制字元的 stderr/stdout 片段。 * - * @param {{error: (Error & {code?: number|string, signal?: string, killed?: boolean})|null, stderr?: string, output?: string}} res + * @param {{error: (Error & {code?: number|string, signal?: string, killed?: boolean})|null, stderr?: string, output?: string}} agentResult * `runAgent` 的回傳物件。 * @returns {string} 單行診斷摘要(各段以「|」分隔);無任何資訊時回傳固定字串。 */ -function agentFailureDetail(res) { +function agentFailureDetail(agentResult) { const parts = []; - const err = res && res.error; + const err = agentResult && agentResult.error; if (err) { if (err.killed) parts.push('已逾時終止'); if (typeof err.code === 'number') parts.push(`exit ${err.code}`); @@ -52,9 +52,9 @@ function agentFailureDetail(res) { } // 失敗輸出可能含 token 或 PII,預設不寫入長期 CI log;debug 模式才輸出遮罩後片段。 if (process.env.ACTIONS_STEP_DEBUG === 'true') { - const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT)); + const stderr = redactSecrets(String((agentResult && agentResult.stderr) || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT)); if (stderr) parts.push(`stderr:${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); - const stdout = redactSecrets(String((res && res.output) || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT)); + const stdout = redactSecrets(String((agentResult && agentResult.output) || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT)); if (stdout) parts.push(`stdout:${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); } if (parts.length === 0) { diff --git a/src/lib/gitea.js b/src/lib/gitea.js index d99e4ce..55a80ae 100644 --- a/src/lib/gitea.js +++ b/src/lib/gitea.js @@ -173,23 +173,23 @@ function createIssue(ctx, { title, body, labels }) { * 對應 endpoint:`POST /repos/{owner}/{repo}/issues/{issueNumber}/dependencies` * (body 為 IssueMeta:`{index, owner, repo}`)。 * - * 語義:URL 的 issue(`issueNumber`)相依於 body 的 issue(`dependency`)—— - * 在 `dependency` 關閉前,`issueNumber` 無法合併/關閉。本 endpoint 需 repo 啟用 + * 語義:URL 的 issue(`blockedIssueNumber`)相依於 body 的 issue(`blockingIssueNumber`)—— + * 在 `blockingIssueNumber` 關閉前,`blockedIssueNumber` 無法合併/關閉。本 endpoint 需 repo 啟用 * 「問題相依(issue dependencies)」功能,屬版本/設定相依;未啟用或不支援時 API 會回非 2xx。 * * @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、 * `owner`(repo 擁有者)、`repo`(repo 名稱)。 - * @param {number|string} issueNumber - 要被阻擋的 issue/PR 編號(相依方)。 - * @param {number} dependency - 作為阻擋來源的 issue 編號(同一 repo)。 + * @param {number|string} blockedIssueNumber - 要被阻擋的 issue/PR 編號(相依方)。 + * @param {number} blockingIssueNumber - 作為阻擋來源的 issue 編號(同一 repo)。 * @returns {Promise} 建立成功的相依關係物件(依 Gitea API 回應而定)。 * @throws {Error} 請求失敗(非 2xx,例如未啟用問題相依功能)由底層 `api` 丟出,錯誤附 `status`、`data`。 * @remarks 使用情境:建問題模式(input: create-issue)下,`main()` 建立追蹤 issue 後, * 以本函式把「PR(`ctx.prNumber`)相依於追蹤 issue」,讓 issue 完成/關閉前 PR 無法合併; * 呼叫端以 try/catch 降級(功能未啟用時記 WRN、不阻斷流程)。 */ -function addIssueDependency(ctx, issueNumber, dependency) { - return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/issues/${issueNumber}/dependencies`, { - index: dependency, +function addIssueDependency(ctx, blockedIssueNumber, blockingIssueNumber) { + return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/issues/${blockedIssueNumber}/dependencies`, { + index: blockingIssueNumber, owner: ctx.owner, repo: ctx.repo, }); diff --git a/src/lib/review.js b/src/lib/review.js index db528f6..0095dd9 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -690,9 +690,9 @@ ${JSON.stringify(brief)} * 不使用 {@link templates.othersComment} 的單一表格——表格僅用於一般模式(PR)。 */ async function postSevereToIssue({ ctx, gitea, issueNumber, severe }) { - await Promise.all( - severe.map((finding) => gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding))), - ); + for (const finding of severe) { + await gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding)); + } log('步驟9', 'INF', `已將 ${severe.length} 條嚴重問題留言到 issue #${issueNumber}。`); } @@ -716,9 +716,9 @@ async function postSevereToIssue({ ctx, gitea, issueNumber, severe }) { * 以本函式把警告+建議逐條留言到追蹤 issue,確保 issue 上每條問題都是可個別回覆的留言。 */ async function postOthersToIssue({ ctx, gitea, issueNumber, others }) { - await Promise.all( - others.map((finding) => gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding))), - ); + for (const finding of others) { + await gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding)); + } log('步驟10', 'INF', `已將 ${others.length} 條警告+建議逐條留言到 issue #${issueNumber}。`); } diff --git a/src/lib/templates.js b/src/lib/templates.js index c970629..3393803 100644 --- a/src/lib/templates.js +++ b/src/lib/templates.js @@ -400,7 +400,7 @@ function nothingToReviewComment(ignoredCount) { * 以本函式對 PR 留一則連結留言,達成「問題關聯回 PR」;issue 內文另以 * {@link issueBody} 反向引用 `PR #N`,形成雙向交叉連結。 */ -function issueLinkComment({ issueNumber, issueUrl, severeCount, otherCount }) { +function prIssueLinkComment({ issueNumber, issueUrl, severeCount, otherCount }) { return `${MARK} ## 🔍 AI Code Review|已建立追蹤問題 @@ -421,5 +421,5 @@ module.exports = { issueBody, issueFindingComment, nothingToReviewComment, - issueLinkComment, + prIssueLinkComment, }; diff --git a/test/gitea.test.js b/test/gitea.test.js index f5f6469..a13bcdd 100644 --- a/test/gitea.test.js +++ b/test/gitea.test.js @@ -5,7 +5,7 @@ const test = require('node:test'); const gitea = require('../src/lib/gitea'); -function withFetchStub(handler, fn) { +function withFetchStub(handler, callback) { const originalFetch = global.fetch; const calls = []; global.fetch = async (url, options = {}) => { @@ -13,7 +13,7 @@ function withFetchStub(handler, fn) { return handler(url, options); }; return Promise.resolve() - .then(() => fn(calls)) + .then(() => callback(calls)) .finally(() => { global.fetch = originalFetch; }); diff --git a/test/review.test.js b/test/review.test.js index d49e210..e052818 100644 --- a/test/review.test.js +++ b/test/review.test.js @@ -44,7 +44,7 @@ test('agentFailureDetail 在 debug 模式輸出遮罩後片段', () => { } }); -test('postOthersToIssue 批次送出 issue 留言', async () => { +test('postOthersToIssue 依序送出 issue 留言以維持排序', async () => { const calls = []; let active = 0; let maxActive = 0; @@ -71,7 +71,7 @@ test('postOthersToIssue 批次送出 issue 留言', async () => { assert.equal(calls.length, 2); assert.equal(calls[0].issueNumber, 7); - assert.ok(maxActive > 1); + assert.equal(maxActive, 1); }); test('resultFilesToCommit 在建問題模式有嚴重問題時仍提交 findings', () => {