Compare commits

...
30 Commits
Author SHA1 Message Date
jiantw83 8f909e5397 fix(審查流程): 修正 bot 跳過、JSON 驗證與排除比對邊界
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
2026-07-11 12:18:24 +00:00
jiantw83 268cd05211 docs(AI Code Review): 補齊 action 與 workflow 註解說明
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
2026-07-11 11:56:18 +00:00
admin c2f41b16eb Merge pull request 'feat: 導入 AI 程式碼審查 action 並修正進入點與參數接線' (#1) from ai-review-resolve/develop-20260702-160700 into develop
CI / 2. TEST (pull_request) Has been skipped
CI / 3. RESULT (pull_request) Has been skipped
CI / 1. BUILD (pull_request) Successful in 2s
Reviewed-on: #1
2026-07-03 10:04:33 +00:00
Jeffery 5c2c37f9d1 test(main): 設 AI_REVIEW_SKIP_MAIN 略過自動執行
CI / 2. TEST (pull_request) Successful in 9m44s
CI / 3. RESULT (pull_request) Successful in 1s
CI / 1. BUILD (pull_request) Successful in 1s
2026-07-03 17:53:52 +08:00
Jeffery 5fbfec3aaf fix(main): 改用 AI_REVIEW_SKIP_MAIN 守衛 auto-run,修復 node action runtime 不執行 main 的回歸 2026-07-03 17:53:52 +08:00
Jeffery d1293bb950 chore(ai-review 狀態): 解決 critical #2、其餘 findings 轉入 exclusions
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Successful in 28s
CI / 3. RESULT (pull_request) Successful in 1s
2026-07-03 17:47:48 +08:00
Jeffery 69122dca3e chore(ci): 抽出 IS_BETA 統一 prerelease 與 test 判斷 2026-07-03 17:47:48 +08:00
Jeffery e54c3f5fbe chore(test 設定): 啟用 test module mocks flag 2026-07-03 17:47:48 +08:00
Jeffery 2f294845c8 test(main): 新增 main pipeline 各分支整合測試 2026-07-03 17:47:48 +08:00
Jeffery a7824cff29 refactor(main): 匯出 main 並守衛 auto-run 以利測試載入 2026-07-03 17:47:48 +08:00
AI Review Bot fda3331dd4 chore: update ai-review findings [ai-review-bot][failure]
CI / 3. RESULT (pull_request) Has been skipped
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 29s
2026-07-03 09:23:38 +00:00
Jeffery 11205d5b82 chore(workflows): CI 目標非 develop 跳過 test job、CD 加印 gitea context 與 fetch-tags
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 12m14s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 17:11:21 +08:00
AI Review Bot 0be25e667c chore: update ai-review findings [ai-review-bot][failure]
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 28s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 09:04:44 +00:00
Jeffery 93142e2250 test(llm): 補 mapWithConcurrency 測試
CI / 1. BUILD (pull_request) Successful in 1s
CI / 2. TEST (pull_request) Failing after 11m17s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 16:53:24 +08:00
Jeffery c521451b66 perf(LLM 併發): 角色分析與誤報/補行號裁決改為並行 sub-agent(預設不限併發) 2026-07-03 16:53:24 +08:00
AI Review Bot 94d86809d5 chore: update ai-review findings [ai-review-bot][failure]
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 29s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 06:01:07 +00:00
Jeffery 1279cae575 test(gitea): 補 .reviewignore 解析與載入測試
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 26m13s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 13:34:49 +08:00
Jeffery 47aa199e5e feat(diff 過濾): 改讀 .reviewignore 外部化 diff 排除清單 2026-07-03 13:34:49 +08:00
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
AI Review Bot fc5baf32db chore: update ai-review findings [ai-review-bot][failure]
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 29s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 03:29:16 +00:00
Jeffery 3393e43877 test(gitea): 補 filterDiff 排除 node_modules/lock 測試
CI / 1. BUILD (pull_request) Successful in 1s
CI / 2. TEST (pull_request) Failing after 6m15s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 11:22:57 +08:00
Jeffery 6868dbdc8f fix(gitea): diff 過濾排除 node_modules 與 lock 檔,避免超出 LLM 輸入上限 2026-07-03 11:22:57 +08:00
Jeffery 9e70bb2245 test(llm): 補 extractMeaningfulError 測試
CI / 1. BUILD (pull_request) Successful in 1s
CI / 2. TEST (pull_request) Failing after 32s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-03 09:12:17 +08:00
Jeffery fa9be791ee fix(llm): 錯誤訊息改抽尾端錯誤,避免被 codex banner 洗掉 2026-07-03 09:12:17 +08:00
Jeffery 51e9568ccf test(preflight): 補 codex 模型檢查與 auth 失效測試
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 38s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-02 18:47:03 +08:00
Jeffery 1118606e97 feat(preflight): Step2 前置驗證加入 codex 模型清單檢查 2026-07-02 18:47:03 +08:00
Jeffery 64af9e9e92 fix(ci): 串接 build→test 的 VERSION 輸出
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 33s
CI / 3. RESULT (pull_request) Has been skipped
2026-07-02 18:16:23 +08:00
Jeffery 7c35dc0601 fix(config): comment token 缺省時回退至 token
CI / 2. TEST (pull_request) Failing after 1s
CI / 3. RESULT (pull_request) Has been skipped
CI / 1. BUILD (pull_request) Successful in 2s
2026-07-02 18:13:27 +08:00
25 changed files with 1357 additions and 127 deletions
+398
View File
@@ -0,0 +1,398 @@
[
{
"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 註解並存屬設計選擇。"
},
{
"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 的 commentsPR 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 commentreconcile 時只會帶回第一筆,另一筆不會進入 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
View File
@@ -0,0 +1 @@
[]
+97 -29
View File
@@ -1,44 +1,112 @@
# 用途:CI workflow 的 command-file 草稿,保留原始流程並補上逐行說明。
# 更新時間:2026/07/11 19:00:45
# workflow 名稱,對應 Gitea UI 中的顯示標題。
name: CI name: CI
# 定義此 workflow 的觸發事件。
on: on:
# 在 pull request 事件時執行。
pull_request: pull_request:
# 只在建立與同步更新 PR 時觸發。
types: [opened, synchronize] types: [opened, synchronize]
# workflow 內的工作列表。
jobs: jobs:
# 第一個 job:負責版本計算與 release 發佈。
build: build:
# job 顯示名稱,用來區分執行階段。
name: 1. BUILD name: 1. BUILD
# 使用 Ubuntu runner 執行。
runs-on: ubuntu runs-on: ubuntu
# job 層級環境變數。
env: env:
VERSION: "0.0.0-beta.${{ gitea.run_number }}" # 目標分支為 develop 時視為 beta。
steps: IS_BETA: ${{ gitea.base_ref == 'develop' }}
- name: Publishing Release # 對外輸出供後續 job 使用。
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
with:
name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}"
tag_name: "v${{ env.VERSION }}"
target_commitish: ${{ gitea.sha }}
prerelease: ${{ gitea.base_ref == 'develop' }}
test:
name: 2. TEST
runs-on: ubuntu
needs: [build]
outputs: outputs:
message: ${{ steps.composite-template.outputs.message }} # 輸出版本字串。
version: ${{ env.VERSION }}
# 輸出是否為 beta。
is_beta: ${{ env.IS_BETA }}
# build job 的執行步驟。
steps: steps:
- name: Setup LLM CLI # 先依 repo 狀態計算版本號。
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }} - name: Calculate Version
with: # 供後續步驟讀取輸出用的 step id。
oauth: ${{ secrets.LLM_OAUTH }} id: calculate-version
- name: Run AI Code Review # 使用版本計算 action。
id: ai-code-review uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }}
uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }} # 傳入 action 參數。
with: with:
token: ${{ secrets.TOKEN }} # 告知 action 是否為 beta 分支情境。
model: ${{ vars.LLM_NAME }} is_beta: ${{ env.IS_BETA }}
result: # 依計算出的版本建立 release。
name: 3. RESULT - name: Publishing Release
# 使用 release action。
uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }}
# 這個 step 的環境變數。
env:
# 取前一步算出的版本號。
VERSION: ${{ steps.calculate-version.outputs.version }}
# release action 的參數。
with:
# release 名稱。
name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}"
# release tag 名稱。
tag_name: "v${{ env.VERSION }}"
# 指向目前提交。
target_commitish: ${{ gitea.sha }}
# beta 情境時標記為 prerelease。
prerelease: ${{ env.IS_BETA }}
# 第二個 job:在 beta 情境執行 AI Code Review。
test:
# job 顯示名稱。
name: 2. TEST
# 使用 Ubuntu runner。
runs-on: ubuntu runs-on: ubuntu
needs: [build,test] # 必須等 build job 完成。
needs: [build]
# 只有 beta 情境才執行。
if: ${{ needs.build.outputs.is_beta == 'true' }}
# job 層級環境變數。
env: env:
# 使用 build job 輸出的版本號。
VERSION: ${{ needs.build.outputs.version }} VERSION: ${{ needs.build.outputs.version }}
# test job 的步驟。
steps: steps:
- name: Show Version # 安裝或設定 LLM CLI。
run: echo "$VERSION" - name: Setup LLM CLI
# 使用對應的 setup action。
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
# 傳入設定。
with:
# LLM CLI 的 OAuth 憑證。
oauth: ${{ secrets.LLM_OAUTH }}
# 執行 AI Code Review action。
- name: Run AI Code Review
# step id,方便追蹤。
id: ai-code-review
# 使用本 repo 發佈的 action。
uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }}
# action 參數。
with:
# 存取 Gitea API 的 token。
token: ${{ secrets.TOKEN }}
# 指定 LLM 模型名稱。
model: ${{ vars.LLM_NAME }}
# 第三個 job:輸出最終版本資訊。
result:
# job 顯示名稱。
name: 3. RESULT
# 使用 Ubuntu runner。
runs-on: ubuntu
# 需等待前兩個 job。
needs: [build,test]
# job 層級環境變數。
env:
# 延續 build 的版本號。
VERSION: ${{ needs.build.outputs.version }}
# result job 的步驟。
steps:
# 顯示版本號。
- name: Show Version
# 將版本輸出到 log。
run: echo "$VERSION"
+41 -10
View File
@@ -1,21 +1,52 @@
# 用途:master workflow 的 command-file 草稿,保留原始流程並補上逐行說明。
# 更新時間:2026/07/11 19:00:45
# workflow 名稱,對應 Gitea UI 中的顯示標題。
name: CD name: CD
# 定義此 workflow 的觸發事件。
on: on:
# 在 push 事件時執行。
push: push:
# 限定觸發分支。
branches: branches:
- master # 只有推送到 master 分支才執行。
- master
# workflow 內的工作列表。
jobs: jobs:
# 單一 job:輸出 context、檢查 commit tag。
deploy: deploy:
# job 顯示名稱。
name: DEPLOY name: DEPLOY
# 使用 Ubuntu runner。
runs-on: ubuntu runs-on: ubuntu
# job 層級環境變數。
env: env:
# 將完整 Gitea context 轉成 JSON 字串。
GITEA_CONTEXT: ${{ toJSON(gitea) }}
# 取第二筆 commit 的 id 作為查詢目標。
COMMIT_SHA: ${{ gitea.event.commits[1].id }} COMMIT_SHA: ${{ gitea.event.commits[1].id }}
# deploy job 的步驟。
steps: steps:
- name: Source Code Checkout # 顯示 Gitea context。
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - name: Show Gitea Context
with: # 將 context 格式化輸出。
fetch-depth: 0 run: echo "$GITEA_CONTEXT" | jq .
- name: Get Commit Tag # 取回完整原始碼與 tags。
id: commit - name: Source Code Checkout
run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT # 使用 checkout action。
- name: Show Tag uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
run: echo "${{ steps.commit.outputs.tag }}" # checkout 參數。
with:
# 取得完整歷史。
fetch-depth: 0
# 一併抓取 tags。
fetch-tags: true
# 查詢指定 commit 對應的 tag。
- name: Get Commit Tag
# 供後續步驟讀取輸出。
id: commit
# 把查到的 tag 寫入 action 輸出。
run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT
# 顯示剛查到的 tag。
- name: Show Tag
# 將 tag 印到 log。
run: echo "${{ steps.commit.outputs.tag }}"
+48 -8
View File
@@ -1,9 +1,49 @@
# GITEA NODE ACTION 工作流列表 # GITEA NODE ACTION 工作流說明草稿
- CI 更新時間:2026/07/11 18:54:51
- BUILD
- TEST ## 總覽
- RESULT
- CD 此專案目前包含兩個 workflow:
- BUILD
- DEPLOY - `CI`:處理 pull request 期間的版本計算、釋出與 AI 程式碼審查。
- `CD`:處理推送到 `master` 分支後的部署相關檢查與資訊輸出。
## Workflow 明細
### CI
- 檔案位置:`.gitea/workflows/ci.yaml`
- 用途:在 pull request 事件中計算版本,必要時建立 release,並在 beta 情境下執行 AI 程式碼審查。
- 觸發條件:`pull_request`,事件類型為 `opened``synchronize`
- 主要輸入 / 環境參數:
- `gitea.base_ref`:用來判斷是否為 `develop`,進而決定 `IS_BETA`
- `vars.ACTION_CALCULATE_VERSION`:提供 `calculate-version` action 的版本。
- `vars.ACTION_GITEA_RELEASE_VERSION`:提供 release action 的版本。
- `secrets.LLM_OAUTH`:設定 LLM CLI 的 OAuth。
- `secrets.TOKEN`:提供 AI 程式碼審查 action 存取 Gitea API。
- `vars.LLM_NAME`:指定審查使用的模型名稱。
- 重要注意事項:
- `test` job 只會在 `IS_BETA == true` 時執行,也就是 pull request 目標分支為 `develop` 時。
- `Publishing Release` 會使用 `VERSION``gitea.sha` 建立 release 與 tag。
- 若變數或 secret 未設定,對應步驟會失敗,需人工確認部署前置條件。
### CD
- 檔案位置:`.gitea/workflows/master.yaml`
- 用途:在 `master` 分支推送後輸出 Gitea context、檢查提交標籤,作為後續部署流程的基礎。
- 觸發條件:`push``master` 分支。
- 主要輸入 / 環境參數:
- `gitea` 事件內容:轉成 `GITEA_CONTEXT` 後交給 `jq` 顯示。
- `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢 commit tag。
- `vars.ACTION_CHECKOUT_VERSION`:提供 `actions/checkout` 的版本。
- `GITEA_OUTPUT`:寫入 `git describe --contains` 的結果。
- 重要注意事項:
- `COMMIT_SHA` 取用 commits 陣列的第 2 筆資料,若 push 事件實際只有 1 筆 commit,需人工確認是否會發生索引風險。
- `Get Commit Tag` 依賴完整的 git 歷史與 tags,因此 checkout 已設定 `fetch-depth: 0``fetch-tags: true`
- `Show Gitea Context` 會輸出完整事件內容,若包含敏感資訊,需注意執行環境的日誌保存策略。
## 備註
- 本檔為草稿版本,僅整理 workflow 行為與設定重點,不修改任何 workflow 實際邏輯。
- 若後續要覆蓋正式檔,請先確認 `ci.yaml``master.yaml` 的變數與 secret 已在目標環境中正確配置。
+13
View File
@@ -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/
+18
View File
@@ -1,16 +1,34 @@
# 用途:AI Code Review Action 的設定檔草稿,保留原始輸入與 Node 入口,並補上逐行說明。
# 更新時間:2026/07/11 18:54:51
# Action 顯示名稱,讓工作流程與使用者介面可以辨識此動作。
name: 'AI Code Review' name: 'AI Code Review'
# Action 的簡短用途說明。
description: 'AI 程式碼審查' description: 'AI 程式碼審查'
# 作者或維護者名稱。
author: 'Jeffery' author: 'Jeffery'
# 定義此 Action 對外提供的輸入參數。
inputs: inputs:
# 用於存取 Gitea API 的授權 Token。
token: token:
# token 參數的用途說明。
description: '操作 Gitea API 的 Token' description: '操作 Gitea API 的 Token'
# 此參數為必要,執行時必須提供。
required: true required: true
# 用於存取 Gitea Commit API 的授權 Token。
comment_token: comment_token:
# comment_token 參數的用途說明。
description: '操作 Gitea Commit API 的 Token' description: '操作 Gitea Commit API 的 Token'
# 此參數為選填,沒有提供時不影響 Action 啟動。
required: false required: false
# 指定執行 AI 程式碼審查時所使用的模型名稱。
model: model:
# model 參數的用途說明。
description: '執行 AI 程式碼審查使用的 LLM 名稱' description: '執行 AI 程式碼審查使用的 LLM 名稱'
# 此參數為選填,未提供時由執行環境或預設值決定。
required: false required: false
# 宣告 Action 的執行方式與主要進入點。
runs: runs:
# 使用 Node.js 24 執行此 Action。
using: 'node24' using: 'node24'
# Action 的主要進入檔,實際邏輯從此檔案開始。
main: 'src/main.js' main: 'src/main.js'
+2 -1
View File
@@ -69,7 +69,8 @@ export function parseLocation(location) {
if (trimmed.includes(',')) return null; if (trimmed.includes(',')) return null;
const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/); const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/);
if (!match) return null; if (!match) return null;
return { file: match[1], line: Number(match[2]) }; const line = Number(match[2]);
return line > 0 ? { file: match[1], line } : null;
} }
/** 行內 comment 內容:等級/審查員/建議 */ /** 行內 comment 內容:等級/審查員/建議 */
+19 -1
View File
@@ -30,7 +30,7 @@ const PR = EVENT.pull_request || {};
// 無對應輸入的欄位(server url / repository / PR_*)則走專用 env → runner 內建 / 事件 payload。 // 無對應輸入的欄位(server url / repository / PR_*)則走專用 env → runner 內建 / 事件 payload。
// 使用端 workflow 只需傳 `with: token`。 // 使用端 workflow 只需傳 `with: token`。
export const GITEA_TOKEN = process.env.INPUT_TOKEN || process.env.GITEA_TOKEN || ''; export const GITEA_TOKEN = process.env.INPUT_TOKEN || process.env.GITEA_TOKEN || '';
export const GITEA_COMMENT_TOKEN = process.env.INPUT_COMMENT_TOKEN || process.env.GITEA_COMMENT_TOKEN || ''; export const GITEA_COMMENT_TOKEN = process.env.INPUT_COMMENT_TOKEN || process.env.GITEA_COMMENT_TOKEN || process.env.INPUT_TOKEN || process.env.GITEA_TOKEN || '';
export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || process.env.GITHUB_SERVER_URL || 'https://gitea.com'; export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || process.env.GITHUB_SERVER_URL || 'https://gitea.com';
export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || process.env.GITHUB_REPOSITORY || ''; export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || process.env.GITHUB_REPOSITORY || '';
export const PR_NUMBER = process.env.PR_NUMBER || (PR.number != null ? String(PR.number) : ''); export const PR_NUMBER = process.env.PR_NUMBER || (PR.number != null ? String(PR.number) : '');
@@ -51,6 +51,12 @@ export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。 * @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
*/ */
let _insecureHttpsAgent = null; let _insecureHttpsAgent = null;
/**
* 取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。
*
* @remarks 只應在信任的內網或測試環境使用;若需要完整 TLS 安全性,應改用預設
* `https.Agent`,不要調用這個函式。
*/
export function getInsecureHttpsAgent() { export function getInsecureHttpsAgent() {
return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false })); return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
} }
@@ -86,10 +92,22 @@ const CLI_CANDIDATES = [
}, },
]; ];
/**
* 取得目前支援的 LLM CLI 指令名稱清單。
*
* @remarks 內容直接取自 `CLI_CANDIDATES`,若日後候選清單增減,輸出會同步變動。
*/
export function getLLMCLICommands() { export function getLLMCLICommands() {
return CLI_CANDIDATES.map(c => c.command); return CLI_CANDIDATES.map(c => c.command);
} }
/**
* 檢查指定 CLI 指令是否可在目前環境中執行。
*
* @param {string} command - 要檢查的指令名稱。
* @returns {boolean} 找得到指令時回傳 `true`,否則回傳 `false`。
* @remarks 透過 `/bin/sh -lc "command -v <command>"` 檢查,屬於同步存在性檢查。
*/
function commandExists(command) { function commandExists(command) {
try { try {
execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' }); execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' });
+37 -24
View File
@@ -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';
@@ -111,6 +111,12 @@ function cleanText(value) {
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複跑 NFKC/正則替換。 * 以模組層級 Map 對「字串輸入」做 memoization,避免重複跑 NFKC/正則替換。
*/ */
const _normalizeTextCache = new Map(); const _normalizeTextCache = new Map();
/**
* 將文字正規化成比對用形式。
*
* @param {*} value - 任意值。
* @remarks 適合用於誤報過濾與排除條目比對。
*/
export function normalizeText(value) { export function normalizeText(value) {
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value); if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
const result = cleanText(value) const result = cleanText(value)
@@ -368,14 +374,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 +396,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}`);
if (pending > 0) ok(`補行號: ${resolved}/${pending} 筆成功定位`); return false;
});
ok(`補行號: ${outcomes.filter(Boolean).length}/${pending.length} 筆成功定位`);
return findings; return findings;
} }
@@ -420,13 +427,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);
} }
@@ -536,7 +548,7 @@ export function applyExclusions(findings, exclusions) {
const fPath = String(f.location).split(':')[0]; const fPath = String(f.location).split(':')[0];
const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null); const exPath = ex.filePath || (ex.location ? String(ex.location).split(':')[0] : null);
const findingText = normalizeText(f.suggestion || f.title || ''); const findingText = normalizeText(f.suggestion || f.title || '');
const exclusionText = ex.textKey || normalizeText(ex.text || ex.suggestion || ex.title || ''); const exclusionText = normalizeText(ex.text || ex.original_finding || ex.suggestion || ex.title || ex.textKey || '');
const locationMatches = (!exPath || fPath === exPath); const locationMatches = (!exPath || fPath === exPath);
const roleMatches = (!ex.role || ex.role === f.role); const roleMatches = (!ex.role || ex.role === f.role);
const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText); const textMatches = !exclusionText || !findingText || findingText.includes(exclusionText) || exclusionText.includes(findingText);
@@ -571,10 +583,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;
+54 -16
View File
@@ -41,20 +41,55 @@ export function getBotReviewOutcome(message) {
return match?.[1]?.toLowerCase() || 'unknown'; return match?.[1]?.toLowerCase() || 'unknown';
} }
// 找不到 .reviewignore 時(例如其他 repo 未提供)採用的內建預設排除清單。
// 任何深度的 node_modules/ 另由 filterDiff 內建強制排除,不倚賴此清單。
export const DEFAULT_REVIEW_IGNORE = [
'.gitea/',
'.github/',
'README.md',
'TODO.md',
'package-lock.json',
'src/package-lock.json',
'dist/',
];
/** /**
* 取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(.gitea/、.github/、README.md、TODO.md)。 * 解析 .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。 * 透過 Gitea `GET /repos/{repo}/pulls/{index}.diff`(純文字 diff),授權使用 GITEA_TOKEN。
* @returns {Promise<string>} 過濾後的 diff 文字。 * @returns {Promise<string>} 過濾後的 diff 文字。
* @throws {Error} 當 Gitea API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。 * @throws {Error} 當 Gitea 取 diff 的 API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。
*/ */
export async function getPRDiff() { 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 }); const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
return filterDiff(resp.data, [ return filterDiff(resp.data, patterns);
'.gitea/',
'.github/',
'README.md',
'TODO.md',
]);
} }
/** /**
@@ -111,10 +146,10 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
*/ */
export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) { export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) {
const shaMessage = await getCommitMessageBySha(sha); const shaMessage = await getCommitMessageBySha(sha);
if (sha && shaMessage.includes('[ai-review-bot]')) return true; if (sha && shaMessage.includes('[ai-review-bot]') && getBotReviewOutcome(shaMessage) !== 'failure') return true;
const branchMessage = await getBranchHeadCommitMessage(branch); const branchMessage = await getBranchHeadCommitMessage(branch);
if (branch && branchMessage.includes('[ai-review-bot]')) return true; if (branch && branchMessage.includes('[ai-review-bot]') && getBotReviewOutcome(branchMessage) !== 'failure') return true;
return false; return false;
} }
@@ -126,13 +161,16 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。 * @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
* @returns {string} 過濾後重新接合的 diff 文字。 * @returns {string} 過濾後重新接合的 diff 文字。
*/ */
export function filterDiff(diff, excludePrefixes) { export function filterDiff(diff, excludePrefixes = []) {
return diff.split(/(?=^diff --git )/m) return diff.split(/(?=^diff --git )/m)
.filter(block => !excludePrefixes.some(p => { .filter(block => {
const prefix = `diff --git a/${p}`; const m = block.match(/^diff --git a\/(.+?) b\//);
const singleFile = `diff --git a/${p} b/${p}`; const path = m ? m[1] : '';
return block.startsWith(prefix) || block.startsWith(singleFile); if (!path) return true;
})) // 一律排除任何深度的 node_modulesvendored 依賴不是審查對象,且會撐爆 LLM 輸入上限。
if (/(^|\/)node_modules\//.test(path)) return false;
return !excludePrefixes.some(p => path === p || path.startsWith(p));
})
.join(''); .join('');
} }
+4 -1
View File
@@ -109,7 +109,10 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
const repaired = await repairer(fullPath, label, original); const repaired = await repairer(fullPath, label, original);
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`; const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。 // 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
JSON.parse(normalized); const parsed = JSON.parse(normalized);
if (!Array.isArray(parsed)) {
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
}
fs.writeFileSync(fullPath, normalized, 'utf8'); fs.writeFileSync(fullPath, normalized, 'utf8');
ok(`${label} 已由 AI 修正並通過再次驗證`); ok(`${label} 已由 AI 修正並通過再次驗證`);
return { exists: true, valid: true, repaired: true }; return { exists: true, valid: true, repaired: true };
+82 -2
View File
@@ -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 呼叫用的輸入。
*/ */
@@ -23,6 +56,16 @@ function buildPrompt(systemPrompt, userContent) {
].join('\n'); ].join('\n');
} }
/**
* 依不同 AI provider 產生 CLI 參數。
*
* @param {*} provider - AI provider 名稱。
* @param {*} model - 模型名稱。
* @param {*} promptFile - prompt 檔路徑,供 `opencode` 使用。
* @param {*} prompt - 直接傳給 CLI 的 prompt 文字,供部分 provider 使用。
* @remarks 適合把不同 CLI 的參數差異集中管理。
* @remarks 目前支援的 provider 名稱是硬編碼的,新增 provider 時需人工確認是否同步更新所有呼叫端。
*/
function cliArgs({ provider, model, promptFile = null, prompt = null }) { function cliArgs({ provider, model, promptFile = null, prompt = null }) {
if (provider === 'codex') { if (provider === 'codex') {
return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-']; return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
@@ -39,12 +82,50 @@ function cliArgs({ provider, model, promptFile = null, prompt = null }) {
throw new Error(`不支援的 AI 助理 CLI: ${provider}`); throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
} }
/**
* 從 CLI 輸出中抽出「真正有意義的錯誤」。
*
* 像 codex 這類 CLI 會先印出一大段 bannerworkdir/model/...)與回顯的 prompt
* 真正的失敗原因(例如 401、token 失效、額度不足)通常落在**尾端**。直接取前段
* 會被 banner/prompt 洗掉,因此改為:先抽出看起來像錯誤的行;抽不到再退取尾段。
*
* @param {string} raw - CLI 的原始輸出(stderr 或 stdout)。
* @param {number} [limit=1000] - 回傳字串長度上限。
* @returns {string} 最能說明失敗原因的片段。
*/
export function extractMeaningfulError(raw, limit = 1000) {
const text = String(raw || '').trim();
const errorLines = text
.split('\n')
.filter(l => /\bERROR\b|error:|unauthorized|invalidated|revoked|forbidden|\b40[13]\b|rate.?limit|quota|insufficient/i.test(l));
const picked = (errorLines.length ? errorLines.join('\n') : text).trim();
return picked.length > limit ? picked.slice(-limit) : picked;
}
/**
* 將 CLI 例外整理成較精簡的錯誤摘要。
*
* @param {*} e - 被拋出的錯誤物件,可能含 `stderr`、`stdout`、`message`。
* @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
* @remarks 若錯誤物件結構和預期不同,仍會退回字串化處理,屬保守容錯。
*/
function summarizeCliError(e) { function summarizeCliError(e) {
const stderr = String(e.stderr || '').trim(); const stderr = String(e.stderr || '').trim();
const stdout = String(e.stdout || '').trim(); const stdout = String(e.stdout || '').trim();
return (stderr || stdout || e.message || String(e)).slice(0, 1000); return extractMeaningfulError(stderr || stdout || e.message || String(e));
} }
/**
* 執行 AI 助理 CLI 並回傳純文字結果。
*
* @param {*} provider - CLI provider 名稱。
* @param {*} command - 實際可執行指令。
* @param {*} model - 要使用的模型名稱。
* @param {*} prompt - 送給 CLI 的完整 prompt 內容。
* @remarks 適合用在需呼叫外部 AI CLI 的情境。
* @remarks 逾時與輸出上限由環境變數控制,預設值是保守設定。
* @remarks 若子行程回傳非 0,錯誤訊息會由上層摘要處理。
*/
async function runAssistantCLI({ provider, command, model }, prompt) { async function runAssistantCLI({ provider, command, model }, prompt) {
let tempDir = null; let tempDir = null;
let promptFile = null; let promptFile = null;
@@ -67,7 +148,6 @@ async function runAssistantCLI({ provider, command, model }, prompt) {
child.kill('SIGTERM'); child.kill('SIGTERM');
reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`)); reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`));
}, timeout); }, timeout);
const append = (kind, chunk) => { const append = (kind, chunk) => {
if (kind === 'stdout') stdout += chunk; if (kind === 'stdout') stdout += chunk;
else stderr += chunk; else stderr += chunk;
+27 -10
View File
@@ -9,6 +9,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 +52,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 +121,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 +216,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', '嚴重問題把關');
@@ -223,7 +236,11 @@ async function main() {
section('Pipeline 結束'); section('Pipeline 結束');
} }
main().catch(e => { // 預設一律自動啟動 pipeline(正式以 node 執行、或由 Gitea node action runtime 載入時皆會執行)。
error(`Runner failed: ${e.message}`); // 僅單元測試會設 AI_REVIEW_SKIP_MAIN=1 略過自動執行,改為手動呼叫 export 的 main() 注入 mock 測試。
process.exit(1); if (!process.env.AI_REVIEW_SKIP_MAIN) {
}); main().catch(e => {
error(`Runner failed: ${e.message}`);
process.exit(1);
});
}
+1 -1
View File
@@ -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",
+78 -6
View File
@@ -1,4 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import fs from 'fs';
import os from 'os';
import { join } from 'path';
import { import {
GITEA_TOKEN, GITEA_TOKEN,
GITEA_COMMENT_TOKEN, GITEA_COMMENT_TOKEN,
@@ -11,6 +14,9 @@ import {
import { verifyRemoteAccess } from './git.js'; import { verifyRemoteAccess } from './git.js';
import { step, line, ok, error, result } from './log.js'; import { step, line, ok, error, result } from './log.js';
// codex 內部用來取得帳號可用模型清單的端點;auth 失效時會回 HTTP 401。
const CODEX_MODELS_ENDPOINT = 'https://chatgpt.com/backend-api/codex/models';
const httpsAgent = getInsecureHttpsAgent(); const httpsAgent = getInsecureHttpsAgent();
/** /**
* 組出 Gitea REST API v1 的完整網址。 * 組出 Gitea REST API v1 的完整網址。
@@ -95,22 +101,87 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
} }
} }
/**
* 讀取本機 codex 認證檔,向模型清單端點確認帳號目前可用的模型 slug。
*
* 用途:preflight 期即時分辨「auth 失效(HTTP 401)」與「模型無權限(不在清單)」,
* 不必等到 Step5 每個角色送 prompt 才神秘失敗。只讀清單、不送 prompt,不消耗生成額度。
* 所有錯誤都被攔截並轉為回傳值,不會 throw。
*
* @param {object} [deps] - 可注入相依,供測試避免真的讀檔/打網路。
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
* @param {string} [deps.authPath=~/.codex/auth.json] - codex 認證檔路徑。
* @param {string} [deps.clientVersion] - 帶給端點的 client_version 查詢參數。
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
* 成功回傳可用模型 slug 陣列;失敗回傳格式化錯誤訊息。
*/
export async function fetchCodexModels({
fetchImpl = fetch,
authPath = join(os.homedir(), '.codex', 'auth.json'),
clientVersion = '0.142.5',
} = {}) {
let auth;
try {
auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
} catch (e) {
return { ok: false, error: `無法讀取 codex 認證檔(${authPath}: ${e.message}` };
}
const tokens = auth.tokens || {};
if (!tokens.access_token) return { ok: false, error: 'codex 認證檔缺少 tokens.access_token' };
const headers = { Authorization: `Bearer ${tokens.access_token}` };
if (tokens.account_id) headers['chatgpt-account-id'] = tokens.account_id;
let resp;
try {
resp = await fetchImpl(`${CODEX_MODELS_ENDPOINT}?client_version=${clientVersion}`, { headers });
} catch (e) {
return { ok: false, error: `codex 模型清單查詢連線錯誤: ${e.message}` };
}
if (resp.status === 401) {
return { ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' };
}
if (!resp.ok) {
return { ok: false, error: `codex 模型清單查詢失敗(HTTP ${resp.status}` };
}
let data;
try {
data = await resp.json();
} catch (e) {
return { ok: false, error: `codex 模型清單回應解析失敗: ${e.message}` };
}
const slugs = Array.isArray(data.models) ? data.models.map(m => m.slug).filter(Boolean) : [];
return { ok: true, slugs };
}
/** /**
* 驗證 LLM(AI 助理 CLI)設定可用。 * 驗證 LLM(AI 助理 CLI)設定可用。
* *
* 確認目前環境可偵測到支援的 CLI且已解析出 model。實際模型可用性由 CLI * 確認目前環境可偵測到支援的 CLI 且已解析出 modelprovider 為 codex 時,
* 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度 * 額外向模型清單端點確認 auth 有效且設定的 model 在可用清單內(不送 prompt)
* @param {object} [deps] - 可注入相依,供測試。
* @param {Function} [deps.fetchCodexModelsFn=fetchCodexModels] - codex 模型清單取得函式。
* @returns {Promise< * @returns {Promise<
* {ok: true, provider: string, command: string, model: string} | * {ok: true, provider: string, command: string, model: string, models?: string[]} |
* {ok: false, provider?: string, error: string} * {ok: false, provider?: string, command?: string, model?: string, error: string}
* >} * >}
* 通過時含 provider、command model;未設定 provider 的失敗分支不含 provider 欄位 * 通過時含 provider、command、modelcodex 另含 models 清單);未設定 provider 的失敗分支不含 provider。
* @remarks 設定來源為 config.js 的 getLLMConfig()。 * @remarks 設定來源為 config.js 的 getLLMConfig()。
*/ */
export async function verifyLLM() { export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {}) {
const { provider, command, model } = getLLMConfig(); const { provider, command, model } = getLLMConfig();
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' }; if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' };
if (!model) return { ok: false, provider, error: '未設定 MODEL' }; if (!model) return { ok: false, provider, error: '未設定 MODEL' };
if (provider === 'codex') {
const models = await fetchCodexModelsFn();
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
if (!models.slugs.includes(model)) {
return { ok: false, provider, command, model, error: `模型 ${model} 不在 codex 可用清單: [${models.slugs.join(', ')}]` };
}
return { ok: true, provider, command, model, models: models.slugs };
}
return { ok: true, provider, command, model }; return { ok: true, provider, command, model };
} }
@@ -174,6 +245,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
return false; return false;
} }
ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model}`); ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model}`);
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
result(true, '前置驗證通過'); result(true, '前置驗證通過');
return true; return true;
+21 -9
View File
@@ -75,19 +75,21 @@ 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, botFindings: [] });
} }
const g = groups.get(key); const g = groups.get(key);
if (c?.id != null) g.commentIds.push(c.id); if (c?.id != null) g.commentIds.push(c.id);
const body = typeof c?.body === 'string' ? c.body : ''; const body = typeof c?.body === 'string' ? c.body : '';
if (body) g.bodies.push(body); if (body) g.bodies.push(body);
if (c?.resolver) g.resolved = true; if (c?.resolver) g.resolved = true;
if (!g.botFinding) { const finding = parseBotReviewComment(body);
const finding = parseBotReviewComment(body); if (finding) {
if (finding) g.botFinding = { ...finding, location: lineNum ? `${filePath}:${lineNum}` : filePath }; const normalizedFinding = { ...finding, location: lineNum ? `${filePath}:${lineNum}` : filePath };
g.botFindings.push(normalizedFinding);
if (!g.botFinding) g.botFinding = normalizedFinding;
} }
} }
return [...groups.values()].map(g => ({ ...g, thread: g.bodies.join('\n---\n') })); return [...groups.values()].map(g => ({ ...g, thread: g.bodies.join('\n---\n') }));
@@ -146,8 +148,12 @@ export async function judgeConversations(items, chatFn = chatJSON) {
* @returns {void} * @returns {void}
*/ */
function pushCarried(target, conversation) { function pushCarried(target, conversation) {
if (!conversation.botFinding) return; const findings = conversation.botFindings?.length
target.push({ ...conversation.botFinding, is_new: false }); ? conversation.botFindings
: (conversation.botFinding ? [conversation.botFinding] : []);
for (const finding of findings) {
target.push({ ...finding, is_new: false });
}
} }
/** /**
@@ -273,10 +279,16 @@ export async function reconcileConversations(deps = {}) {
const verdict = verdictByIdx.get(i) || 'open'; const verdict = verdictByIdx.get(i) || 'open';
if (verdict === 'resolved') { if (verdict === 'resolved') {
resolvedCount += 1; resolvedCount += 1;
if (c.botFinding) resolvedFindings.push({ ...c.botFinding, is_new: false }); const findings = c.botFindings?.length ? c.botFindings : (c.botFinding ? [c.botFinding] : []);
for (const finding of findings) {
resolvedFindings.push({ ...finding, is_new: false });
}
} else if (verdict === 'false_positive') { } else if (verdict === 'false_positive') {
falsePositiveCount += 1; falsePositiveCount += 1;
if (c.botFinding) excludedFindings.push(toExclusion(c.botFinding)); const findings = c.botFindings?.length ? c.botFindings : (c.botFinding ? [c.botFinding] : []);
for (const finding of findings) {
excludedFindings.push(toExclusion(finding));
}
} else { } else {
openCount += 1; openCount += 1;
pushCarried(carriedFindings, c); pushCarried(carriedFindings, c);
+4
View File
@@ -87,6 +87,10 @@ describe('parseLocation', () => {
assert.equal(parseLocation('app/preflight.test.js'), null); assert.equal(parseLocation('app/preflight.test.js'), null);
}); });
it('returns null when the parsed line number is zero', () => {
assert.equal(parseLocation('app/preflight.js:0'), null);
});
it('returns null when multiple files are listed', () => { it('returns null when multiple files are listed', () => {
assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null); assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null);
}); });
+15
View File
@@ -144,6 +144,21 @@ describe('findings exclusions', () => {
assert.equal(filtered[0].location, 'README.md:12'); assert.equal(filtered[0].location, 'README.md:12');
}); });
it('applies pure text exclusions using the original finding text', () => {
const findings = [
{ location: 'src/app.ts:10', role: 'Maya', suggestion: 'Update tests' },
{ location: 'src/app.ts:11', role: 'Maya', suggestion: 'Keep this' },
];
const exclusions = [
{ original_finding: 'update tests' },
];
const filtered = applyExclusions(findings, exclusions);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].suggestion, 'Keep this');
});
it('dedupes repeated exclusions when loading exclusions', () => { it('dedupes repeated exclusions when loading exclusions', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+56 -3
View File
@@ -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());
@@ -197,17 +197,25 @@ describe('gitea', () => {
assert.equal(await getFileContentAtRef('missing.js', 'ref'), ''); assert.equal(await getFileContentAtRef('missing.js', 'ref'), '');
}); });
it('shouldSkipBotCommit returns true when either sha or branch head is bot commit', async () => { it('shouldSkipBotCommit returns true when either sha or branch head is a bot success commit, but not failure', async () => {
mock.method(axios, 'get', async (url) => { mock.method(axios, 'get', async (url) => {
if (url.includes('/git/commits/sha-bot')) { if (url.includes('/git/commits/sha-bot')) {
return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } }; return { data: { message: 'chore: update ai-review findings [ai-review-bot][failure]' } };
} }
if (url.includes('/git/commits/sha-success')) {
return { data: { message: 'chore: update ai-review findings [ai-review-bot][success]' } };
}
if (url.includes('/branches/feat%2Ftest')) { if (url.includes('/branches/feat%2Ftest')) {
return { data: { commit: { id: 'sha-bot' } } }; return { data: { commit: { id: 'sha-bot' } } };
} }
if (url.includes('/branches/feat%2Fsuccess')) {
return { data: { commit: { id: 'sha-success' } } };
}
return { data: { message: 'regular commit' } }; return { data: { message: 'regular commit' } };
}); });
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), true); await assert.equal(await shouldSkipBotCommit({ sha: 'sha-bot', branch: 'feat/test' }), false);
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-success', branch: 'feat/success' }), true);
await assert.equal(await shouldSkipBotCommit({ sha: 'sha-success', branch: 'feat/test' }), true);
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][failure]'), 'failure'); assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][failure]'), 'failure');
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][success]'), 'success'); assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][success]'), 'success');
assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown'); assert.equal(getBotReviewOutcome('chore: update ai-review findings [ai-review-bot]'), 'unknown');
@@ -241,4 +249,49 @@ describe('filterDiff', () => {
it('returns empty string for empty diff', () => { it('returns empty string for empty diff', () => {
assert.equal(filterDiff('', ['.gitea/']), ''); assert.equal(filterDiff('', ['.gitea/']), '');
}); });
it('always drops node_modules blocks at any depth (avoids blowing the LLM input limit)', () => {
const diff = block('src/node_modules/axios/index.js')
+ block('node_modules/js-yaml/lib.js')
+ block('src/main.js');
const result = filterDiff(diff, []);
assert.ok(!result.includes('node_modules'));
assert.ok(result.includes('src/main.js'));
});
it('excludes lock files and dist via the getPRDiff prefix list', () => {
const diff = block('src/package-lock.json') + block('dist/index.js') + block('src/main.js');
const result = filterDiff(diff, ['package-lock.json', 'src/package-lock.json', 'dist/']);
assert.ok(!result.includes('package-lock.json'));
assert.ok(!result.includes('dist/'));
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);
});
}); });
+12
View File
@@ -77,6 +77,18 @@ describe('json helpers', () => {
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n'); assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
}); });
it('rejects repaired JSON that is not an array', async () => {
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, '{broken', 'utf8');
await assert.rejects(
() => validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async () => '{"ok":true}'),
/不是 JSON 陣列/,
);
assert.equal(fs.readFileSync(fullPath, 'utf8'), '{broken');
});
it('reads a valid JSON file whose size equals the maximum limit', async () => { it('reads a valid JSON file whose size equals the maximum limit', async () => {
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+76 -1
View File
@@ -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 } 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',
@@ -237,3 +237,78 @@ describe('extractJSONText', () => {
assert.equal(result, 'not json at all'); assert.equal(result, 'not json at all');
}); });
}); });
describe('extractMeaningfulError', () => {
it('抽出尾端真正的錯誤,而非開頭的 codex banner/回顯 prompt', () => {
const raw = [
'OpenAI Codex v0.142.5',
'--------',
'workdir: /workspace/actions/ai-code-review',
'model: gpt-5.4-mini',
'reasoning effort: none',
'--------',
'user',
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
'ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 401 Unauthorized',
'ERROR: Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
].join('\n');
const result = extractMeaningfulError(raw);
assert.match(result, /401 Unauthorized/);
assert.match(result, /refresh token was revoked/);
assert.doesNotMatch(result, /workdir:/);
assert.doesNotMatch(result, /請依照以下系統指示/);
});
it('抽不到錯誤行時退取尾段(不取開頭)', () => {
const raw = 'A'.repeat(1200) + '\nTAIL-CONTENT';
const result = extractMeaningfulError(raw, 100);
assert.ok(result.length <= 100);
assert.match(result, /TAIL-CONTENT$/);
});
it('容錯處理空輸入', () => {
assert.equal(extractMeaningfulError(''), '');
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), []);
});
});
+137
View File
@@ -0,0 +1,137 @@
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)。
// 略過 main.js 被 import 時的自動執行;本測試改為手動呼叫 export 的 main()。
process.env.AI_REVIEW_SKIP_MAIN = '1';
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);
});
});
+92 -5
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import { mkdtemp, writeFile, chmod, rm } from 'fs/promises'; import { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchCodexModels, runPreflight } from '../preflight.js';
const LLM_ENV_KEYS = [ const LLM_ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH',
@@ -130,18 +130,52 @@ describe('verifyLLM', () => {
assert.match(result.error, /AI 助理 CLI/); assert.match(result.error, /AI 助理 CLI/);
}); });
it('passes when a supported assistant CLI is detected', async () => { it('passes when a supported assistant CLI is detected and the model is in the codex list', async () => {
clearLLMEnv(); clearLLMEnv();
await installFakeCLI('codex'); await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex'; process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5-mini'; process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM(); const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.provider, 'codex'); assert.equal(result.provider, 'codex');
assert.equal(result.command, 'codex'); assert.equal(result.command, 'codex');
assert.equal(result.model, 'gpt-5-mini'); assert.equal(result.model, 'gpt-5.4-mini');
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
});
it('fails when codex auth is invalid (model list check reports 401)', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'codex');
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/);
});
it('fails when the configured model is not in the codex available list', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-9-imaginary';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, false);
assert.match(result.error, /不在 codex 可用清單/);
assert.match(result.error, /gpt-5\.4-mini/);
}); });
it('fails when a requested CLI is not installed', async () => { it('fails when a requested CLI is not installed', async () => {
@@ -157,6 +191,59 @@ describe('verifyLLM', () => {
}); });
describe('fetchCodexModels', () => {
async function writeAuth(json) {
tempDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-'));
const authPath = join(tempDir, 'auth.json');
await writeFile(authPath, JSON.stringify(json));
return authPath;
}
it('returns the model slugs on HTTP 200', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'tok', account_id: 'acc' } });
let capturedUrl, capturedHeaders;
const result = await fetchCodexModels({
authPath,
fetchImpl: async (url, opts) => {
capturedUrl = url;
capturedHeaders = opts.headers;
return { status: 200, ok: true, json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }] }) };
},
});
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] });
assert.match(capturedUrl, /client_version=/);
assert.equal(capturedHeaders['Authorization'], 'Bearer tok');
assert.equal(capturedHeaders['chatgpt-account-id'], 'acc');
});
it('reports an auth failure on HTTP 401', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'revoked' } });
const result = await fetchCodexModels({
authPath,
fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }),
});
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/);
});
it('fails when the auth file cannot be read', async () => {
const result = await fetchCodexModels({
authPath: join(tmpdir(), 'definitely-missing-codex-auth-xyz.json'),
fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({ models: [] }) }),
});
assert.equal(result.ok, false);
assert.match(result.error, /無法讀取 codex 認證檔/);
});
it('fails when the auth file lacks an access_token', async () => {
const authPath = await writeAuth({ tokens: {} });
const result = await fetchCodexModels({ authPath, fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
assert.equal(result.ok, false);
assert.match(result.error, /缺少 tokens\.access_token/);
});
});
describe('runPreflight', () => { describe('runPreflight', () => {
function makeDeps(overrides = {}) { function makeDeps(overrides = {}) {
return { return {
+24
View File
@@ -83,6 +83,17 @@ describe('groupConversations', () => {
const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]); const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]);
assert.equal(convos[0].line, 7); assert.equal(convos[0].line, 7);
}); });
it('keeps multiple bot findings on the same path and line', () => {
const comments = [
{ id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 's1') },
{ id: 2, path: 'a.js', position: 10, body: reviewBody('🟡 警告', 'Mage', 'p2', 's2') },
];
const convos = groupConversations(comments);
assert.equal(convos.length, 1);
assert.equal(convos[0].botFindings.length, 2);
assert.deepEqual(convos[0].botFindings.map(f => f.role), ['Assassin', 'Mage']);
});
}); });
describe('codeWindow', () => { describe('codeWindow', () => {
@@ -207,6 +218,19 @@ describe('reconcileConversations', () => {
assert.equal(result.closedCount, 3); assert.equal(result.closedCount, 3);
}); });
it('preserves multiple bot findings when a grouped conversation is still open', async () => {
const deps = baseDeps();
deps.listComments = async () => [
{ id: 10, path: 'a.js', position: 5, body: reviewBody('🔴 嚴重', 'Assassin', 'p', 's10') },
{ id: 11, path: 'a.js', position: 5, body: reviewBody('🟡 警告', 'Mage', 'p', 's11') },
];
deps.judge = async (items) => items.map(it => ({ idx: it.idx, verdict: 'open' }));
const result = await reconcileConversations(deps);
assert.deepEqual(result.carriedFindings.map(f => f.suggestion).sort(), ['s10', 's11']);
});
it('counts only successful closes when some resolve calls fail', async () => { it('counts only successful closes when some resolve calls fail', async () => {
const deps = baseDeps(); const deps = baseDeps();
// a.js(id1) 關閉成功、b.js(id2) 關閉失敗(c.js 已 resolved 略過) // a.js(id1) 關閉成功、b.js(id2) 關閉失敗(c.js 已 resolved 略過)