From 449b17480b1e6841b980f2dc924b1dca9b6d0852 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 21 Jul 2026 17:21:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai-review):=20=E7=B5=90=E6=9E=9C=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E5=A4=B1=E6=95=97=E6=99=82=E5=9B=9E=E5=A0=B1=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E5=A4=B1=E6=95=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- action.yml | 2 +- readme.md | 2 +- src/index.js | 37 ++++++++++++++++++------------------- src/lib/review.js | 13 +++++++++++++ test/gitrepo.test.js | 27 +++++++++++++++++++++++++++ test/review.test.js | 24 ++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 21 deletions(-) diff --git a/action.yml b/action.yml index e1ef5de..b3a3fd1 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ # ============================================================================ # 用途:定義 AI Code Review Node action 的名稱、輸入參數與 Node.js 24 進入點,供 Gitea / GitHub workflow 以 uses 引用。 -# 更新時間:2026/07/17 18:49:58 +# 更新時間:2026/07/21 17:19:15 # ============================================================================ # Gitea / GitHub node action 的 manifest(action.yml): # 定義本 action 的名稱、說明、輸入參數(inputs)與執行方式(runs), diff --git a/readme.md b/readme.md index daf0b4f..facea15 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # AI Code Review -> 更新時間:2026/07/17 18:49:58 +> 更新時間:2026/07/21 17:19:15 AI 多角色 code review 的 Gitea **node action**(`node24`、零外部相依):以攻擊方六角色(🔮 Mage 邏輯、🗡️ Assassin 安全、⚡ Rogue 效率、🎼 Bard 風格、🧪 Maya 測試、🧰 Leo 可維護性)並行找問題、防守方(🛡️ Paladin)裁決誤報,結果留言到 PR、保存 findings,並以 bot commit 標記審查結果(`[success]`/`[failure]`)供下次觸發快速回報。 diff --git a/src/index.js b/src/index.js index b750e16..c6957da 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ 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('更新時間: 2026/07/21 17:19:15'); console.log('================================================'); const fs = require('fs'); @@ -126,15 +126,14 @@ function commitFindings({ cwd, ctx, files, result }) { * 建問題模式會把審查情境與每條 finding 發到追蹤 issue;沒有保留 finding 時不建立 issue、PR 也不留言。 * 嚴重 finding 會寫入 failure 結果 commit,警告與建議只建立追蹤資訊,不直接阻擋合併。 * - * @returns {Promise} process exit code:本輪「審查」一律回傳 0(不因嚴重問題直接讓檢查失敗—— - * 失敗改由推出的 `[ai-review-bot][failure]` 結果 commit,於下一輪在步驟 1 讀 commit 訊息時回報); - * 回傳 1 僅發生於:步驟 1 偵測到 `[ai-review-bot][failure]` 結果 commit,或前置條件不足 - * (缺 PR 編號/token、找不到 AI 工具)等無法進行審查的情況。 + * @returns {Promise} 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;而「失敗檢查(exit 1)」只由步驟 1 讀到該 failure 結果 commit 時產生,審查本輪不直接 exit 1。 + * 標記為 failure;若結果檔需要 push 卻失敗,本輪直接 exit 1,避免缺權限時無聲放行。 * 建問題模式只改變問題明細的落地方式(issue 留言取代 findings 進版控),不改變上述結果標記判定。 */ async function main() { @@ -193,7 +192,7 @@ async function main() { return created; }; /** - * 建問題模式:建立追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立), + * 建問題模式:開啟追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立), * 並把 `pendingIssueCommentBodies` 內暫存的情境留言依流程順序寫入 issue; * 設定閉包變數 `trackingIssue` 供後續留言直接發到 issue。 * 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。 @@ -202,17 +201,18 @@ async function main() { * 空陣列或省略時不掛任何標籤(`gitea.createIssue` 對空陣列不帶 labels 欄位)。 * @returns {Promise} 無回傳值;結果反映在閉包變數 `trackingIssue` 與 issue 留言。 */ - const createIssueAndFlushBufferedComments = async (labelIds = []) => { + 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} 則情境留言。`); - for (const body of pendingIssueCommentBodies) { + while (pendingIssueCommentBodies.length > 0) { + const body = pendingIssueCommentBodies[0]; await gitea.createCommentOnIssue(ctx, trackingIssue.number, body); + pendingIssueCommentBodies.shift(); } - pendingIssueCommentBodies.length = 0; }; /** * 建問題模式降級:追蹤 issue 無法建立或寫入時,改把已暫存的情境留言發回 PR,後續沿用一般模式。 @@ -337,7 +337,7 @@ async function main() { log('建問題', 'WRN', `標籤挑選失敗(${err.message}),issue 不掛標籤。`); } try { - await createIssueAndFlushBufferedComments(labelIds); + await openTrackingIssue(labelIds); } catch (err) { log('建問題', 'WRN', `建立或寫入追蹤 issue 失敗(${err.message}),改用 PR 留言與 findings 檔流程。`); await fallbackToPrComments(); @@ -377,7 +377,7 @@ async function main() { } // ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─ - // 標籤已於建立 issue 時一次帶入(見上方 selectLabels → createIssueAndFlushBufferedComments),此處不再補掛。 + // 標籤已於建立 issue 時一次帶入(見上方 selectLabels → openTrackingIssue),此處不再補掛。 if (issueModeActive && trackingIssue) { await review.resolveOldIssueLinkComments({ ctx, gitea }); await gitea.createIssueComment( @@ -420,14 +420,13 @@ async function main() { } else { log('收尾', 'INF', '建問題模式且 exclusions.json 無變更,略過 commit/push。'); } - // 本輪「審查」一律以成功收場、不直接讓檢查失敗;有嚴重問題時已推出 [failure] 結果 commit, - // 由它再觸發的下一輪在步驟 1 讀 commit 訊息時才回報失敗(exit 1)。如此失敗檢查落在帶有結果 - // 標記的最新 head 上,與合併判定一致。(result 僅用於上方 commit 訊息的結果標記。) + // 需要推送結果檔卻沒成功時直接失敗;這通常代表 token 權限不足、非快轉或分支保護阻擋。 + if (review.shouldFailMissingResultCommit({ filesToCommit, resultCommitted })) { + log('收尾', 'ERR', '審查結果檔需要 push 但未成功產生結果 commit;直接回報失敗避免缺權限時靜默通過。'); + return 1; + } + // 嚴重問題已推出 [failure] 結果 commit 時,由它再觸發的下一輪在步驟 1 讀 commit 訊息回報失敗。 if (result === 'failure') { - if (!resultCommitted) { - log('收尾', 'ERR', '本輪有嚴重問題,但未成功產生 [failure] 結果 commit;直接回報失敗避免 fail-open。'); - return 1; - } log('收尾', 'INF', '本輪有嚴重問題:已標記結果 commit 為 [failure],失敗檢查由下一輪步驟 1 讀 commit 訊息回報。'); } return 0; diff --git a/src/lib/review.js b/src/lib/review.js index 2c0af8f..51ef319 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -579,6 +579,18 @@ function resultFilesToCommit({ createIssue, severeCount, relativePath, exclusion return files; } +/** + * 判斷本輪是否因結果 commit 未成功推送而必須失敗。 + * + * @param {Object} params - 解構參數。 + * @param {string[]} params.filesToCommit - 本輪預期寫回遠端分支的結果檔清單。 + * @param {boolean} params.resultCommitted - 結果檔是否已成功 commit 並 push。 + * @returns {boolean} 需要寫回結果檔卻沒有成功推送時回傳 true。 + */ +function shouldFailMissingResultCommit({ filesToCommit, resultCommitted }) { + return filesToCommit.length > 0 && !resultCommitted; +} + /** * 就地排序 findings:依 嚴重→警告→建議、再依檔案路徑、再依起始行遞增。 * @@ -914,6 +926,7 @@ module.exports = { sortFindings, appendExclusions, resultFilesToCommit, + shouldFailMissingResultCommit, selectLabels, postSevereToIssue, postOthersToIssue, diff --git a/test/gitrepo.test.js b/test/gitrepo.test.js index dcc9fc1..831f9dc 100644 --- a/test/gitrepo.test.js +++ b/test/gitrepo.test.js @@ -17,6 +17,33 @@ test('assertSafeBranchRef 拒絕路徑穿越分支名稱', () => { ); }); +test('assertSafeBranchRef 拒絕空白分支名稱', () => { + for (const value of ['', ' ', null, undefined]) { + assert.throws( + () => gitref.assertSafeBranchRef(value, 'baseRef'), + /baseRef 不可為空/, + ); + } +}); + +test('assertSafeBranchRef 拒絕不安全的 refspec 分支名稱', () => { + for (const value of ['/feature', 'feature/', 'feature\\x', 'feature..x']) { + assert.throws( + () => gitref.assertSafeBranchRef(value, 'baseRef'), + /不是安全的分支名稱/, + ); + } +}); + +test('assertSafeBranchRef 拒絕 git 不合法的分支名稱', () => { + for (const value of ['feature.lock', 'feature@{x}', 'feature~x', 'feature^x']) { + assert.throws( + () => gitref.assertSafeBranchRef(value, 'baseRef'), + /不是合法的 git 分支名稱/, + ); + } +}); + test('resolveMergeBase 會在 git fetch 前拒絕不安全 baseRef', () => { assert.throws( () => gitrepo.resolveMergeBase(process.cwd(), '../../hooks/pre-push'), diff --git a/test/review.test.js b/test/review.test.js index 8c97f9e..1364502 100644 --- a/test/review.test.js +++ b/test/review.test.js @@ -98,6 +98,30 @@ test('resultFilesToCommit 在建問題模式無嚴重問題時只提交 exclusio ); }); +test('shouldFailMissingResultCommit 在結果檔需要 push 但未成功時回報失敗', () => { + assert.equal( + review.shouldFailMissingResultCommit({ + filesToCommit: ['.gitea/ai-review/findings/run.json'], + resultCommitted: false, + }), + true, + ); + assert.equal( + review.shouldFailMissingResultCommit({ + filesToCommit: ['.gitea/ai-review/findings/run.json'], + resultCommitted: true, + }), + false, + ); + assert.equal( + review.shouldFailMissingResultCommit({ + filesToCommit: [], + resultCommitted: false, + }), + false, + ); +}); + test('resolveOldIssueLinkComments 只標註舊追蹤 issue 連結', async () => { const edited = []; const fakeGitea = {