feat: 啟動時前置驗證所有驗證相關設定
新增 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>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user