From aaf2e484d5717ca20eecad861f16386e06597850 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 21 Jul 2026 15:57:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai-review):=20=E9=81=BF=E5=85=8D=E5=BB=BA?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E6=A8=A1=E5=BC=8F=E9=98=BB=E6=93=8B=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E8=88=87=E9=80=A3=E7=B5=90=E7=B4=AF=E7=A9=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.js | 59 +++++++++++++++++++++++++++++++++------------ src/lib/review.js | 35 +++++++++++++++++++++++++++ test/review.test.js | 37 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/index.js b/src/index.js index 1b63e3b..b750e16 100644 --- a/src/index.js +++ b/src/index.js @@ -72,7 +72,7 @@ function saveFindings({ cwd, ctx, tool, kept, excluded }) { * 供下一回合 `main()` 步驟 1 比對辨識、直接回報結果而不重複審查。 * 依 `commitAndPushFindings` 的回傳值記錄不同日誌:true=已 commit/push; * false=檔案無實際變更(空 commit 防護),記「略過 commit/push」。 - * commit/push 失敗(例如與開發者新 commit 競態)時僅記 WRN log,不拋出例外、不改變審查結果。 + * commit/push 失敗(例如與開發者新 commit 競態)時僅記 WRN log,不拋出例外;呼叫端可依回傳值決定是否阻擋。 * * @param {Object} params - 解構參數。 * @param {string} params.cwd - repo 根目錄(workspace)絕對路徑,git 操作在此目錄執行。 @@ -84,7 +84,7 @@ function saveFindings({ cwd, ctx, tool, kept, excluded }) { * @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 上。 + * @returns {boolean} true=已 commit/push;false=無變更或 commit/push 失敗。 * @remarks * 使用情境:`main()` 於流程尾端依 `severe.length === 0 ? 'success' : 'failure'` 決定 result、 * 依模式組出 filesToCommit(一般模式:findings 檔+有變更時的 exclusions.json; @@ -107,12 +107,15 @@ function commitFindings({ cwd, ctx, files, result }) { }); if (committed) { log('收尾', 'INF', `審查結果檔已 commit 並 push 回 ${ctx.headRef}(結果:${result})。`); + return true; } else { log('收尾', 'INF', '審查結果檔無實際變更,略過 commit/push。'); + return false; } } catch (err) { - // push 失敗(例如與開發者新 commit 競態)時只記錄,不改變審查結果。 + // push 失敗(例如與開發者新 commit 競態)時只記錄,交由呼叫端依嚴重度決定是否阻擋。 log('收尾', 'WRN', `commit/push 審查結果檔失敗:${err.message}。`); + return false; } } @@ -165,6 +168,7 @@ async function main() { // 建問題模式:追蹤 issue 於「確定有保留問題」後才建立;在那之前的情境留言(工具/diff/角色) // 先暫存於 pendingIssueCommentBodies,建立 issue 後一次寫入。 const pendingIssueCommentBodies = []; + let issueModeActive = ctx.createIssue; let trackingIssue = null; /** * 發布一則審查留言。依模式決定去向: @@ -179,7 +183,7 @@ async function main() { * 警告/建議彙整等留言。若 Gitea API 失敗,例外會往上拋出並由主流程頂層 catch 收斂。 */ const queueOrPostComment = async (body) => { - if (ctx.createIssue) { + if (issueModeActive) { if (trackingIssue) return gitea.createCommentOnIssue(ctx, trackingIssue.number, body); pendingIssueCommentBodies.push(body); return null; @@ -210,13 +214,27 @@ async function main() { } pendingIssueCommentBodies.length = 0; }; + /** + * 建問題模式降級:追蹤 issue 無法建立或寫入時,改把已暫存的情境留言發回 PR,後續沿用一般模式。 + * + * @returns {Promise} 無回傳值;會關閉建問題模式並把 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)。 + // 建問題模式不清理 PR 既有審查內容,只在收束時標記舊追蹤 issue 連結。 // ── 步驟 3:偵測 AI agent 工具並留言 ────────────────────────────────── const tool = agents.detectTool(); @@ -248,7 +266,7 @@ async function main() { if (files.length === 0) { // 沒有可審查的變更:保存空 findings、以 success 收場。 // 一般模式在 PR 留言告知;建問題模式靜默通過(不建 issue、PR 也不留言,暫存的情境留言捨棄)。 - if (ctx.createIssue) { + if (issueModeActive) { log('步驟4', 'INF', '建問題模式且無可審查變更:靜默通過(不建 issue、PR 不留言)。'); } else { await queueOrPostComment(templates.nothingToReviewComment(ignoredCount)); @@ -256,7 +274,7 @@ async function main() { await review.resolveOldComments({ ctx, gitea, currentRunCommentIds }); } const relativePath = saveFindings({ cwd, ctx, tool, kept: [], excluded: [] }); - if (ctx.createIssue) { + if (issueModeActive) { // 建問題模式下 findings 不進版控,且 exclusions.json 無變更 → 沒東西可提交。 log('收尾', 'INF', '建問題模式且無可審查變更,略過 commit/push。'); } else { @@ -299,7 +317,7 @@ async function main() { // ── 建問題模式:確定有保留問題才建立 issue,並把暫存的情境留言一次寫入; // 無保留問題則不建 issue、PR 也完全不留言(靜默通過,暫存的情境留言捨棄)。 ────── - if (ctx.createIssue) { + if (issueModeActive) { if (kept.length > 0) { // 先依保留問題挑好標籤,於建立 issue 時一次帶入(省去「先建空標籤 issue 再補掛」的多餘 API 往返); // 標籤挑選失敗一律降級為不掛標籤,不阻斷建 issue 流程。 @@ -318,7 +336,12 @@ async function main() { } catch (err) { log('建問題', 'WRN', `標籤挑選失敗(${err.message}),issue 不掛標籤。`); } - await createIssueAndFlushBufferedComments(labelIds); + try { + await createIssueAndFlushBufferedComments(labelIds); + } catch (err) { + log('建問題', 'WRN', `建立或寫入追蹤 issue 失敗(${err.message}),改用 PR 留言與 findings 檔流程。`); + await fallbackToPrComments(); + } } else { // 無保留問題 → 不建 issue、PR 也不留言(靜默通過,暫存的情境留言捨棄)。 log('建問題', 'INF', '沒有保留的問題:靜默通過(不建 issue、PR 不留言)。'); @@ -329,13 +352,13 @@ async function main() { // 延後到此可避免工具偵測/diff/攻防裁決任一失敗時舊結果先被清掉卻無新結果; // 本回合的工具/diff/角色留言已登錄於 currentRunCommentIds,不會被誤標為過時; // 嚴重/其他問題留言於本步驟之後才發布,同樣不受影響。 - if (!ctx.createIssue) { + if (!issueModeActive) { await review.resolveOldComments({ ctx, gitea, currentRunCommentIds }); } // ── 步驟 9:嚴重問題留言(一般模式掛在 PR 程式碼行上;建問題模式逐條發到 issue)─ if (severe.length > 0) { - if (ctx.createIssue) { + if (issueModeActive && trackingIssue) { await review.postSevereToIssue({ ctx, gitea, issueNumber: trackingIssue.number, severe }); } else { await review.postSevereComments({ ctx, gitea, severe, cwd }); @@ -345,7 +368,7 @@ async function main() { // ── 步驟 10:警告+建議——一般模式彙整為單一表格留言到 PR; // 建問題模式逐條發到 issue,讓每條問題都能被個別回覆。 ── if (others.length > 0) { - if (ctx.createIssue) { + if (issueModeActive && trackingIssue) { await review.postOthersToIssue({ ctx, gitea, issueNumber: trackingIssue.number, others }); } else { await queueOrPostComment(templates.othersComment(others)); @@ -355,7 +378,8 @@ async function main() { // ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─ // 標籤已於建立 issue 時一次帶入(見上方 selectLabels → createIssueAndFlushBufferedComments),此處不再補掛。 - if (ctx.createIssue && trackingIssue) { + if (issueModeActive && trackingIssue) { + await review.resolveOldIssueLinkComments({ ctx, gitea }); await gitea.createIssueComment( ctx, templates.prIssueLinkComment({ @@ -385,13 +409,14 @@ async function main() { // 若有嚴重問題,仍 commit findings 檔產生 [failure] 結果 commit,避免相依 API 不支援時 fail-open。 const result = severe.length === 0 ? 'success' : 'failure'; const filesToCommit = review.resultFilesToCommit({ - createIssue: ctx.createIssue, + createIssue: issueModeActive, severeCount: severe.length, relativePath, exclusionsChanged, }); + let resultCommitted = false; if (filesToCommit.length > 0) { - commitFindings({ cwd, ctx, files: filesToCommit, result }); + resultCommitted = commitFindings({ cwd, ctx, files: filesToCommit, result }); } else { log('收尾', 'INF', '建問題模式且 exclusions.json 無變更,略過 commit/push。'); } @@ -399,6 +424,10 @@ async function main() { // 由它再觸發的下一輪在步驟 1 讀 commit 訊息時才回報失敗(exit 1)。如此失敗檢查落在帶有結果 // 標記的最新 head 上,與合併判定一致。(result 僅用於上方 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 0095dd9..2c0af8f 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -832,6 +832,40 @@ async function resolveOldComments({ ctx, gitea, currentRunCommentIds }) { } } +/** + * 建問題模式:只將 PR 上舊的「追蹤問題連結」留言標註為過時,不觸碰 issue 內審查內容。 + * + * @param {Object} params - 解構參數。 + * @param {Object} params.ctx - 執行環境 context(`loadContext()` 產出)。 + * @param {Object} params.gitea - Gitea API 模組,需提供 `whoAmI`、`listIssueComments`、`editIssueComment`。 + * @returns {Promise} 無回傳值;失敗時只記 WRN,不阻斷主流程。 + */ +async function resolveOldIssueLinkComments({ ctx, gitea }) { + let botLogin = ''; + try { + botLogin = (await gitea.whoAmI(ctx)).login || ''; + } catch (err) { + log('建問題', 'WRN', `無法取得 bot 身分(${err.message}),略過舊追蹤連結標註。`); + return; + } + + try { + const comments = await gitea.listIssueComments(ctx); + let outdatedCount = 0; + for (const comment of comments) { + const isBot = comment.user && comment.user.login === botLogin; + const body = typeof comment.body === 'string' ? comment.body : ''; + const isIssueLink = body.includes(templates.MARK) && body.includes('## 🔍 AI Code Review|已建立追蹤問題'); + if (!isBot || !isIssueLink || body.startsWith(templates.OUTDATED_PREFIX)) continue; + await gitea.editIssueComment(ctx, comment.id, `${templates.OUTDATED_PREFIX}${body}`); + outdatedCount += 1; + } + log('建問題', 'INF', `舊追蹤 issue 連結已標註〔已過時〕:${outdatedCount} 則。`); + } catch (err) { + log('建問題', 'WRN', `標註舊追蹤 issue 連結失敗:${err.message}。`); + } +} + /** * 步驟 9:嚴重問題逐條掛在 PR 程式碼行上留言(建立 code review); * 建立 review 失敗時降級為一般留言逐條發布(留言內補上檔案與行號位置)。 @@ -884,5 +918,6 @@ module.exports = { postSevereToIssue, postOthersToIssue, resolveOldComments, + resolveOldIssueLinkComments, postSevereComments, }; diff --git a/test/review.test.js b/test/review.test.js index e052818..8c97f9e 100644 --- a/test/review.test.js +++ b/test/review.test.js @@ -97,3 +97,40 @@ test('resultFilesToCommit 在建問題模式無嚴重問題時只提交 exclusio ['.gitea/ai-review/exclusions.json'], ); }); + +test('resolveOldIssueLinkComments 只標註舊追蹤 issue 連結', async () => { + const edited = []; + const fakeGitea = { + async whoAmI() { + return { login: 'bot' }; + }, + async listIssueComments() { + return [ + { + id: 1, + user: { login: 'bot' }, + body: '\n## 🔍 AI Code Review|已建立追蹤問題\nold', + }, + { + id: 2, + user: { login: 'bot' }, + body: '\n## 📋 變更摘要(送審 git diff)\nkeep', + }, + { + id: 3, + user: { login: 'someone' }, + body: '\n## 🔍 AI Code Review|已建立追蹤問題\nkeep', + }, + ]; + }, + async editIssueComment(ctx, commentId, body) { + edited.push({ ctx, commentId, body }); + }, + }; + + await review.resolveOldIssueLinkComments({ ctx: { token: 'hidden' }, gitea: fakeGitea }); + + assert.equal(edited.length, 1); + assert.equal(edited[0].commentId, 1); + assert.match(edited[0].body, /^> 〔已過時〕/); +});