Compare commits

...

6 Commits

Author SHA1 Message Date
Jeffery 69371eb993 feat: update GEMINI_API_KEY configuration for AI Code Review
AI / 計算版本號 (pull_request) Successful in 2s
AI / Code Review (pull_request) Failing after 2s
2026-06-15 10:44:19 +08:00
Jeffery 766f2ddf40 feat: 啟動時前置驗證所有驗證相關設定
AI / 計算版本號 (pull_request) Failing after 1s
AI / Code Review (pull_request) Has been skipped
新增 app/preflight.js,在 action 啟動(Step1 之後、其餘步驟之前)集中
檢查必要環境變數、GITEA_TOKEN 讀 repo、GITEA_COMMENT_TOKEN、以及 LLM
provider/API Key(多把只要一把通過即可,Ollama 改檢查 base URL 連線)。
任一項失敗即印出原因並 exit 1,避免分析到一半或發 comment 時才失敗。

main.js 在 Step1 後呼叫 runPreflight();新增 preflight.test.js 覆蓋
成功、缺環境變數、token 無效、所有 LLM key 失敗、Ollama 等情境。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:41:40 +08:00
Jeffery 1b34298d4b docs: 規劃 action 啟動前置驗證所有驗證相關設定
在 README 流程新增第 0 點與設計第 11 點,並在 TODO 新增階段十二,
說明 action 啟動時集中驗證 Gitea token、comment token 與 LLM API Key
是否可用,任一失敗即 exit 1。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:37:11 +08:00
jiantw83 9af09de0d3 Merge pull request 'feat: implement Git integration for automated repository instruction syncing and commit management' (#130) from feat/ai_merge into develop
Reviewed-on: #130
2026-05-21 03:56:41 +00:00
Jeffery fbff9b3a86 chore: initialize ai-review exclusion and findings configuration files 2026-05-21 11:52:18 +08:00
jiantw83 7a01b7e3f4 Merge pull request 'feat: 加入 Codex 的 Triage Findings 技能' (#129) from feat/codex into develop
Reviewed-on: #129
2026-05-21 03:36:41 +00:00
8 changed files with 707 additions and 357 deletions
+354 -347
View File
@@ -1,348 +1,355 @@
[
{
"role": "Rex",
"location": "app/git.js",
"suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數"
},
{
"location": "app/git.js",
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入"
},
{
"role": "Rex",
"location": "README.md",
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
},
{
"location": "app/config.js",
"suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報"
},
{
"location": ".gitea/ai-review/exclusions.json",
"suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位"
},
{
"location": "app/findings.js",
"suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程"
},
{
"role": "Rex",
"location": ".gitea/workflows/review.yaml",
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
},
{
"role": "Rex",
"location": ".gitea/workflows/review.yaml",
"suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法"
},
{
"role": "Aria",
"location": "README.md",
"suggestion": "章節編號連續且正確,無需調整"
},
{
"role": "Maya",
"location": ".gitea/workflows/review.yaml",
"suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題"
},
{
"role": "Aria",
"location": ".gitea/workflows/review.yaml",
"suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在"
},
{
"role": "Aria",
"location": "app/config.test.js",
"suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改"
},
{
"role": "Aria",
"location": "action.yaml",
"suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔"
},
{
"role": "Maya",
"location": "app/",
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯"
},
{
"role": "Rex",
"location": "app/",
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異"
},
{
"role": "Rex",
"location": "app/config.test.js",
"suggestion": "import 語句長度合理,無需拆分為多行"
},
{
"role": "Rex",
"location": ".gitea/ai-review/findings.json",
"suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題"
},
{
"role": "Rex",
"location": "app/comments.js",
"suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題"
},
{
"location": ".gitea/ai-review/findings.json",
"suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題"
},
{
"role": "Rex",
"location": ".gitea/workflows/review.yaml",
"suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題"
},
{
"role": "Leo",
"location": "app/llm.js",
"suggestion": "Authorization 標頭已有 provider !== 'ollama' 判斷,不會無條件加入,已正確處理"
},
{
"role": "Zara",
"location": "app/llm.js",
"suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度"
},
{
"role": "Rex",
"location": "app/llm.js",
"suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常"
},
{
"role": "Maya",
"location": "app/llm.js",
"suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例"
},
{
"role": "Zara",
"location": "app/comments.js",
"suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題"
},
{
"role": "Leo",
"location": ".gitea/workflows/review.yaml",
"suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷"
},
{
"role": "Maya",
"location": "app/llm.test.js",
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
},
{
"role": "Maya",
"location": "app/llm.test.js",
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
},
{
"role": "Aria",
"location": ".gitea/workflows/master.yaml",
"suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改"
},
{
"role": "Leo",
"location": "app/llm.test.js",
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
},
{
"role": "Leo",
"location": "app/llm.test.js",
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
},
{
"role": "Leo",
"location": "app/main.js",
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
},
{
"role": "Maya",
"location": "app/log.test.js",
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。"
},
{
"role": "Rex",
"location": "app/package.json",
"suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題"
},
{
"role": "Aria",
"location": "app/llm.js",
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
},
{
"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 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
},
{
"location": "Dockerfile",
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
},
{
"location": "Dockerfile",
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
},
{
"role": "Aria",
"location": "Dockerfile",
"suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
},
{
"role": "Aria",
"location": "entrypoint.sh",
"suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
},
{
"role": "Maya",
"location": "app/main.js",
"suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋"
},
{
"role": "Maya",
"location": "app/comments.js",
"suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低"
},
{
"role": "Maya",
"location": "app/roles.js",
"suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接"
},
{
"role": "Leo",
"location": "app/gitea.js",
"suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞"
},
{
"role": "Zara",
"location": "Dockerfile",
"suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案"
},
{
"role": "Aria",
"location": "app/package.json",
"suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案"
},
{
"role": "Zara",
"location": "app/main.js",
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
},
{
"role": "Leo",
"location": "app/comments.js",
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
},
{
"role": "Maya",
"location": "app/gitea.js",
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
},
{
"role": "Leo",
"location": "TODO.md",
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
},
{
"role": "Rex",
"location": "app/gitea.js",
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
},
{
"role": "Zara",
"location": "app/git.js",
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
},
{
"role": "Aria",
"location": "app/main.js",
"suggestion": "在 main.js 中,表達式 repoDir。"
},
{
"role": "Zara",
"location": "app/gitea.js:L20-L21",
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
},
{
"location": "TODO.md",
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
},
{
"location": "TODO.md",
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
},
{
"location": "app/findings.js",
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
},
{
"location": "app/findings.js",
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
},
{
"location": "app/comments.js",
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
},
{
"location": "app/main.js",
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
},
{
"location": "app/git.js",
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
},
{
"location": "app/main.js",
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
},
{
"location": "app/json.js",
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
},
{
"location": "app/json.test.js",
"suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。"
},
{
"location": "app/gitea.test.js:64",
"suggestion": "`describe` 已改為同步 callback`async` 不再出現在這個區塊。"
},
{
"location": "app/git.test.js:13",
"suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。"
},
{
"location": "app/gitea.js:32",
"suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。"
},
{
"location": "app/json.test.js:76",
"suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。"
},
{
"location": "app/json.test.js:7",
"suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。"
},
{
"location": "app/json.test.js:10",
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
},
{
"role": "Maya",
"location": "action.yaml:6, action.yaml:12, action.yaml:81",
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。"
},
{
"role": "Leo",
"location": "action.yaml:80",
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
},
{
"role": "Rex",
"location": "action.yaml:18",
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。"
},
{
"role": "Leo",
"location": "app/log.js",
"suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。"
}
]
{
"role": "Rex",
"location": "app/git.js",
"suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數"
},
{
"location": "app/git.js",
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入"
},
{
"role": "Rex",
"location": "README.md",
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
},
{
"location": "app/config.js",
"suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報"
},
{
"location": ".gitea/ai-review/exclusions.json",
"suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位"
},
{
"location": "app/findings.js",
"suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程"
},
{
"role": "Rex",
"location": ".gitea/workflows/review.yaml",
"suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減"
},
{
"role": "Rex",
"location": ".gitea/workflows/review.yaml",
"suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法"
},
{
"role": "Aria",
"location": "README.md",
"suggestion": "章節編號連續且正確,無需調整"
},
{
"role": "Maya",
"location": ".gitea/workflows/review.yaml",
"suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題"
},
{
"role": "Aria",
"location": ".gitea/workflows/review.yaml",
"suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在"
},
{
"role": "Aria",
"location": "app/config.test.js",
"suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改"
},
{
"role": "Aria",
"location": "action.yaml",
"suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔"
},
{
"role": "Maya",
"location": "app/",
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯"
},
{
"role": "Rex",
"location": "app/",
"suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異"
},
{
"role": "Rex",
"location": "app/config.test.js",
"suggestion": "import 語句長度合理,無需拆分為多行"
},
{
"role": "Rex",
"location": ".gitea/ai-review/findings.json",
"suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題"
},
{
"role": "Rex",
"location": "app/comments.js",
"suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題"
},
{
"location": ".gitea/ai-review/findings.json",
"suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題"
},
{
"role": "Rex",
"location": ".gitea/workflows/review.yaml",
"suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題"
},
{
"role": "Leo",
"location": "app/llm.js",
"suggestion": "Authorization 標頭已有 provider !== \u0027ollama\u0027 判斷,不會無條件加入,已正確處理"
},
{
"role": "Zara",
"location": "app/llm.js",
"suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度"
},
{
"role": "Rex",
"location": "app/llm.js",
"suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常"
},
{
"role": "Maya",
"location": "app/llm.js",
"suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例"
},
{
"role": "Zara",
"location": "app/comments.js",
"suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題"
},
{
"role": "Leo",
"location": ".gitea/workflows/review.yaml",
"suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷"
},
{
"role": "Maya",
"location": "app/llm.test.js",
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
},
{
"role": "Maya",
"location": "app/llm.test.js",
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
},
{
"role": "Aria",
"location": ".gitea/workflows/master.yaml",
"suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改"
},
{
"role": "Leo",
"location": "app/llm.test.js",
"suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出"
},
{
"role": "Leo",
"location": "app/llm.test.js",
"suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值"
},
{
"role": "Leo",
"location": "app/main.js",
"suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務"
},
{
"role": "Maya",
"location": "app/log.test.js",
"suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。"
},
{
"role": "Rex",
"location": "app/package.json",
"suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題"
},
{
"role": "Aria",
"location": "app/llm.js",
"suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為"
},
{
"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 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。"
},
{
"location": "Dockerfile",
"suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。"
},
{
"location": "Dockerfile",
"suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。"
},
{
"role": "Aria",
"location": "Dockerfile",
"suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
},
{
"role": "Aria",
"location": "entrypoint.sh",
"suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例"
},
{
"role": "Maya",
"location": "app/main.js",
"suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋"
},
{
"role": "Maya",
"location": "app/comments.js",
"suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低"
},
{
"role": "Maya",
"location": "app/roles.js",
"suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接"
},
{
"role": "Leo",
"location": "app/gitea.js",
"suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞"
},
{
"role": "Zara",
"location": "Dockerfile",
"suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案"
},
{
"role": "Aria",
"location": "app/package.json",
"suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案"
},
{
"role": "Zara",
"location": "app/main.js",
"suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化"
},
{
"role": "Leo",
"location": "app/comments.js",
"suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤"
},
{
"role": "Maya",
"location": "app/gitea.js",
"suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境"
},
{
"role": "Leo",
"location": "TODO.md",
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
},
{
"role": "Rex",
"location": "app/gitea.js",
"suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。"
},
{
"role": "Zara",
"location": "app/git.js",
"suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。"
},
{
"role": "Aria",
"location": "app/main.js",
"suggestion": "在 main.js 中,表達式 repoDir。"
},
{
"role": "Zara",
"location": "app/gitea.js:L20-L21",
"suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。"
},
{
"location": "TODO.md",
"suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。"
},
{
"location": "TODO.md",
"suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。"
},
{
"location": "app/findings.js",
"suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。"
},
{
"location": "app/findings.js",
"suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。"
},
{
"location": "app/comments.js",
"suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。"
},
{
"location": "app/main.js",
"suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。"
},
{
"location": "app/git.js",
"suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。"
},
{
"location": "app/main.js",
"suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。"
},
{
"location": "app/json.js",
"suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。"
},
{
"location": "app/json.test.js",
"suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。"
},
{
"location": "app/gitea.test.js:64",
"suggestion": "`describe` 已改為同步 callback`async` 不再出現在這個區塊。"
},
{
"location": "app/git.test.js:13",
"suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。"
},
{
"location": "app/gitea.js:32",
"suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。"
},
{
"location": "app/json.test.js:76",
"suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。"
},
{
"location": "app/json.test.js:7",
"suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。"
},
{
"location": "app/json.test.js:10",
"suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。"
},
{
"role": "Maya",
"location": "action.yaml:6, action.yaml:12, action.yaml:81",
"suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。"
},
{
"role": "Leo",
"location": "action.yaml:80",
"suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。"
},
{
"role": "Rex",
"location": "action.yaml:18",
"suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。"
},
{
"role": "Leo",
"location": "app/log.js",
"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
}
]
+1 -9
View File
@@ -1,9 +1 @@
[
{
"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
}
]
[]
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_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_MODEL: ${{ vars.GEMINI_MODEL }}
permissions:
+6
View File
@@ -4,6 +4,11 @@
# 流程(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 失效而中斷
- 已選定一個 LLM provider,且其 API Key 至少有一把通過驗證:實際送出一個最小請求確認認證可用;逗號分隔的多把 Key 只要一把成功即可,逐把記錄成敗;Ollama 無 Key,改為檢查 `OLLAMA_BASE_URL` 可連線
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Pull Request
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案
@@ -26,6 +31,7 @@
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 用量
10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題
11. action 一啟動就先做「前置驗證」(流程第 0 點):集中檢查 Gitea 與 LLM 的所有驗證相關設定是否可用,全部通過才往下跑。驗證邏輯獨立成 `app/preflight.js` 並由 `main.js` 在 Step1 之後、其餘步驟之前呼叫;任何一項失敗都印出是哪一項、原因為何後 `exit 1`,避免在分析到一半或發 comment 時才因 token / key 無效而中斷
# 使用說明
+11
View File
@@ -57,3 +57,14 @@
- 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestionAI 回傳後補回原始完整欄位(含 is_new)。
- 驗收:AI 呼叫的 payload 不含 is_new 等內部欄位,去重與誤報過濾後的 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. 已選定一個 LLM provider`getLLMConfig().provider` 非 null)。
5. 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``main.js` 已在 Step1 之後、bot-check 之前呼叫 `runPreflight()`,未通過即印出原因並 `exit 1``app/preflight.test.js` 覆蓋上述情境,`node --test *.test.js` 全數通過。
+7
View File
@@ -6,6 +6,7 @@ import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplica
import { saveFindings, postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
import { runPreflight } from './preflight.js';
import { section, step, line, ok, warn, error } from './log.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
@@ -16,6 +17,12 @@ async function main() {
line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
if (!(await runPreflight())) {
error('前置驗證未通過,終止流程');
section('Pipeline 結束');
process.exit(1);
}
const headSha = process.env.PR_HEAD_SHA || process.env.GITHUB_SHA || '';
const headMessage = await getCommitMessageBySha(headSha);
const headOutcome = getBotReviewOutcome(headMessage);
+130
View File
@@ -0,0 +1,130 @@
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 { 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() {
step('Step1.5', '前置驗證(驗證相關設定)');
const env = checkRequiredEnv();
if (!env.ok) {
error(`缺少必要環境變數: ${env.missing.join(', ')}`);
return false;
}
ok('必要環境變數齊全 (GITEA_TOKEN, GITEA_REPOSITORY, PR_NUMBER)');
const gitea = await verifyGiteaToken();
if (!gitea.ok) {
error(`GITEA_TOKEN 驗證失敗(無法讀取 repo ${GITEA_REPOSITORY}: ${gitea.error}`);
return false;
}
ok(`GITEA_TOKEN 可讀取 repo ${GITEA_REPOSITORY}`);
const comment = await verifyCommentToken();
if (!comment.ok) {
error(`GITEA_COMMENT_TOKEN 驗證失敗: ${comment.error}`);
return false;
}
if (comment.skipped) line('未提供 GITEA_COMMENT_TOKENcomment 將沿用 GITEA_TOKEN');
else ok('GITEA_COMMENT_TOKEN 可用');
const llm = await verifyLLM();
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;
}
+197
View File
@@ -0,0 +1,197 @@
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', () => {
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);
});
});