Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1293bb950 | ||
|
|
69122dca3e | ||
|
|
e54c3f5fbe | ||
|
|
2f294845c8 | ||
|
|
a7824cff29 | ||
|
|
fda3331dd4 | ||
|
|
11205d5b82 | ||
|
|
0be25e667c | ||
|
|
93142e2250 | ||
|
|
c521451b66 | ||
|
|
94d86809d5 | ||
|
|
1279cae575 | ||
|
|
47aa199e5e | ||
|
|
4b4b934cda | ||
|
|
1a9a6bce5c | ||
|
|
2c0ac71c08 |
@@ -0,0 +1,398 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"location": "src/config.js:7",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡把 `NODE_TLS_REJECT_UNAUTHORIZED` 全域設為 `0`,等於讓整個 Node 程序放棄 TLS 憑證驗證。攻擊者只要能站到 runner 與 Gitea/LLM/任何 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 註解並存屬設計選擇。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:253",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡會把 PR 上所有未解決的 review comment ID 全部送去 resolve,而不是只處理 AI Review bot 自己建立的 thread。攻擊者只要開 PR 觸發這個 action,就可能讓 bot 關閉人類審查者留下的安全疑慮或阻擋性對話,繞過人工審查流程。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/gitea.js:66",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡直接從 PR head 讀取 `.reviewignore`,再拿它當成排除規則。攻擊者可以在自己的分支塞入排除條目,讓 bot 故意跳過包含惡意變更的檔案或整個目錄,等於自己決定哪些地方不被審查。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:352",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡會直接讀取 PR 工作樹中的 `.gitea/ai-review/exclusions.json` 當成可信排除來源。攻擊者可以先在分支裡放一份藏在 `.gitea/` 下的 exclusions 檔,利用被忽略的路徑把自己的問題先排除掉,讓後續的 findings 被靜默吃掉。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/main.js:158",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡直接載入 PR 工作樹中的 `.gitea/ai-review/exclusions.json` 當成既有排除規則。攻擊者可以先在 PR 內預埋一份排除清單,因為 `.gitea/` 又被預設排除於 diff 之外,這些惡意排除不會被審查到,卻會被流程直接拿來吞掉真正的 findings,形成靜默的審查繞過。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/gitea.js:118",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡只靠 commit 訊息是否包含 `[ai-review-bot]` 來判斷要不要跳過審查,等於把信任建立在可由任何提交者自行偽造的字串上。攻擊者只要把自己的 PR head commit 訊息改成這個標記,整個審查流程就會被直接略過。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/llm.js:21",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡把未清洗的 `userContent` 直接塞進模型提示詞,等於讓 PR 內容、留言內容或其他外部文字能反過來操控 LLM。攻擊者可以在 diff 裡埋入『忽略前述規則、回傳空陣列』這類指令,讓審查模型漏報真正的風險或把嚴重問題降級成誤報。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/main.js:69",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡先檢查 head SHA 對應的訊息是否為 failure,但如果 SHA 查詢失敗或是空值,後面的 `shouldSkipBotCommit()` 仍可能只看到分支 head 上的 `[ai-review-bot]` 標記就直接跳過。最小重現:`getCommitMessageBySha()` 因 Gitea API 暫時失敗回空字串,而分支 head 正好是 `[ai-review-bot][failure]`,流程就會 exit 0,等於把本來應該失敗的 bot commit 當成可跳過的自動提交。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/main.js:132",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這裡把每個角色的 LLM 分析逐一 await,6 個角色就把總耗時堆成約 6 倍單次模型延遲;這些分析彼此獨立,CPU 沒偷到時間,反而把整條 pipeline 卡在序列網路/CLI 呼叫上。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:381",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "`loadExclusions()` 同時負責讀檔、解析多種格式、正規化、去重、記錄 repo 狀態、改寫原檔、鏡像寫入與建立 AI prompt 摘要。這個函式的職責過多,之後只要調整 exclusions 格式或同步策略,就很容易牽動不相關行為。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:466",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡優先使用 `ex.textKey`,但 `textKey` 是由 `toKeyText()` 產生的無分隔且未轉小寫文字,而 findingText 是 `normalizeText()` 產生的小寫、以空白分隔文字。最小重現:排除文字 `Update tests` 會變成 `Updatetests`,finding suggestion 會變成 `update tests`,兩邊互相 `includes` 都不成立,導致純文字排除規則失效。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:416",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "applyExclusions 的核心比對支援「只有文字、沒有路徑/角色」的排除規則,但現有測試多半靠相同檔案路徑命中,沒有驗證純文字排除、空文字排除、大小寫/標點差異等邊界。這條排除規則的最脆弱分支還沒被測到。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/gitea.js:95",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "shouldSkipBotCommit 目前只看到命中 bot marker 的測試,缺少「commit API 失敗、分支查詢失敗、sha/branch 都沒有 marker」時應回 false 的失敗與保守路徑驗證。這是避免 workflow 誤跳過審查的關鍵判斷,不能只測快樂路徑。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/llm.js:66",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`runAssistantCLI()` 目前只有成功與一般失敗的測試,沒有覆蓋 timeout、`maxBuffer` 超限、以及 `opencode` 分支建立的暫存 prompt 檔在例外發生時是否確實清理。這些都是外部 CLI 整合最常出問題的失敗路徑,沒有測到就很難確定不會留下殘檔或把流程卡死。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/gitea.js:239",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這裡逐一 await 每個 review 的 comments,PR review 一多就變成 N 次遠端呼叫的線性延遲累加;例如 30 個 review 就是 30 個 round-trip 排隊等,時間都被網路空轉偷走。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:442",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這裡每一筆 finding 都要跟整包 exclusions 做一次 `.some()`,而且內層還反覆跑 `normalizeText` 和字串包含比對,資料一多就直接變成 O(F×E) 的熱點。像 300 筆 finding 配 500 筆 exclusions,會吃掉 15 萬次以上的比對與正規化,CPU 和字串配置都在浪費。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:213",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "`findingSig` 只用檔案路徑加上 `suggestion` 來識別問題,忽略了 `problem`、`role`,也沒有留下任何穩定的 thread 識別;最小重現:同一個 `a.js` 內有兩條都建議「加上 null 檢查」但其實是不同位置的 finding,先解掉其中一條後,另一條也會被當成同一筆而被 `dropResolvedFindings` / `addCarriedFindings` 誤合併或誤刪。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:40",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡把 `file:0` 也視為有效行號;最小重現:只要上游傳進 `app/foo.js:0`,`parseLocation()` 會回傳 line=0,後續 `postPullReviewComment` 會帶著 `new_position: 0` 發到 Gitea,通常會被拒絕或定位失敗。也就是說,0 行號沒有被當成缺值處理。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:233",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`postFindingsReview` 的降級流程有兩層:先嘗試批次 review,再失敗時改成 summary-only,最後 summary-only 也失敗才退回一般 comment。現在的測試只驗到第一層失敗後、第二層成功的情境,沒有驗證 summary-only 也失敗時是否真的會呼叫 `postIssue(body)`,這是最脆弱的 fallback 路徑之一。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/config.js:31",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "這裡是整個 action 讀取 `INPUT_*`、`GITEA_*` 與事件 payload 的入口,但測試只覆蓋了 `getLLMConfig()`,沒有把 `GITEA_TOKEN`、`GITEA_COMMENT_TOKEN`、`PR_NUMBER`、`PR_HEAD_SHA` 這些環境與 payload 的優先序鎖住。特別是 comment token 退回主 token、以及 event 檔讀不到時回到空值的情境,都是 CI 最容易因環境差異壞掉的地方。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/config.js:27",
|
||||||
|
"role": "Bard",
|
||||||
|
"original_finding": "這段註解已經跟著介面走音了。它宣稱使用端「只需傳 `with: token`」,但這次 action 其實已新增 `comment_token` 與 `model` 等輸入,註解仍停留在舊旋律,容易讓讀者誤判介面現況。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:227",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這個抽取器一旦命中目標檔案,就一路把後面的 diff 全部帶進去,沒有在下一個 `diff --git` 區塊時停下來。最小重現:diff 同時有 `a.js` 和 `b.js`,要補 `a.js` 的行號時,送給 LLM 的內容會混進 `b.js` 的 hunks,結果很容易定位到錯的行,或讓模型把別檔的內容誤認成目標檔上下文。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:277",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`deduplicateWithAI` 是新的核心語意去重流程,但目前完全沒有直接測試它的成功與失敗分支。尤其是 LLM 回傳排序不同、夾雜幻覺項目、回傳空陣列或超量結果時,程式會改走保守 fallback,這些都是很容易壞掉但現在沒被驗證的邊界。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:322",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "每一筆缺行號的 finding 都重新呼叫 `extractFileDiff(diff, file)` 掃完整份 diff,若同一檔案有 k 筆問題,就會重複做 k 次整份 diff 解析,浪費量是 O(k × diff長度)。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/llm.js:56",
|
||||||
|
"role": "Bard",
|
||||||
|
"original_finding": "`cliArgs` 把不同提供者的參數拼湊在同一個分支裡,還讓 `opencode` 走了另一套文字輸入路線,整個 helper 的節奏忽然一分為二。讀起來像兩個介面硬塞進同一支笛子。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:1",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "這個模組同時處理舊 findings 載入、合併去重、缺行號補齊、排除規則正規化、誤報過濾、AI 去重、以及 exclusions 的讀寫,職責已經混成一包。更麻煩的是 `loadExclusions`、`appendExclusions`、`applyExclusions` 各自都有一套相近但不完全一致的比對邏輯,未來只要規則改一處,另一處沒同步就會開始出現不可預期的行為差異。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/json.js:113",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡只檢查 `JSON.parse(normalized)` 能不能成功,沒有確認修復後的內容真的是陣列。最小重現是 AI 把 `findings.json` 修成 `{ \"a\": 1 }`,函式會照樣寫回檔案並回報成功,但下一輪讀取時 `readJSONArray` 會把它當成非陣列而視為空值,等於把資料靜默吃掉。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:74",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡用 `path + line` 當唯一群組鍵,且只保留第一筆 `botFinding`。最小重現是同一個檔案同一行同時被兩個角色指出不同問題,`groupConversations` 會把它們合成同一組,後來的那筆 finding 會被吞掉,導致後續關閉、回寫或保留時少掉一個問題。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:214",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "這裡新增了 `postOldFindingsComment` 與 `postNewNonCriticalComment` 兩條公開的留言分流路徑,但現有測試只驗證了 `postNewCriticalComments` 與 `postFindingsReview`,完全沒有案例確認這兩個函式的篩選條件、空陣列時是否跳過、以及輸出的 Markdown 內容是否真的只包含對應的 findings。這種分流邏輯一旦算錯,就會發生該發的沒發、或不該公告的問題被貼出去。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:171",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`postFindingsReview` 的救援路徑只測到「批次 review 失敗後,改發逐筆 inline comment」這一段,卻沒有驗證第二次 `postReview({ comments: [] })` 也失敗時,會正確降級到 `postIssue(body)`。這條路徑是 Gitea review API 整個故障時保住摘要的最後保險絲,沒測到的話,真正出事時很容易靜默漏報。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/main.js:17",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`main()` 整個流程目前沒有任何直接測試,只能靠零散的子函式單測推測結果;但這裡包含多個關鍵分支與 `process.exit` 行為,例如 preflight 失敗、bot 自動提交跳過、空 diff 提早結束、JSON 驗證失敗、以及偵測到 critical 後結束失敗。只要接線順序或退出碼改壞,現有測試不會第一時間抓到。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/gitea.js:71",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "`.reviewignore` 是從被審查的 PR head 直接讀回來的,提交者自己就能在同一個 PR 裡新增排除規則,把惡意檔案或關鍵目錄整批從 diff 中消失。攻擊者只要加幾條前綴,就能讓這個審查流程根本看不到真正危險的變更。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/roles.js:41",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "角色 prompt 直接從目前 checkout 的 `src/prompts/roles/*.md` 載入,等於把 system prompt 放在可被 PR 修改的位置。攻擊者只要改這些 markdown,就能改寫審查角色的指令,命令模型忽略漏洞、輸出空陣列,或把所有問題打成誤報。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:16",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "這裡把整份 diff 直接塞進 LLM 輸入,沒有做結構化封裝或輸出約束。惡意提交者可以在程式碼註解、字串或檔案內容裡埋 prompt injection,誘導模型少報、漏報,甚至捏造不該存在的問題,讓後續去重與發布流程建立在被污染的判斷上。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:153",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "這裡開始對 `is_new` 的解讀就和前面的統計邏輯不一致了:`!f.is_new` 會把 `undefined` 當成舊問題,但同檔前面的 `newFindingsOnly()` 又把 `undefined` 當新問題。之後 `formatFindingsStats`、舊問題留言、新問題留言會各自走不同分類,未來只要上游少填一個欄位,結果就會悄悄分岔,很難追。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:91",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "這裡的文字正規化規則和 `normalizeText()` 不一致,還在註解裡直接寫了「不確定是否預期」。再往下又有 `mergeFindings`、`appendExclusions`、`applyExclusions` 各自用不同簽章做去重/比對,等於同一份排除資料在不同流程可能被視為不同東西。這種規則分裂最容易在半年後變成『怎麼這筆有時候去重,有時候又新增』的維運災難。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:426",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡只要 `exPath` 或 `ex.role` 存在,就直接把 `textMatches` 跳過。實際結果是:同一個檔案、同一個角色的任何其他 finding,只要碰上這筆排除規則就會被整包濾掉,哪怕問題本質完全不同。最小重現:先把 `app/a.js` 某個誤報加入 exclusions,之後同檔同角色的另一個真問題也會一起消失。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:202",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這個去重 key 把 `suggestion` 截成前 50 個字元。只要兩筆 finding 在同檔、同角色、同位置,且建議文字前 50 字相同,後面的差異就會被吃掉。最小重現:兩個不同問題的 suggestion 都以相同開頭描述,第二筆會被當成重複直接丟失。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:303",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡回填 AI 去重結果時,也用同一個 `location + suggestion 前 50 字` 當對照鍵。只要兩筆原始 finding 的 key 撞到,`origMap` 會只留最後一筆,AI 回傳的結果就可能對到錯的原始 finding,或直接被 `filter(Boolean)` 吃掉。最小重現:同檔同位置兩筆 suggestion 前 50 字相同,去重後會錯配或少一筆。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:75",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這裡把 `file:0` 當成有效行號回傳。後續 `postFindingsReview` 和行內 critical comment 會把它送進 Gitea,但 `new_position = 0` 並不是有效 diff 行號,結果不是 API 拒絕,就是整筆 comment 被降級/略過。最小重現:LLM 回 `app/a.js:0`,流程仍會嘗試建立行內註解。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:91",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "同一個 `path|line` 的多筆 bot comment 只會保留第一筆 `botFinding`,後面的 finding 會被靜默丟掉。最小重現:同一行上有兩個不同角色或不同建議的 bot comment,reconcile 時只會帶回第一筆,另一筆不會進入 resolved/excluded/carried 清單。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/main.js:31",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "這個 orchestrator 是整條 pipeline 的入口,但目前測試都停在零件層,沒有直接驗證 `main()` 的關鍵分支:前置驗證失敗、bot 自動提交直接退出、diff 為空直接退出、JSON 驗證失敗退出、以及發現 critical 時的 exit 1。這些流程一旦接線錯了,單元測試還是可能全綠。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:267",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`postOldFindingsComment` 和緊接著的 `postNewNonCriticalComment` 都是新公開行為,但測試只覆蓋 `postNewCriticalComments` 與 `postFindingsReview`,沒有直接驗證這兩個 comment helper 的內容格式、空陣列跳過、以及 `is_new`/`level` 過濾是否正確。這會讓留言分流一旦退化,測試完全抓不到。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:249",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`mergeFindings` 與 `sortByLevel` 是 Step6 的核心邏輯,但目前沒有直接測到去重 key 的行為,也沒有測到合併後的排序是否真的維持 critical > warning > info。只靠上層流程的間接測試,對這種資料整理規則不夠穩。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/llm.js:17",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這裡把 `limit <= 0` 解讀成「不限制」,直接開到 `items.length` 個 worker。只要 finding 或對話一多,就會同時 spawn 一整排 LLM 子行程,CPU、記憶體、檔案描述元一起被打爆,熱路徑很容易從平行加速變成資源風暴。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:169",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這段為了產生約 41 行的 `codeWindow`,先把整個檔案內容從 Gitea 抓回來。大檔案時等於每個 open conversation 都在付整份檔案的網路與記憶體成本,實際只用到一小段片段,浪費量會跟檔案大小線性成長。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:228",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這個 `extractFileDiff` 一旦開始抓到目標檔案,就一路把後面的所有 diff 都塞進去,根本沒有在下一個 `diff --git` 停下來。結果本來只想餵單一檔案的提示,可能膨脹成接近整份 PR diff,每次補行號都在多燒 token 與傳輸時間。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:406",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這裡是典型的 `findings × exclusions` 雙層掃描,而且內層每次還要做字串正規化與比對。排除規則一多就變成 O(F*E) 熱點,幾百筆 finding 配幾百條 exclusions 時,白白重複掃描與正規化的成本會很明顯。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:25",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "Markdown 表格列直接嵌入 role、location、suggestion,但測試沒有覆蓋 suggestion 含 `|`、換行或 Markdown 特殊字元時的輸出。這不是要求現在一定要改格式,而是目前缺少案例確認表格在真實 LLM 輸出下不會被破壞。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:102",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "統計表與單行摘要各欄位都用 `filter(...).length` 重掃多次,同一批 findings 會被走 4 到 8 次。資料量一大,連 log 文字本身都開始吃不必要的掃描成本。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:266",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`postOldFindingsComment` 與緊接著的 `postNewNonCriticalComment` 都是這次新加的對外 comment 發布行為,但目前沒有專門測試它們的空陣列早退、標題文字與表格內容。這會讓 comment 分流邏輯只靠間接測試支撐,回歸時很容易漏掉。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/findings.js:393",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "前面已經把 exclusions 正規化、去重過一次了,這裡為了 log 又再丟進 `buildExclusionContext` 重做 normalize / dedupe / group。等於同一批資料在同一輪流程裡被重算兩次,白白多吃一輪 O(n) 到 O(n log n) 的 CPU。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:88",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這個 `codeWindow` 每遇到一筆 open conversation 就對整份檔案內容再 `split('\\n')` 一次。若同一個檔案有多條 thread,O(L) 的切割和陣列配置會被重複吃掉,明明同一份內容卻一直重複解剖。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:201",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "`reconcileConversations()` 同時在做 comment 分組、關閉遠端 review、讀檔、抽 code window、AI 裁決、再把結果拆成 resolved / excluded / carried 三條路徑,流程很完整,但也很難局部理解或替換。未來任何一段判斷要調整,都得先吞下整個函式的心智負擔,維護門檻偏高。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/usage.js:212",
|
||||||
|
"role": "Mage",
|
||||||
|
"original_finding": "這個百分比計算只擋了 `limit <= 0`,沒有擋 `remaining < 0`。最小重現是 `resolveRemainingPercent({ available: true, used: 150, limit: 100 }, null)` 或 `remaining = -1`,會算出負百分比,讓使用量摘要出現不合理的 `-50%` 之類結果,和函式註解宣告的「負數視為無法計算」不一致。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:21",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "Markdown 表格列直接把 `role`、`location`、`suggestion` 原樣插進去,沒有處理 `|`、換行或其他會破壞表格結構的字元。現在看起來能跑,但只要 LLM 產出一個含管線符號的建議,表格格式就會裂掉,後續維護者會一直在修奇怪的留言排版。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/preflight.js:126",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "`clientVersion` 被硬編成 `0.142.5`,這種版本字串沒有單一來源,時間一久幾乎一定會過期。到時候 preflight 會因為一個靜態常數失效,維護者還得回頭搜尋到底是哪裡卡住,排查成本很高。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/roles.js:181",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`buildVerdictPrompt` 是防守方裁決流程的 prompt 契約,但目前沒有任何測試確認它會帶入預設 Paladin 人設、`exclusionHint`、以及 `confirmed / false_positive` 的輸出格式。這類 prompt 一旦格式偏掉,後面的對話收斂會很難診斷。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/resolve.js:54",
|
||||||
|
"role": "Maya",
|
||||||
|
"original_finding": "`groupConversations` 目前有測 `position` 與 `original_position`,但沒有測 `new_position` 這個常見的 Gitea review comment 欄位。這代表如果 API 回來的是 `new_position`,對話分組與 bot finding 對位是否正確,現在沒有被試煉過。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/comments.js:186",
|
||||||
|
"role": "Rogue",
|
||||||
|
"original_finding": "這裡先把 `commentFindings` 全部排序,再過濾掉 `is_new === false` 的舊問題。等於對一批最後根本不會送出的資料先付一次 O(n log n) 排序成本,舊 finding 越多,這筆白工越大。",
|
||||||
|
"reason": "使用者裁定:本輪僅修復 main() 整合測試(critical #2),其餘 findings 一律判為排除(可接受現況/待後續人工處理)。"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,266 +1 @@
|
|||||||
[
|
[]
|
||||||
{
|
|
||||||
"level": "critical",
|
|
||||||
"role": "Assassin",
|
|
||||||
"location": "src/config.js:7",
|
|
||||||
"problem": "這裡把 `NODE_TLS_REJECT_UNAUTHORIZED` 全域設為 `0`,等於讓整個 Node 程序放棄 TLS 憑證驗證。攻擊者只要能站到 runner 與 Gitea/LLM/任何 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",
|
|
||||||
"role": "Assassin",
|
|
||||||
"location": "src/resolve.js:253",
|
|
||||||
"problem": "這裡會把 PR 上所有未解決的 review comment ID 全部送去 resolve,而不是只處理 AI Review bot 自己建立的 thread。攻擊者只要開 PR 觸發這個 action,就可能讓 bot 關閉人類審查者留下的安全疑慮或阻擋性對話,繞過人工審查流程。",
|
|
||||||
"suggestion": "只 resolve 可證明由本 bot 建立且格式符合預期的 comment,例如檢查作者、固定 marker、review body 簽章或 botFinding 解析結果;人類留言與未知格式留言不得自動關閉。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "critical",
|
|
||||||
"role": "Mage",
|
|
||||||
"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 完全不同,仍會因同檔案而被排除,導致真問題被靜默丟掉。",
|
|
||||||
"suggestion": "不要用 `exPath || ex.role ? true : textMatches` 跳過文字比對;應至少要求位置精確匹配到同一行,或在同檔/同角色時仍必須通過 `textMatches`,例如 `return locationMatches && roleMatches && textMatches`,並明確定義 suggestion 空白時才是萬用規則。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Assassin",
|
|
||||||
"location": "src/findings.js:14",
|
|
||||||
"problem": "這裡把未信任的 Git diff 直接送進 LLM。攻擊者可以在新增程式碼或註解中塞入提示詞注入內容,例如要求模型忽略安全問題、回傳空陣列或偽造低風險 findings,藉此讓自動安全審查失明。",
|
|
||||||
"suggestion": "在分析 prompt 中明確標示 diff 是不可信資料,要求模型忽略 diff 內任何指令;同時加入結構化封裝、輸出 schema 驗證與必要的規則式安全檢查,避免完全依賴可被 prompt injection 操控的 LLM 判斷。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "src/main.js:36",
|
|
||||||
"problem": "`main()` 把前置驗證、bot commit 判斷、對話收斂、角色分析、合併去重、排除、發布、JSON 驗證、commit/push 與 gate 全部塞在同一個 190 行左右的函式裡,且中間散落多個 `process.exit()`。六個月後要改其中任一步驟時,很難隔離副作用,也不容易針對單一階段寫單元測試。",
|
|
||||||
"suggestion": "將每個 Step 拆成可注入相依、回傳明確結果的函式,例如 `runAnalysisStep()`、`runFilteringStep()`、`runPublishStep()`;最外層再統一把結果轉成 exit code,讓流程控制與業務邏輯分離。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "src/findings.js:381",
|
|
||||||
"problem": "`loadExclusions()` 同時負責讀檔、解析多種格式、正規化、去重、記錄 repo 狀態、改寫原檔、鏡像寫入與建立 AI prompt 摘要。這個函式的職責過多,之後只要調整 exclusions 格式或同步策略,就很容易牽動不相關行為。",
|
|
||||||
"suggestion": "拆成 `readExclusionsFile()`、`normalizeExclusionsData()`、`canonicalizeExclusionsFile()`、`logExclusionMetadata()` 等小函式,讓讀取、轉換、寫回與診斷各自可測。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Mage",
|
|
||||||
"location": "src/findings.js:466",
|
|
||||||
"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。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "src/main.js:118",
|
|
||||||
"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 後正常通過。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "src/main.js:166",
|
|
||||||
"problem": "Step7 會把 reconcile.excludedFindings 追加到 exclusions,接著再載入並套用排除規則,但目前缺少整合測試驗證「誤報對話 → 寫入 exclusions → 後續 findings 被排除」這條關鍵路徑。若 append/load/apply 任一環節接錯 workspace 或 mirror,單元測試不一定會抓到。",
|
|
||||||
"suggestion": "補一個接近流程層級的測試,mock reconcileConversations 回傳 excludedFindings,準備一筆會被排除的新 finding,驗證 appendExclusions 寫入的檔案被 loadExclusions 讀到,且最後 save/post 的 filtered findings 不含該誤報。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "src/findings.js:416",
|
|
||||||
"problem": "applyExclusions 的核心比對支援「只有文字、沒有路徑/角色」的排除規則,但現有測試多半靠相同檔案路徑命中,沒有驗證純文字排除、空文字排除、大小寫/標點差異等邊界。這條排除規則的最脆弱分支還沒被測到。",
|
|
||||||
"suggestion": "補上 applyExclusions 的邊界測試:只有 suggestion/title 文字沒有 location 的 exclusion 應如何比對;空文字 exclusion 不應意外排除全部;標點、空白、大小寫正規化後相同的文字應依預期排除。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "src/gitea.js:95",
|
|
||||||
"problem": "shouldSkipBotCommit 目前只看到命中 bot marker 的測試,缺少「commit API 失敗、分支查詢失敗、sha/branch 都沒有 marker」時應回 false 的失敗與保守路徑驗證。這是避免 workflow 誤跳過審查的關鍵判斷,不能只測快樂路徑。",
|
|
||||||
"suggestion": "新增測試讓 getCommitMessageBySha / getBranchHeadCommitMessage 對應的 axios 呼叫拋錯或回一般 commit message,斷言 shouldSkipBotCommit 回 false,且不會把查詢失敗誤判成 bot commit。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "src/llm.js:91",
|
|
||||||
"problem": "runAssistantCLI 有 timeout 與 maxBuffer 兩條重要失敗路徑,但目前測試只覆蓋 CLI 非零退出,沒有驗證逾時會 kill 子程序並拒絕、輸出超過限制會中止且不產生未處理的重複 reject。這些是 CI 上最常見的失敗情境。",
|
|
||||||
"suggestion": "新增 llm 測試:用假的 CLI sleep 超過 AI_ASSISTANT_TIMEOUT_MS,斷言錯誤訊息包含逾時;再用大量 stdout/stderr 超過 AI_ASSISTANT_MAX_BUFFER,斷言錯誤訊息正確且測試過程沒有 unhandled rejection。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Rogue",
|
|
||||||
"location": "src/main.js:132",
|
|
||||||
"problem": "這裡把每個角色的 LLM 分析逐一 await,6 個角色就把總耗時堆成約 6 倍單次模型延遲;這些分析彼此獨立,CPU 沒偷到時間,反而把整條 pipeline 卡在序列網路/CLI 呼叫上。",
|
|
||||||
"suggestion": "改用 Promise.allSettled 平行執行 roles.map(role => analyzeWithRole(role, diff)),再彙整 fulfilled 結果與 warning;保留 fulfilledAnalyses 的判斷即可。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Rogue",
|
|
||||||
"location": "src/gitea.js:239",
|
|
||||||
"problem": "這裡逐一 await 每個 review 的 comments,PR review 一多就變成 N 次遠端呼叫的線性延遲累加;例如 30 個 review 就是 30 個 round-trip 排隊等,時間都被網路空轉偷走。",
|
|
||||||
"suggestion": "把 reviews.map(review => getPullReviewComments(review.id).catch(...)) 丟進 Promise.all 或 Promise.allSettled 平行抓取,再 flat 結果;單筆失敗仍可記 warn 後略過。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Rogue",
|
|
||||||
"location": "src/findings.js:430",
|
|
||||||
"problem": "applyExclusions 在 findings × exclusions 的巢狀比對裡,每遇到一條 exclusion 就重算同一個 finding 的 normalizeText;F 筆 finding、E 條 exclusion 會做最多 F×E 次正規化與正則替換,這是很明顯的 CPU 浪費。",
|
|
||||||
"suggestion": "先把 findings 預處理成含 fPath、normalizedFindingText 的陣列,exclusions 也先補齊 normalizedExclusionText,再做比對;同一筆文字只正規化一次。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Bard",
|
|
||||||
"location": "src/findings.js:138",
|
|
||||||
"problem": "註解中留下「不確定」這種未定案語氣,像樂譜上的猶豫記號;讀者無法判斷這是刻意設計、待辦事項,還是審查遺留。",
|
|
||||||
"suggestion": "若是刻意差異,改寫成明確理由;若待確認,改成可追蹤的 TODO 並標明決策者或議題。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Bard",
|
|
||||||
"location": "src/git.js:226",
|
|
||||||
"problem": "_sourceRoot` 的參數文件寫著「不確定,待確認」,讓公開函式簽名帶著未完成的旁白,破壞 API 文件的一致與可信度。",
|
|
||||||
"suggestion": "若參數已不使用,移除它;若為相容性保留,明確寫成 deprecated/compatibility note,不要留下模糊語句。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Bard",
|
|
||||||
"location": "src/config.js:4",
|
|
||||||
"problem": "註解說「需要內部服務相容時才使用 getInsecureHttpsAgent()」,下一行卻在模組載入時全域設定 TLS 環境變數,文件與程式碼唱了不同旋律。",
|
|
||||||
"suggestion": "讓註解忠實描述目前行為,或把全域設定移到明確命名的初始化函式;至少避免文件暗示這是選擇性使用。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "src/json.js:13",
|
|
||||||
"problem": "`stripCodeFence()` 與 `src/llm.js` 內的 `stripOuterFence()` 幾乎是同一個功能,未來如果要支援更多 fence 格式或修 bug,兩邊需要同步修改,容易產生行為漂移。",
|
|
||||||
"suggestion": "抽成共用的 JSON/text utility,例如 `src/text.js` 或 `src/json.js` 匯出單一 fence 清理函式,讓 LLM JSON 解析與 JSON repair 共用同一套邏輯。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "src/findings.js:104",
|
|
||||||
"problem": "文字正規化邏輯分散在 `normalizeText()`、`toKeyText()`,而 `src/resolve.js` 也有另一套 `normalizeKey()`。這些函式對大小寫、標點與空白的處理不完全一致,長期會讓 finding 去重、排除與對話收斂出現難追的差異。",
|
|
||||||
"suggestion": "建立單一 normalization 模組,明確定義 `normalizeForDisplayMatch`、`normalizeForSignature` 等用途,再讓 findings、resolve、exclusions 共用,並補上跨模組測試鎖定語意。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "src/comments.js:25",
|
|
||||||
"problem": "Markdown 表格列直接嵌入 role、location、suggestion,但測試沒有覆蓋 suggestion 含 `|`、換行或 Markdown 特殊字元時的輸出。這不是要求現在一定要改格式,而是目前缺少案例確認表格在真實 LLM 輸出下不會被破壞。",
|
|
||||||
"suggestion": "補一個 comment body 格式測試,輸入 suggestion 含 pipe、換行與粗體符號,斷言輸出的 Markdown 結構符合預期;若目前行為會破表,應先定義轉義或替換規則再測。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
|
||||||
"role": "Rogue",
|
|
||||||
"location": "src/findings.js:382",
|
|
||||||
"problem": "loadExclusions 前面已經 normalizeExclusionEntry + dedupeExclusions,這裡又呼叫 buildExclusionContext(exclusions) 重新 normalize、dedupe、group 一輪,只為了 log groups 數;排除規則多時會多跑一趟 O(e log e) 的整理成本。",
|
|
||||||
"suggestion": "讓 buildExclusionContext 可接受已正規化/已去重的 exclusions,或直接在 loadExclusions 重用現有 exclusions 進行 group 統計,避免重複正規化與排序。",
|
|
||||||
"is_new": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ jobs:
|
|||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
env:
|
env:
|
||||||
VERSION: "0.0.0-beta.${{ gitea.run_number }}"
|
VERSION: "0.0.0-beta.${{ gitea.run_number }}"
|
||||||
|
IS_BETA: ${{ gitea.base_ref == 'develop' }}
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ env.VERSION }}
|
version: ${{ env.VERSION }}
|
||||||
|
is_beta: ${{ env.IS_BETA }}
|
||||||
steps:
|
steps:
|
||||||
- name: Publishing Release
|
- name: Publishing Release
|
||||||
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
|
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
|
||||||
@@ -17,11 +19,12 @@ jobs:
|
|||||||
name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}"
|
name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}"
|
||||||
tag_name: "v${{ env.VERSION }}"
|
tag_name: "v${{ env.VERSION }}"
|
||||||
target_commitish: ${{ gitea.sha }}
|
target_commitish: ${{ gitea.sha }}
|
||||||
prerelease: ${{ gitea.base_ref == 'develop' }}
|
prerelease: ${{ env.IS_BETA }}
|
||||||
test:
|
test:
|
||||||
name: 2. TEST
|
name: 2. TEST
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
needs: [build]
|
needs: [build]
|
||||||
|
if: ${{ needs.build.outputs.is_beta == 'true' }}
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.build.outputs.version }}
|
VERSION: ${{ needs.build.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ jobs:
|
|||||||
name: DEPLOY
|
name: DEPLOY
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
env:
|
env:
|
||||||
|
GITEA_CONTEXT: ${{ toJSON(gitea) }}
|
||||||
COMMIT_SHA: ${{ gitea.event.commits[1].id }}
|
COMMIT_SHA: ${{ gitea.event.commits[1].id }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Show Gitea Context
|
||||||
|
run: echo "$GITEA_CONTEXT" | jq .
|
||||||
- name: Source Code Checkout
|
- name: Source Code Checkout
|
||||||
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
|
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
- name: Get Commit Tag
|
- name: Get Commit Tag
|
||||||
id: commit
|
id: commit
|
||||||
run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT
|
run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# AI Code Review 忽略清單
|
||||||
|
# 符合下列前綴/路徑的檔案不會納入送給 LLM 的 git diff。
|
||||||
|
# 規則:每行一個路徑前綴(相對 repo 根),# 開頭為註解,空行略過。
|
||||||
|
# 註:任何深度的 node_modules/ 一律排除(程式內建保險),此處列出僅為明示。
|
||||||
|
|
||||||
|
.gitea/
|
||||||
|
.github/
|
||||||
|
README.md
|
||||||
|
TODO.md
|
||||||
|
package-lock.json
|
||||||
|
src/package-lock.json
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
+30
-23
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chatJSON } from './llm.js';
|
import { chatJSON, mapWithConcurrency, LLM_CONCURRENCY } from './llm.js';
|
||||||
import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js';
|
import { buildAnalysisPrompt, loadRole, buildVerdictPrompt, buildLocateLinePrompt } from './roles.js';
|
||||||
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
import { FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||||||
import { line, ok, warn } from './log.js';
|
import { line, ok, warn } from './log.js';
|
||||||
@@ -368,14 +368,14 @@ function extractFileDiff(diff, file) {
|
|||||||
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
|
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
|
||||||
*/
|
*/
|
||||||
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
||||||
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS } = deps;
|
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
|
||||||
let resolved = 0;
|
// 只挑「缺行號且有檔名」的 finding;各自以獨立 LLM 子行程並行定位(併發上限見 concurrency)。
|
||||||
let pending = 0;
|
const pending = findings.filter(f => findingLine(f.location) == null
|
||||||
for (const f of findings) {
|
&& String(f.location || '').split(',')[0].split(':')[0].trim());
|
||||||
if (findingLine(f.location) != null) continue; // 已有行號
|
if (pending.length === 0) return findings;
|
||||||
|
|
||||||
|
const outcomes = await mapWithConcurrency(pending, concurrency, async (f) => {
|
||||||
const file = String(f.location || '').split(',')[0].split(':')[0].trim();
|
const file = String(f.location || '').split(',')[0].split(':')[0].trim();
|
||||||
if (!file) continue;
|
|
||||||
pending += 1;
|
|
||||||
const systemPrompt = buildLocateLinePrompt(getRole(f.role) || { name: f.role });
|
const systemPrompt = buildLocateLinePrompt(getRole(f.role) || { name: f.role });
|
||||||
const userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`;
|
const userContent = `${JSON.stringify({ file, problem: f.problem, suggestion: f.suggestion })}\n\n--- ${file} Git Diff ---\n${extractFileDiff(diff, file)}`;
|
||||||
let located = null;
|
let located = null;
|
||||||
@@ -390,12 +390,13 @@ export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
|||||||
}
|
}
|
||||||
if (located != null) {
|
if (located != null) {
|
||||||
f.location = `${file}:${located}`;
|
f.location = `${file}:${located}`;
|
||||||
resolved += 1;
|
return true;
|
||||||
} else {
|
}
|
||||||
warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`);
|
warn(`[${f.role}] ${maxAttempts} 次嘗試後仍無法定位行號,保留檔名: ${file}`);
|
||||||
}
|
return false;
|
||||||
}
|
});
|
||||||
if (pending > 0) ok(`補行號: ${resolved}/${pending} 筆成功定位`);
|
|
||||||
|
ok(`補行號: ${outcomes.filter(Boolean).length}/${pending.length} 筆成功定位`);
|
||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,13 +421,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);
|
||||||
}
|
}
|
||||||
@@ -571,10 +577,11 @@ export async function filterFalsePositivesWithAI(findings, exclusions = [], chat
|
|||||||
? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
? `${exclusionContext.prompt}\n規則:若此 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 每條 finding 各派一個防守方 sub-agent 裁決,多條時平行處理
|
// 每條 finding 各派一個防守方 sub-agent 裁決;併發上限與其他 LLM 任務共用 LLM_CONCURRENCY(預設不限制)。
|
||||||
const verdicts = await Promise.all(
|
const verdicts = await mapWithConcurrency(findings, LLM_CONCURRENCY, async (f) => ({
|
||||||
findings.map(f => judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn).then(isFP => ({ f, isFP }))),
|
f,
|
||||||
);
|
isFP: await judgeFindingIsFalsePositive(f, defender, exclusionHint, chatFn),
|
||||||
|
}));
|
||||||
const kept = verdicts.filter(v => !v.isFP).map(v => v.f);
|
const kept = verdicts.filter(v => !v.isFP).map(v => v.f);
|
||||||
ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`);
|
ok(`AI 誤報過濾(防守方${findings.length > 1 ? '平行' : ''}裁決): ${findings.length} -> ${kept.length} 筆`);
|
||||||
return kept;
|
return kept;
|
||||||
|
|||||||
+42
-10
@@ -41,15 +41,9 @@ export function getBotReviewOutcome(message) {
|
|||||||
return match?.[1]?.toLowerCase() || 'unknown';
|
return match?.[1]?.toLowerCase() || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 找不到 .reviewignore 時(例如其他 repo 未提供)採用的內建預設排除清單。
|
||||||
* 取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(.gitea/、.github/、README.md、TODO.md)。
|
// 任何深度的 node_modules/ 另由 filterDiff 內建強制排除,不倚賴此清單。
|
||||||
* 透過 Gitea `GET /repos/{repo}/pulls/{index}.diff`(純文字 diff),授權使用 GITEA_TOKEN。
|
export const DEFAULT_REVIEW_IGNORE = [
|
||||||
* @returns {Promise<string>} 過濾後的 diff 文字。
|
|
||||||
* @throws {Error} 當 Gitea API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。
|
|
||||||
*/
|
|
||||||
export async function getPRDiff() {
|
|
||||||
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
|
||||||
return filterDiff(resp.data, [
|
|
||||||
'.gitea/',
|
'.gitea/',
|
||||||
'.github/',
|
'.github/',
|
||||||
'README.md',
|
'README.md',
|
||||||
@@ -57,7 +51,45 @@ export async function getPRDiff() {
|
|||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
'src/package-lock.json',
|
'src/package-lock.json',
|
||||||
'dist/',
|
'dist/',
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 .reviewignore 文字為排除前綴陣列(gitignore 風格)。
|
||||||
|
* 規則:每行一個路徑前綴,trim 後略過空行與 `#` 開頭的註解行。
|
||||||
|
* @param {string} text - .reviewignore 檔案內容。
|
||||||
|
* @returns {string[]} 排除前綴清單。
|
||||||
|
*/
|
||||||
|
export function parseReviewIgnore(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.split('\n')
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(l => l && !l.startsWith('#'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從被審 PR 的 head ref 取得 `.reviewignore` 並解析為排除清單。
|
||||||
|
* 檔案不存在或為空時退回 {@link DEFAULT_REVIEW_IGNORE}。
|
||||||
|
* @returns {Promise<string[]>} 套用於 diff 過濾的排除前綴清單。
|
||||||
|
*/
|
||||||
|
export async function getReviewIgnore() {
|
||||||
|
const patterns = parseReviewIgnore(await getFileContentAtRef('.reviewignore'));
|
||||||
|
if (patterns.length > 0) {
|
||||||
|
line(`已套用 .reviewignore:${patterns.length} 條排除規則`);
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
return DEFAULT_REVIEW_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得目前 PR 的完整 Git diff,並依 `.reviewignore`(讀不到時用內建預設)排除不需審查的路徑。
|
||||||
|
* 透過 Gitea `GET /repos/{repo}/pulls/{index}.diff`(純文字 diff),授權使用 GITEA_TOKEN。
|
||||||
|
* @returns {Promise<string>} 過濾後的 diff 文字。
|
||||||
|
* @throws {Error} 當 Gitea 取 diff 的 API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。
|
||||||
|
*/
|
||||||
|
export async function getPRDiff() {
|
||||||
|
const patterns = await getReviewIgnore();
|
||||||
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
||||||
|
return filterDiff(resp.data, patterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+33
@@ -6,6 +6,39 @@ import { getLLMConfig } from './config.js';
|
|||||||
import { recordUsage } from './usage.js';
|
import { recordUsage } from './usage.js';
|
||||||
import { line } from './log.js';
|
import { line } from './log.js';
|
||||||
|
|
||||||
|
// 每個 LLM CLI 呼叫(角色分析、補行號等)都是一個獨立子行程。預設「不限制」併發(全部同時跑);
|
||||||
|
// 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。
|
||||||
|
// 0 / 未設定 / 非正整數 → 不限制。
|
||||||
|
export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。
|
||||||
|
*
|
||||||
|
* limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。
|
||||||
|
* fn 需自行處理例外(內部 try/catch);本函式不會因單一項目 reject 而中斷其餘工作。
|
||||||
|
* @template T, R
|
||||||
|
* @param {T[]} items - 要處理的項目。
|
||||||
|
* @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。
|
||||||
|
* @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
|
||||||
|
* @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。
|
||||||
|
*/
|
||||||
|
export async function mapWithConcurrency(items, limit, fn) {
|
||||||
|
const list = Array.isArray(items) ? items : [];
|
||||||
|
const results = new Array(list.length);
|
||||||
|
if (list.length === 0) return results;
|
||||||
|
const n = Number(limit);
|
||||||
|
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
|
||||||
|
let cursor = 0;
|
||||||
|
async function run() {
|
||||||
|
while (cursor < list.length) {
|
||||||
|
const i = cursor++;
|
||||||
|
results[i] = await fn(list[i], i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: workers }, run));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
||||||
*/
|
*/
|
||||||
|
|||||||
+26
-8
@@ -1,4 +1,5 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
|
||||||
import { loadRoles, getRoleIntro } from './roles.js';
|
import { loadRoles, getRoleIntro } from './roles.js';
|
||||||
import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js';
|
import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js';
|
||||||
@@ -9,6 +10,7 @@ import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatU
|
|||||||
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
||||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||||
import { runPreflight } from './preflight.js';
|
import { runPreflight } from './preflight.js';
|
||||||
|
import { mapWithConcurrency, LLM_CONCURRENCY } from './llm.js';
|
||||||
import { section, step, line, input, output, result, warn, error } from './log.js';
|
import { section, step, line, input, output, result, warn, error } from './log.js';
|
||||||
|
|
||||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||||
@@ -51,7 +53,7 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
|||||||
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
|
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
|
||||||
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
|
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
|
||||||
*/
|
*/
|
||||||
async function main() {
|
export async function main() {
|
||||||
section('AI Code Review Pipeline');
|
section('AI Code Review Pipeline');
|
||||||
|
|
||||||
// Step1 啟動
|
// Step1 啟動
|
||||||
@@ -120,15 +122,21 @@ async function main() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
// 各角色以獨立 LLM 子行程並行分析(併發上限見 LLM_CONCURRENCY),單一角色失敗僅 warn 後跳過。
|
||||||
const newFindings = [];
|
const newFindings = [];
|
||||||
let fulfilledAnalyses = 0;
|
let fulfilledAnalyses = 0;
|
||||||
for (const role of roles) {
|
const roleResults = await mapWithConcurrency(roles, LLM_CONCURRENCY, async (role) => {
|
||||||
try {
|
try {
|
||||||
const findings = await analyzeWithRole(role, diff);
|
return await analyzeWithRole(role, diff);
|
||||||
fulfilledAnalyses += 1;
|
|
||||||
newFindings.push(...findings);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
|
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const findings of roleResults) {
|
||||||
|
if (findings) {
|
||||||
|
fulfilledAnalyses += 1;
|
||||||
|
newFindings.push(...findings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fulfilledAnalyses === 0) {
|
if (fulfilledAnalyses === 0) {
|
||||||
@@ -209,7 +217,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/push,findings/exclusions 無法持久化到 PR 分支。
|
||||||
|
if (!repoDir) {
|
||||||
|
warn('來源分支 clone 失敗,略過 findings/exclusions 持久化(不以 WORKSPACE 當 repoDir)');
|
||||||
|
} else {
|
||||||
|
await commitAndPush(WORKSPACE, repoDir, undefined, undefined, reviewOutcome);
|
||||||
|
}
|
||||||
|
|
||||||
// Step11 嚴重問題把關
|
// Step11 嚴重問題把關
|
||||||
step('Step11', '嚴重問題把關');
|
step('Step11', '嚴重問題把關');
|
||||||
@@ -223,7 +237,11 @@ async function main() {
|
|||||||
section('Pipeline 結束');
|
section('Pipeline 結束');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(e => {
|
// 僅在作為 CLI 進入點(node src/main.js)執行時自動啟動 pipeline;
|
||||||
|
// 被 import(例如單元測試)時不自動執行,方便注入 mock 測試各分支。
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
main().catch(e => {
|
||||||
error(`Runner failed: ${e.message}`);
|
error(`Runner failed: ${e.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/*.test.js"
|
"test": "node --experimental-test-module-mocks --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -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 });
|
||||||
|
|||||||
+29
-1
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getPRDiff, filterDiff, postComment, postPullReviewComment, postPullReview, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome, listPullReviews, getPullReviewComments, listAllReviewComments, resolvePullReviewComment, getFileContentAtRef } from '../gitea.js';
|
import { getPRDiff, filterDiff, parseReviewIgnore, getReviewIgnore, DEFAULT_REVIEW_IGNORE, postComment, postPullReviewComment, postPullReview, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome, listPullReviews, getPullReviewComments, listAllReviewComments, resolvePullReviewComment, getFileContentAtRef } from '../gitea.js';
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -259,3 +259,31 @@ describe('filterDiff', () => {
|
|||||||
assert.ok(result.includes('src/main.js'));
|
assert.ok(result.includes('src/main.js'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseReviewIgnore', () => {
|
||||||
|
it('parses prefixes, skipping blanks and comments', () => {
|
||||||
|
const text = '# comment\n\n.gitea/\n dist/ \n# another\nREADME.md\n';
|
||||||
|
assert.deepEqual(parseReviewIgnore(text), ['.gitea/', 'dist/', 'README.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array for empty/nullish input', () => {
|
||||||
|
assert.deepEqual(parseReviewIgnore(''), []);
|
||||||
|
assert.deepEqual(parseReviewIgnore(null), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getReviewIgnore', () => {
|
||||||
|
it('uses patterns fetched from .reviewignore when present', async () => {
|
||||||
|
mock.method(axios, 'get', async () => ({
|
||||||
|
data: { content: Buffer.from('a/\nb/\n# c\n').toString('base64'), encoding: 'base64' },
|
||||||
|
}));
|
||||||
|
const patterns = await getReviewIgnore();
|
||||||
|
assert.deepEqual(patterns, ['a/', 'b/']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default list when .reviewignore is missing/empty', async () => {
|
||||||
|
mock.method(axios, 'get', async () => ({ data: {} }));
|
||||||
|
const patterns = await getReviewIgnore();
|
||||||
|
assert.deepEqual(patterns, DEFAULT_REVIEW_IGNORE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+40
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
|
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { extractBalancedJSON, extractJSONText, extractMeaningfulError } from '../llm.js';
|
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
||||||
@@ -273,3 +273,42 @@ describe('extractMeaningfulError', () => {
|
|||||||
assert.equal(extractMeaningfulError(null), '');
|
assert.equal(extractMeaningfulError(null), '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mapWithConcurrency', () => {
|
||||||
|
it('回傳與輸入同索引對應的結果(保序)', async () => {
|
||||||
|
const out = await mapWithConcurrency([1, 2, 3, 4], 2, async (n) => n * 10);
|
||||||
|
assert.deepEqual(out, [10, 20, 30, 40]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('遵守併發上限(同時執行數不超過 limit)', async () => {
|
||||||
|
let active = 0, peak = 0;
|
||||||
|
const wait = () => new Promise(r => setTimeout(r, 5));
|
||||||
|
await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async () => {
|
||||||
|
active += 1; peak = Math.max(peak, active);
|
||||||
|
await wait();
|
||||||
|
active -= 1;
|
||||||
|
});
|
||||||
|
assert.ok(peak <= 2, `peak=${peak} 應 <= 2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limit 大於項目數時仍全部執行', async () => {
|
||||||
|
const out = await mapWithConcurrency(['a', 'b'], 10, async (s) => s.toUpperCase());
|
||||||
|
assert.deepEqual(out, ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limit<=0 表示不限制(全部同時並行)', async () => {
|
||||||
|
let active = 0, peak = 0;
|
||||||
|
const wait = () => new Promise(r => setTimeout(r, 5));
|
||||||
|
await mapWithConcurrency([1, 2, 3, 4, 5], 0, async () => {
|
||||||
|
active += 1; peak = Math.max(peak, active);
|
||||||
|
await wait();
|
||||||
|
active -= 1;
|
||||||
|
});
|
||||||
|
assert.equal(peak, 5, `peak=${peak} 應等於項目數(不限制)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空輸入回傳空陣列', async () => {
|
||||||
|
assert.deepEqual(await mapWithConcurrency([], 3, async () => 1), []);
|
||||||
|
assert.deepEqual(await mapWithConcurrency(null, 3, async () => 1), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// main() 是整個 action 的流程總管(Step1~Step11),本測試用 node:test 的 module mock
|
||||||
|
// 把所有相依模組換成可控 stub,逐一驗證關鍵分支的 exit code 與退出時機。
|
||||||
|
// 需要 `--experimental-test-module-mocks`(見 package.json test script)。
|
||||||
|
|
||||||
|
const U = (rel) => new URL(rel, import.meta.url).href;
|
||||||
|
|
||||||
|
// 每個相依模組的預設 stub(快樂路徑:無 critical、diff 非空、角色分析成功)。
|
||||||
|
// 各測試以 overrides 覆寫要驗證的分支。
|
||||||
|
function baseStubs() {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
GITEA_REPOSITORY: 'owner/repo', PR_NUMBER: '1', PR_HEAD_BRANCH: 'feat', PR_BASE_BRANCH: 'develop',
|
||||||
|
FINDINGS_PATH: '.gitea/ai-review/findings.json', EXCLUSIONS_PATH: '.gitea/ai-review/exclusions.json',
|
||||||
|
getLLMConfig: () => ({ provider: 'codex', apiKeys: ['codex'], baseURL: null, model: 'gpt-5.5', command: 'codex' }),
|
||||||
|
},
|
||||||
|
roles: { loadRoles: () => [{ name: 'Mage' }], getRoleIntro: () => 'intro' },
|
||||||
|
gitea: {
|
||||||
|
getPRDiff: async () => 'diff --git a/x b/x\n+code',
|
||||||
|
postComment: async () => ({ id: 1 }),
|
||||||
|
getCommitMessageBySha: async () => 'normal commit',
|
||||||
|
getBotReviewOutcome: () => null,
|
||||||
|
shouldSkipBotCommit: async () => false,
|
||||||
|
},
|
||||||
|
findings: {
|
||||||
|
analyzeWithRole: async () => [],
|
||||||
|
loadOldFindings: () => [],
|
||||||
|
mergeFindings: (a, b) => [...a, ...b],
|
||||||
|
sortByLevel: (a) => a,
|
||||||
|
deduplicateWithAI: async (a) => a,
|
||||||
|
loadExclusions: () => [],
|
||||||
|
applyExclusions: (a) => a,
|
||||||
|
filterFalsePositivesWithAI: async (a) => a,
|
||||||
|
appendExclusions: () => null,
|
||||||
|
resolveMissingLineNumbers: async (a) => a,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
reconcileConversations: async () => ({ resolvedFindings: [], excludedFindings: [], carriedFindings: [], resolvedCount: 0, falsePositiveCount: 0, openCount: 0, closedCount: 0 }),
|
||||||
|
dropResolvedFindings: (a) => a,
|
||||||
|
addCarriedFindings: (a) => a,
|
||||||
|
},
|
||||||
|
comments: { saveFindings: () => {}, postFindingsReview: async () => {}, formatFindingsStatsLine: () => '' },
|
||||||
|
usage: { getRunUsage: () => ({}), getRateLimit: () => ({}), fetchAccountQuota: async () => ({}), formatUsageStats: () => '', formatUsageStatsLine: () => '' },
|
||||||
|
git: { cloneRepo: () => '/repo', commitAndPush: async () => {}, getRepoState: () => ({ branch: 'feat', shortSha: 'abc' }) },
|
||||||
|
json: { validateJSONArrayFile: async () => ({ exists: true }), ensureJSONArrayFileExists: () => {} },
|
||||||
|
preflight: { runPreflight: async () => true },
|
||||||
|
llm: { mapWithConcurrency: async (items, _l, fn) => Promise.all(items.map(fn)), LLM_CONCURRENCY: 0 },
|
||||||
|
log: { section() {}, step() {}, line() {}, input() {}, output() {}, result() {}, warn() {}, error() {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 套用 mock 並載入一份全新的 main(以 query 破快取),回傳 main()。
|
||||||
|
let importSeq = 0;
|
||||||
|
async function loadMain(overrides = {}) {
|
||||||
|
const s = baseStubs();
|
||||||
|
for (const k of Object.keys(overrides)) s[k] = { ...s[k], ...overrides[k] };
|
||||||
|
mock.module(U('../config.js'), { namedExports: s.config });
|
||||||
|
mock.module(U('../roles.js'), { namedExports: s.roles });
|
||||||
|
mock.module(U('../gitea.js'), { namedExports: s.gitea });
|
||||||
|
mock.module(U('../findings.js'), { namedExports: s.findings });
|
||||||
|
mock.module(U('../resolve.js'), { namedExports: s.resolve });
|
||||||
|
mock.module(U('../comments.js'), { namedExports: s.comments });
|
||||||
|
mock.module(U('../usage.js'), { namedExports: s.usage });
|
||||||
|
mock.module(U('../git.js'), { namedExports: s.git });
|
||||||
|
mock.module(U('../json.js'), { namedExports: s.json });
|
||||||
|
mock.module(U('../preflight.js'), { namedExports: s.preflight });
|
||||||
|
mock.module(U('../llm.js'), { namedExports: s.llm });
|
||||||
|
mock.module(U('../log.js'), { namedExports: s.log });
|
||||||
|
const mod = await import(`../main.js?seq=${importSeq++}`);
|
||||||
|
return mod.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行 main(),攔截 process.exit 並回傳 exit code(正常結束回 0)。
|
||||||
|
async function runMain(overrides) {
|
||||||
|
const main = await loadMain(overrides);
|
||||||
|
mock.method(process, 'exit', (code) => { throw Object.assign(new Error('__exit__'), { __code: code ?? 0 }); });
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
return 0;
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.__code !== undefined) return e.__code;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('main pipeline', () => {
|
||||||
|
beforeEach(() => { process.env.PR_HEAD_SHA = 'deadbeef'; });
|
||||||
|
afterEach(() => { mock.restoreAll(); delete process.env.PR_HEAD_SHA; });
|
||||||
|
|
||||||
|
it('前置驗證失敗 → exit 1', async () => {
|
||||||
|
assert.equal(await runMain({ preflight: { runPreflight: async () => false } }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('偵測到 [ai-review-bot][failure] → exit 1', async () => {
|
||||||
|
assert.equal(await runMain({
|
||||||
|
gitea: { getCommitMessageBySha: async () => 'chore: update [ai-review-bot][failure]', getBotReviewOutcome: () => 'failure' },
|
||||||
|
}), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('本次為 bot 自動提交 → exit 0(跳過審查)', async () => {
|
||||||
|
assert.equal(await runMain({ gitea: { shouldSkipBotCommit: async () => true } }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未偵測到 LLM provider → exit 1', async () => {
|
||||||
|
assert.equal(await runMain({ config: { getLLMConfig: () => ({ provider: null, apiKeys: [], baseURL: null, model: null, command: null }) } }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('diff 為空 → exit 0', async () => {
|
||||||
|
assert.equal(await runMain({ gitea: { getPRDiff: async () => ' ' } }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('所有角色分析皆失敗 → exit 1', async () => {
|
||||||
|
assert.equal(await runMain({ findings: { analyzeWithRole: async () => { throw new Error('boom'); } } }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON 格式驗證失敗 → exit 1', async () => {
|
||||||
|
assert.equal(await runMain({ json: { validateJSONArrayFile: async () => { throw new Error('bad json'); } } }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('產生 critical finding → exit 1', async () => {
|
||||||
|
const crit = [{ level: 'critical', role: 'Mage', location: 'a.js:1', suggestion: 'fix' }];
|
||||||
|
assert.equal(await runMain({ findings: { analyzeWithRole: async () => crit, filterFalsePositivesWithAI: async () => crit } }), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('無 critical → 正常走完(exit 0)', async () => {
|
||||||
|
assert.equal(await runMain(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => {
|
||||||
|
assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user