Author SHA1 Message Date
Jeffery 216bc39255 chore(ai-review 狀態): 回寫本輪已處理 findings
node-actions/template: CI / BUILD (pull_request) Successful in 4s
CI / TEST (Claude) (pull_request) Successful in 26s
CI / TEST (Antigravity) (pull_request) Successful in 49s
CI / TEST (Codex) (pull_request) Successful in 2m34s
2026-07-21 15:57:53 +08:00
Jeffery 75ca8cdb83 refactor(ai-review 診斷): 收斂失敗輸出處理與推送註解 2026-07-21 15:57:53 +08:00
Jeffery aaf2e484d5 fix(ai-review): 避免建問題模式阻擋失效與連結累積 2026-07-21 15:57:53 +08:00
ai-review-bot 55da29a86f chore: update ai-review findings [ai-review-bot][failure]
CI / TEST (Claude) (pull_request) Failing after 22s
CI / TEST (Codex) (pull_request) Failing after 26s
CI / TEST (Antigravity) (pull_request) Failing after 33s
node-actions/template: CI / BUILD (pull_request) Successful in 4s
2026-07-21 06:42:40 +00:00
7 changed files with 436 additions and 22 deletions
+99
View File
@@ -1791,5 +1791,104 @@
"endLine": 32, "endLine": 32,
"problem": "`redactSecrets` 新增多個遮罩規則與控制字元單行化,但測試只透過 `agentFailureDetail` 間接覆蓋 Authorization 與 token 兩種格式。URL 內嵌帳密、長 token、GitHub token 樣式、控制字元注入與 null/undefined 邊界都還沒被直接驗證。", "problem": "`redactSecrets` 新增多個遮罩規則與控制字元單行化,但測試只透過 `agentFailureDetail` 間接覆蓋 Authorization 與 token 兩種格式。URL 內嵌帳密、長 token、GitHub token 樣式、控制字元注入與 null/undefined 邊界都還沒被直接驗證。",
"reason": "Paladin:可排除(重複)。歷史 findings 已記錄失敗診斷與 redactSecrets 對 Authorization、URL 帳密、token 格式、控制字元、截斷與空值邊界缺少測試;本條為同一測試缺口。" "reason": "Paladin:可排除(重複)。歷史 findings 已記錄失敗診斷與 redactSecrets 對 Authorization、URL 帳密、token 格式、控制字元、截斷與空值邊界缺少測試;本條為同一測試缺口。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Bard",
"severity": "建議",
"file": "src/index.js",
"startLine": 179,
"endLine": 212,
"problem": "`queueOrPostComment` 與 `createIssueAndFlushBufferedComments` 這兩個閉包名稱像兩段不同旋律:一個強調「排隊或發布」,另一個卻把「建立 issue、flush 暫存留言、設定狀態」全塞進名稱與實作。讀者要來回追 `trackingIssue`、`pendingIssueCommentBodies`,節奏偏長且語意負擔重。",
"reason": "Paladin:可排除(重複)。歷史 finding 已指出 main() 內新增閉包與 issue/comment 路由細節讓主流程閱讀負擔加重;本條只是改以目前函式名稱描述同一維護性問題。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Leo",
"severity": "警告",
"file": "src/index.js",
"startLine": 165,
"endLine": 213,
"problem": "`main()` 這次把「留言目的地切換、issue 建立、暫存留言 flush、標籤挑選前置狀態」都塞進閉包與可變狀態(`pendingIssueCommentBodies`、`trackingIssue`、`currentRunCommentIds`)。半年後要改建問題模式時,維護者得同時追蹤主流程步驟、閉包副作用與留言落點,任何新增留言點都可能忘記處理 queue/flush 或 PR 排除清單,長期會讓流程編排變得很脆弱。",
"reason": "Paladin:可排除(命中已知排除事項且重複)。已知與歷史 findings 已涵蓋 main() 內留言目的地、issue 狀態、緩衝佇列與可變閉包耦合,及應抽出發布器/sink 的方向。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Leo",
"severity": "警告",
"file": "src/index.js",
"startLine": 302,
"endLine": 353,
"problem": "建問題模式的收束邏輯把「挑標籤、建立 issue、發 finding、回貼 PR、設定 issue dependency、決定是否阻擋合併」全部展開在 `main()`。這段和前面的 `createIssueAndFlushBufferedComments` 共同組成一條隱性的 issue workflow,但邊界沒有被封裝;未來要新增 issue 模板、改排序、改阻擋條件或重試策略時,會在主流程裡到處補條件,維護成本會快速上升。",
"reason": "Paladin:可排除(重複)。此條與 F006 及歷史 findings 同樣指向建問題模式在 main() 中承擔 issue workflow、發布分流與狀態管理,屬同一維護性問題的延伸描述。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Leo",
"severity": "建議",
"file": "readme.md",
"startLine": 56,
"endLine": 132,
"problem": "README 內大量維護到原始碼行號的連結,這次 diff 只是分支與行號位移就需要同步改一整片表格。這種文件和程式碼行號的硬耦合很容易在後續修改時過期,讀者點到錯誤位置,維護者也要花時間做機械式同步。",
"reason": "Paladin:可排除(重複)。歷史 findings 已多次指出 README 功能表硬編分支與行號連結,導致文件與原始碼高度耦合且容易失準。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Mage",
"severity": "警告",
"file": "src/index.js",
"startLine": 251,
"endLine": 253,
"problem": "無可審查變更的分支在一般模式下會先把舊留言標為過時,之後才保存 findings 與 commit/push 結果。最小重現:`files.length === 0`、`nothingToReviewComment` 成功、`resolveOldComments` 成功,但接著 `saveFindings` 因檔案系統錯誤或 `commitFindings` 因 push 失敗拋例外。此時舊審查結果已被標過時,但沒有成功落地本回合的 success 結果 commit,狀態會停在半更新。",
"reason": "Paladin:可排除(命中已知排除事項)。維護者已裁示舊留言應刻意在本回合結果發布前標為過時;本條要求延後清理,落在同一已排除範圍。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Maya",
"severity": "警告",
"file": "src/index.js",
"startLine": 160,
"endLine": 353,
"problem": "建問題模式的主流程被大幅改寫,但目前測試只驗證了少數 helper,沒有驗證「留言先暫存、確定有 kept finding 才建 issue、無保留問題時靜默通過、嚴重問題才加 issue dependency、最後回貼 PR 連結」這些新增流程。這些行為一旦順序或條件寫錯,測試不會擋下來。",
"reason": "Paladin:可排除(重複)。歷史 findings 已涵蓋建問題模式 main() 層級流程缺測試,包括暫存留言、有 finding 才建 issue、無 finding 靜默、PR 回貼、嚴重與非嚴重分流及 dependency 失敗降級。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Maya",
"severity": "警告",
"file": "src/lib/gitrepo.js",
"startLine": 307,
"endLine": 361,
"problem": "`pushWithCredential` 新增了關鍵認證行為,但沒有測試驗證它真的用 `GIT_CONFIG_*` 清掉 checkout 的 extraheader、再注入 PAT Authorization,也沒有測試遠端 URL 不符與 push 失敗時不洩漏 URL/token 的失敗路徑。這段是結果 commit 能否觸發下一輪 CI 的核心,沒有測試等於這個新契約還沒通過試煉。",
"reason": "Paladin:可排除(重複)。歷史 findings 已記錄 pushWithCredentialpushToken 認證推送路徑缺少測試,包含 token 不進 argv、認證遮蔽、推送目標與失敗訊息不洩漏敏感資訊。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Maya",
"severity": "警告",
"file": "src/lib/gitrepo.js",
"startLine": 130,
"endLine": 192,
"problem": "`resolveMergeBase` 新增多段 fetch fallback:先抓 base、再 deepen base、deepen PR HEAD、最後 shallow repo 才 unshallow,但現有測試只驗證不安全 baseRef 會被拒絕,沒有驗證淺層歷史補抓順序、每次 fetch 後會重試 merge-base、成功後會短路,也沒有覆蓋所有策略失敗時的診斷錯誤。",
"reason": "Paladin:可排除(重複)。歷史 findings 已針對 resolveMergeBase 多階段 fetch、淺層與非淺層分支、成功短路、降級順序與最終失敗診斷缺測試提出相同問題。"
},
{
"addedAt": "2026/07/21 14:42:39",
"prNumber": 6,
"reviewer": "Maya",
"severity": "建議",
"file": "src/lib/diagnostics.js",
"startLine": 18,
"endLine": 69,
"problem": "`redactSecrets` 與 `agentFailureDetail` 是新加入的安全診斷防線,但測試只覆蓋 Authorization/token 的基本遮罩。控制字元單行化、URL 內嵌帳密、長 token/hex、輸入與輸出長度截斷、以及沒有 error code 時的 fallback 訊息都還沒被驗證。",
"reason": "Paladin:可排除(重複)。歷史 findings 已涵蓋 redactSecretsagentFailureDetail 對控制字元、URL 帳密、token 格式、截斷與 fallback 診斷等邊界缺少測試。"
} }
] ]
@@ -0,0 +1,204 @@
{
"generatedAt": "2026/07/21 14:42:39",
"commitSha": "d424447d1502f49538fcb3093d23b5ac7bfa16c5",
"prNumber": 6,
"tool": {
"name": "codex",
"version": "codex-cli 0.144.6",
"model": "gpt-5.5"
},
"findings": [
{
"reviewer": "Bard",
"focus": "style",
"badge": "🎼",
"severity": "建議",
"file": "src/lib/gitrepo.js",
"startLine": 139,
"endLine": 160,
"problem": "`runFetch`、`tryMergeBase`、`firstError`、`strategies` 夾在 `resolveMergeBase` 主旋律中,使這個函式同時負責驗證、fetch 策略編排、診斷文字組裝與錯誤包裝。即使邏輯可行,閱讀節奏已偏密,維護者很難一眼分辨「主要流程」與「補救策略」。",
"suggestion": "建議將 fetch 策略與診斷收集抽成小函式,例如 `fetchAndRecord`、`resolveMergeBaseWithStrategies`,讓 `resolveMergeBase` 保留高階流程:驗證 baseRef → fetch base → 嘗試 merge-base → 補抓歷史。",
"suggestedCode": "",
"id": "F003",
"verdicts": {
"Paladin": {
"exclude": false,
"reason": "保留。歷史 findings 主要涵蓋 resolveMergeBase 缺測試與診斷不足,本條指向函式內策略編排、診斷組裝與錯誤包裝混雜的可維護性問題,未被既有排除事項完整涵蓋。"
}
}
}
],
"excluded": [
{
"reviewer": "Bard",
"focus": "style",
"badge": "🎼",
"severity": "建議",
"file": "src/index.js",
"startLine": 179,
"endLine": 212,
"problem": "`queueOrPostComment` 與 `createIssueAndFlushBufferedComments` 這兩個閉包名稱像兩段不同旋律:一個強調「排隊或發布」,另一個卻把「建立 issue、flush 暫存留言、設定狀態」全塞進名稱與實作。讀者要來回追 `trackingIssue`、`pendingIssueCommentBodies`,節奏偏長且語意負擔重。",
"suggestion": "建議統一命名語彙,例如把「暫存/發布」集中成 `postReviewComment`、`flushPendingIssueComments`,讓函式名稱只描述一件事;建立 issue 與 flush 留言也可拆成兩段,讀起來會更像樂句而不是長句。",
"suggestedCode": "",
"id": "F002",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(重複)。歷史 finding 已指出 main() 內新增閉包與 issue/comment 路由細節讓主流程閱讀負擔加重;本條只是改以目前函式名稱描述同一維護性問題。"
}
}
},
{
"reviewer": "Leo",
"focus": "maintainability",
"badge": "🧰",
"severity": "警告",
"file": "src/index.js",
"startLine": 165,
"endLine": 213,
"problem": "`main()` 這次把「留言目的地切換、issue 建立、暫存留言 flush、標籤挑選前置狀態」都塞進閉包與可變狀態(`pendingIssueCommentBodies`、`trackingIssue`、`currentRunCommentIds`)。半年後要改建問題模式時,維護者得同時追蹤主流程步驟、閉包副作用與留言落點,任何新增留言點都可能忘記處理 queue/flush 或 PR 排除清單,長期會讓流程編排變得很脆弱。",
"suggestion": "把留言目的地抽成明確的小型物件或模組,例如 `ReviewCommentSink` / `IssueCommentSink`,由它負責 `post()`、`flush()`、`currentRunCommentIds`。`main()` 只保留流程順序,不直接管理留言暫存與 issue 狀態。",
"suggestedCode": "",
"id": "F006",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(命中已知排除事項且重複)。已知與歷史 findings 已涵蓋 main() 內留言目的地、issue 狀態、緩衝佇列與可變閉包耦合,及應抽出發布器/sink 的方向。"
}
}
},
{
"reviewer": "Leo",
"focus": "maintainability",
"badge": "🧰",
"severity": "警告",
"file": "src/index.js",
"startLine": 302,
"endLine": 353,
"problem": "建問題模式的收束邏輯把「挑標籤、建立 issue、發 finding、回貼 PR、設定 issue dependency、決定是否阻擋合併」全部展開在 `main()`。這段和前面的 `createIssueAndFlushBufferedComments` 共同組成一條隱性的 issue workflow,但邊界沒有被封裝;未來要新增 issue 模板、改排序、改阻擋條件或重試策略時,會在主流程裡到處補條件,維護成本會快速上升。",
"suggestion": "把建問題模式整理成單一高階函式,例如 `review.publishIssueModeResults({ ctx, gitea, tool, model, cwd, kept, severe, others, bufferedComments })`,主流程只接收 `trackingIssue` / `filesToCommit` 等結果。這樣 PR 模式與 issue 模式的責任邊界會清楚很多。",
"suggestedCode": "",
"id": "F007",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(重複)。此條與 F006 及歷史 findings 同樣指向建問題模式在 main() 中承擔 issue workflow、發布分流與狀態管理,屬同一維護性問題的延伸描述。"
}
}
},
{
"reviewer": "Leo",
"focus": "maintainability",
"badge": "🧰",
"severity": "建議",
"file": "readme.md",
"startLine": 56,
"endLine": 132,
"problem": "README 內大量維護到原始碼行號的連結,這次 diff 只是分支與行號位移就需要同步改一整片表格。這種文件和程式碼行號的硬耦合很容易在後續修改時過期,讀者點到錯誤位置,維護者也要花時間做機械式同步。",
"suggestion": "若這份表格是 API 索引,建議改成自動產生,或至少移除 `#Lxx` 行號錨點,改連到檔案或穩定章節錨點。讓文件描述 API,而不是跟著每次程式碼行號漂移。",
"suggestedCode": "",
"id": "F008",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(重複)。歷史 findings 已多次指出 README 功能表硬編分支與行號連結,導致文件與原始碼高度耦合且容易失準。"
}
}
},
{
"reviewer": "Mage",
"focus": "logic",
"badge": "🔮",
"severity": "警告",
"file": "src/index.js",
"startLine": 251,
"endLine": 253,
"problem": "無可審查變更的分支在一般模式下會先把舊留言標為過時,之後才保存 findings 與 commit/push 結果。最小重現:`files.length === 0`、`nothingToReviewComment` 成功、`resolveOldComments` 成功,但接著 `saveFindings` 因檔案系統錯誤或 `commitFindings` 因 push 失敗拋例外。此時舊審查結果已被標過時,但沒有成功落地本回合的 success 結果 commit,狀態會停在半更新。",
"suggestion": "把 `review.resolveOldComments` 延後到 `saveFindings` 與必要的 `commitFindings` 成功之後;或至少在空變更路徑也遵守同一個交易順序:先產生並提交本回合結果,再清理舊留言。",
"suggestedCode": "",
"id": "F010",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(命中已知排除事項)。維護者已裁示舊留言應刻意在本回合結果發布前標為過時;本條要求延後清理,落在同一已排除範圍。"
}
}
},
{
"reviewer": "Maya",
"focus": "testing",
"badge": "🧪",
"severity": "警告",
"file": "src/index.js",
"startLine": 160,
"endLine": 353,
"problem": "建問題模式的主流程被大幅改寫,但目前測試只驗證了少數 helper,沒有驗證「留言先暫存、確定有 kept finding 才建 issue、無保留問題時靜默通過、嚴重問題才加 issue dependency、最後回貼 PR 連結」這些新增流程。這些行為一旦順序或條件寫錯,測試不會擋下來。",
"suggestion": "請補 `main()` 層級的流程測試,stub `agents`、`review`、`gitea`、`gitrepo`,至少覆蓋:`createIssue=true && kept=[]` 不建立 issue/不貼 PR 留言;`kept` 只有警告/建議時建立 issue、flush 暫存留言、回貼 PR 連結但不呼叫 `addIssueDependency``kept` 含嚴重時呼叫 `addIssueDependency`,且 dependency 失敗時流程仍完成並 commit failure findings。",
"suggestedCode": "",
"id": "F011",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(重複)。歷史 findings 已涵蓋建問題模式 main() 層級流程缺測試,包括暫存留言、有 finding 才建 issue、無 finding 靜默、PR 回貼、嚴重與非嚴重分流及 dependency 失敗降級。"
}
}
},
{
"reviewer": "Maya",
"focus": "testing",
"badge": "🧪",
"severity": "警告",
"file": "src/lib/gitrepo.js",
"startLine": 307,
"endLine": 361,
"problem": "`pushWithCredential` 新增了關鍵認證行為,但沒有測試驗證它真的用 `GIT_CONFIG_*` 清掉 checkout 的 extraheader、再注入 PAT Authorization,也沒有測試遠端 URL 不符與 push 失敗時不洩漏 URL/token 的失敗路徑。這段是結果 commit 能否觸發下一輪 CI 的核心,沒有測試等於這個新契約還沒通過試煉。",
"suggestion": "請補推送行為測試。可用可注入的 exec wrapper 或子程序測試方式驗證:`git push` argv 不含 token、env 包含兩筆同 scope extraheader、`GIT_CONFIG_VALUE_0` 為空值、`GIT_CONFIG_VALUE_1` 為 Basic header;再補 `remote.origin !== server.origin` 與 exec 失敗時錯誤訊息不含 token/remoteUrl 的案例。",
"suggestedCode": "",
"id": "F012",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(重複)。歷史 findings 已記錄 pushWithCredentialpushToken 認證推送路徑缺少測試,包含 token 不進 argv、認證遮蔽、推送目標與失敗訊息不洩漏敏感資訊。"
}
}
},
{
"reviewer": "Maya",
"focus": "testing",
"badge": "🧪",
"severity": "警告",
"file": "src/lib/gitrepo.js",
"startLine": 130,
"endLine": 192,
"problem": "`resolveMergeBase` 新增多段 fetch fallback:先抓 base、再 deepen base、deepen PR HEAD、最後 shallow repo 才 unshallow,但現有測試只驗證不安全 baseRef 會被拒絕,沒有驗證淺層歷史補抓順序、每次 fetch 後會重試 merge-base、成功後會短路,也沒有覆蓋所有策略失敗時的診斷錯誤。",
"suggestion": "請補針對 `resolveMergeBase` 的失敗與邊界測試。建議把 git 執行函式抽成可替換依賴,或用臨時 git repo 模擬 shallow 狀態,斷言:首次 merge-base 成功不走 fallbackdeepen base 成功後立即回傳;deepen PR HEAD 使用目前 HEAD SHA 而不是遠端 `HEAD`;非 shallow repo 不呼叫 `--unshallow`;全部失敗時錯誤包含策略診斷。",
"suggestedCode": "",
"id": "F013",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(重複)。歷史 findings 已針對 resolveMergeBase 多階段 fetch、淺層與非淺層分支、成功短路、降級順序與最終失敗診斷缺測試提出相同問題。"
}
}
},
{
"reviewer": "Maya",
"focus": "testing",
"badge": "🧪",
"severity": "建議",
"file": "src/lib/diagnostics.js",
"startLine": 18,
"endLine": 69,
"problem": "`redactSecrets` 與 `agentFailureDetail` 是新加入的安全診斷防線,但測試只覆蓋 Authorization/token 的基本遮罩。控制字元單行化、URL 內嵌帳密、長 token/hex、輸入與輸出長度截斷、以及沒有 error code 時的 fallback 訊息都還沒被驗證。",
"suggestion": "請補診斷邊界測試,至少包含:含換行與 tab 的輸出不會產生多行 log`https://user:pass@host` 會被遮罩;40 字元以上 token 會被遮罩;debug 模式下 stderr/stdout 只輸出到限制長度;沒有 `error.code`、`signal`、`stderr/stdout` 時仍回傳安全的 fallback 訊息。",
"suggestedCode": "",
"id": "F014",
"verdicts": {
"Paladin": {
"exclude": true,
"reason": "可排除(重複)。歷史 findings 已涵蓋 redactSecretsagentFailureDetail 對控制字元、URL 帳密、token 格式、截斷與 fallback 診斷等邊界缺少測試。"
}
}
}
]
}
+43 -14
View File
@@ -72,7 +72,7 @@ function saveFindings({ cwd, ctx, tool, kept, excluded }) {
* 供下一回合 `main()` 步驟 1 比對辨識、直接回報結果而不重複審查。 * 供下一回合 `main()` 步驟 1 比對辨識、直接回報結果而不重複審查。
* 依 `commitAndPushFindings` 的回傳值記錄不同日誌:true=已 commit/push * 依 `commitAndPushFindings` 的回傳值記錄不同日誌:true=已 commit/push
* false=檔案無實際變更(空 commit 防護),記「略過 commit/push」。 * false=檔案無實際變更(空 commit 防護),記「略過 commit/push」。
* commit/push 失敗(例如與開發者新 commit 競態)時僅記 WRN log,不拋出例外、不改變審查結果 * commit/push 失敗(例如與開發者新 commit 競態)時僅記 WRN log,不拋出例外;呼叫端可依回傳值決定是否阻擋
* *
* @param {Object} params - 解構參數。 * @param {Object} params - 解構參數。
* @param {string} params.cwd - repo 根目錄(workspace)絕對路徑,git 操作在此目錄執行。 * @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.ctx.repository - `owner/repo` 形式的 repo 名稱。
* @param {string[]} params.files - 要 commit 的檔案 repo 相對路徑陣列(如 findings 檔、`.gitea/ai-review/exclusions.json`);全數無變更時只記 INF 略過。 * @param {string[]} params.files - 要 commit 的檔案 repo 相對路徑陣列(如 findings 檔、`.gitea/ai-review/exclusions.json`);全數無變更時只記 INF 略過。
* @param {'success'|'failure'} params.result - 本回合審查結果:success=無嚴重問題、failure=有嚴重問題;會拼進 commit 訊息尾端。 * @param {'success'|'failure'} params.result - 本回合審查結果:success=無嚴重問題、failure=有嚴重問題;會拼進 commit 訊息尾端。
* @returns {void} 無回傳值;成敗僅反映在 log 上 * @returns {boolean} true=已 commit/pushfalse=無變更或 commit/push 失敗
* @remarks * @remarks
* 使用情境:`main()` 於流程尾端依 `severe.length === 0 ? 'success' : 'failure'` 決定 result、 * 使用情境:`main()` 於流程尾端依 `severe.length === 0 ? 'success' : 'failure'` 決定 result、
* 依模式組出 filesToCommit(一般模式:findings 檔+有變更時的 exclusions.json * 依模式組出 filesToCommit(一般模式:findings 檔+有變更時的 exclusions.json
@@ -107,12 +107,15 @@ function commitFindings({ cwd, ctx, files, result }) {
}); });
if (committed) { if (committed) {
log('收尾', 'INF', `審查結果檔已 commit 並 push 回 ${ctx.headRef}(結果:${result})。`); log('收尾', 'INF', `審查結果檔已 commit 並 push 回 ${ctx.headRef}(結果:${result})。`);
return true;
} else { } else {
log('收尾', 'INF', '審查結果檔無實際變更,略過 commit/push。'); log('收尾', 'INF', '審查結果檔無實際變更,略過 commit/push。');
return false;
} }
} catch (err) { } catch (err) {
// push 失敗(例如與開發者新 commit 競態)時只記錄,不改變審查結果 // push 失敗(例如與開發者新 commit 競態)時只記錄,交由呼叫端依嚴重度決定是否阻擋
log('收尾', 'WRN', `commit/push 審查結果檔失敗:${err.message}`); log('收尾', 'WRN', `commit/push 審查結果檔失敗:${err.message}`);
return false;
} }
} }
@@ -165,6 +168,7 @@ async function main() {
// 建問題模式:追蹤 issue 於「確定有保留問題」後才建立;在那之前的情境留言(工具/diff/角色) // 建問題模式:追蹤 issue 於「確定有保留問題」後才建立;在那之前的情境留言(工具/diff/角色)
// 先暫存於 pendingIssueCommentBodies,建立 issue 後一次寫入。 // 先暫存於 pendingIssueCommentBodies,建立 issue 後一次寫入。
const pendingIssueCommentBodies = []; const pendingIssueCommentBodies = [];
let issueModeActive = ctx.createIssue;
let trackingIssue = null; let trackingIssue = null;
/** /**
* 發布一則審查留言。依模式決定去向: * 發布一則審查留言。依模式決定去向:
@@ -179,7 +183,7 @@ async function main() {
* 警告/建議彙整等留言。若 Gitea API 失敗,例外會往上拋出並由主流程頂層 catch 收斂。 * 警告/建議彙整等留言。若 Gitea API 失敗,例外會往上拋出並由主流程頂層 catch 收斂。
*/ */
const queueOrPostComment = async (body) => { const queueOrPostComment = async (body) => {
if (ctx.createIssue) { if (issueModeActive) {
if (trackingIssue) return gitea.createCommentOnIssue(ctx, trackingIssue.number, body); if (trackingIssue) return gitea.createCommentOnIssue(ctx, trackingIssue.number, body);
pendingIssueCommentBodies.push(body); pendingIssueCommentBodies.push(body);
return null; return null;
@@ -210,13 +214,27 @@ async function main() {
} }
pendingIssueCommentBodies.length = 0; pendingIssueCommentBodies.length = 0;
}; };
/**
* 建問題模式降級:追蹤 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:延後執行 ─────────────────────────────────────────────────── // ── 步驟 2:延後執行 ───────────────────────────────────────────────────
// 「將 PR 既有留言標記為解決」原本在此執行,但若工具偵測/diff/攻防裁決任一失敗, // 「將 PR 既有留言標記為解決」原本在此執行,但若工具偵測/diff/攻防裁決任一失敗,
// 舊結果會先被清掉卻沒有新結果。故延後到「本回合審查已成功產生結果、發布問題留言前」 // 舊結果會先被清掉卻沒有新結果。故延後到「本回合審查已成功產生結果、發布問題留言前」
// 才呼叫 review.resolveOldComments(見下方步驟 4 空變更路徑與步驟 9 前); // 才呼叫 review.resolveOldComments(見下方步驟 4 空變更路徑與步驟 9 前);
// 屆時本回合的工具/diff/角色留言已登錄於 currentRunCommentIds,不會被誤標為過時。 // 屆時本回合的工具/diff/角色留言已登錄於 currentRunCommentIds,不會被誤標為過時。
// 建問題模式全程不觸碰 PR 既有留言(審查內容改發到 issue // 建問題模式不清理 PR 既有審查內容,只在收束時標記舊追蹤 issue 連結
// ── 步驟 3:偵測 AI agent 工具並留言 ────────────────────────────────── // ── 步驟 3:偵測 AI agent 工具並留言 ──────────────────────────────────
const tool = agents.detectTool(); const tool = agents.detectTool();
@@ -248,7 +266,7 @@ async function main() {
if (files.length === 0) { if (files.length === 0) {
// 沒有可審查的變更:保存空 findings、以 success 收場。 // 沒有可審查的變更:保存空 findings、以 success 收場。
// 一般模式在 PR 留言告知;建問題模式靜默通過(不建 issue、PR 也不留言,暫存的情境留言捨棄)。 // 一般模式在 PR 留言告知;建問題模式靜默通過(不建 issue、PR 也不留言,暫存的情境留言捨棄)。
if (ctx.createIssue) { if (issueModeActive) {
log('步驟4', 'INF', '建問題模式且無可審查變更:靜默通過(不建 issue、PR 不留言)。'); log('步驟4', 'INF', '建問題模式且無可審查變更:靜默通過(不建 issue、PR 不留言)。');
} else { } else {
await queueOrPostComment(templates.nothingToReviewComment(ignoredCount)); await queueOrPostComment(templates.nothingToReviewComment(ignoredCount));
@@ -256,7 +274,7 @@ async function main() {
await review.resolveOldComments({ ctx, gitea, currentRunCommentIds }); await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });
} }
const relativePath = saveFindings({ cwd, ctx, tool, kept: [], excluded: [] }); const relativePath = saveFindings({ cwd, ctx, tool, kept: [], excluded: [] });
if (ctx.createIssue) { if (issueModeActive) {
// 建問題模式下 findings 不進版控,且 exclusions.json 無變更 → 沒東西可提交。 // 建問題模式下 findings 不進版控,且 exclusions.json 無變更 → 沒東西可提交。
log('收尾', 'INF', '建問題模式且無可審查變更,略過 commit/push。'); log('收尾', 'INF', '建問題模式且無可審查變更,略過 commit/push。');
} else { } else {
@@ -299,7 +317,7 @@ async function main() {
// ── 建問題模式:確定有保留問題才建立 issue,並把暫存的情境留言一次寫入; // ── 建問題模式:確定有保留問題才建立 issue,並把暫存的情境留言一次寫入;
// 無保留問題則不建 issue、PR 也完全不留言(靜默通過,暫存的情境留言捨棄)。 ────── // 無保留問題則不建 issue、PR 也完全不留言(靜默通過,暫存的情境留言捨棄)。 ──────
if (ctx.createIssue) { if (issueModeActive) {
if (kept.length > 0) { if (kept.length > 0) {
// 先依保留問題挑好標籤,於建立 issue 時一次帶入(省去「先建空標籤 issue 再補掛」的多餘 API 往返); // 先依保留問題挑好標籤,於建立 issue 時一次帶入(省去「先建空標籤 issue 再補掛」的多餘 API 往返);
// 標籤挑選失敗一律降級為不掛標籤,不阻斷建 issue 流程。 // 標籤挑選失敗一律降級為不掛標籤,不阻斷建 issue 流程。
@@ -318,7 +336,12 @@ async function main() {
} catch (err) { } catch (err) {
log('建問題', 'WRN', `標籤挑選失敗(${err.message}),issue 不掛標籤。`); log('建問題', 'WRN', `標籤挑選失敗(${err.message}),issue 不掛標籤。`);
} }
try {
await createIssueAndFlushBufferedComments(labelIds); await createIssueAndFlushBufferedComments(labelIds);
} catch (err) {
log('建問題', 'WRN', `建立或寫入追蹤 issue 失敗(${err.message}),改用 PR 留言與 findings 檔流程。`);
await fallbackToPrComments();
}
} else { } else {
// 無保留問題 → 不建 issue、PR 也不留言(靜默通過,暫存的情境留言捨棄)。 // 無保留問題 → 不建 issue、PR 也不留言(靜默通過,暫存的情境留言捨棄)。
log('建問題', 'INF', '沒有保留的問題:靜默通過(不建 issue、PR 不留言)。'); log('建問題', 'INF', '沒有保留的問題:靜默通過(不建 issue、PR 不留言)。');
@@ -329,13 +352,13 @@ async function main() {
// 延後到此可避免工具偵測/diff/攻防裁決任一失敗時舊結果先被清掉卻無新結果; // 延後到此可避免工具偵測/diff/攻防裁決任一失敗時舊結果先被清掉卻無新結果;
// 本回合的工具/diff/角色留言已登錄於 currentRunCommentIds,不會被誤標為過時; // 本回合的工具/diff/角色留言已登錄於 currentRunCommentIds,不會被誤標為過時;
// 嚴重/其他問題留言於本步驟之後才發布,同樣不受影響。 // 嚴重/其他問題留言於本步驟之後才發布,同樣不受影響。
if (!ctx.createIssue) { if (!issueModeActive) {
await review.resolveOldComments({ ctx, gitea, currentRunCommentIds }); await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });
} }
// ── 步驟 9:嚴重問題留言(一般模式掛在 PR 程式碼行上;建問題模式逐條發到 issue)─ // ── 步驟 9:嚴重問題留言(一般模式掛在 PR 程式碼行上;建問題模式逐條發到 issue)─
if (severe.length > 0) { if (severe.length > 0) {
if (ctx.createIssue) { if (issueModeActive && trackingIssue) {
await review.postSevereToIssue({ ctx, gitea, issueNumber: trackingIssue.number, severe }); await review.postSevereToIssue({ ctx, gitea, issueNumber: trackingIssue.number, severe });
} else { } else {
await review.postSevereComments({ ctx, gitea, severe, cwd }); await review.postSevereComments({ ctx, gitea, severe, cwd });
@@ -345,7 +368,7 @@ async function main() {
// ── 步驟 10:警告+建議——一般模式彙整為單一表格留言到 PR; // ── 步驟 10:警告+建議——一般模式彙整為單一表格留言到 PR;
// 建問題模式逐條發到 issue,讓每條問題都能被個別回覆。 ── // 建問題模式逐條發到 issue,讓每條問題都能被個別回覆。 ──
if (others.length > 0) { if (others.length > 0) {
if (ctx.createIssue) { if (issueModeActive && trackingIssue) {
await review.postOthersToIssue({ ctx, gitea, issueNumber: trackingIssue.number, others }); await review.postOthersToIssue({ ctx, gitea, issueNumber: trackingIssue.number, others });
} else { } else {
await queueOrPostComment(templates.othersComment(others)); await queueOrPostComment(templates.othersComment(others));
@@ -355,7 +378,8 @@ async function main() {
// ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─ // ── 建問題模式收束:在 PR 回貼 issue 連結(雙向關聯);僅在有嚴重問題時才讓 PR 相依於該 issue ─
// 標籤已於建立 issue 時一次帶入(見上方 selectLabels → createIssueAndFlushBufferedComments),此處不再補掛。 // 標籤已於建立 issue 時一次帶入(見上方 selectLabels → createIssueAndFlushBufferedComments),此處不再補掛。
if (ctx.createIssue && trackingIssue) { if (issueModeActive && trackingIssue) {
await review.resolveOldIssueLinkComments({ ctx, gitea });
await gitea.createIssueComment( await gitea.createIssueComment(
ctx, ctx,
templates.prIssueLinkComment({ templates.prIssueLinkComment({
@@ -385,13 +409,14 @@ async function main() {
// 若有嚴重問題,仍 commit findings 檔產生 [failure] 結果 commit,避免相依 API 不支援時 fail-open。 // 若有嚴重問題,仍 commit findings 檔產生 [failure] 結果 commit,避免相依 API 不支援時 fail-open。
const result = severe.length === 0 ? 'success' : 'failure'; const result = severe.length === 0 ? 'success' : 'failure';
const filesToCommit = review.resultFilesToCommit({ const filesToCommit = review.resultFilesToCommit({
createIssue: ctx.createIssue, createIssue: issueModeActive,
severeCount: severe.length, severeCount: severe.length,
relativePath, relativePath,
exclusionsChanged, exclusionsChanged,
}); });
let resultCommitted = false;
if (filesToCommit.length > 0) { if (filesToCommit.length > 0) {
commitFindings({ cwd, ctx, files: filesToCommit, result }); resultCommitted = commitFindings({ cwd, ctx, files: filesToCommit, result });
} else { } else {
log('收尾', 'INF', '建問題模式且 exclusions.json 無變更,略過 commit/push。'); log('收尾', 'INF', '建問題模式且 exclusions.json 無變更,略過 commit/push。');
} }
@@ -399,6 +424,10 @@ async function main() {
// 由它再觸發的下一輪在步驟 1 讀 commit 訊息時才回報失敗(exit 1)。如此失敗檢查落在帶有結果 // 由它再觸發的下一輪在步驟 1 讀 commit 訊息時才回報失敗(exit 1)。如此失敗檢查落在帶有結果
// 標記的最新 head 上,與合併判定一致。(result 僅用於上方 commit 訊息的結果標記。) // 標記的最新 head 上,與合併判定一致。(result 僅用於上方 commit 訊息的結果標記。)
if (result === 'failure') { if (result === 'failure') {
if (!resultCommitted) {
log('收尾', 'ERR', '本輪有嚴重問題,但未成功產生 [failure] 結果 commit;直接回報失敗避免 fail-open。');
return 1;
}
log('收尾', 'INF', '本輪有嚴重問題:已標記結果 commit 為 [failure],失敗檢查由下一輪步驟 1 讀 commit 訊息回報。'); log('收尾', 'INF', '本輪有嚴重問題:已標記結果 commit 為 [failure],失敗檢查由下一輪步驟 1 讀 commit 訊息回報。');
} }
return 0; return 0;
+15 -4
View File
@@ -29,6 +29,19 @@ function redactSecrets(text) {
.trim(); .trim();
} }
/**
* 將 AI CLI 失敗輸出整理成單行、遮罩且限長的診斷片段。
*
* @param {string[]} parts - 要附加診斷片段的陣列。
* @param {string} label - 診斷欄位名稱(如 stderrstdout)。
* @param {*} value - 原始診斷輸出。
* @returns {void}
*/
function appendRedactedOutput(parts, label, value) {
const redacted = redactSecrets(String(value || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT));
if (redacted) parts.push(`${label}${redacted.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`);
}
/** /**
* 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號為主,原始輸出預設隱藏。 * 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號為主,原始輸出預設隱藏。
* *
@@ -52,10 +65,8 @@ function agentFailureDetail(agentResult) {
} }
// 失敗輸出可能含 token 或 PII,預設不寫入長期 CI log;debug 模式才輸出遮罩後片段。 // 失敗輸出可能含 token 或 PII,預設不寫入長期 CI log;debug 模式才輸出遮罩後片段。
if (process.env.ACTIONS_STEP_DEBUG === 'true') { if (process.env.ACTIONS_STEP_DEBUG === 'true') {
const stderr = redactSecrets(String((agentResult && agentResult.stderr) || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT)); appendRedactedOutput(parts, 'stderr', agentResult && agentResult.stderr);
if (stderr) parts.push(`stderr${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); appendRedactedOutput(parts, 'stdout', agentResult && agentResult.output);
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) { if (parts.length === 0) {
parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)'); parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)');
+2 -3
View File
@@ -300,8 +300,7 @@ function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, s
); );
const refspec = `HEAD:refs/heads/${headRef}`; const refspec = `HEAD:refs/heads/${headRef}`;
const remoteUrl = `${serverUrl}/${repository}.git`; const remoteUrl = `${serverUrl}/${repository}.git`;
// 一律以 token 的身分明確認證推送(不走 origin 的自動 token)——只要 token 是能觸發 CI 的 PAT // 一律以 token 的身分明確認證推送,不沿用 origin 的自動 token
// 結果 commit 就會讓 PR 的 synchronize 事件再觸發 CI,由步驟 1 快速回報把結果蓋到新 head。
pushWithCredential(cwd, remoteUrl, token, refspec, serverUrl); pushWithCredential(cwd, remoteUrl, token, refspec, serverUrl);
return true; return true;
} }
@@ -348,7 +347,7 @@ function pushWithCredential(cwd, remoteUrl, token, refspec, serverUrl) {
env: { env: {
...process.env, ...process.env,
GIT_TERMINAL_PROMPT: '0', GIT_TERMINAL_PROMPT: '0',
// 兩筆同 scope 設定:先空值清掉 checkout 的自動 token,再注入 PAT 的 Authorization // 先清空 checkout extraheader,再注入本次 PAT header
GIT_CONFIG_COUNT: '2', GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: headerScope, GIT_CONFIG_KEY_0: headerScope,
GIT_CONFIG_VALUE_0: '', GIT_CONFIG_VALUE_0: '',
+35
View File
@@ -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<void>} 無回傳值;失敗時只記 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); * 步驟 9:嚴重問題逐條掛在 PR 程式碼行上留言(建立 code review);
* 建立 review 失敗時降級為一般留言逐條發布(留言內補上檔案與行號位置)。 * 建立 review 失敗時降級為一般留言逐條發布(留言內補上檔案與行號位置)。
@@ -884,5 +918,6 @@ module.exports = {
postSevereToIssue, postSevereToIssue,
postOthersToIssue, postOthersToIssue,
resolveOldComments, resolveOldComments,
resolveOldIssueLinkComments,
postSevereComments, postSevereComments,
}; };
+37
View File
@@ -97,3 +97,40 @@ test('resultFilesToCommit 在建問題模式無嚴重問題時只提交 exclusio
['.gitea/ai-review/exclusions.json'], ['.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: '<!-- ai-code-review -->\n## 🔍 AI Code Review|已建立追蹤問題\nold',
},
{
id: 2,
user: { login: 'bot' },
body: '<!-- ai-code-review -->\n## 📋 變更摘要(送審 git diff\nkeep',
},
{
id: 3,
user: { login: 'someone' },
body: '<!-- ai-code-review -->\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, /^> 〔已過時〕/);
});