diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index d566ae3..0345147 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -351,5 +351,35 @@ "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": "Rex", + "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": "Zara", + "location": "app/preflight.js:70-82", + "suggestion": "在 `verifyLLM` 函式中,當配置了多個 LLM API Key 時,系統會依序嘗試驗證每個 Key,每個嘗試都有 30 秒的逾時時間。如果前幾個 Key 驗證失敗,這可能導致顯著的累積延遲。雖然這是為了找到一個可用的 Key,但若 Key 數量多且網路不穩定,可能會造成啟動時間過長。可以考慮縮短單次 Key 驗證的逾時時間,或在特定情況下提供更快的失敗機制。" + }, + { + "role": "Rex", + "location": "app/preflight.js:100", + "suggestion": "在記錄 LLM API 驗證失敗時,直接輸出了錯誤訊息 `e.message`。雖然通常情況下 `e.message` 不會包含敏感資訊,但為了最佳安全實踐,建議審查 LLM 服務提供商的錯誤訊息格式,確保其中不會意外洩漏 API 金鑰或其他敏感請求內容。若有疑慮,應對錯誤訊息進行消毒或僅記錄高層次的錯誤類型。" + }, + { + "role": "Aria", + "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 });`" } ] \ No newline at end of file diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..946ea6a 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,37 @@ -[] +[ + { + "level": "warning", + "role": "Aria", + "location": "app/preflight.test.js:25", + "suggestion": "測試描述使用英文。請確保專案在測試描述的語言上保持一致性。如果專案主要使用繁體中文(如 `app/preflight.js` 中的 JSDoc 和日誌),則應將此測試描述翻譯為繁體中文。", + "is_new": true + }, + { + "level": "info", + "role": "Aria", + "location": "app/preflight.test.js:1-4", + "suggestion": "匯入語句的排序不一致。建議遵循一致的排序規則,例如:內建模組、第三方模組、本地模組,並在各組內按字母順序排序。", + "is_new": true + }, + { + "level": "info", + "role": "Aria", + "location": "app/preflight.test.js:7-12", + "suggestion": "此陣列字面量較長。雖然已分行,但可以考慮將每個元素獨立一行並保持一致的縮排,以提高可讀性。", + "is_new": true + }, + { + "level": "info", + "role": "Aria", + "location": "app/preflight.test.js:14", + "suggestion": "函數名稱 `clearLLMEnv` 雖然可理解,但可以更具描述性,例如 `clearLlmEnvironmentVariables` 或 `resetLlmEnv`。", + "is_new": true + }, + { + "level": "info", + "role": "Aria", + "location": "app/preflight.test.js:149", + "suggestion": "此單行註解風格與其他部分可能不一致。建議遵循專案統一的註解風格指南。", + "is_new": true + } +] diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index 35988a4..f104879 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -31,8 +31,8 @@ jobs: uses: https://gitea.jsc.idv.tw/actions/code-review@v${{ needs.version.outputs.version }} 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 }} + GITEA_COMMENT_TOKEN: ${{ secrets.RUNNER_TOKEN }} + 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: diff --git a/README.md b/README.md index 6e31f81..dcda95a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ # 流程(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 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 PR 的問題表格(PR問題表格)覆蓋問題檔案 @@ -26,6 +32,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 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 / 認證無效而中斷 # 使用說明 diff --git a/TODO.md b/TODO.md index f71e908..17e570a 100644 --- a/TODO.md +++ b/TODO.md @@ -57,3 +57,15 @@ - 目標:傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion);system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion;AI 回傳後補回原始完整欄位(含 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. 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` 全數通過。 diff --git a/app/git.js b/app/git.js index ba95084..38c5582 100644 --- a/app/git.js +++ b/app/git.js @@ -62,11 +62,22 @@ function withAskpass(workspace, fn) { const askpassScript = path.join(workspace, '.git-askpass.sh'); 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 cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} }; + let result; try { - return fn(credEnv); - } finally { - try { fs.unlinkSync(askpassScript); } catch {} + result = fn(credEnv); + } catch (e) { + 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) { @@ -258,6 +269,24 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) { 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) */ diff --git a/app/git.test.js b/app/git.test.js index 6bd6dbb..5d92566 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js'; +import { commitAndPush, cloneRepo, verifyRemoteAccess, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js'; // --- helpers --- 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 () => { await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); @@ -334,3 +346,47 @@ describe('cloneRepo', () => { 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'); + }); +}); diff --git a/app/main.js b/app/main.js index 3ebe32e..b793148 100644 --- a/app/main.js +++ b/app/main.js @@ -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(WORKSPACE))) { + 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); diff --git a/app/preflight.js b/app/preflight.js new file mode 100644 index 0000000..781bdb3 --- /dev/null +++ b/app/preflight.js @@ -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; +} diff --git a/app/preflight.test.js b/app/preflight.test.js new file mode 100644 index 0000000..d654bc2 --- /dev/null +++ b/app/preflight.test.js @@ -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'); + }); +});