Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffc9038923 | |||
| 862f4e46ef | |||
| 97888f8b22 | |||
| fa95a463f8 | |||
| 60001499da | |||
| d714cf7665 | |||
| 9e3c7f61bf | |||
| d8b681e63b | |||
| 1602853c99 | |||
| 5ac73091cd | |||
| e03b1c7045 | |||
| f047b4473e | |||
| 8419e60848 | |||
| caebd2b112 | |||
| 7caf3d0490 | |||
| fce2cd3c45 | |||
| 33f1291a0f | |||
| cedcb04424 | |||
| 9d780788e9 | |||
| 7ba9a4e223 | |||
| 7339145641 | |||
| 40ebfe99a8 | |||
| 00f5bc7dae | |||
| 69371eb993 | |||
| 766f2ddf40 | |||
| 1b34298d4b | |||
| 9af09de0d3 | |||
| fbff9b3a86 | |||
| 7a01b7e3f4 | |||
| 097b6fb721 | |||
| adf37520cb |
+401
-346
@@ -1,348 +1,403 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "app/git.js",
|
"location": "app/git.js",
|
||||||
"suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數"
|
"suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/git.js",
|
"location": "app/git.js",
|
||||||
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入"
|
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "README.md",
|
"location": "README.md",
|
||||||
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/config.js",
|
"location": "app/config.js",
|
||||||
"suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報"
|
"suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": ".gitea/ai-review/exclusions.json",
|
"location": ".gitea/ai-review/exclusions.json",
|
||||||
"suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位"
|
"suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/findings.js",
|
"location": "app/findings.js",
|
||||||
"suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程"
|
"suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法"
|
"suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "README.md",
|
"location": "README.md",
|
||||||
"suggestion": "章節編號連續且正確,無需調整"
|
"suggestion": "章節編號連續且正確,無需調整"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題"
|
"suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在"
|
"suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "app/config.test.js",
|
"location": "app/config.test.js",
|
||||||
"suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改"
|
"suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "action.yaml",
|
"location": "action.yaml",
|
||||||
"suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔"
|
"suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/",
|
"location": "app/",
|
||||||
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯"
|
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "app/",
|
"location": "app/",
|
||||||
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異"
|
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "app/config.test.js",
|
"location": "app/config.test.js",
|
||||||
"suggestion": "import 語句長度合理,無需拆分為多行"
|
"suggestion": "import 語句長度合理,無需拆分為多行"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": ".gitea/ai-review/findings.json",
|
"location": ".gitea/ai-review/findings.json",
|
||||||
"suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題"
|
"suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "app/comments.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題"
|
"suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": ".gitea/ai-review/findings.json",
|
"location": ".gitea/ai-review/findings.json",
|
||||||
"suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題"
|
"suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題"
|
"suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "Authorization 標頭已有 provider !== 'ollama' 判斷,不會無條件加入,已正確處理"
|
"suggestion": "Authorization 標頭已有 provider !== \u0027ollama\u0027 判斷,不會無條件加入,已正確處理"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Rogue",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度"
|
"suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常"
|
"suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例"
|
"suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Rogue",
|
||||||
"location": "app/comments.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題"
|
"suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": ".gitea/workflows/review.yaml",
|
"location": ".gitea/workflows/review.yaml",
|
||||||
"suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷"
|
"suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": ".gitea/workflows/master.yaml",
|
"location": ".gitea/workflows/master.yaml",
|
||||||
"suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改"
|
"suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/llm.test.js",
|
"location": "app/llm.test.js",
|
||||||
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
|
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/log.test.js",
|
"location": "app/log.test.js",
|
||||||
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。"
|
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "app/package.json",
|
"location": "app/package.json",
|
||||||
"suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題"
|
"suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "app/llm.js",
|
"location": "app/llm.js",
|
||||||
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "Dockerfile, app/git.js, app/git.test.js",
|
"location": "Dockerfile, app/git.js, app/git.test.js",
|
||||||
"suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
|
"suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "Dockerfile",
|
"location": "Dockerfile",
|
||||||
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
|
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "Dockerfile",
|
"location": "Dockerfile",
|
||||||
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
|
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "Dockerfile",
|
"location": "Dockerfile",
|
||||||
"suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
"suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "entrypoint.sh",
|
"location": "entrypoint.sh",
|
||||||
"suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
"suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋"
|
"suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/comments.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低"
|
"suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/roles.js",
|
"location": "app/roles.js",
|
||||||
"suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接"
|
"suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/gitea.js",
|
"location": "app/gitea.js",
|
||||||
"suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞"
|
"suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Rogue",
|
||||||
"location": "Dockerfile",
|
"location": "Dockerfile",
|
||||||
"suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案"
|
"suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "app/package.json",
|
"location": "app/package.json",
|
||||||
"suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案"
|
"suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Rogue",
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
|
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/comments.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
|
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "app/gitea.js",
|
"location": "app/gitea.js",
|
||||||
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
|
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "TODO.md",
|
"location": "TODO.md",
|
||||||
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
|
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "app/gitea.js",
|
"location": "app/gitea.js",
|
||||||
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
|
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Rogue",
|
||||||
"location": "app/git.js",
|
"location": "app/git.js",
|
||||||
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
|
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Aria",
|
"role": "Bard",
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "在 main.js 中,表達式 repoDir。"
|
"suggestion": "在 main.js 中,表達式 repoDir。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Zara",
|
"role": "Rogue",
|
||||||
"location": "app/gitea.js:L20-L21",
|
"location": "app/gitea.js:L20-L21",
|
||||||
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
|
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "TODO.md",
|
"location": "TODO.md",
|
||||||
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
|
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "TODO.md",
|
"location": "TODO.md",
|
||||||
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
|
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/findings.js",
|
"location": "app/findings.js",
|
||||||
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
|
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/findings.js",
|
"location": "app/findings.js",
|
||||||
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
|
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/comments.js",
|
"location": "app/comments.js",
|
||||||
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
|
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
|
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/git.js",
|
"location": "app/git.js",
|
||||||
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
|
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/main.js",
|
"location": "app/main.js",
|
||||||
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
|
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/json.js",
|
"location": "app/json.js",
|
||||||
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
|
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/json.test.js",
|
"location": "app/json.test.js",
|
||||||
"suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。"
|
"suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/gitea.test.js:64",
|
"location": "app/gitea.test.js:64",
|
||||||
"suggestion": "`describe` 已改為同步 callback,`async` 不再出現在這個區塊。"
|
"suggestion": "`describe` 已改為同步 callback,`async` 不再出現在這個區塊。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/git.test.js:13",
|
"location": "app/git.test.js:13",
|
||||||
"suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。"
|
"suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/gitea.js:32",
|
"location": "app/gitea.js:32",
|
||||||
"suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。"
|
"suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/json.test.js:76",
|
"location": "app/json.test.js:76",
|
||||||
"suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。"
|
"suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/json.test.js:7",
|
"location": "app/json.test.js:7",
|
||||||
"suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。"
|
"suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"location": "app/json.test.js:10",
|
"location": "app/json.test.js:10",
|
||||||
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
|
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Maya",
|
"role": "Maya",
|
||||||
"location": "action.yaml:6, action.yaml:12, action.yaml:81",
|
"location": "action.yaml:6, action.yaml:12, action.yaml:81",
|
||||||
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。"
|
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "action.yaml:80",
|
"location": "action.yaml:80",
|
||||||
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
|
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Rex",
|
"role": "Assassin",
|
||||||
"location": "action.yaml:18",
|
"location": "action.yaml:18",
|
||||||
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。"
|
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/log.js",
|
"location": "app/log.js",
|
||||||
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
|
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "Dockerfile, app/git.js, app/gitea.js",
|
||||||
|
"suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。",
|
||||||
|
"is_new": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Assassin",
|
||||||
|
"location": "app/preflight.js:12",
|
||||||
|
"suggestion": "程式碼中根據 `GITEA_SKIP_TLS_VERIFY` 環境變數來禁用 TLS 憑證驗證 (`rejectUnauthorized: false`),這會使應用程式容易受到中間人 (Man-in-the-Middle, MITM) 攻擊。攻擊者可能在不被察覺的情況下攔截和修改與 Gitea 伺服器的通訊。建議移除此功能,或確保在任何生產環境中永不啟用。如果 Gitea 伺服器使用自簽憑證,應將其憑證加入信任儲存區,而非禁用驗證。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "app/preflight.js:56",
|
||||||
|
"suggestion": "函式 `verifyLLM` 處理了多種 LLM 供應商的驗證邏輯(Ollama、Claude、OpenAI 相容等),導致其長度較長且複雜度較高。建議將不同供應商的驗證邏輯拆分成獨立的輔助函式(例如 `_verifyOllama`、`_verifyOpenAICompatible`),以提高模組化程度和可讀性。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Rogue",
|
||||||
|
"location": "app/preflight.js:70-82",
|
||||||
|
"suggestion": "在 `verifyLLM` 函式中,當配置了多個 LLM API Key 時,系統會依序嘗試驗證每個 Key,每個嘗試都有 30 秒的逾時時間。如果前幾個 Key 驗證失敗,這可能導致顯著的累積延遲。雖然這是為了找到一個可用的 Key,但若 Key 數量多且網路不穩定,可能會造成啟動時間過長。可以考慮縮短單次 Key 驗證的逾時時間,或在特定情況下提供更快的失敗機制。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Assassin",
|
||||||
|
"location": "app/preflight.js:100",
|
||||||
|
"suggestion": "在記錄 LLM API 驗證失敗時,直接輸出了錯誤訊息 `e.message`。雖然通常情況下 `e.message` 不會包含敏感資訊,但為了最佳安全實踐,建議審查 LLM 服務提供商的錯誤訊息格式,確保其中不會意外洩漏 API 金鑰或其他敏感請求內容。若有疑慮,應對錯誤訊息進行消毒或僅記錄高層次的錯誤類型。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Bard",
|
||||||
|
"location": "app/preflight.js:30",
|
||||||
|
"suggestion": "在 `checkRequiredEnv`、`verifyGiteaToken` 和 `verifyCommentToken` 等函式中,預設參數直接引用了從 `config.js` 匯入的常數。雖然這在功能上可行,但為了提高程式碼的清晰度和一致性,建議考慮以下兩種方式之一:1. 將所有配置值作為明確的參數從呼叫端傳入。2. 讓函式直接從 `config.js` 模組中讀取這些值,而不是透過預設參數。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "app/preflight.js:107",
|
||||||
|
"suggestion": "在 `verifyLLM` 函數中,呼叫 `axios.post` 時缺少 `httpsAgent` 選項。這會導致即使設定了 `GITEA_SKIP_TLS_VERIFY`,LLM 的 API 請求仍可能因 TLS 憑證問題而失敗。請將 `httpsAgent` 傳遞給 `axios.post` 的選項物件,例如:`await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000, httpsAgent });`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Bard",
|
||||||
|
"location": "app/preflight.test.js:25",
|
||||||
|
"suggestion": "測試描述使用英文。請確保專案在測試描述的語言上保持一致性。如果專案主要使用繁體中文(如 app/preflight.js 中的 JSDoc 和日誌),則應將此測試描述翻譯為繁體中文。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"role": "Bard",
|
||||||
|
"location": "app/preflight.test.js:1-4",
|
||||||
|
"suggestion": "匯入語句的排序不一致。建議遵循一致的排序規則,例如:內建模組、第三方模組、本地模組,並在各組內按字母順序排序。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"role": "Bard",
|
||||||
|
"location": "app/preflight.test.js:14",
|
||||||
|
"suggestion": "函數名稱 clearLLMEnv 雖然可理解,但可以更具描述性,例如 clearLlmEnvironmentVariables 或 resetLlmEnv。"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
@@ -1 +1,9 @@
|
|||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Bard",
|
||||||
|
"location": "app/comments.test.js:172",
|
||||||
|
"suggestion": "此處斷言使用了魔術字串 `/嚴重問題/`,就像樂譜中突然出現的無標記音符,雖能理解,卻少了點優雅與明確。建議將此字串提取為一個具名常數,或至少賦予一個描述性變數,以提升可讀性與未來維護的便利性,讓意圖更加清晰。",
|
||||||
|
"is_new": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ jobs:
|
|||||||
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
|
uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }}
|
||||||
with:
|
with:
|
||||||
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_COMMENT_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }}
|
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_1_1 }},${{ secrets.GEMINI_API_KEY_1_2 }},${{ secrets.GEMINI_API_KEY_1_3 }},${{ secrets.GEMINI_API_KEY_1_4 }},${{ secrets.GEMINI_API_KEY_1_5 }},${{ secrets.GEMINI_API_KEY_1_6 }},${{ secrets.GEMINI_API_KEY_1_7 }},${{ secrets.GEMINI_API_KEY_1_8 }},${{ secrets.GEMINI_API_KEY_1_9 }}
|
||||||
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta
|
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta
|
||||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -4,13 +4,19 @@
|
|||||||
|
|
||||||
# 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過)
|
# 流程(Pull Request opened / synchronize 觸發;若偵測到 AI 助理的自動提交則直接跳過)
|
||||||
|
|
||||||
|
0. 前置驗證(action 最開始執行、做任何分析或發 comment 前):檢查所有驗證相關設定是否都可用,全部通過才繼續;任何一項失敗就印出明確訊息並立即 `exit 1`
|
||||||
|
- 必要環境變數齊全:`GITEA_TOKEN`、`GITEA_REPOSITORY`、`PR_NUMBER`(缺一即失敗)
|
||||||
|
- Gitea API 可連線且 `GITEA_TOKEN` 有權限讀取此 repo(呼叫 `GET /api/v1/repos/{repo}` 驗證 token 與 repo 同時有效)
|
||||||
|
- 若有提供 `GITEA_COMMENT_TOKEN`,額外用它驗證可用(呼叫 `GET /api/v1/user`),確保後續發 comment 不會因 token 失效而中斷
|
||||||
|
- git push 認證可用:用與第 8 點 commit/push 完全相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(例如 `could not read Username`)的問題。此路徑與上面的 REST API 不同,API token 有效不代表 git push 一定能用,故獨立驗證
|
||||||
|
- 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線
|
||||||
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request
|
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request
|
||||||
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
|
||||||
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
|
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
|
||||||
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾 PR 問題表格中不需要處理的問題
|
||||||
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
5. 從 PR 問題表格中取出所有舊問題,依照等級排序後 Comment 到 Pull Request
|
||||||
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
6. 從 PR 問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Pull Request
|
||||||
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Pull Request
|
7. 從 PR 問題表格中取出所有新問題,將每個嚴重等級的問題以 Gitea 行內 review comment 標註在問題所在的檔案與行數上,留言內容為等級/審查員/建議;若問題位置無法解析出行號(例如未標行號或一次列出多個檔案),或該行不在本次 diff 範圍內導致行內留言失敗,則降級為一般 PR Comment
|
||||||
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容。自動提交的 commit message 會帶上 `[ai-review-bot]`,供 workflow 判斷是否要跳過重跑
|
||||||
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
9. 如果 PR 問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@
|
|||||||
8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]`
|
||||||
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
|
||||||
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
|
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
|
||||||
|
11. action 一啟動就先做「前置驗證」(流程第 0 點):集中檢查 Gitea REST API token、comment token、git push 認證與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js`(git push 驗證委派給 `app/git.js` 的 `verifyRemoteAccess`),由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半、發 comment 或最後 push 時才因 token / key / 認證無效而中斷
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
@@ -243,4 +250,4 @@ Antigravity:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
|
|||||||
|
|
||||||
### 版本包含
|
### 版本包含
|
||||||
|
|
||||||
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json` 與 `exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
提交時一併包含 `triage-findings` skill 與各平台入口檔;其中 `AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md` 會在目標專案已存在時先做規則化合併,並在可用 LLM 時再用 AI 輔助檢查是否有遺失任何 skill、command 或規則;其餘同步檔則以來源覆蓋;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json` 與 `exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。
|
||||||
|
|
||||||
## 階段八:記憶區 commit/push 與錯誤處理
|
## 階段八:記憶區 commit/push 與錯誤處理
|
||||||
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
|
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;`AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md` 在目標專案已存在時會先做規則化合併,並在可用 LLM 時再做 AI 輔助檢查以避免遺失 skill、command 或規則;其餘同步檔則以來源覆蓋;workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
|
||||||
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出四個入口檔會先規則合併、再由 AI 輔助檢查,其他同步檔會被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
|
||||||
- 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。
|
- 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。
|
||||||
|
|
||||||
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
## 階段九:阻擋嚴重問題 PR(第 8 點)
|
||||||
@@ -57,3 +57,15 @@
|
|||||||
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion;AI 回傳後補回原始完整欄位(含 is_new)。
|
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion;AI 回傳後補回原始完整欄位(含 is_new)。
|
||||||
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。
|
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 findings 仍保有完整欄位供後續流程使用。
|
||||||
- 已驗收:`app/findings.js` 已只傳必要欄位給 AI,並在回傳後補回原始 findings 的完整欄位。
|
- 已驗收:`app/findings.js` 已只傳必要欄位給 AI,並在回傳後補回原始 findings 的完整欄位。
|
||||||
|
|
||||||
|
## 階段十二:啟動前置驗證所有驗證相關設定
|
||||||
|
- 目標:action 一開始(Step1 之後、其餘步驟之前)就集中檢查所有「驗證相關設定」是否可用,全部通過才繼續,任何一項失敗就印出明確原因並 `exit 1`。檢查項目:
|
||||||
|
1. 必要環境變數齊全:`GITEA_TOKEN`、`GITEA_REPOSITORY`、`PR_NUMBER`(缺一即失敗)。
|
||||||
|
2. Gitea API 可連線且 `GITEA_TOKEN` 能讀取此 repo(`GET /api/v1/repos/{repo}`)。
|
||||||
|
3. 若有提供 `GITEA_COMMENT_TOKEN`,另外用它驗證可用(`GET /api/v1/user`)。
|
||||||
|
4. git push 認證可用:用與階段八 commit/push 相同的 askpass + remote URL 機制跑唯讀的 `git ls-remote`,提前抓出 askpass 無法執行或 HTTP 認證失敗(`could not read Username`)的問題;此檢查為 fatal,失敗即 `exit 1`。
|
||||||
|
5. 已選定一個 LLM provider(`getLLMConfig().provider` 非 null)。
|
||||||
|
6. LLM API Key 至少一把通過驗證:送出最小請求確認認證可用,逗號分隔多把只要一把成功即可並逐把記錄成敗;Ollama 改為檢查 `OLLAMA_BASE_URL` 可連線。
|
||||||
|
- 驗收:log 中能看到 `Step1.5`(或對等)前置驗證的每一項結果(成功/失敗),任一失敗時 log 指出是哪一項與錯誤訊息,且 workflow 狀態為失敗;全部通過時 log 出「前置驗證通過」後才進入後續流程;驗證邏輯由 `app/preflight.js` 提供並有單元測試覆蓋(成功、缺環境變數、Gitea token 無效、comment token 無效、所有 LLM key 失敗、Ollama base url 等情境)。
|
||||||
|
- 補充紀錄:前置驗證不應發布任何 PR comment,只做唯讀的認證/連線確認;LLM 驗證請用最小 payload,避免浪費 token。
|
||||||
|
- 已驗收:`app/preflight.js` 提供 `checkRequiredEnv` / `verifyGiteaToken` / `verifyCommentToken` / `verifyLLM` / `runPreflight`,git push 認證驗證由 `app/git.js` 的 `verifyRemoteAccess`(`git ls-remote`)提供;`main.js` 已在 Step1 之後、bot-check 之前呼叫 `runPreflight(WORKSPACE)`,未通過即印出原因並 `exit 1`;`app/preflight.test.js` 與 `app/git.test.js` 覆蓋上述情境(含 git push 認證成功/失敗、token 不外洩、askpass 清理),`node --test *.test.js` 全數通過。
|
||||||
|
|||||||
+38
-6
@@ -1,8 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { postComment } from './gitea.js';
|
import { postComment, postPullReviewComment } from './gitea.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
import { ok, line } from './log.js';
|
import { ok, line, warn } from './log.js';
|
||||||
|
|
||||||
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
||||||
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
||||||
@@ -16,6 +16,26 @@ function buildTable(findings) {
|
|||||||
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。
|
||||||
|
* 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。
|
||||||
|
*/
|
||||||
|
export function parseLocation(location) {
|
||||||
|
if (typeof location !== 'string') return null;
|
||||||
|
const trimmed = location.trim();
|
||||||
|
if (trimmed.includes(',')) return null;
|
||||||
|
const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { file: match[1], line: Number(match[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行內 comment 內容:等級/審查員/建議 */
|
||||||
|
function inlineCommentBody(f) {
|
||||||
|
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 寫入 findings.json。
|
* 寫入 findings.json。
|
||||||
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
||||||
@@ -61,17 +81,29 @@ export async function postNewNonCriticalComment(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每個新 critical 問題各發一個 comment
|
* 每個新 critical 問題各發一個 comment。
|
||||||
|
* 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議);
|
||||||
|
* 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 comment。
|
||||||
*/
|
*/
|
||||||
export async function postNewCriticalComments(findings) {
|
export async function postNewCriticalComments(findings, deps = {}) {
|
||||||
|
const { postInline = postPullReviewComment, postIssue = postComment } = deps;
|
||||||
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
|
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
|
||||||
if (criticals.length === 0) {
|
if (criticals.length === 0) {
|
||||||
line('無新的嚴重問題,跳過');
|
line('無新的嚴重問題,跳過');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const f of criticals) {
|
for (const f of criticals) {
|
||||||
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
|
const loc = parseLocation(f.location);
|
||||||
await postComment(body);
|
if (loc) {
|
||||||
|
try {
|
||||||
|
await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) });
|
||||||
|
ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`);
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
warn(`行內 comment 發布失敗,改用一般 comment: [${f.role}] ${f.location} error=${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await postIssue(`## 🚨 嚴重問題\n\n${buildTable([f])}`);
|
||||||
ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { saveFindings } from './comments.js';
|
import { saveFindings, parseLocation, postNewCriticalComments } from './comments.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
|
|
||||||
describe('saveFindings', () => {
|
describe('saveFindings', () => {
|
||||||
@@ -73,3 +73,114 @@ describe('saveFindings', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseLocation', () => {
|
||||||
|
it('parses file and single line', () => {
|
||||||
|
assert.deepEqual(parseLocation('app/preflight.js:19'), { file: 'app/preflight.js', line: 19 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the start line for a line range', () => {
|
||||||
|
assert.deepEqual(parseLocation('app/preflight.js:70-82'), { file: 'app/preflight.js', line: 70 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no line number', () => {
|
||||||
|
assert.equal(parseLocation('app/preflight.test.js'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when multiple files are listed', () => {
|
||||||
|
assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-string input', () => {
|
||||||
|
assert.equal(parseLocation(undefined), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postNewCriticalComments', () => {
|
||||||
|
const critical = { level: 'critical', role: 'Rex', location: 'app/preflight.js:19', suggestion: '修這個', is_new: true };
|
||||||
|
|
||||||
|
it('posts an inline review comment annotating file/line with level/role/suggestion', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([critical], {
|
||||||
|
postInline: async (args) => { inlineCalls.push(args); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 1);
|
||||||
|
assert.equal(issueCalls.length, 0);
|
||||||
|
assert.equal(inlineCalls[0].path, 'app/preflight.js');
|
||||||
|
assert.equal(inlineCalls[0].line, 19);
|
||||||
|
assert.match(inlineCalls[0].body, /等級/);
|
||||||
|
assert.match(inlineCalls[0].body, /審查員.*Rex/s);
|
||||||
|
assert.match(inlineCalls[0].body, /建議.*修這個/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a normal comment when the location has no line number', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([{ ...critical, location: 'app/preflight.js' }], {
|
||||||
|
postInline: async (args) => { inlineCalls.push(args); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 0);
|
||||||
|
assert.equal(issueCalls.length, 1);
|
||||||
|
assert.match(issueCalls[0], /嚴重問題/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a normal comment when the inline post fails', async () => {
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([critical], {
|
||||||
|
postInline: async () => { throw new Error('line not in diff'); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(issueCalls.length, 1);
|
||||||
|
assert.match(issueCalls[0], /嚴重問題/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only posts for new critical findings', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([
|
||||||
|
{ ...critical, is_new: false },
|
||||||
|
{ level: 'warning', role: 'Leo', location: 'a.js:1', suggestion: 'x', is_new: true },
|
||||||
|
], {
|
||||||
|
postInline: async (args) => { inlineCalls.push(args); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 0);
|
||||||
|
assert.equal(issueCalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts nothing when given an empty findings array', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
await postNewCriticalComments([], {
|
||||||
|
postInline: async (args) => { inlineCalls.push(args); },
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 0);
|
||||||
|
assert.equal(issueCalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
|
||||||
|
const inlineCalls = [];
|
||||||
|
const issueCalls = [];
|
||||||
|
const findings = [
|
||||||
|
{ ...critical, location: 'app/a.js:10', suggestion: 'A' }, // 有行號、inline 成功
|
||||||
|
{ ...critical, location: 'app/b.js', suggestion: 'B' }, // 無行號 → 降級為一般 comment
|
||||||
|
{ ...critical, location: 'app/c.js:20', suggestion: 'C' }, // inline 拋錯 → 降級為一般 comment
|
||||||
|
];
|
||||||
|
await postNewCriticalComments(findings, {
|
||||||
|
postInline: async (args) => {
|
||||||
|
if (args.path === 'app/c.js') throw new Error('line not in diff');
|
||||||
|
inlineCalls.push(args);
|
||||||
|
},
|
||||||
|
postIssue: async (body) => { issueCalls.push(body); },
|
||||||
|
});
|
||||||
|
assert.equal(inlineCalls.length, 1);
|
||||||
|
assert.equal(inlineCalls[0].path, 'app/a.js');
|
||||||
|
assert.equal(inlineCalls[0].line, 10);
|
||||||
|
assert.equal(issueCalls.length, 2);
|
||||||
|
assert.ok(issueCalls.every(b => /嚴重問題/.test(b)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+8
-6
@@ -1,19 +1,21 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chatJSON } from './llm.js';
|
import { chatJSON } from './llm.js';
|
||||||
|
import { buildAnalysisPrompt } 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';
|
||||||
|
|
||||||
const LEVELS = ['critical', 'warning', 'info'];
|
const LEVELS = ['critical', 'warning', 'info'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用單一角色分析 diff,回傳 findings 陣列
|
* 用單一角色分析 diff,回傳 findings 陣列。
|
||||||
|
* role 欄位一律以角色定義的 name 為準,避免 LLM 自行填入不一致的名稱。
|
||||||
*/
|
*/
|
||||||
export async function analyzeWithRole(role, diff) {
|
export async function analyzeWithRole(role, diff) {
|
||||||
line(`[${role.name}] 開始分析`);
|
line(`[${role.name}] 開始分析`);
|
||||||
const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`);
|
const findings = await chatJSON(buildAnalysisPrompt(role), `以下是 Git Diff 內容:\n\n${diff}`);
|
||||||
const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion)
|
const valid = findings.filter(f => f.level && f.location && f.suggestion)
|
||||||
.map(f => ({ ...f, is_new: true }));
|
.map(f => ({ ...f, role: role.name, is_new: true }));
|
||||||
ok(`[${role.name}] 找到 ${valid.length} 個問題`);
|
ok(`[${role.name}] 找到 ${valid.length} 個問題`);
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
@@ -253,7 +255,7 @@ function toAIPayload(findings) {
|
|||||||
export async function deduplicateWithAI(findings) {
|
export async function deduplicateWithAI(findings) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|
||||||
const systemPrompt = `移除語意重複的程式碼審查問題(JSON 陣列)。保留等級較高者(critical > warning > info)。只回傳去重後的 JSON 陣列。`;
|
const systemPrompt = `你是 🛡️ Paladin(聖騎士),這座程式碼競技場沉穩公正的裁判。攻擊方提出了一批程式碼審查問題(JSON 陣列)。請就事論事,把「同檔案位置 + 同問題本質」的重複指控合併,重複者只保留等級較高的一條(critical > warning > info)。只回傳去重後的 JSON 陣列,不要有其他文字。`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
||||||
@@ -350,7 +352,7 @@ export async function filterFalsePositivesWithAI(findings, exclusions = [], chat
|
|||||||
? `\n${exclusionContext.prompt}\n規則:若 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
? `\n${exclusionContext.prompt}\n規則:若 finding 與上述任何一類的路徑、角色或描述高度相似,優先視為誤報或不適用。`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
|
const systemPrompt = `你是 🛡️ Paladin(聖騎士),公正的裁判。逐條審視攻擊方的指控,剔除誤報或不適用者(例如:已正確使用 secrets、CI/CD 必要權限、他處已妥善處理、語義其實正確)。不冤枉無辜的程式碼,也不放水。移除誤報後,只回傳需保留(成立)的 JSON 陣列,不要有其他文字。${exclusionHint}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings)));
|
||||||
|
|||||||
+203
-29
@@ -2,8 +2,8 @@ import { spawnSync } from 'child_process';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH, getLLMConfig } from './config.js';
|
||||||
import { line, ok, warn } from './log.js';
|
import { line, ok, warn, error } from './log.js';
|
||||||
|
|
||||||
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
||||||
@@ -31,6 +31,13 @@ const FORCE_SYNC_FILE_PATHS = [
|
|||||||
'CLAUDE.md',
|
'CLAUDE.md',
|
||||||
'GEMINI.md',
|
'GEMINI.md',
|
||||||
];
|
];
|
||||||
|
const MERGE_SYNC_FILE_PATHS = new Set([
|
||||||
|
'AGENTS.md',
|
||||||
|
'ANTIGRAVITY.md',
|
||||||
|
'CLAUDE.md',
|
||||||
|
'GEMINI.md',
|
||||||
|
]);
|
||||||
|
let instructionMergeAssistantPromise = null;
|
||||||
const SYNC_TREE_PATHS = [
|
const SYNC_TREE_PATHS = [
|
||||||
'.agents/skills/triage-findings',
|
'.agents/skills/triage-findings',
|
||||||
'.antigravity/skills/triage-findings',
|
'.antigravity/skills/triage-findings',
|
||||||
@@ -55,11 +62,22 @@ function withAskpass(workspace, fn) {
|
|||||||
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
||||||
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
||||||
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
||||||
|
const cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} };
|
||||||
|
let result;
|
||||||
try {
|
try {
|
||||||
return fn(credEnv);
|
result = fn(credEnv);
|
||||||
} finally {
|
} catch (e) {
|
||||||
try { fs.unlinkSync(askpassScript); } catch {}
|
cleanup();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
// Defer cleanup until an async callback settles, otherwise the askpass script
|
||||||
|
// is deleted at the first `await` and later network ops (e.g. git push) fail
|
||||||
|
// with "cannot exec .git-askpass.sh". Sync callbacks clean up immediately.
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
return result.finally(cleanup);
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readGitOutput(run, args, cwd, env) {
|
function readGitOutput(run, args, cwd, env) {
|
||||||
@@ -70,35 +88,169 @@ function readGitOutput(run, args, cwd, env) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyTree(sourceRoot, repoDir, relDir) {
|
function normalizeText(text) {
|
||||||
|
return text.replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTextBlocks(text) {
|
||||||
|
const normalized = normalizeText(text).replace(/\n+$/, '');
|
||||||
|
if (!normalized) return [];
|
||||||
|
return normalized.split(/\n{2,}/).map(block => block.trimEnd()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeText(existingText, sourceText) {
|
||||||
|
const existing = normalizeText(existingText);
|
||||||
|
const source = normalizeText(sourceText);
|
||||||
|
if (existing === source) return existing;
|
||||||
|
|
||||||
|
const mergedBlocks = splitTextBlocks(existing);
|
||||||
|
const seenBlocks = new Set(mergedBlocks.map(block => block.trim()));
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const block of splitTextBlocks(source)) {
|
||||||
|
const key = block.trim();
|
||||||
|
if (seenBlocks.has(key)) continue;
|
||||||
|
seenBlocks.add(key);
|
||||||
|
mergedBlocks.push(block);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return existing;
|
||||||
|
return `${mergedBlocks.join('\n\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBlocksFromTexts(...texts) {
|
||||||
|
const seen = new Set();
|
||||||
|
const blocks = [];
|
||||||
|
for (const text of texts) {
|
||||||
|
for (const block of splitTextBlocks(text)) {
|
||||||
|
const key = block.trim();
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
blocks.push(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMergedInstructionText(mergedText, requiredBlocks) {
|
||||||
|
const candidate = normalizeText(mergedText);
|
||||||
|
return requiredBlocks.every(block => candidate.includes(normalizeText(block).trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
class InstructionMergeError extends Error {
|
||||||
|
constructor(message, options) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'InstructionMergeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortInstructionMerge(message) {
|
||||||
|
error(message);
|
||||||
|
process.exit(1);
|
||||||
|
throw new InstructionMergeError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFileOverwrite(sourceRoot, repoDir, relPath) {
|
||||||
|
const src = path.join(sourceRoot, relPath);
|
||||||
|
if (!fs.existsSync(src)) return null;
|
||||||
|
|
||||||
|
const dest = path.join(repoDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
return relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInstructionMergeAssistant() {
|
||||||
|
const { provider } = getLLMConfig();
|
||||||
|
if (!provider) return null;
|
||||||
|
if (instructionMergeAssistantPromise) return instructionMergeAssistantPromise;
|
||||||
|
|
||||||
|
instructionMergeAssistantPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const { chatJSON } = await import('./llm.js');
|
||||||
|
return async ({ relPath, existingText, sourceText, deterministicText }) => {
|
||||||
|
const systemPrompt = [
|
||||||
|
'You merge repository instruction files without losing any skill, command, or rule.',
|
||||||
|
'Never delete unique content from either input.',
|
||||||
|
'You may only remove exact duplicates or improve ordering/formatting.',
|
||||||
|
'Return JSON with a single field: merged_text.',
|
||||||
|
].join(' ');
|
||||||
|
const userContent = JSON.stringify({
|
||||||
|
path: relPath,
|
||||||
|
existing_text: existingText,
|
||||||
|
source_text: sourceText,
|
||||||
|
deterministic_candidate: deterministicText,
|
||||||
|
});
|
||||||
|
const result = await chatJSON(systemPrompt, userContent);
|
||||||
|
if (typeof result === 'string') return result;
|
||||||
|
if (result && typeof result.merged_text === 'string') return result.merged_text;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
warn(`[merge] AI instruction merge unavailable: ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return instructionMergeAssistantPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant = null) {
|
||||||
|
const deterministic = mergeText(existingText, sourceText);
|
||||||
|
const requiredBlocks = uniqueBlocksFromTexts(existingText, sourceText);
|
||||||
|
if (!aiMergeAssistant || requiredBlocks.length === 0) return deterministic;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks });
|
||||||
|
if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) {
|
||||||
|
return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged;
|
||||||
|
}
|
||||||
|
abortInstructionMerge(`[merge] ${relPath} AI result rejected; refusing fallback`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof InstructionMergeError) throw e;
|
||||||
|
abortInstructionMerge(`[merge] ${relPath} AI merge failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant = null) {
|
||||||
|
const src = path.join(sourceRoot, relPath);
|
||||||
|
if (!fs.existsSync(src)) return null;
|
||||||
|
|
||||||
|
const dest = path.join(repoDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
return relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingText = fs.readFileSync(dest, 'utf8');
|
||||||
|
const sourceText = fs.readFileSync(src, 'utf8');
|
||||||
|
const merged = await mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant);
|
||||||
|
if (merged !== existingText) {
|
||||||
|
fs.writeFileSync(dest, merged, 'utf8');
|
||||||
|
}
|
||||||
|
return relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTree(sourceRoot, repoDir, relDir) {
|
||||||
const srcDir = path.join(sourceRoot, relDir);
|
const srcDir = path.join(sourceRoot, relDir);
|
||||||
if (!fs.existsSync(srcDir)) return [];
|
if (!fs.existsSync(srcDir)) return [];
|
||||||
|
|
||||||
const copied = [];
|
const copied = [];
|
||||||
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||||
const relPath = path.join(relDir, entry.name);
|
const relPath = path.join(relDir, entry.name);
|
||||||
const src = path.join(sourceRoot, relPath);
|
|
||||||
const dest = path.join(repoDir, relPath);
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
copied.push(...copyTree(sourceRoot, repoDir, relPath));
|
copied.push(...syncTree(sourceRoot, repoDir, relPath));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
const synced = syncFileOverwrite(sourceRoot, repoDir, relPath);
|
||||||
fs.copyFileSync(src, dest);
|
if (synced) copied.push(synced);
|
||||||
copied.push(relPath);
|
|
||||||
}
|
}
|
||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyFileOverwrite(sourceRoot, repoDir, relPath) {
|
|
||||||
const src = path.join(sourceRoot, relPath);
|
|
||||||
if (!fs.existsSync(src)) return null;
|
|
||||||
const dest = path.join(repoDir, relPath);
|
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
fs.copyFileSync(src, dest);
|
|
||||||
return relPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
||||||
@@ -117,6 +269,24 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
|||||||
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,
|
||||||
|
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
|
||||||
|
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
|
||||||
|
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
|
||||||
|
*/
|
||||||
|
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
|
||||||
|
const run = makeRunner(_spawnSync);
|
||||||
|
try {
|
||||||
|
return withAskpass(workspace, credEnv => {
|
||||||
|
run(['ls-remote', remoteUrl, PR_HEAD_BRANCH || 'HEAD'], workspace, credEnv);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone PR head branch to workspace/repo (idempotent)
|
* Clone PR head branch to workspace/repo (idempotent)
|
||||||
*/
|
*/
|
||||||
@@ -150,26 +320,30 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingSyncPaths = new Set();
|
const existingSyncPaths = new Set();
|
||||||
|
const aiMergeAssistant = await getInstructionMergeAssistant();
|
||||||
|
|
||||||
// Copy action skill trees into the target repo. Existing files are overwritten;
|
// Copy action skill trees into the target repo. Existing files are merged with
|
||||||
// missing source files are ignored so we do not delete target repo content.
|
// the action source; missing source files are ignored so we do not delete
|
||||||
|
// target repo content.
|
||||||
for (const relDir of SYNC_TREE_PATHS) {
|
for (const relDir of SYNC_TREE_PATHS) {
|
||||||
for (const relPath of copyTree(sourceRoot, repoDir, relDir)) {
|
for (const relPath of syncTree(sourceRoot, repoDir, relDir)) {
|
||||||
existingSyncPaths.add(relPath);
|
existingSyncPaths.add(relPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force overwrite the direct instruction files first so the target repo always
|
// Merge only the direct instruction files that must preserve repository-specific
|
||||||
// receives the action-owned versions even if the repo has drifted.
|
// skills, commands, and rules. Everything else keeps the source copy.
|
||||||
for (const relPath of FORCE_SYNC_FILE_PATHS) {
|
for (const relPath of FORCE_SYNC_FILE_PATHS) {
|
||||||
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath);
|
const copied = MERGE_SYNC_FILE_PATHS.has(relPath)
|
||||||
|
? await syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant)
|
||||||
|
: syncFileOverwrite(sourceRoot, repoDir, relPath);
|
||||||
if (copied) existingSyncPaths.add(copied);
|
if (copied) existingSyncPaths.add(copied);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy standalone action files into the target repo. Existing files are overwritten.
|
// Merge standalone action files into the target repo.
|
||||||
for (const relPath of SYNC_PATHS) {
|
for (const relPath of SYNC_PATHS) {
|
||||||
if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue;
|
if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue;
|
||||||
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath);
|
const copied = syncFileOverwrite(sourceRoot, repoDir, relPath);
|
||||||
if (copied) existingSyncPaths.add(copied);
|
if (copied) existingSyncPaths.add(copied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+118
-23
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js';
|
import { commitAndPush, cloneRepo, verifyRemoteAccess, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
function makeTmpWorkspace() {
|
function makeTmpWorkspace() {
|
||||||
@@ -93,6 +93,18 @@ describe('commitAndPush', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the askpass script present while the network push runs', async () => {
|
||||||
|
let askpassExistsAtPush = null;
|
||||||
|
const spawn = makeSpawn({
|
||||||
|
push: (_args, opts) => {
|
||||||
|
askpassExistsAtPush = !!(opts?.env?.GIT_ASKPASS && fs.existsSync(opts.env.GIT_ASKPASS));
|
||||||
|
return { status: 0, stdout: '', stderr: '', error: null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
|
assert.equal(askpassExistsAtPush, true, 'askpass script must still exist when git push runs');
|
||||||
|
});
|
||||||
|
|
||||||
it('cleans up askpass script after successful run', async () => {
|
it('cleans up askpass script after successful run', async () => {
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
|
||||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
@@ -159,40 +171,79 @@ describe('commitAndPush', () => {
|
|||||||
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
|
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overwrites existing repo copies with workspace files', async () => {
|
it('merges existing repo copies with workspace files', async () => {
|
||||||
const repoDir = path.join(workspace, 'repo');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
fs.writeFileSync(path.join(repoDir, '.agents/skills/triage-findings/SKILL.md'), 'stale');
|
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'repo agents doc');
|
||||||
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
|
fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'repo antigravity doc');
|
||||||
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'stale');
|
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'repo claude doc');
|
||||||
fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'stale');
|
fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'repo gemini doc');
|
||||||
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
|
|
||||||
fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'stale');
|
|
||||||
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale');
|
|
||||||
|
|
||||||
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
||||||
|
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md');
|
const agentsDoc = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, '.agents/skills/triage-findings/SKILL.md'), 'utf8'), '.agents/skills/triage-findings/SKILL.md');
|
const antigravityDoc = fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8');
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'), 'AGENTS.md');
|
const claudeDoc = fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8');
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8'), 'ANTIGRAVITY.md');
|
const geminiDoc = fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8');
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
|
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8'), 'GEMINI.md');
|
assert.ok(agentsDoc.includes('repo agents doc'));
|
||||||
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
|
assert.ok(agentsDoc.includes('AGENTS.md'));
|
||||||
|
assert.ok(antigravityDoc.includes('repo antigravity doc'));
|
||||||
|
assert.ok(antigravityDoc.includes('ANTIGRAVITY.md'));
|
||||||
|
assert.ok(claudeDoc.includes('repo claude doc'));
|
||||||
|
assert.ok(claudeDoc.includes('CLAUDE.md'));
|
||||||
|
assert.ok(geminiDoc.includes('repo gemini doc'));
|
||||||
|
assert.ok(geminiDoc.includes('GEMINI.md'));
|
||||||
|
assert.ok(agentsDoc.includes('repo agents doc'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recursively overwrites skill tree files from the action source', async () => {
|
it('accepts AI merged instruction text when all unique blocks are preserved', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const aiMergeAssistant = async payload => {
|
||||||
|
calls.push(payload);
|
||||||
|
return ['repo block', 'source block', 'extra block'].join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant);
|
||||||
|
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.ok(result.includes('repo block'));
|
||||||
|
assert.ok(result.includes('source block'));
|
||||||
|
assert.ok(result.includes('extra block'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits when AI output drops a block', async () => {
|
||||||
|
const originalExit = process.exit;
|
||||||
|
let exitCode = null;
|
||||||
|
process.exit = code => { exitCode = code; };
|
||||||
|
try {
|
||||||
|
const aiMergeAssistant = async () => 'source block only';
|
||||||
|
await assert.rejects(() => mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant));
|
||||||
|
assert.equal(exitCode, 1);
|
||||||
|
} finally {
|
||||||
|
process.exit = originalExit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites non-merge sync files with workspace files', async () => {
|
||||||
const repoDir = path.join(workspace, 'repo');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
const nestedRelPath = '.codex/skills/triage-findings/assets/example.txt';
|
const sourceSkillPath = path.join(sourceRoot, '.github/skills/triage-findings/SKILL.md');
|
||||||
const sourceNestedPath = path.join(sourceRoot, nestedRelPath);
|
const repoSkillPath = path.join(repoDir, '.github/skills/triage-findings/SKILL.md');
|
||||||
const repoNestedPath = path.join(repoDir, nestedRelPath);
|
const sourceNestedPath = path.join(sourceRoot, '.codex/skills/triage-findings/assets/example.txt');
|
||||||
|
const repoNestedPath = path.join(repoDir, '.codex/skills/triage-findings/assets/example.txt');
|
||||||
|
|
||||||
|
fs.writeFileSync(sourceSkillPath, 'fresh github skill');
|
||||||
|
fs.writeFileSync(repoSkillPath, 'stale github skill');
|
||||||
fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true });
|
fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true });
|
||||||
fs.writeFileSync(sourceNestedPath, 'fresh');
|
fs.writeFileSync(sourceNestedPath, 'fresh nested');
|
||||||
fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true });
|
fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true });
|
||||||
fs.writeFileSync(repoNestedPath, 'stale');
|
fs.writeFileSync(repoNestedPath, 'stale nested');
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale copilot');
|
||||||
|
|
||||||
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
|
||||||
|
|
||||||
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh');
|
assert.equal(fs.readFileSync(repoSkillPath, 'utf8'), 'fresh github skill');
|
||||||
|
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh nested');
|
||||||
|
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when git command fails', async () => {
|
it('does not throw when git command fails', async () => {
|
||||||
@@ -295,3 +346,47 @@ describe('cloneRepo', () => {
|
|||||||
assert.equal(isBotAutoCommit(workspace, spawn), true);
|
assert.equal(isBotAutoCommit(workspace, spawn), true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('verifyRemoteAccess', () => {
|
||||||
|
let workspace;
|
||||||
|
before(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'git-lsremote-')); });
|
||||||
|
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
it('runs git ls-remote with the askpass credential env and reports ok on success', () => {
|
||||||
|
const calls = [];
|
||||||
|
const spawn = (cmd, args, opts) => {
|
||||||
|
calls.push({ cmd, args, opts });
|
||||||
|
return { status: 0, stdout: 'abc123\tHEAD', stderr: '', error: null };
|
||||||
|
};
|
||||||
|
const result = verifyRemoteAccess(workspace, spawn);
|
||||||
|
assert.deepEqual(result, { ok: true });
|
||||||
|
const lsRemote = calls.find(c => c.args[0] === 'ls-remote');
|
||||||
|
assert.ok(lsRemote, 'expected git ls-remote to run');
|
||||||
|
assert.ok(lsRemote.opts?.env?.GIT_ASKPASS, 'expected GIT_ASKPASS env for ls-remote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak the token in ls-remote args', () => {
|
||||||
|
const calls = [];
|
||||||
|
const spawn = (cmd, args, opts) => {
|
||||||
|
calls.push({ args });
|
||||||
|
return { status: 0, stdout: '', stderr: '', error: null };
|
||||||
|
};
|
||||||
|
verifyRemoteAccess(workspace, spawn);
|
||||||
|
for (const { args } of calls) {
|
||||||
|
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports failure (not throw) when git ls-remote fails', () => {
|
||||||
|
const spawn = () => ({ status: 128, stdout: '', stderr: 'fatal: could not read Username', error: null });
|
||||||
|
const result = verifyRemoteAccess(workspace, spawn);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /could not read Username/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up the askpass script after running', () => {
|
||||||
|
verifyRemoteAccess(workspace, () => ({ status: 0, stdout: '', stderr: '', error: null }));
|
||||||
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -127,3 +127,22 @@ export async function postComment(body) {
|
|||||||
);
|
);
|
||||||
return resp.data;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。
|
||||||
|
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
|
||||||
|
* 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。
|
||||||
|
*/
|
||||||
|
export async function postPullReviewComment({ path: filePath, line, body }) {
|
||||||
|
const resp = await axios.post(
|
||||||
|
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
|
||||||
|
{
|
||||||
|
commit_id: PR_HEAD_SHA || undefined,
|
||||||
|
event: 'COMMENT',
|
||||||
|
body: '',
|
||||||
|
comments: [{ path: filePath, body, new_position: line }],
|
||||||
|
},
|
||||||
|
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|||||||
+26
-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, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
|
import { getPRDiff, filterDiff, postComment, postPullReviewComment, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome } from './gitea.js';
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -57,6 +57,31 @@ describe('gitea', () => {
|
|||||||
await assert.rejects(() => postComment('test'), /api error/);
|
await assert.rejects(() => postComment('test'), /api error/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('postPullReviewComment posts an inline review comment to the pulls reviews API', async () => {
|
||||||
|
let capturedUrl, capturedBody, capturedOpts;
|
||||||
|
mock.method(axios, 'post', async (url, body, opts) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedBody = body;
|
||||||
|
capturedOpts = opts;
|
||||||
|
return { data: { id: 7 } };
|
||||||
|
});
|
||||||
|
const result = await postPullReviewComment({ path: 'app/preflight.js', line: 19, body: 'inline body' });
|
||||||
|
assert.deepEqual(result, { id: 7 });
|
||||||
|
assert.ok(capturedUrl.includes('/api/v1/repos/'));
|
||||||
|
assert.ok(capturedUrl.endsWith('/reviews'));
|
||||||
|
assert.equal(capturedBody.event, 'COMMENT');
|
||||||
|
assert.equal(capturedBody.comments.length, 1);
|
||||||
|
assert.equal(capturedBody.comments[0].path, 'app/preflight.js');
|
||||||
|
assert.equal(capturedBody.comments[0].new_position, 19);
|
||||||
|
assert.equal(capturedBody.comments[0].body, 'inline body');
|
||||||
|
assert.ok(capturedOpts.headers['Authorization'].startsWith('token '));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('postPullReviewComment propagates axios errors', async () => {
|
||||||
|
mock.method(axios, 'post', async () => { throw new Error('not in diff'); });
|
||||||
|
await assert.rejects(() => postPullReviewComment({ path: 'a.js', line: 1, body: 'x' }), /not in diff/);
|
||||||
|
});
|
||||||
|
|
||||||
it('getCommitMessageBySha reads commit message from Gitea API', async () => {
|
it('getCommitMessageBySha reads commit message from Gitea API', async () => {
|
||||||
let capturedUrl;
|
let capturedUrl;
|
||||||
mock.method(axios, 'get', async (url) => {
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplica
|
|||||||
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
|
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
|
||||||
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 { section, step, line, ok, warn, error } from './log.js';
|
import { section, step, line, ok, warn, error } from './log.js';
|
||||||
|
|
||||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||||
@@ -16,6 +17,12 @@ async function main() {
|
|||||||
line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
||||||
line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
||||||
|
|
||||||
|
if (!(await runPreflight(WORKSPACE))) {
|
||||||
|
error('前置驗證未通過,終止流程');
|
||||||
|
section('Pipeline 結束');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || '';
|
const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || '';
|
||||||
const headMessage = await getCommitMessageBySha(headSha);
|
const headMessage = await getCommitMessageBySha(headSha);
|
||||||
const headOutcome = getBotReviewOutcome(headMessage);
|
const headOutcome = getBotReviewOutcome(headMessage);
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import https from 'https';
|
||||||
|
import {
|
||||||
|
GITEA_TOKEN,
|
||||||
|
GITEA_COMMENT_TOKEN,
|
||||||
|
GITEA_SERVER_URL,
|
||||||
|
GITEA_REPOSITORY,
|
||||||
|
GITEA_SKIP_TLS_VERIFY,
|
||||||
|
PR_NUMBER,
|
||||||
|
getLLMConfig,
|
||||||
|
} from './config.js';
|
||||||
|
import { verifyRemoteAccess } from './git.js';
|
||||||
|
import { step, line, ok, error } from './log.js';
|
||||||
|
|
||||||
|
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||||
|
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||||
|
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
||||||
|
|
||||||
|
function giteaErr(e) {
|
||||||
|
const status = e.response?.status;
|
||||||
|
return status ? `HTTP ${status} ${e.message}` : e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 檢查必要環境變數是否齊全;可傳入覆寫值供測試使用 */
|
||||||
|
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
|
||||||
|
const missing = [];
|
||||||
|
if (!token) missing.push('GITEA_TOKEN');
|
||||||
|
if (!repo) missing.push('GITEA_REPOSITORY');
|
||||||
|
if (!pr) missing.push('PR_NUMBER');
|
||||||
|
return { ok: missing.length === 0, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用 GITEA_TOKEN 讀取此 repo,同時驗證 token 有效與有讀取權限 */
|
||||||
|
export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSITORY) {
|
||||||
|
try {
|
||||||
|
await axios.get(api(`/repos/${repo}`), { headers: giteaHeaders(token), timeout: 30000, httpsAgent });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: giteaErr(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 若有提供 comment token,用它呼叫 /user 驗證可用;沒提供則略過 */
|
||||||
|
export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
||||||
|
if (!token) return { ok: true, skipped: true };
|
||||||
|
try {
|
||||||
|
await axios.get(api('/user'), { headers: giteaHeaders(token), timeout: 30000, httpsAgent });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: giteaErr(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驗證 LLM 設定可用:
|
||||||
|
* - 須已選定一個 provider
|
||||||
|
* - Ollama 檢查 base URL 是否可連線
|
||||||
|
* - 其餘 provider 以最小請求驗證認證,多把 Key 只要一把成功即可
|
||||||
|
*/
|
||||||
|
export async function verifyLLM() {
|
||||||
|
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
||||||
|
if (!provider) return { ok: false, error: '未設定任何 LLM provider 或 API Key' };
|
||||||
|
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
|
||||||
|
|
||||||
|
const base = baseURL.replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (provider === 'ollama') {
|
||||||
|
try {
|
||||||
|
await axios.get(`${base}/models`, { timeout: 30000 });
|
||||||
|
return { ok: true, provider };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, provider, error: `Ollama base URL 無法連線: ${e.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
||||||
|
const payload = { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
|
||||||
|
|
||||||
|
for (let i = 0; i < apiKeys.length; i++) {
|
||||||
|
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
|
||||||
|
try {
|
||||||
|
await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000 });
|
||||||
|
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
|
||||||
|
} catch (e) {
|
||||||
|
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, provider, error: `所有 ${apiKeys.length} 把 ${provider} API Key 驗證失敗` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。
|
||||||
|
* 僅做唯讀的認證/連線確認,不發布任何 comment。
|
||||||
|
*/
|
||||||
|
export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) {
|
||||||
|
const {
|
||||||
|
checkEnv = checkRequiredEnv,
|
||||||
|
verifyToken = verifyGiteaToken,
|
||||||
|
verifyComment = verifyCommentToken,
|
||||||
|
verifyRemote = verifyRemoteAccess,
|
||||||
|
verifyLLMFn = verifyLLM,
|
||||||
|
} = deps;
|
||||||
|
step('Step1.5', '前置驗證(驗證相關設定)');
|
||||||
|
|
||||||
|
const env = checkEnv();
|
||||||
|
if (!env.ok) {
|
||||||
|
error(`缺少必要環境變數: ${env.missing.join(', ')}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ok('必要環境變數齊全 (GITEA_TOKEN, GITEA_REPOSITORY, PR_NUMBER)');
|
||||||
|
|
||||||
|
const gitea = await verifyToken();
|
||||||
|
if (!gitea.ok) {
|
||||||
|
error(`GITEA_TOKEN 驗證失敗(無法讀取 repo ${GITEA_REPOSITORY}): ${gitea.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ok(`GITEA_TOKEN 可讀取 repo ${GITEA_REPOSITORY}`);
|
||||||
|
|
||||||
|
const comment = await verifyComment();
|
||||||
|
if (!comment.ok) {
|
||||||
|
error(`GITEA_COMMENT_TOKEN 驗證失敗: ${comment.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (comment.skipped) line('未提供 GITEA_COMMENT_TOKEN,comment 將沿用 GITEA_TOKEN');
|
||||||
|
else ok('GITEA_COMMENT_TOKEN 可用');
|
||||||
|
|
||||||
|
const remote = verifyRemote(workspace);
|
||||||
|
if (!remote.ok) {
|
||||||
|
error(`git push 認證/連線驗證失敗(ls-remote): ${remote.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ok('git remote 認證可用(ls-remote 成功)');
|
||||||
|
|
||||||
|
const llm = await verifyLLMFn();
|
||||||
|
if (!llm.ok) {
|
||||||
|
error(`LLM 驗證失敗: ${llm.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (llm.keyIndex) ok(`LLM provider=${llm.provider} 驗證通過(key ${llm.keyIndex}/${llm.total})`);
|
||||||
|
else ok(`LLM provider=${llm.provider} 連線正常`);
|
||||||
|
|
||||||
|
ok('前置驗證通過');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from './preflight.js';
|
||||||
|
|
||||||
|
const LLM_ENV_KEYS = [
|
||||||
|
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||||
|
'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL',
|
||||||
|
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
||||||
|
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
||||||
|
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
function clearLLMEnv() {
|
||||||
|
for (const k of LLM_ENV_KEYS) delete process.env[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restoreAll();
|
||||||
|
clearLLMEnv();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkRequiredEnv', () => {
|
||||||
|
it('reports all three missing when nothing provided', () => {
|
||||||
|
const result = checkRequiredEnv({ token: '', repo: '', pr: '' });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports only the missing ones', () => {
|
||||||
|
const result = checkRequiredEnv({ token: 't', repo: '', pr: '5' });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.deepEqual(result.missing, ['GITEA_REPOSITORY']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ok when all provided', () => {
|
||||||
|
const result = checkRequiredEnv({ token: 't', repo: 'owner/repo', pr: '5' });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(result.missing, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyGiteaToken', () => {
|
||||||
|
it('ok when repo endpoint returns successfully', async () => {
|
||||||
|
let capturedUrl, capturedOpts;
|
||||||
|
mock.method(axios, 'get', async (url, opts) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedOpts = opts;
|
||||||
|
return { data: { full_name: 'owner/repo' } };
|
||||||
|
});
|
||||||
|
const result = await verifyGiteaToken('tok', 'owner/repo');
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.ok(capturedUrl.includes('/api/v1/repos/owner/repo'));
|
||||||
|
assert.equal(capturedOpts.headers['Authorization'], 'token tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with HTTP status when token is invalid', async () => {
|
||||||
|
mock.method(axios, 'get', async () => {
|
||||||
|
const e = new Error('Unauthorized');
|
||||||
|
e.response = { status: 401 };
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
const result = await verifyGiteaToken('bad', 'owner/repo');
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /HTTP 401/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyCommentToken', () => {
|
||||||
|
it('skips when no comment token provided', async () => {
|
||||||
|
const result = await verifyCommentToken('');
|
||||||
|
assert.deepEqual(result, { ok: true, skipped: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ok when /user returns successfully', async () => {
|
||||||
|
let capturedUrl, capturedOpts;
|
||||||
|
mock.method(axios, 'get', async (url, opts) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedOpts = opts;
|
||||||
|
return { data: { login: 'bot' } };
|
||||||
|
});
|
||||||
|
const result = await verifyCommentToken('ctok');
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.ok(capturedUrl.endsWith('/api/v1/user'));
|
||||||
|
assert.equal(capturedOpts.headers['Authorization'], 'token ctok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when comment token is invalid', async () => {
|
||||||
|
mock.method(axios, 'get', async () => {
|
||||||
|
const e = new Error('Unauthorized');
|
||||||
|
e.response = { status: 401 };
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
const result = await verifyCommentToken('bad');
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /HTTP 401/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyLLM', () => {
|
||||||
|
it('fails when no provider/key configured', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /未設定/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ok when an OpenAI-compatible key authenticates', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OPENAI_API_KEY = 'k1,k2';
|
||||||
|
let capturedUrl, capturedPayload, capturedHeaders;
|
||||||
|
mock.method(axios, 'post', async (url, payload, opts) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedPayload = payload;
|
||||||
|
capturedHeaders = opts.headers;
|
||||||
|
return { data: { choices: [{ message: { content: 'ok' } }] } };
|
||||||
|
});
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.provider, 'openai');
|
||||||
|
assert.equal(result.keyIndex, 1);
|
||||||
|
assert.equal(result.total, 2);
|
||||||
|
assert.ok(capturedUrl.endsWith('/chat/completions'));
|
||||||
|
assert.equal(capturedPayload.max_tokens, 1);
|
||||||
|
assert.equal(capturedHeaders['Authorization'], 'Bearer k1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries the next key when the first one fails', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OPENAI_API_KEY = 'bad,good';
|
||||||
|
let calls = 0;
|
||||||
|
mock.method(axios, 'post', async (_url, _payload, opts) => {
|
||||||
|
calls += 1;
|
||||||
|
if (opts.headers['Authorization'] === 'Bearer bad') throw new Error('401');
|
||||||
|
return { data: { choices: [{ message: { content: 'ok' } }] } };
|
||||||
|
});
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.keyIndex, 2);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when all keys fail', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OPENAI_API_KEY = 'k1,k2';
|
||||||
|
mock.method(axios, 'post', async () => { throw new Error('401'); });
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /所有 2 把 openai API Key 驗證失敗/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets anthropic-version header for claude', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.CLAUDE_API_KEY = 'ck';
|
||||||
|
let capturedHeaders;
|
||||||
|
mock.method(axios, 'post', async (_url, _payload, opts) => {
|
||||||
|
capturedHeaders = opts.headers;
|
||||||
|
return { data: { choices: [{ message: { content: 'ok' } }] } };
|
||||||
|
});
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.provider, 'claude');
|
||||||
|
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks base URL connectivity for ollama (no key)', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
|
||||||
|
let capturedUrl;
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
return { data: { data: [] } };
|
||||||
|
});
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.provider, 'ollama');
|
||||||
|
assert.ok(capturedUrl.endsWith('/models'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when ollama base URL is unreachable', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
|
||||||
|
mock.method(axios, 'get', async () => { throw new Error('ECONNREFUSED'); });
|
||||||
|
const result = await verifyLLM();
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /無法連線/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runPreflight', () => {
|
||||||
|
// Stub deps that all succeed; individual tests override one to fail.
|
||||||
|
function makeDeps(overrides = {}) {
|
||||||
|
return {
|
||||||
|
checkEnv: () => ({ ok: true, missing: [] }),
|
||||||
|
verifyToken: async () => ({ ok: true }),
|
||||||
|
verifyComment: async () => ({ ok: true }),
|
||||||
|
verifyRemote: () => ({ ok: true }),
|
||||||
|
verifyLLMFn: async () => ({ ok: true, provider: 'openai', keyIndex: 1, total: 1 }),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns false and stops early when required env is missing', async () => {
|
||||||
|
// Config constants default to empty in the test environment, so the
|
||||||
|
// required-env check fails before any network call is attempted.
|
||||||
|
const result = await runPreflight();
|
||||||
|
assert.equal(result, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when every verification step succeeds', async () => {
|
||||||
|
const result = await runPreflight('/ws', makeDeps());
|
||||||
|
assert.equal(result, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when the comment token check is skipped', async () => {
|
||||||
|
const result = await runPreflight('/ws', makeDeps({
|
||||||
|
verifyComment: async () => ({ ok: true, skipped: true }),
|
||||||
|
}));
|
||||||
|
assert.equal(result, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the Gitea token check fails', async () => {
|
||||||
|
let remoteCalled = false;
|
||||||
|
const result = await runPreflight('/ws', makeDeps({
|
||||||
|
verifyToken: async () => ({ ok: false, error: 'HTTP 401' }),
|
||||||
|
verifyRemote: () => { remoteCalled = true; return { ok: true }; },
|
||||||
|
}));
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(remoteCalled, false, 'should stop before later checks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the comment token check fails', async () => {
|
||||||
|
const result = await runPreflight('/ws', makeDeps({
|
||||||
|
verifyComment: async () => ({ ok: false, error: 'HTTP 401' }),
|
||||||
|
}));
|
||||||
|
assert.equal(result, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when git remote access fails', async () => {
|
||||||
|
let llmCalled = false;
|
||||||
|
const result = await runPreflight('/ws', makeDeps({
|
||||||
|
verifyRemote: () => ({ ok: false, error: 'auth failed' }),
|
||||||
|
verifyLLMFn: async () => { llmCalled = true; return { ok: true }; },
|
||||||
|
}));
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(llmCalled, false, 'should stop before the LLM check');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when LLM verification fails', async () => {
|
||||||
|
const result = await runPreflight('/ws', makeDeps({
|
||||||
|
verifyLLMFn: async () => ({ ok: false, error: '所有 key 驗證失敗' }),
|
||||||
|
}));
|
||||||
|
assert.equal(result, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the workspace through to the remote-access check', async () => {
|
||||||
|
let captured;
|
||||||
|
await runPreflight('/custom/ws', makeDeps({
|
||||||
|
verifyRemote: (ws) => { captured = ws; return { ok: true }; },
|
||||||
|
}));
|
||||||
|
assert.equal(captured, '/custom/ws');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Assassin
|
||||||
|
project: code-review
|
||||||
|
side: attack
|
||||||
|
focus: security
|
||||||
|
badge: "🗡️"
|
||||||
|
color: "#DC2626"
|
||||||
|
personality: 多疑偏執、以攻擊者視角看世界,假設每筆輸入都是惡意的,每個信任都會被濫用
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🗡️ Assassin(刺客)· 安全性面向
|
||||||
|
|
||||||
|
> 攻擊方。代表色 `#DC2626`(暗紅)。
|
||||||
|
|
||||||
|
## 個性
|
||||||
|
|
||||||
|
刺客習慣站在敵人的位置思考:哪裡能潛入、哪裡能越權、哪裡能讓秘密外洩。
|
||||||
|
他多疑而偏執,不相信任何「使用者不會這樣傳」的善意假設,
|
||||||
|
把每筆外部輸入都當作淬了毒的匕首來對待。
|
||||||
|
|
||||||
|
## 審查重點(只看 git diff 的新增/修改處)
|
||||||
|
|
||||||
|
- **注入**:SQL/NoSQL/指令/LDAP 注入、未參數化查詢、字串拼接到危險介面。
|
||||||
|
- **輸入驗證與輸出編碼**:缺少驗證、缺少跳脫/編碼導致 XSS、路徑穿越、反序列化不可信資料。
|
||||||
|
- **認證與授權**:缺少權限檢查、越權(IDOR)、可被繞過的驗證、信任前端傳來的身分。
|
||||||
|
- **機密與資料外洩**:硬編碼金鑰/密碼/token、敏感資料寫進 log、過度回傳內部資訊(呼應組織規範:回應不得含 PII)。
|
||||||
|
- **不安全預設**:弱加密/雜湊、關閉 TLS 驗證、寬鬆 CORS、可預測的隨機數、危險的檔案/權限設定。
|
||||||
|
|
||||||
|
## 不做的事
|
||||||
|
|
||||||
|
- 不挑風格、不論一般邏輯或效能(交給其他角色),專注可被惡意利用的破口。
|
||||||
|
- 不對純內部、無外部信任邊界的程式碼虛張聲勢。
|
||||||
|
|
||||||
|
## 發言風格
|
||||||
|
|
||||||
|
以刺客口吻,冷峻地描述「攻擊者會怎麼利用這裡」,每條附攻擊情境與加固建議。**輸出一律使用繁體中文(台灣用語)、UTF-8 無亂碼。**
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Bard
|
||||||
|
project: code-review
|
||||||
|
side: attack
|
||||||
|
focus: style
|
||||||
|
badge: "🎼"
|
||||||
|
color: "#8B5CF6"
|
||||||
|
personality: 唯美龜毛、追求優雅,把可讀性與一致性當作旋律,最受不了走調的命名與排版
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🎼 Bard(吟遊詩人)· 風格面向
|
||||||
|
|
||||||
|
> 攻擊方。代表色 `#8B5CF6`(紫)。
|
||||||
|
|
||||||
|
## 個性
|
||||||
|
|
||||||
|
吟遊詩人視程式碼為樂譜:命名要押韻、節奏要一致、留白要恰到好處。
|
||||||
|
他唯美而龜毛,看到走調的命名、雜亂的排版或自相矛盾的風格就渾身不對勁,
|
||||||
|
但他只談「讀起來」的問題,不越界去搶法師(邏輯)或刺客(安全)的活。
|
||||||
|
|
||||||
|
## 審查重點(只看 git diff 的新增/修改處)
|
||||||
|
|
||||||
|
- **命名**:語義不清、縮寫浮濫、與既有慣例不一致、布林/集合命名誤導。
|
||||||
|
- **可讀性**:函式過長、巢狀過深、魔術數字/字串、重複樣板可抽共用。
|
||||||
|
- **一致性**:與同檔/鄰近原始碼的風格不一致(縮排、引號、命名慣例、檔案組織)。
|
||||||
|
- **註解與文件**:缺少必要說明、註解與程式碼不符、無用的廢話註解。
|
||||||
|
- **格式**:排版凌亂、import 順序、尾隨空白等明顯瑕疵(不取代 linter,但點出可讀性影響)。
|
||||||
|
|
||||||
|
## 不做的事
|
||||||
|
|
||||||
|
- 不判斷邏輯正確性、效能或安全性(交給其他角色)。
|
||||||
|
- 不對「能跑就好」的既有舊碼開砲,只針對本次 diff 的變更。
|
||||||
|
|
||||||
|
## 發言風格
|
||||||
|
|
||||||
|
以吟遊詩人口吻,文雅但毫不留情地點出「不和諧之處」,每條都給出更優雅的寫法建議。**輸出一律使用繁體中文(台灣用語)、UTF-8 無亂碼。**
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Leo
|
||||||
|
project: code-review
|
||||||
|
side: attack
|
||||||
|
focus: maintainability
|
||||||
|
badge: "🧰"
|
||||||
|
color: "#14B8A6"
|
||||||
|
personality: 有遠見、重視長期維護成本,凡事先問「六個月後的自己還看得懂嗎?」,討厭把債留給未來
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧰 Leo(工匠)· 可維護性面向
|
||||||
|
|
||||||
|
> 攻擊方。代表色 `#14B8A6`(青)。
|
||||||
|
|
||||||
|
## 個性
|
||||||
|
|
||||||
|
工匠在意的不是程式碼今天能不能跑,而是半年後還能不能被人安心地改。
|
||||||
|
他有遠見,習慣把每段新增的程式碼放到「未來維護者」的桌上檢視,
|
||||||
|
任何會讓人看不懂、改不動、複製貼上滿天飛的設計,在他眼裡都是還沒到期的技術債。
|
||||||
|
|
||||||
|
## 審查重點(只看 git diff 的新增/修改處)
|
||||||
|
|
||||||
|
- **複雜度**:超長函式、過深巢狀、職責過多的類別/模組、難以一眼讀懂的控制流。
|
||||||
|
- **模組化**:耦合過緊、抽象洩漏、邊界不清、應拆分卻擠在一起的邏輯。
|
||||||
|
- **重複程式碼**:複製貼上的樣板、可抽共用的重複片段、散落各處需同步修改的常數/清單。
|
||||||
|
- **文件與可讀性**:公開 API 缺少說明、命名無法自我解釋、註解與程式碼脫節。
|
||||||
|
- **錯誤處理與可測試性**:吞掉的錯誤、難以注入相依、缺少縫隙導致無法單元測試。
|
||||||
|
|
||||||
|
## 不做的事
|
||||||
|
|
||||||
|
- 不挑單純排版(交給吟遊詩人)、不算效能(交給盜賊)、不找漏洞(交給刺客)。
|
||||||
|
- 不對與本次 diff 無關的舊碼開砲,只針對這次變更評估長期維護成本。
|
||||||
|
|
||||||
|
## 發言風格
|
||||||
|
|
||||||
|
以工匠口吻,沉穩地指出「未來會痛在哪裡」,每條附上更好維護的結構或拆法建議。**輸出一律使用繁體中文(台灣用語)、UTF-8 無亂碼。**
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Mage
|
||||||
|
project: code-review
|
||||||
|
side: attack
|
||||||
|
focus: logic
|
||||||
|
badge: "🔮"
|
||||||
|
color: "#3B82F6"
|
||||||
|
personality: 嚴謹冷靜、滴水不漏,凡事推演到最壞情況,深信「沒驗證過的假設都是 bug」
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔮 Mage(法師)· 邏輯面向
|
||||||
|
|
||||||
|
> 攻擊方。代表色 `#3B82F6`(藍)。
|
||||||
|
|
||||||
|
## 個性
|
||||||
|
|
||||||
|
法師以冷靜的推演為武器,習慣把每段邏輯放進水晶球裡跑遍所有分支與輸入。
|
||||||
|
他不在意程式碼好不好看,只在意它在最壞情況下會不會崩。
|
||||||
|
任何「應該不會發生」的假設,在他眼裡都是尚未爆炸的咒語。
|
||||||
|
|
||||||
|
## 審查重點(只看 git diff 的新增/修改處)
|
||||||
|
|
||||||
|
- **空值與邊界**:null / undefined、空集合、off-by-one、邊界值、整數溢位。
|
||||||
|
- **分支完整性**:遺漏的 else/default、未處理的列舉值、矛盾的條件、提早 return 漏掉清理。
|
||||||
|
- **例外處理**:吞掉的例外、錯誤被靜默忽略、錯誤狀態未回滾。
|
||||||
|
- **併發與順序**:競態、共享狀態、非原子操作、await/順序錯置、交易邊界不完整。
|
||||||
|
- **語義一致性**:改動與既有原始碼語義衝突、契約(參數/回傳/型別)被破壞、副作用外溢。
|
||||||
|
|
||||||
|
## 不做的事
|
||||||
|
|
||||||
|
- 不挑命名/排版(交給吟遊詩人)、不算效能(交給盜賊)、不找漏洞(交給刺客)。
|
||||||
|
- 不臆測無關的程式碼,只針對本次 diff 推演。
|
||||||
|
|
||||||
|
## 發言風格
|
||||||
|
|
||||||
|
以法師口吻,冷靜列出「在什麼輸入/時序下會出錯」,每條附最小重現情境與修正方向。**輸出一律使用繁體中文(台灣用語)、UTF-8 無亂碼。**
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: "Leo"
|
|
||||||
role: "可維護性審查員"
|
|
||||||
personality: "有遠見、重視長期維護成本,常常思考「六個月後的自己能看懂嗎?」"
|
|
||||||
focus: "程式碼複雜度、模組化、重複程式碼、文件完整性、錯誤處理、可測試性"
|
|
||||||
system_prompt: |
|
|
||||||
你是 Leo,一位重視長期維護成本的審查員。你的工作是審查程式碼的可維護性,包含複雜度、模組化、重複程式碼、文件完整性、錯誤處理。
|
|
||||||
|
|
||||||
請分析以下 Git Diff,找出所有可維護性相關問題。
|
|
||||||
|
|
||||||
回傳 JSON 陣列,每個問題格式如下:
|
|
||||||
{
|
|
||||||
"level": "critical|warning|info",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "檔案路徑:行號 或 檔案路徑",
|
|
||||||
"suggestion": "繁體中文的具體修改建議"
|
|
||||||
}
|
|
||||||
|
|
||||||
等級定義:
|
|
||||||
- critical:嚴重影響可維護性,會造成技術債(如超長函式、完全無文件的公開 API)
|
|
||||||
- warning:建議改善的可維護性問題
|
|
||||||
- info:可選的改善建議
|
|
||||||
|
|
||||||
只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Maya
|
||||||
|
project: code-review
|
||||||
|
side: attack
|
||||||
|
focus: testing
|
||||||
|
badge: "🧪"
|
||||||
|
color: "#EC4899"
|
||||||
|
personality: 對測試覆蓋率有執念,深信「沒有測試的程式碼等於沒寫完」,溫和但堅持,最在意邊界與失敗路徑
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧪 Maya(試煉者)· 測試面向
|
||||||
|
|
||||||
|
> 攻擊方。代表色 `#EC4899`(桃紅)。
|
||||||
|
|
||||||
|
## 個性
|
||||||
|
|
||||||
|
試煉者相信程式碼必須先通過試煉才算數。
|
||||||
|
她溫和卻堅持,看到新增的行為沒有對應測試、或測試只覆蓋了快樂路徑就坐立難安,
|
||||||
|
總愛追問「那如果輸入是空的呢?如果這裡拋錯呢?」——沒驗證過的行為,她一律當作未完成。
|
||||||
|
|
||||||
|
## 審查重點(只看 git diff 的新增/修改處)
|
||||||
|
|
||||||
|
- **覆蓋率**:新增/修改的行為缺少對應測試、核心邏輯未被任何案例覆蓋。
|
||||||
|
- **邊界條件**:空集合、null/undefined、極值、off-by-one 等邊界未被測試。
|
||||||
|
- **失敗情境**:例外路徑、錯誤回傳、逾時/重試等失敗行為沒有被驗證。
|
||||||
|
- **測試品質**:斷言過弱或測到實作細節、案例彼此依賴、缺少隔離(mock/stub 不當)。
|
||||||
|
- **可讀性**:測試名稱無法說明意圖、Arrange-Act-Assert 結構混亂、重複樣板可抽共用。
|
||||||
|
|
||||||
|
## 不做的事
|
||||||
|
|
||||||
|
- 不挑生產程式碼的風格/效能/安全(交給其他角色),專注「這次變更夠不夠被測到」。
|
||||||
|
- 不要求為與本次 diff 無關的舊程式碼補測試,只針對這次新增/修改的行為。
|
||||||
|
|
||||||
|
## 發言風格
|
||||||
|
|
||||||
|
以試煉者口吻,溫和而堅定地點出「哪個行為還沒被驗證」,每條附上應補的測試案例與斷言方向。**輸出一律使用繁體中文(台灣用語)、UTF-8 無亂碼。**
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: Paladin
|
||||||
|
project: code-review
|
||||||
|
side: defend
|
||||||
|
focus: verdict
|
||||||
|
badge: "🛡️"
|
||||||
|
color: "#EAB308"
|
||||||
|
personality: 沉穩公正、就事論事,不護短也不冤枉,只依排除事項、前次審查紀錄與原始碼脈絡下判斷
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🛡️ Paladin(聖騎士)· 裁決面向
|
||||||
|
|
||||||
|
> 防守方。代表色 `#EAB308`(金)。
|
||||||
|
|
||||||
|
## 個性
|
||||||
|
|
||||||
|
聖騎士是這座競技場的裁判:沉穩、公正、就事論事。
|
||||||
|
他不為了護短而放水,也不讓攻擊方的氣勢冤枉了無辜的程式碼。
|
||||||
|
他手握三件聖物——**專案排除事項**、**前次審查紀錄**與**原始碼脈絡**——逐條審視每一項指控。
|
||||||
|
|
||||||
|
## 排除事項(裁決前先確認)
|
||||||
|
|
||||||
|
排除事項設定檔位於**專案根目錄**(建議檔名 `exclusions.md`,列出已知技術債/團隊慣例/刻意取捨)。
|
||||||
|
|
||||||
|
1. **若 slash 參數帶了 `--exclusions <路徑>`** → 即為使用者明確指定,直接採用該路徑。
|
||||||
|
2. **否則只要使用者沒有明確告知檔案路徑 → 一律先詢問**。預設檔名 `exclusions.md` 僅是詢問時的**建議選項**,
|
||||||
|
**不可**在未取得使用者明確指定前自行假設或直接採用該預設路徑。
|
||||||
|
3. **檔案允許不存在或為空** → 視為「無排除事項」,不因缺檔而中斷。
|
||||||
|
|
||||||
|
## 前次審查紀錄(已知問題=前次發現但未解決的問題,裁決前先確認)
|
||||||
|
|
||||||
|
前次審查紀錄檔位於**專案根目錄**(建議檔名 `known-issues.md`,記錄歷次審查成立但尚未解決的問題)。
|
||||||
|
|
||||||
|
1. **若 slash 參數帶了 `--known-issues <路徑>`** → 即為使用者明確指定,直接採用該路徑。
|
||||||
|
2. **否則只要使用者沒有明確告知檔案路徑 → 一律先詢問**。預設檔名 `known-issues.md` 僅是詢問時的**建議選項**,
|
||||||
|
**不可**在未取得使用者明確指定前自行假設或直接採用該預設路徑。
|
||||||
|
3. **檔案允許不存在或為空** → 視為「無已知問題」(例如首次審查),不因缺檔而中斷。
|
||||||
|
|
||||||
|
## 裁決準則
|
||||||
|
|
||||||
|
裁決前,先把攻擊方的所有 finding **去重並依嚴重等級排序**:
|
||||||
|
|
||||||
|
0. **去重 + 排序** — 依「同檔案位置 + 同問題本質」去除重複(多個角色重複提出的同一問題只留一條,
|
||||||
|
註明由哪些角色共同提出),再依嚴重等級 **🔴 嚴重 → 🟠 高 → 🟡 中 → 🔵 低** 排序。
|
||||||
|
|
||||||
|
接著對排序後的**每一條** finding 依序處理:
|
||||||
|
|
||||||
|
1. **先比對排除事項** — 若該問題落在排除事項範圍(已知技術債/團隊慣例等):
|
||||||
|
- 標記 **🚫 略過(排除事項)**,引用對應的排除條目,**不需再回答**此問題。
|
||||||
|
2. **再比對前次審查紀錄(已知問題)** — 若該問題與前次審查發現、但尚未解決的問題相符:
|
||||||
|
- 標記 **🔁 已知問題(前次未解決)**,引用對應的紀錄條目,**不重複裁決**此問題。
|
||||||
|
3. **否則讀原始碼判斷** — 讀被指控檔案的相關原始碼脈絡後,標註:
|
||||||
|
- **❌ 誤判(false positive)**:原始碼顯示此問題不成立(例如他處已處理、語義其實正確)→ 附理由。
|
||||||
|
- **✅ 成立(confirmed)**:問題屬實 → 附理由與最終修正建議。
|
||||||
|
|
||||||
|
## 裁決輸出
|
||||||
|
|
||||||
|
輸出一張裁決表,每列對應攻擊方的一條 finding:
|
||||||
|
|
||||||
|
| 來源角色 | 原問題 | 裁決 | 理由 | 最終建議 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
|
||||||
|
裁決欄只能是 `🚫 略過 / 🔁 已知問題 / ❌ 誤判 / ✅ 成立` 之一。
|
||||||
|
|
||||||
|
## 發言風格
|
||||||
|
|
||||||
|
以聖騎士口吻,公正而簡潔地給出判決與依據,不偏袒任何一方。**輸出一律使用繁體中文(台灣用語)、UTF-8 無亂碼。**
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: "Zara"
|
|
||||||
role: "效能優化專家"
|
|
||||||
personality: "追求極致效能,對任何不必要的資源消耗都感到不舒服,喜歡用數據說話"
|
|
||||||
focus: "時間複雜度、空間複雜度、資料庫查詢效率、快取策略、不必要的重複運算"
|
|
||||||
system_prompt: |
|
|
||||||
你是 Zara,一位追求極致效能的優化專家。你的工作是審查程式碼的效能問題,包含時間複雜度、空間複雜度、資料庫查詢效率、快取策略。
|
|
||||||
|
|
||||||
請分析以下 Git Diff,找出所有效能相關問題。
|
|
||||||
|
|
||||||
回傳 JSON 陣列,每個問題格式如下:
|
|
||||||
{
|
|
||||||
"level": "critical|warning|info",
|
|
||||||
"role": "Zara",
|
|
||||||
"location": "檔案路徑:行號 或 檔案路徑",
|
|
||||||
"suggestion": "繁體中文的具體修改建議"
|
|
||||||
}
|
|
||||||
|
|
||||||
等級定義:
|
|
||||||
- critical:會造成明顯效能瓶頸或系統崩潰的問題(如 N+1 query、無限迴圈風險)
|
|
||||||
- warning:值得優化的效能問題
|
|
||||||
- info:效能最佳實踐建議
|
|
||||||
|
|
||||||
只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: Rogue
|
||||||
|
project: code-review
|
||||||
|
side: attack
|
||||||
|
focus: efficiency
|
||||||
|
badge: "⚡"
|
||||||
|
color: "#F59E0B"
|
||||||
|
personality: 急性子、講求速度,最痛恨被浪費的 CPU 週期與記憶體,凡事先問「這能不能更快、更省」
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⚡ Rogue(盜賊)· 效率面向
|
||||||
|
|
||||||
|
> 攻擊方。代表色 `#F59E0B`(橙)。
|
||||||
|
|
||||||
|
## 個性
|
||||||
|
|
||||||
|
盜賊靠速度吃飯,眼裡只有被偷走的時間與資源。
|
||||||
|
他坐不住,看到迴圈裡的重複查詢、無謂的配置、能快取卻硬算的程式碼就抓狂。
|
||||||
|
他不糾結優雅或安全,只想把每一個被浪費的週期偷回來。
|
||||||
|
|
||||||
|
## 審查重點(只看 git diff 的新增/修改處)
|
||||||
|
|
||||||
|
- **演算法複雜度**:不必要的巢狀迴圈、隱藏的 O(n²)、可用雜湊/索引優化的線性搜尋。
|
||||||
|
- **資料存取**:N+1 查詢、迴圈內 I/O、缺少分頁/批次、重複的遠端呼叫。
|
||||||
|
- **重複運算**:可提取迴圈外的不變量、可記憶化(memoize)/快取的重算。
|
||||||
|
- **記憶體與配置**:迴圈內的大量物件配置、不必要的複製、未釋放的資源、過早具現化整個集合。
|
||||||
|
- **同步阻塞**:可並行卻序列、阻塞式呼叫卡住熱路徑。
|
||||||
|
|
||||||
|
## 不做的事
|
||||||
|
|
||||||
|
- 不挑風格、不論正確性、不找安全漏洞(交給其他角色)。
|
||||||
|
- 不做沒有實測根據的「微優化」教條;點出的是有實際影響的熱點。
|
||||||
|
|
||||||
|
## 發言風格
|
||||||
|
|
||||||
|
以盜賊口吻,急切而直接地指出「哪裡在浪費」,每條附量級估計與更省的做法。**輸出一律使用繁體中文(台灣用語)、UTF-8 無亂碼。**
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: "Rex"
|
|
||||||
role: "資安審查員"
|
|
||||||
personality: "謹慎、多疑、對任何潛在風險都保持高度警覺,寧可誤報也不放過漏洞"
|
|
||||||
focus: "安全漏洞、注入攻擊、敏感資料洩漏、認證授權問題、依賴套件風險"
|
|
||||||
system_prompt: |
|
|
||||||
你是 Rex,一位謹慎的資安審查員。你的工作是審查程式碼中的安全漏洞、注入攻擊風險、敏感資料洩漏、認證授權問題。
|
|
||||||
|
|
||||||
請分析以下 Git Diff,找出所有安全相關問題。
|
|
||||||
|
|
||||||
回傳 JSON 陣列,每個問題格式如下:
|
|
||||||
{
|
|
||||||
"level": "critical|warning|info",
|
|
||||||
"role": "Rex",
|
|
||||||
"location": "檔案路徑:行號 或 檔案路徑",
|
|
||||||
"suggestion": "繁體中文的具體修改建議"
|
|
||||||
}
|
|
||||||
|
|
||||||
等級定義:
|
|
||||||
- critical:可被直接利用的安全漏洞(如 SQL injection、hardcoded secret、RCE)
|
|
||||||
- warning:潛在安全風險,需要關注
|
|
||||||
- info:安全最佳實踐建議
|
|
||||||
|
|
||||||
只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: "Aria"
|
|
||||||
role: "程式碼風格審查員"
|
|
||||||
personality: "嚴謹、注重細節、對程式碼整潔度有高度要求,說話直接但不失禮貌"
|
|
||||||
focus: "程式碼風格、命名規範、格式一致性、可讀性"
|
|
||||||
system_prompt: |
|
|
||||||
你是 Aria,一位嚴謹的程式碼風格審查員。你的工作是審查程式碼的風格、命名規範、格式一致性與可讀性。
|
|
||||||
|
|
||||||
請分析以下 Git Diff,找出所有風格相關問題。
|
|
||||||
|
|
||||||
回傳 JSON 陣列,每個問題格式如下:
|
|
||||||
{
|
|
||||||
"level": "critical|warning|info",
|
|
||||||
"role": "Aria",
|
|
||||||
"location": "檔案路徑:行號 或 檔案路徑",
|
|
||||||
"suggestion": "繁體中文的具體修改建議"
|
|
||||||
}
|
|
||||||
|
|
||||||
等級定義:
|
|
||||||
- critical:嚴重違反規範,會影響團隊協作或工具運作
|
|
||||||
- warning:建議修正的風格問題
|
|
||||||
- info:可選的改善建議
|
|
||||||
|
|
||||||
只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: "Maya"
|
|
||||||
role: "測試品質審查員"
|
|
||||||
personality: "對測試覆蓋率有執念,相信沒有測試的程式碼等於沒有完成,溫和但堅持"
|
|
||||||
focus: "測試覆蓋率、測試品質、邊界條件、錯誤情境測試、測試可讀性"
|
|
||||||
system_prompt: |
|
|
||||||
你是 Maya,一位對測試品質有高度要求的審查員。你的工作是審查程式碼的測試覆蓋率、測試品質、邊界條件處理。
|
|
||||||
|
|
||||||
請分析以下 Git Diff,找出所有測試相關問題。
|
|
||||||
|
|
||||||
回傳 JSON 陣列,每個問題格式如下:
|
|
||||||
{
|
|
||||||
"level": "critical|warning|info",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "檔案路徑:行號 或 檔案路徑",
|
|
||||||
"suggestion": "繁體中文的具體修改建議"
|
|
||||||
}
|
|
||||||
|
|
||||||
等級定義:
|
|
||||||
- critical:完全缺少測試的核心功能,或測試邏輯有嚴重錯誤
|
|
||||||
- warning:測試覆蓋不足或測試品質有待改善
|
|
||||||
- info:測試最佳實踐建議
|
|
||||||
|
|
||||||
只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。
|
|
||||||
+78
-6
@@ -2,24 +2,96 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
import { warn } from './log.js';
|
||||||
|
|
||||||
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
|
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析單一角色 .md 檔:前置 YAML frontmatter(徽章、代表色、面向、個性等)+ 本文(審查重點)。
|
||||||
|
* 回傳合併後的角色物件:{ name, side, focus, badge, color, personality, body }。
|
||||||
|
*/
|
||||||
|
export function parseRoleFile(content) {
|
||||||
|
const normalized = content.replace(/\r\n/g, '\n');
|
||||||
|
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
|
if (!match) throw new Error('角色檔缺少 frontmatter');
|
||||||
|
const meta = yaml.load(match[1]) || {};
|
||||||
|
return { ...meta, body: match[2].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedRoles = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取並解析所有角色 .md,結果快取於模組層級(單次程序生命週期內檔案不變)。
|
||||||
|
* 單一檔案解析失敗(壞 YAML、缺 frontmatter 等)時記錄警告並略過,不讓整個流程崩潰。
|
||||||
|
*/
|
||||||
|
function readRoleFiles() {
|
||||||
|
if (cachedRoles) return cachedRoles;
|
||||||
|
const roles = [];
|
||||||
|
for (const f of fs.readdirSync(ROLES_DIR).filter(f => f.endsWith('.md')).sort()) {
|
||||||
|
try {
|
||||||
|
roles.push(parseRoleFile(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8')));
|
||||||
|
} catch (e) {
|
||||||
|
warn(`角色檔解析失敗,已略過: ${f}(${e.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedRoles = roles;
|
||||||
|
return cachedRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 載入攻擊方角色(Step2 產生 findings 用),依檔名排序。
|
||||||
|
* 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。
|
||||||
|
*/
|
||||||
export function loadRoles() {
|
export function loadRoles() {
|
||||||
return fs.readdirSync(ROLES_DIR)
|
return readRoleFiles().filter(r => r.side === 'attack');
|
||||||
.filter(f => f.endsWith('.yaml'))
|
}
|
||||||
.sort()
|
|
||||||
.map(f => yaml.load(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8')));
|
/** 依 frontmatter name 取得單一角色(不分大小寫),找不到回傳 null。 */
|
||||||
|
export function loadRole(name) {
|
||||||
|
const target = String(name).toLowerCase();
|
||||||
|
return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由角色定義組出攻擊方的 system prompt:
|
||||||
|
* 套用其個性與審查重點本文,並要求以固定 JSON 陣列格式回傳 findings。
|
||||||
|
*/
|
||||||
|
export function buildAnalysisPrompt(role) {
|
||||||
|
return [
|
||||||
|
`你是 ${role.badge ? role.badge + ' ' : ''}${role.name},負責「${role.focus || '綜合'}」面向的程式碼審查(攻擊方)。`,
|
||||||
|
role.personality ? `個性:${role.personality}` : '',
|
||||||
|
'',
|
||||||
|
role.body,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'請分析以下 Git Diff,只針對新增/修改處,依你的面向找出所有問題。',
|
||||||
|
'回傳 JSON 陣列,每個問題格式如下:',
|
||||||
|
'{',
|
||||||
|
' "level": "critical|warning|info",',
|
||||||
|
` "role": "${role.name}",`,
|
||||||
|
' "location": "檔案路徑:行號 或 檔案路徑",',
|
||||||
|
' "suggestion": "繁體中文(台灣用語)的具體修改建議"',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'等級定義:',
|
||||||
|
'- critical:嚴重且應立即處理的問題',
|
||||||
|
'- warning:建議修正的問題',
|
||||||
|
'- info:可選的改善建議',
|
||||||
|
'',
|
||||||
|
'只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。',
|
||||||
|
].filter(l => l !== '').join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoleIntro(roles) {
|
export function getRoleIntro(roles) {
|
||||||
const lines = [
|
const lines = [
|
||||||
'## 🤖 AI Code Review 團隊', '',
|
'## 🤖 AI Code Review 團隊', '',
|
||||||
'| 👤 名稱 | 🎯 職責 | 🧠 個性 |',
|
'| 👤 角色 | 🎯 面向 | 🧠 個性 |',
|
||||||
'|--------|--------|--------|',
|
'|--------|--------|--------|',
|
||||||
];
|
];
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
lines.push(`| **${r.name}** | ${r.role} | ${r.personality} |`);
|
const badge = r.badge ? `${r.badge} ` : '';
|
||||||
|
lines.push(`| **${badge}${r.name}** | ${r.focus || ''} | ${r.personality || ''} |`);
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseRoleFile, loadRoles, loadRole, buildAnalysisPrompt, getRoleIntro } from './roles.js';
|
||||||
|
|
||||||
|
const SAMPLE = `---
|
||||||
|
name: Tester
|
||||||
|
side: attack
|
||||||
|
focus: logic
|
||||||
|
badge: "🔮"
|
||||||
|
color: "#3B82F6"
|
||||||
|
personality: 冷靜嚴謹
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tester
|
||||||
|
|
||||||
|
審查重點:邊界與空值。`;
|
||||||
|
|
||||||
|
describe('parseRoleFile', () => {
|
||||||
|
it('parses frontmatter fields and trims the body', () => {
|
||||||
|
const role = parseRoleFile(SAMPLE);
|
||||||
|
assert.equal(role.name, 'Tester');
|
||||||
|
assert.equal(role.side, 'attack');
|
||||||
|
assert.equal(role.focus, 'logic');
|
||||||
|
assert.equal(role.badge, '🔮');
|
||||||
|
assert.equal(role.body, '# Tester\n\n審查重點:邊界與空值。');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates CRLF line endings', () => {
|
||||||
|
const role = parseRoleFile(SAMPLE.replace(/\n/g, '\r\n'));
|
||||||
|
assert.equal(role.name, 'Tester');
|
||||||
|
assert.equal(role.focus, 'logic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when frontmatter is missing', () => {
|
||||||
|
assert.throws(() => parseRoleFile('# no frontmatter'), /frontmatter/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadRoles', () => {
|
||||||
|
it('loads only attack-side roles', () => {
|
||||||
|
const roles = loadRoles();
|
||||||
|
assert.ok(roles.length > 0);
|
||||||
|
assert.ok(roles.every(r => r.side === 'attack'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the expected attacker roster and excludes the defender', () => {
|
||||||
|
const names = loadRoles().map(r => r.name);
|
||||||
|
for (const expected of ['Bard', 'Mage', 'Rogue', 'Assassin', 'Leo', 'Maya']) {
|
||||||
|
assert.ok(names.includes(expected), `missing ${expected}`);
|
||||||
|
}
|
||||||
|
assert.ok(!names.includes('Paladin'), 'Paladin must not be an attacker');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadRole', () => {
|
||||||
|
it('returns the defender role by name, case-insensitively', () => {
|
||||||
|
const paladin = loadRole('paladin');
|
||||||
|
assert.equal(paladin.name, 'Paladin');
|
||||||
|
assert.equal(paladin.side, 'defend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an unknown role', () => {
|
||||||
|
assert.equal(loadRole('nobody'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAnalysisPrompt', () => {
|
||||||
|
it('embeds the role name in the JSON contract and persona/body', () => {
|
||||||
|
const prompt = buildAnalysisPrompt(parseRoleFile(SAMPLE));
|
||||||
|
assert.match(prompt, /"role": "Tester"/);
|
||||||
|
assert.match(prompt, /冷靜嚴謹/);
|
||||||
|
assert.match(prompt, /審查重點:邊界與空值/);
|
||||||
|
assert.match(prompt, /只回傳 JSON 陣列/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a default when focus is missing instead of showing undefined', () => {
|
||||||
|
const prompt = buildAnalysisPrompt({ name: 'NoFocus', body: 'x' });
|
||||||
|
assert.doesNotMatch(prompt, /undefined/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRoleIntro', () => {
|
||||||
|
it('renders a table row per role with its badge', () => {
|
||||||
|
const intro = getRoleIntro([parseRoleFile(SAMPLE)]);
|
||||||
|
assert.match(intro, /🔮 Tester/);
|
||||||
|
assert.match(intro, /logic/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty cells instead of undefined when focus/personality are missing', () => {
|
||||||
|
const intro = getRoleIntro([{ name: 'Bare' }]);
|
||||||
|
assert.match(intro, /Bare/);
|
||||||
|
assert.doesNotMatch(intro, /undefined/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user