Compare commits

..
Author SHA1 Message Date
AI Review Bot 4b4b934cda chore: update ai-review findings [ai-review-bot][failure]
CI / 2. TEST (pull_request) Failing after 28s
CI / 3. RESULT (pull_request) Has been skipped
CI / 1. BUILD (pull_request) Successful in 1s
2026-07-03 04:08:32 +00:00
Jeffery 1a9a6bce5c chore(ai-review 狀態): 移除已修與誤報 findings、登記 exclusions
CI / 1. BUILD (pull_request) Successful in 2s
CI / 3. RESULT (pull_request) Has been skipped
CI / 2. TEST (pull_request) Failing after 28m13s
2026-07-03 11:40:18 +08:00
Jeffery 2c0ac71c08 fix(審查流程): 修復 AI 去重超量、clone 失敗誤持久化、留言行號漏 new_position 2026-07-03 11:40:18 +08:00
5 changed files with 213 additions and 134 deletions
+44
View File
@@ -0,0 +1,44 @@
[
{
"location": "src/config.js:7",
"role": "Assassin",
"original_finding": "這裡把 `NODE_TLS_REJECT_UNAUTHORIZED` 全域設為 `0`,等於讓整個 Node 程序放棄 TLS 憑證驗證。攻擊者只要能站到 runner 與 GiteaLLM/任何 HTTPS API 之間,就能用偽造憑證攔截或竄改 diff、review 結果、token 驗證流程,甚至偷走 Authorization header。",
"reason": "內部自架 Gitea/自簽憑證環境的刻意設計(見 config.js 註解)。如需強化可改用 NODE_EXTRA_CA_CERTS,屬人工決策而非誤判。"
},
{
"location": "src/config.js:53",
"role": "Assassin",
"original_finding": "這個 helper 直接建立 `rejectUnauthorized: false` 的 HTTPS agent,後續 Gitea API 與 preflight 都會用它。攻擊者若能進行中間人攻擊,就能假冒 Gitea 回傳惡意 diff、偽造 comment/review API 回應,或攔截寫入用 token。",
"reason": "同 config.js:7,為相容內部自簽憑證的刻意設計;預設不驗證憑證僅限受信任內網使用。"
},
{
"location": "src/main.js:2",
"role": "Bard",
"original_finding": "這一行 import 把大量設定常數擠成長長一串,讀起來像沒有換氣的樂句,與後續同檔案多個長 import 一起讓檔案開頭難以掃描。",
"reason": "多行 import 排版為風格偏好,非缺陷;本專案採現行單行分組匯入風格。"
},
{
"location": "src/findings.js:4",
"role": "Bard",
"original_finding": "這行把四個 prompt/role helper 壓在同一行,與檔案中龐大的流程函式相比,開頭的依賴清單先失了拍,降低可讀性。",
"reason": "多行 import 排版為風格偏好,非缺陷。"
},
{
"location": "src/gitea.js:2",
"role": "Bard",
"original_finding": "Gitea 設定匯入一口氣列出八個名稱,行寬過長,讓讀者難以快速分辨這個模組真正依賴哪些環境值。",
"reason": "多行 import 排版為風格偏好,非缺陷。"
},
{
"location": "src/comments.js:11",
"role": "Bard",
"original_finding": "大量私有輔助函式都配上篇幅很長的 JSDoc,許多內容只是重述程式碼表面行為,註解的聲量蓋過了旋律本身。",
"reason": "詳盡 JSDoc 為本專案 doc-funcs 流程的刻意文件化風格,非過度註解。"
},
{
"location": "src/main.js:18",
"role": "Bard",
"original_finding": "main() 前的 JSDoc 幾乎把整條 pipeline 逐步重寫一次,和函式內 Step 註解重複,維護時很容易變成兩份會走調的文件。",
"reason": "main() 的 pipeline 概述 JSDoc 為刻意文件化風格,與 Step 註解並存屬設計選擇。"
}
]
+150 -126
View File
@@ -1,27 +1,11 @@
[ [
{
"level": "critical",
"role": "Assassin",
"location": "src/config.js:7",
"problem": "這裡把 `NODE_TLS_REJECT_UNAUTHORIZED` 全域設為 `0`,等於讓整個 Node 程序放棄 TLS 憑證驗證。攻擊者只要能站到 runner 與 GiteaLLM/任何 HTTPS API 之間,就能用偽造憑證攔截或竄改 diff、review 結果、token 驗證流程,甚至偷走 Authorization header。",
"suggestion": "移除全域停用 TLS 的設定。若內部自簽 CA 是必要情境,請改用可設定的 CA bundle(例如 `NODE_EXTRA_CA_CERTS`)或僅對明確允許的內部 host 使用專用 agent,且預設必須啟用憑證驗證。",
"is_new": true
},
{
"level": "critical",
"role": "Assassin",
"location": "src/config.js:53",
"problem": "這個 helper 直接建立 `rejectUnauthorized: false` 的 HTTPS agent,後續 Gitea API 與 preflight 都會用它。攻擊者若能進行中間人攻擊,就能假冒 Gitea 回傳惡意 diff、偽造 comment/review API 回應,或攔截寫入用 token。",
"suggestion": "不要提供預設不驗證憑證的 agent。改成預設安全驗證;若真的要支援自簽憑證,請要求使用者明確提供信任的 CA 憑證,或以白名單 host 加上明確 opt-in 的設定限制風險。",
"is_new": true
},
{ {
"level": "critical", "level": "critical",
"role": "Assassin", "role": "Assassin",
"location": "src/resolve.js:253", "location": "src/resolve.js:253",
"problem": "這裡會把 PR 上所有未解決的 review comment ID 全部送去 resolve,而不是只處理 AI Review bot 自己建立的 thread。攻擊者只要開 PR 觸發這個 action,就可能讓 bot 關閉人類審查者留下的安全疑慮或阻擋性對話,繞過人工審查流程。", "problem": "這裡會把 PR 上所有未解決的 review comment ID 全部送去 resolve,而不是只處理 AI Review bot 自己建立的 thread。攻擊者只要開 PR 觸發這個 action,就可能讓 bot 關閉人類審查者留下的安全疑慮或阻擋性對話,繞過人工審查流程。",
"suggestion": "只 resolve 可證明由本 bot 建立且格式符合預期的 comment,例如檢查作者、固定 marker、review body 簽章或 botFinding 解析結果;人類留言與未知格式留言不得自動關閉。", "suggestion": "只 resolve 可證明由本 bot 建立且格式符合預期的 comment,例如檢查作者、固定 marker、review body 簽章或 botFinding 解析結果;人類留言與未知格式留言不得自動關閉。",
"is_new": true "is_new": false
}, },
{ {
"level": "critical", "level": "critical",
@@ -29,6 +13,30 @@
"location": "src/findings.js:471", "location": "src/findings.js:471",
"problem": "當排除條目有 location 或 role 時,這裡直接把文字比對結果短路成 true。最小重現:exclusions.json 只有 `{ \"location\": \"app/a.js:10\", \"original_finding\": \"誤報 A\" }`,新的 finding 是 `app/a.js:99` 且 suggestion 完全不同,仍會因同檔案而被排除,導致真問題被靜默丟掉。", "problem": "當排除條目有 location 或 role 時,這裡直接把文字比對結果短路成 true。最小重現:exclusions.json 只有 `{ \"location\": \"app/a.js:10\", \"original_finding\": \"誤報 A\" }`,新的 finding 是 `app/a.js:99` 且 suggestion 完全不同,仍會因同檔案而被排除,導致真問題被靜默丟掉。",
"suggestion": "不要用 `exPath || ex.role ? true : textMatches` 跳過文字比對;應至少要求位置精確匹配到同一行,或在同檔/同角色時仍必須通過 `textMatches`,例如 `return locationMatches && roleMatches && textMatches`,並明確定義 suggestion 空白時才是萬用規則。", "suggestion": "不要用 `exPath || ex.role ? true : textMatches` 跳過文字比對;應至少要求位置精確匹配到同一行,或在同檔/同角色時仍必須通過 `textMatches`,例如 `return locationMatches && roleMatches && textMatches`,並明確定義 suggestion 空白時才是萬用規則。",
"is_new": false
},
{
"level": "critical",
"role": "Mage",
"location": "src/findings.js:382",
"problem": "這個排除條件只要命中 `location` 或 `role`,就直接放行,不再檢查文字內容;最小重現:同一個 `app/a.js` 裡有一筆「誤報」排除後,`app/a.js` 的其他不同 finding 也會一起被濾掉。結果是單一排除項目可以吞掉整個檔案的有效問題。",
"suggestion": "不要在有 `location` 或 `role` 時跳過文字比對;至少要同時驗證檔案、角色與正規化後的問題文字都匹配才排除,或改成以更穩定的 finding 簽章精準比對。",
"is_new": true
},
{
"level": "critical",
"role": "Mage",
"location": "src/gitea.js:86",
"problem": "這裡只要 `commit` 或 `branch` 訊息包含 `[ai-review-bot]` 就回傳 true,但沒有區分 `[success]` 與 `[failure]`;最小重現:上一輪 bot commit 是 `[ai-review-bot][failure]`,而 `getCommitMessageBySha` 讀取失敗時,流程會被當成「可跳過」直接結束,原本應該讓 workflow 失敗的訊號被吃掉。",
"suggestion": "讓這個函式回傳結構化結果,例如 `success / failure / unknown`,或至少在偵測到 `[failure]` 時明確回傳失敗狀態,交由 `main()` 先處理失敗再決定是否跳過。",
"is_new": true
},
{
"level": "critical",
"role": "Maya",
"location": "src/main.js:44",
"problem": "這個 `main()` 是整個 action 的流程總管,但目前沒有任何整合測試或端到端測試去驗證 Step3~Step11 的分支切換與 `process.exit()` 行為。像是前置驗證失敗、偵測到 bot 自動提交、diff 為空、所有角色分析都失敗、JSON 驗證失敗、出現 critical finding、以及 commit/push 降級路徑,現在都只靠人工推演,實際接線後一旦流程順序或退出條件出錯,現有單元測試抓不到。",
"suggestion": "補一組 `main.test.js`,把各模組依賴都 mock 掉,分別覆蓋 `runPreflight=false`、`shouldSkipBotCommit=true`、`getPRDiff=''`、分析全失敗、JSON 驗證拋錯、filtered 含 critical、push 失敗但流程不中斷等分支,並斷言對應的 exit code、呼叫順序與關鍵 log。",
"is_new": true "is_new": true
}, },
{ {
@@ -37,47 +45,7 @@
"location": "src/findings.js:14", "location": "src/findings.js:14",
"problem": "這裡把未信任的 Git diff 直接送進 LLM。攻擊者可以在新增程式碼或註解中塞入提示詞注入內容,例如要求模型忽略安全問題、回傳空陣列或偽造低風險 findings,藉此讓自動安全審查失明。", "problem": "這裡把未信任的 Git diff 直接送進 LLM。攻擊者可以在新增程式碼或註解中塞入提示詞注入內容,例如要求模型忽略安全問題、回傳空陣列或偽造低風險 findings,藉此讓自動安全審查失明。",
"suggestion": "在分析 prompt 中明確標示 diff 是不可信資料,要求模型忽略 diff 內任何指令;同時加入結構化封裝、輸出 schema 驗證與必要的規則式安全檢查,避免完全依賴可被 prompt injection 操控的 LLM 判斷。", "suggestion": "在分析 prompt 中明確標示 diff 是不可信資料,要求模型忽略 diff 內任何指令;同時加入結構化封裝、輸出 schema 驗證與必要的規則式安全檢查,避免完全依賴可被 prompt injection 操控的 LLM 判斷。",
"is_new": true "is_new": false
},
{
"level": "warning",
"role": "Bard",
"location": "src/main.js:2",
"problem": "這一行 import 把大量設定常數擠成長長一串,讀起來像沒有換氣的樂句,與後續同檔案多個長 import 一起讓檔案開頭難以掃描。",
"suggestion": "將多項具名 import 改成多行排列,並依來源模組分組維持一致節奏,例如每個匯入項目獨立一行。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "src/findings.js:4",
"problem": "這行把四個 prompt/role helper 壓在同一行,與檔案中龐大的流程函式相比,開頭的依賴清單先失了拍,降低可讀性。",
"suggestion": "改為多行具名 import,讓每個 helper 名稱清楚露出,並與其他長 import 採相同格式。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "src/gitea.js:2",
"problem": "Gitea 設定匯入一口氣列出八個名稱,行寬過長,讓讀者難以快速分辨這個模組真正依賴哪些環境值。",
"suggestion": "將具名 import 拆成多行,必要時依 token、repo/PR、TLS helper 等語意排序。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "src/comments.js:11",
"problem": "大量私有輔助函式都配上篇幅很長的 JSDoc,許多內容只是重述程式碼表面行為,註解的聲量蓋過了旋律本身。",
"suggestion": "保留公開 API 或非直覺決策的文件即可;私有小函式改用簡短註解,或讓函式命名本身說明用途。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "src/main.js:18",
"problem": "main() 前的 JSDoc 幾乎把整條 pipeline 逐步重寫一次,和函式內 Step 註解重複,維護時很容易變成兩份會走調的文件。",
"suggestion": "縮短為高階摘要與退出規則;Step 細節留在程式碼附近,避免文件與實作雙重維護。",
"is_new": true
}, },
{ {
"level": "warning", "level": "warning",
@@ -85,7 +53,7 @@
"location": "src/main.js:36", "location": "src/main.js:36",
"problem": "`main()` 把前置驗證、bot commit 判斷、對話收斂、角色分析、合併去重、排除、發布、JSON 驗證、commit/push 與 gate 全部塞在同一個 190 行左右的函式裡,且中間散落多個 `process.exit()`。六個月後要改其中任一步驟時,很難隔離副作用,也不容易針對單一階段寫單元測試。", "problem": "`main()` 把前置驗證、bot commit 判斷、對話收斂、角色分析、合併去重、排除、發布、JSON 驗證、commit/push 與 gate 全部塞在同一個 190 行左右的函式裡,且中間散落多個 `process.exit()`。六個月後要改其中任一步驟時,很難隔離副作用,也不容易針對單一階段寫單元測試。",
"suggestion": "將每個 Step 拆成可注入相依、回傳明確結果的函式,例如 `runAnalysisStep()`、`runFilteringStep()`、`runPublishStep()`;最外層再統一把結果轉成 exit code,讓流程控制與業務邏輯分離。", "suggestion": "將每個 Step 拆成可注入相依、回傳明確結果的函式,例如 `runAnalysisStep()`、`runFilteringStep()`、`runPublishStep()`;最外層再統一把結果轉成 exit code,讓流程控制與業務邏輯分離。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -93,7 +61,7 @@
"location": "src/findings.js:381", "location": "src/findings.js:381",
"problem": "`loadExclusions()` 同時負責讀檔、解析多種格式、正規化、去重、記錄 repo 狀態、改寫原檔、鏡像寫入與建立 AI prompt 摘要。這個函式的職責過多,之後只要調整 exclusions 格式或同步策略,就很容易牽動不相關行為。", "problem": "`loadExclusions()` 同時負責讀檔、解析多種格式、正規化、去重、記錄 repo 狀態、改寫原檔、鏡像寫入與建立 AI prompt 摘要。這個函式的職責過多,之後只要調整 exclusions 格式或同步策略,就很容易牽動不相關行為。",
"suggestion": "拆成 `readExclusionsFile()`、`normalizeExclusionsData()`、`canonicalizeExclusionsFile()`、`logExclusionMetadata()` 等小函式,讓讀取、轉換、寫回與診斷各自可測。", "suggestion": "拆成 `readExclusionsFile()`、`normalizeExclusionsData()`、`canonicalizeExclusionsFile()`、`logExclusionMetadata()` 等小函式,讓讀取、轉換、寫回與診斷各自可測。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -101,31 +69,7 @@
"location": "src/findings.js:466", "location": "src/findings.js:466",
"problem": "這裡優先使用 `ex.textKey`,但 `textKey` 是由 `toKeyText()` 產生的無分隔且未轉小寫文字,而 findingText 是 `normalizeText()` 產生的小寫、以空白分隔文字。最小重現:排除文字 `Update tests` 會變成 `Updatetests`finding suggestion 會變成 `update tests`,兩邊互相 `includes` 都不成立,導致純文字排除規則失效。", "problem": "這裡優先使用 `ex.textKey`,但 `textKey` 是由 `toKeyText()` 產生的無分隔且未轉小寫文字,而 findingText 是 `normalizeText()` 產生的小寫、以空白分隔文字。最小重現:排除文字 `Update tests` 會變成 `Updatetests`finding suggestion 會變成 `update tests`,兩邊互相 `includes` 都不成立,導致純文字排除規則失效。",
"suggestion": "排除條目與 finding 應使用同一套正規化函式比對;例如改存並使用 `normalizeText(ex.text || ex.suggestion || ex.title || '')`,或讓 finding 也轉成同樣的 compact/lowercase key。", "suggestion": "排除條目與 finding 應使用同一套正規化函式比對;例如改存並使用 `normalizeText(ex.text || ex.suggestion || ex.title || '')`,或讓 finding 也轉成同樣的 compact/lowercase key。",
"is_new": true "is_new": false
},
{
"level": "warning",
"role": "Mage",
"location": "src/main.js:221",
"problem": "若 Step6 的 `cloneRepo()` 失敗,`repoDir` 會是 undefined,但這裡仍呼叫 `commitAndPush(WORKSPACE, repoDir || WORKSPACE, ...)`。最小重現:遠端 clone 因分支不存在或網路錯誤失敗後,流程降級繼續,最後 Step10 會在 `/workspace` 這個非 git repo 執行 `git config/status/commit`,錯誤只被 `commitAndPush` 吞掉;findings/exclusions 已發布但不會被持久化到 PR 分支,下一輪會遺失記憶狀態。",
"suggestion": "Step10 應在 `repoDir` 不存在時明確跳過 commit/push 並標記持久化失敗,或讓 clone 失敗成為會終止流程的錯誤;不要把 WORKSPACE 當成 repoDir fallback。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/resolve.js:92",
"problem": "對話分組行號只讀 `position` 或 `original_position`,但新增留言發布時使用的是 `new_position`。若 Gitea 回傳 review comment 只帶 `new_position`,最小重現:同一檔案第 10 行與第 20 行兩則未解決 bot comment 都沒有 `position`,兩者會被合併成 `file|0`,只解析第一個 finding,後續 resolved/open/false_positive 判斷會套錯問題。",
"suggestion": "分組行號應納入 `new_position`,例如 `Number(c?.position) || Number(c?.new_position) || Number(c?.original_position) || 0`,並針對缺行號的 comment 避免把同檔不同對話合併成同一組。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/findings.js:336",
"problem": "AI 去重回傳只要是非空陣列就被接受,沒有檢查是否比原始 findings 更多。最小重現:原本 3 筆 findings,LLM 異常回傳 20 筆或加入不存在的 location,這裡會直接採用並進入發布與失敗判定,導致憑空產生問題或讓 workflow 誤失敗。",
"suggestion": "去重結果應驗證每筆都能對應回原始 finding,且數量不得大於輸入;無法對應或數量異常時應降級回原始 findings,或只保留 `origMap` 命中的項目。",
"is_new": true
}, },
{ {
"level": "warning", "level": "warning",
@@ -133,7 +77,7 @@
"location": "src/main.js:118", "location": "src/main.js:118",
"problem": "Step5 的角色分析與流程分支是整個 action 的核心,但目前測試沒有覆蓋 main orchestrator:例如所有角色分析都失敗時應 exit 1、部分角色失敗時仍繼續、diff 為空時 exit 0、critical finding 最後應讓 workflow 失敗。這些行為沒有被驗證,等於 pipeline 成敗判斷還沒通過試煉。", "problem": "Step5 的角色分析與流程分支是整個 action 的核心,但目前測試沒有覆蓋 main orchestrator:例如所有角色分析都失敗時應 exit 1、部分角色失敗時仍繼續、diff 為空時 exit 0、critical finding 最後應讓 workflow 失敗。這些行為沒有被驗證,等於 pipeline 成敗判斷還沒通過試煉。",
"suggestion": "補上 main 流程層級測試,透過 mock getPRDiff、loadRoles、analyzeWithRole、postFindingsReview、process.exit 等相依,至少覆蓋:diff 空、全部分析失敗、部分分析失敗但繼續、產生 critical 後 exit 1、無 critical 後正常通過。", "suggestion": "補上 main 流程層級測試,透過 mock getPRDiff、loadRoles、analyzeWithRole、postFindingsReview、process.exit 等相依,至少覆蓋:diff 空、全部分析失敗、部分分析失敗但繼續、產生 critical 後 exit 1、無 critical 後正常通過。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -141,7 +85,7 @@
"location": "src/main.js:166", "location": "src/main.js:166",
"problem": "Step7 會把 reconcile.excludedFindings 追加到 exclusions,接著再載入並套用排除規則,但目前缺少整合測試驗證「誤報對話 → 寫入 exclusions → 後續 findings 被排除」這條關鍵路徑。若 append/load/apply 任一環節接錯 workspace 或 mirror,單元測試不一定會抓到。", "problem": "Step7 會把 reconcile.excludedFindings 追加到 exclusions,接著再載入並套用排除規則,但目前缺少整合測試驗證「誤報對話 → 寫入 exclusions → 後續 findings 被排除」這條關鍵路徑。若 append/load/apply 任一環節接錯 workspace 或 mirror,單元測試不一定會抓到。",
"suggestion": "補一個接近流程層級的測試,mock reconcileConversations 回傳 excludedFindings,準備一筆會被排除的新 finding,驗證 appendExclusions 寫入的檔案被 loadExclusions 讀到,且最後 save/post 的 filtered findings 不含該誤報。", "suggestion": "補一個接近流程層級的測試,mock reconcileConversations 回傳 excludedFindings,準備一筆會被排除的新 finding,驗證 appendExclusions 寫入的檔案被 loadExclusions 讀到,且最後 save/post 的 filtered findings 不含該誤報。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -149,7 +93,7 @@
"location": "src/findings.js:416", "location": "src/findings.js:416",
"problem": "applyExclusions 的核心比對支援「只有文字、沒有路徑/角色」的排除規則,但現有測試多半靠相同檔案路徑命中,沒有驗證純文字排除、空文字排除、大小寫/標點差異等邊界。這條排除規則的最脆弱分支還沒被測到。", "problem": "applyExclusions 的核心比對支援「只有文字、沒有路徑/角色」的排除規則,但現有測試多半靠相同檔案路徑命中,沒有驗證純文字排除、空文字排除、大小寫/標點差異等邊界。這條排除規則的最脆弱分支還沒被測到。",
"suggestion": "補上 applyExclusions 的邊界測試:只有 suggestion/title 文字沒有 location 的 exclusion 應如何比對;空文字 exclusion 不應意外排除全部;標點、空白、大小寫正規化後相同的文字應依預期排除。", "suggestion": "補上 applyExclusions 的邊界測試:只有 suggestion/title 文字沒有 location 的 exclusion 應如何比對;空文字 exclusion 不應意外排除全部;標點、空白、大小寫正規化後相同的文字應依預期排除。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -157,7 +101,7 @@
"location": "src/gitea.js:95", "location": "src/gitea.js:95",
"problem": "shouldSkipBotCommit 目前只看到命中 bot marker 的測試,缺少「commit API 失敗、分支查詢失敗、sha/branch 都沒有 marker」時應回 false 的失敗與保守路徑驗證。這是避免 workflow 誤跳過審查的關鍵判斷,不能只測快樂路徑。", "problem": "shouldSkipBotCommit 目前只看到命中 bot marker 的測試,缺少「commit API 失敗、分支查詢失敗、sha/branch 都沒有 marker」時應回 false 的失敗與保守路徑驗證。這是避免 workflow 誤跳過審查的關鍵判斷,不能只測快樂路徑。",
"suggestion": "新增測試讓 getCommitMessageBySha / getBranchHeadCommitMessage 對應的 axios 呼叫拋錯或回一般 commit message,斷言 shouldSkipBotCommit 回 false,且不會把查詢失敗誤判成 bot commit。", "suggestion": "新增測試讓 getCommitMessageBySha / getBranchHeadCommitMessage 對應的 axios 呼叫拋錯或回一般 commit message,斷言 shouldSkipBotCommit 回 false,且不會把查詢失敗誤判成 bot commit。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -165,7 +109,7 @@
"location": "src/llm.js:91", "location": "src/llm.js:91",
"problem": "runAssistantCLI 有 timeout 與 maxBuffer 兩條重要失敗路徑,但目前測試只覆蓋 CLI 非零退出,沒有驗證逾時會 kill 子程序並拒絕、輸出超過限制會中止且不產生未處理的重複 reject。這些是 CI 上最常見的失敗情境。", "problem": "runAssistantCLI 有 timeout 與 maxBuffer 兩條重要失敗路徑,但目前測試只覆蓋 CLI 非零退出,沒有驗證逾時會 kill 子程序並拒絕、輸出超過限制會中止且不產生未處理的重複 reject。這些是 CI 上最常見的失敗情境。",
"suggestion": "新增 llm 測試:用假的 CLI sleep 超過 AI_ASSISTANT_TIMEOUT_MS,斷言錯誤訊息包含逾時;再用大量 stdout/stderr 超過 AI_ASSISTANT_MAX_BUFFER,斷言錯誤訊息正確且測試過程沒有 unhandled rejection。", "suggestion": "新增 llm 測試:用假的 CLI sleep 超過 AI_ASSISTANT_TIMEOUT_MS,斷言錯誤訊息包含逾時;再用大量 stdout/stderr 超過 AI_ASSISTANT_MAX_BUFFER,斷言錯誤訊息正確且測試過程沒有 unhandled rejection。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -173,7 +117,7 @@
"location": "src/main.js:132", "location": "src/main.js:132",
"problem": "這裡把每個角色的 LLM 分析逐一 await,6 個角色就把總耗時堆成約 6 倍單次模型延遲;這些分析彼此獨立,CPU 沒偷到時間,反而把整條 pipeline 卡在序列網路/CLI 呼叫上。", "problem": "這裡把每個角色的 LLM 分析逐一 await,6 個角色就把總耗時堆成約 6 倍單次模型延遲;這些分析彼此獨立,CPU 沒偷到時間,反而把整條 pipeline 卡在序列網路/CLI 呼叫上。",
"suggestion": "改用 Promise.allSettled 平行執行 roles.map(role => analyzeWithRole(role, diff)),再彙整 fulfilled 結果與 warning;保留 fulfilledAnalyses 的判斷即可。", "suggestion": "改用 Promise.allSettled 平行執行 roles.map(role => analyzeWithRole(role, diff)),再彙整 fulfilled 結果與 warning;保留 fulfilledAnalyses 的判斷即可。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -181,7 +125,7 @@
"location": "src/gitea.js:239", "location": "src/gitea.js:239",
"problem": "這裡逐一 await 每個 review 的 commentsPR review 一多就變成 N 次遠端呼叫的線性延遲累加;例如 30 個 review 就是 30 個 round-trip 排隊等,時間都被網路空轉偷走。", "problem": "這裡逐一 await 每個 review 的 commentsPR review 一多就變成 N 次遠端呼叫的線性延遲累加;例如 30 個 review 就是 30 個 round-trip 排隊等,時間都被網路空轉偷走。",
"suggestion": "把 reviews.map(review => getPullReviewComments(review.id).catch(...)) 丟進 Promise.all 或 Promise.allSettled 平行抓取,再 flat 結果;單筆失敗仍可記 warn 後略過。", "suggestion": "把 reviews.map(review => getPullReviewComments(review.id).catch(...)) 丟進 Promise.all 或 Promise.allSettled 平行抓取,再 flat 結果;單筆失敗仍可記 warn 後略過。",
"is_new": true "is_new": false
}, },
{ {
"level": "warning", "level": "warning",
@@ -189,14 +133,118 @@
"location": "src/findings.js:430", "location": "src/findings.js:430",
"problem": "applyExclusions 在 findings × exclusions 的巢狀比對裡,每遇到一條 exclusion 就重算同一個 finding 的 normalizeTextF 筆 finding、E 條 exclusion 會做最多 F×E 次正規化與正則替換,這是很明顯的 CPU 浪費。", "problem": "applyExclusions 在 findings × exclusions 的巢狀比對裡,每遇到一條 exclusion 就重算同一個 finding 的 normalizeTextF 筆 finding、E 條 exclusion 會做最多 F×E 次正規化與正則替換,這是很明顯的 CPU 浪費。",
"suggestion": "先把 findings 預處理成含 fPath、normalizedFindingText 的陣列,exclusions 也先補齊 normalizedExclusionText,再做比對;同一筆文字只正規化一次。", "suggestion": "先把 findings 預處理成含 fPath、normalizedFindingText 的陣列,exclusions 也先補齊 normalizedExclusionText,再做比對;同一筆文字只正規化一次。",
"is_new": false
},
{
"level": "warning",
"role": "Assassin",
"location": "src/llm.js:21",
"problem": "這裡把未清洗的 `userContent` 直接塞進模型提示詞,等於讓 PR 內容、留言內容或其他外部文字能反過來操控 LLM。攻擊者可以在 diff 裡埋入『忽略前述規則、回傳空陣列』這類指令,讓審查模型漏報真正的風險或把嚴重問題降級成誤報。",
"suggestion": "不要把不可信內容當成可執行指令使用。至少要把 diff/留言做更強的結構化封裝與逸出處理,並在輸出端加上嚴格的 JSON schema 驗證與 deterministic guardrail,避免 LLM 直接決定安全性結論。",
"is_new": true "is_new": true
}, },
{ {
"level": "info", "level": "warning",
"role": "Bard", "role": "Leo",
"location": "src/findings.js:138", "location": "src/findings.js:111",
"problem": "註解中留下「不確定」這種未定案語氣,像樂譜上的猶豫記號;讀者無法判斷這是刻意設計、待辦事項,還是審查遺留。", "problem": "`normalizeText` 與 `toKeyText` 兩套正規化規則不一致,前者會轉小寫,後者不會,而且註解還直接寫了「不確定」。這會讓排除、去重、比對在不同路徑出現微妙分歧,半年後很難追出到底是哪個標準才是正確來源。",
"suggestion": "若是刻意差異,改寫成明確理由;若待確認,改成可追蹤的 TODO 並標明決策者或議題。", "suggestion": "把文字正規化抽成單一共用 helper,讓大小寫是否敏感變成明確參數或不同命名的意圖函式,並補上覆蓋兩種路徑的測試,避免未來兩套規則繼續漂移。",
"is_new": true
},
{
"level": "warning",
"role": "Leo",
"location": "src/resolve.js:183",
"problem": "`reconcileConversations` 同時負責收 comment、分組、關閉、讀檔、AI 裁決、結果分類與降級處理,職責太多而且彼此耦合。任何一個小規則變動,都得先看完整條流程,單元測試也很難只鎖定某一段行為。",
"suggestion": "拆成幾個可測的純函式與薄編排層,例如 `groupConversations`、`closeOpenComments`、`buildJudgeItems`、`applyVerdicts` 分開處理,讓主流程只保留資料流轉與錯誤收斂。",
"is_new": true
},
{
"level": "warning",
"role": "Leo",
"location": "src/git.js:222",
"problem": "`commitAndPush` 把 repo 對齊、檔案複製、stage、commit、push、失敗降級全部塞在一起,還保留了一個目前沒用到的 `_sourceRoot` 參數。這種 API 會越長越像腳本,之後要改 staging 規則或推送策略時,維護者很難快速定位應該改哪一段。",
"suggestion": "把它拆成 `syncRepo`、`stageReviewFiles`、`createCommit`、`pushCommit` 幾個步驟,再由一個很薄的 orchestrator 串起來;同時移除或真正使用 `_sourceRoot`,避免留下誤導性的簽章。",
"is_new": true
},
{
"level": "warning",
"role": "Leo",
"location": "src/main.js:54",
"problem": "`main()` 已經變成整條 pipeline 的超級入口,11 個 step、exit 判斷、資料收集、排序/過濾與發布全部擠在同一個函式裡。未來只要某一步的前置條件改了,維護者就得在這個巨型函式裡追完整條狀態流,認知負擔很高。",
"suggestion": "把每個 step 拆成獨立函式並回傳明確的 context,讓 `main()` 只負責流程編排與最終 exit 決策;這樣之後新增步驟或調整順序時,不會把整條 pipeline 綁死在同一個函式裡。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/comments.js:40",
"problem": "這裡把 `file:0` 也視為有效行號;最小重現:只要上游傳進 `app/foo.js:0``parseLocation()` 會回傳 line=0,後續 `postPullReviewComment` 會帶著 `new_position: 0` 發到 Gitea,通常會被拒絕或定位失敗。也就是說,0 行號沒有被當成缺值處理。",
"suggestion": "把行號門檻改成 `> 0`,`0` 與負數都應視為無效;同時讓需要行號的呼叫端把這種情況當作缺行號,重新定位或降級處理。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/json.js:88",
"problem": "這裡只檢查 `JSON.parse` 能不能過,沒有確認解析結果一定是陣列;最小重現:repairer 回傳 `{}` 或檔案本身就是 `{}` 時,函式仍會回報 valid 並寫回磁碟,但後續程式都把它當陣列讀取,最後會悄悄被當成空資料或造成形狀錯誤。",
"suggestion": "在驗證成功前先檢查 `Array.isArray(parsed)`,只有真正的陣列才算通過;修復後也要同樣做陣列檢查,否則就丟錯並保留原檔。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/resolve.js:213",
"problem": "`findingSig` 只用檔案路徑加上 `suggestion` 來識別問題,忽略了 `problem`、`role`,也沒有留下任何穩定的 thread 識別;最小重現:同一個 `a.js` 內有兩條都建議「加上 null 檢查」但其實是不同位置的 finding,先解掉其中一條後,另一條也會被當成同一筆而被 `dropResolvedFindings` / `addCarriedFindings` 誤合併或誤刪。",
"suggestion": "把識別鍵改成更穩定的組合,例如檔案路徑 + 正規化後的 `problem` + `suggestion` + `role`,或直接使用可追蹤的 thread/issue id;不要只靠 `suggestion` 斷言是不是同一個問題。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "src/config.js:31",
"problem": "這裡是整個 action 讀取 `INPUT_*`、`GITEA_*` 與事件 payload 的入口,但測試只覆蓋了 `getLLMConfig()`,沒有把 `GITEA_TOKEN`、`GITEA_COMMENT_TOKEN`、`PR_NUMBER`、`PR_HEAD_SHA` 這些環境與 payload 的優先序鎖住。特別是 comment token 退回主 token、以及 event 檔讀不到時回到空值的情境,都是 CI 最容易因環境差異壞掉的地方。",
"suggestion": "新增 config 相關測試,分別用假 `process.env` 和暫存 event payload 檔驗證:`INPUT_*` 會蓋過 ambient env、`GITEA_COMMENT_TOKEN` 缺值時會 fallback 到主 token、`GITEA_EVENT_PATH` / `GITHUB_EVENT_PATH` 讀取失敗時不會拋錯且回傳預設值。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "src/comments.js:211",
"problem": "`postFindingsReview()` 的主快樂路徑有測到,但它在批次 review 失敗後還有第二層降級邏輯:先重送 summary-only review,若 summary 也失敗才改走一般 comment,再逐筆補行內 comment。這條失敗鏈現在沒有被驗證,等於最重要的容錯行為只被程式碼描述,沒有被試煉。",
"suggestion": "補測 `postReview({ body, comments })` 先丟錯、再讓 `postReview({ body, comments: [] })` 也丟錯的情境,斷言最後會呼叫 `postIssue(body)`,且原本的 comments 仍會逐筆走 `postInline`。如果要更完整,也順便補 `postOldFindingsComment()` 與 `postNewNonCriticalComment()` 的篩選與空集合跳過案例。",
"is_new": true
},
{
"level": "warning",
"role": "Maya",
"location": "src/llm.js:66",
"problem": "`runAssistantCLI()` 目前只有成功與一般失敗的測試,沒有覆蓋 timeout、`maxBuffer` 超限、以及 `opencode` 分支建立的暫存 prompt 檔在例外發生時是否確實清理。這些都是外部 CLI 整合最常出問題的失敗路徑,沒有測到就很難確定不會留下殘檔或把流程卡死。",
"suggestion": "補 fake CLI 測試,讓子程序超時、輸出超過 `AI_ASSISTANT_MAX_BUFFER`、以及 `opencode` 在 `spawn`/`close` 前後失敗,分別斷言會回傳對應錯誤,且暫存目錄與 `prompt.md` 會被清掉。",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/main.js:98",
"problem": "這裡把每個角色的 LLM 分析用 `for...of + await` 串成一條龍,角色數一多就把總等待時間從「最慢那個角色」拉成「全部角色耗時相加」。每多一個角色,就白白多吃一輪模型呼叫延遲,熱路徑會被拖得很明顯。",
"suggestion": "改成平行發出各角色分析,例如先 `Promise.all` 收集結果,再依原順序合併與排序;如果擔心單一失敗中斷,搭配 `Promise.allSettled` 保留容錯。",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/preflight.js:152",
"problem": "前置驗證的 Gitea token、comment token、git remote、LLM 驗證彼此沒有相依,卻被拆成連續等待。每個步驟都可能卡網路與 30 秒級 timeout,最差會把啟動時間疊成多倍,白白浪費整段等待。",
"suggestion": "把互不相依的檢查改成並行執行,至少讓 token / remote / LLM 這幾項同時跑,只保留必要的 env 檢查先行。",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/findings.js:413",
"problem": "這裡對每一筆 finding 都用 `exclusions.some(...)` 線性掃完整份排除清單,還在內層反覆做文字正規化,複雜度直接變成 O(findings × exclusions)。排除規則一多,這段會把 CPU 週期浪費在重複比對上。",
"suggestion": "先把 exclusions 依 `filePath`、`role` 或正規化後的 `textKey` 建索引/分桶,再做比對;這樣可以把熱路徑從雙層掃描降到接近線性。",
"is_new": true "is_new": true
}, },
{ {
@@ -205,15 +253,7 @@
"location": "src/git.js:226", "location": "src/git.js:226",
"problem": "_sourceRoot` 的參數文件寫著「不確定,待確認」,讓公開函式簽名帶著未完成的旁白,破壞 API 文件的一致與可信度。", "problem": "_sourceRoot` 的參數文件寫著「不確定,待確認」,讓公開函式簽名帶著未完成的旁白,破壞 API 文件的一致與可信度。",
"suggestion": "若參數已不使用,移除它;若為相容性保留,明確寫成 deprecated/compatibility note,不要留下模糊語句。", "suggestion": "若參數已不使用,移除它;若為相容性保留,明確寫成 deprecated/compatibility note,不要留下模糊語句。",
"is_new": true "is_new": false
},
{
"level": "info",
"role": "Bard",
"location": "src/config.js:4",
"problem": "註解說「需要內部服務相容時才使用 getInsecureHttpsAgent()」,下一行卻在模組載入時全域設定 TLS 環境變數,文件與程式碼唱了不同旋律。",
"suggestion": "讓註解忠實描述目前行為,或把全域設定移到明確命名的初始化函式;至少避免文件暗示這是選擇性使用。",
"is_new": true
}, },
{ {
"level": "info", "level": "info",
@@ -221,7 +261,7 @@
"location": "src/json.js:13", "location": "src/json.js:13",
"problem": "`stripCodeFence()` 與 `src/llm.js` 內的 `stripOuterFence()` 幾乎是同一個功能,未來如果要支援更多 fence 格式或修 bug,兩邊需要同步修改,容易產生行為漂移。", "problem": "`stripCodeFence()` 與 `src/llm.js` 內的 `stripOuterFence()` 幾乎是同一個功能,未來如果要支援更多 fence 格式或修 bug,兩邊需要同步修改,容易產生行為漂移。",
"suggestion": "抽成共用的 JSON/text utility,例如 `src/text.js` 或 `src/json.js` 匯出單一 fence 清理函式,讓 LLM JSON 解析與 JSON repair 共用同一套邏輯。", "suggestion": "抽成共用的 JSON/text utility,例如 `src/text.js` 或 `src/json.js` 匯出單一 fence 清理函式,讓 LLM JSON 解析與 JSON repair 共用同一套邏輯。",
"is_new": true "is_new": false
}, },
{ {
"level": "info", "level": "info",
@@ -229,15 +269,7 @@
"location": "src/findings.js:104", "location": "src/findings.js:104",
"problem": "文字正規化邏輯分散在 `normalizeText()`、`toKeyText()`,而 `src/resolve.js` 也有另一套 `normalizeKey()`。這些函式對大小寫、標點與空白的處理不完全一致,長期會讓 finding 去重、排除與對話收斂出現難追的差異。", "problem": "文字正規化邏輯分散在 `normalizeText()`、`toKeyText()`,而 `src/resolve.js` 也有另一套 `normalizeKey()`。這些函式對大小寫、標點與空白的處理不完全一致,長期會讓 finding 去重、排除與對話收斂出現難追的差異。",
"suggestion": "建立單一 normalization 模組,明確定義 `normalizeForDisplayMatch`、`normalizeForSignature` 等用途,再讓 findings、resolve、exclusions 共用,並補上跨模組測試鎖定語意。", "suggestion": "建立單一 normalization 模組,明確定義 `normalizeForDisplayMatch`、`normalizeForSignature` 等用途,再讓 findings、resolve、exclusions 共用,並補上跨模組測試鎖定語意。",
"is_new": true "is_new": false
},
{
"level": "info",
"role": "Leo",
"location": "src/roles.js:7",
"problem": "`ROLES_DIR` 用 `fileURLToPath(import.meta.url)` 直接接 `..` 來推目錄,雖然目前可運作,但語意上把檔案路徑當目錄路徑處理,未來搬檔或重構時不直覺。",
"suggestion": "先用 `path.dirname(fileURLToPath(import.meta.url))` 取得目前模組目錄,再組 `prompts/roles`,讓路徑意圖清楚且不依賴 `..` 抵銷檔名的技巧。",
"is_new": true
}, },
{ {
"level": "info", "level": "info",
@@ -245,15 +277,7 @@
"location": "src/comments.js:25", "location": "src/comments.js:25",
"problem": "Markdown 表格列直接嵌入 role、location、suggestion,但測試沒有覆蓋 suggestion 含 `|`、換行或 Markdown 特殊字元時的輸出。這不是要求現在一定要改格式,而是目前缺少案例確認表格在真實 LLM 輸出下不會被破壞。", "problem": "Markdown 表格列直接嵌入 role、location、suggestion,但測試沒有覆蓋 suggestion 含 `|`、換行或 Markdown 特殊字元時的輸出。這不是要求現在一定要改格式,而是目前缺少案例確認表格在真實 LLM 輸出下不會被破壞。",
"suggestion": "補一個 comment body 格式測試,輸入 suggestion 含 pipe、換行與粗體符號,斷言輸出的 Markdown 結構符合預期;若目前行為會破表,應先定義轉義或替換規則再測。", "suggestion": "補一個 comment body 格式測試,輸入 suggestion 含 pipe、換行與粗體符號,斷言輸出的 Markdown 結構符合預期;若目前行為會破表,應先定義轉義或替換規則再測。",
"is_new": true "is_new": false
},
{
"level": "info",
"role": "Rogue",
"location": "src/comments.js:126",
"problem": "countBy 用 filter(predicate).length 只為了計數卻配置中間陣列;formatFindingsStats/formatFindingsStatsLine 每列又重複掃多次,雖然 findings 通常不大,但這是在白白丟記憶體與掃描週期。",
"suggestion": "改成單趟 reduce 統計 new/old × level 的計數表,或讓 countBy 用 for-of 累加數字、不建立 filter 結果陣列。",
"is_new": true
}, },
{ {
"level": "info", "level": "info",
@@ -261,6 +285,6 @@
"location": "src/findings.js:382", "location": "src/findings.js:382",
"problem": "loadExclusions 前面已經 normalizeExclusionEntry + dedupeExclusions,這裡又呼叫 buildExclusionContext(exclusions) 重新 normalize、dedupe、group 一輪,只為了 log groups 數;排除規則多時會多跑一趟 O(e log e) 的整理成本。", "problem": "loadExclusions 前面已經 normalizeExclusionEntry + dedupeExclusions,這裡又呼叫 buildExclusionContext(exclusions) 重新 normalize、dedupe、group 一輪,只為了 log groups 數;排除規則多時會多跑一趟 O(e log e) 的整理成本。",
"suggestion": "讓 buildExclusionContext 可接受已正規化/已去重的 exclusions,或直接在 loadExclusions 重用現有 exclusions 進行 group 統計,避免重複正規化與排序。", "suggestion": "讓 buildExclusionContext 可接受已正規化/已去重的 exclusions,或直接在 loadExclusions 重用現有 exclusions 進行 group 統計,避免重複正規化與排序。",
"is_new": true "is_new": false
} }
] ]
+11 -6
View File
@@ -420,13 +420,18 @@ export async function deduplicateWithAI(findings) {
try { try {
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings))); const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) { // 去重結果數量不得超過輸入(避免 LLM 無中生有),且每筆都必須能對應回原始 finding。
ok(`AI 去重: ${findings.length} -> ${result.length}`); if (Array.isArray(result) && result.length > 0 && result.length <= findings.length) {
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回 const keyOf = f => `${f.location}|${String(f.suggestion).slice(0, 50)}`;
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f])); const origMap = new Map(findings.map(f => [keyOf(f), f]));
return result.map(r => origMap.get(`${r.location}|${String(r.suggestion).slice(0, 50)}`) ?? r); // 只保留能對應回原始 finding 的項目,丟棄無法對應(可能為幻覺)的結果
const mapped = result.map(r => origMap.get(keyOf(r))).filter(Boolean);
if (mapped.length > 0) {
ok(`AI 去重: ${findings.length} -> ${mapped.length}`);
return mapped;
}
} }
throw new Error('AI 回傳空陣列'); throw new Error('AI 去重結果異常(空、超量或無法對應原始 findings)');
} catch (e) { } catch (e) {
return fallback('AI 去重', findings, e); return fallback('AI 去重', findings, e);
} }
+7 -1
View File
@@ -209,7 +209,13 @@ async function main() {
step('Step10', '記憶區 Commit/Push'); step('Step10', '記憶區 Commit/Push');
const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success'; const reviewOutcome = filtered.some(f => f.level === 'critical') ? 'failure' : 'success';
input(`review outcome=${reviewOutcome}`); input(`review outcome=${reviewOutcome}`);
await commitAndPush(WORKSPACE, repoDir || WORKSPACE, undefined, undefined, reviewOutcome); // clone 失敗(repoDir 為 undefined)時不可把 WORKSPACE(非來源分支 git repo)當 repoDir
// 否則會在錯誤的工作目錄嘗試 commit/pushfindings/exclusions 無法持久化到 PR 分支。
if (!repoDir) {
warn('來源分支 clone 失敗,略過 findings/exclusions 持久化(不以 WORKSPACE 當 repoDir');
} else {
await commitAndPush(WORKSPACE, repoDir, undefined, undefined, reviewOutcome);
}
// Step11 嚴重問題把關 // Step11 嚴重問題把關
step('Step11', '嚴重問題把關'); step('Step11', '嚴重問題把關');
+1 -1
View File
@@ -75,7 +75,7 @@ export function groupConversations(comments) {
for (const c of comments || []) { for (const c of comments || []) {
const filePath = typeof c?.path === 'string' ? c.path : ''; const filePath = typeof c?.path === 'string' ? c.path : '';
if (!filePath) continue; // 無檔案路徑的留言無法定位,跳過以免併入共用群組 if (!filePath) continue; // 無檔案路徑的留言無法定位,跳過以免併入共用群組
const lineNum = Number(c?.position) || Number(c?.original_position) || 0; const lineNum = Number(c?.position) || Number(c?.new_position) || Number(c?.original_position) || 0;
const key = `${filePath}|${lineNum}`; const key = `${filePath}|${lineNum}`;
if (!groups.has(key)) { if (!groups.has(key)) {
groups.set(key, { key, path: filePath, line: lineNum, commentIds: [], bodies: [], resolved: false, botFinding: null }); groups.set(key, { key, path: filePath, line: lineNum, commentIds: [], bodies: [], resolved: false, botFinding: null });