diff --git a/TODO.md b/TODO.md index d03beba..09239bb 100644 --- a/TODO.md +++ b/TODO.md @@ -67,3 +67,4 @@ 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` 全數通過。 diff --git a/app/main.js b/app/main.js index 3ebe32e..2cba174 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())) { + 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..abe223d --- /dev/null +++ b/app/preflight.js @@ -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_TOKEN,comment 將沿用 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; +} diff --git a/app/preflight.test.js b/app/preflight.test.js new file mode 100644 index 0000000..b49e673 --- /dev/null +++ b/app/preflight.test.js @@ -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); + }); +});