248 lines
11 KiB
JavaScript
248 lines
11 KiB
JavaScript
import axios from 'axios';
|
||
import {
|
||
GITEA_TOKEN,
|
||
GITEA_COMMENT_TOKEN,
|
||
GITEA_SERVER_URL,
|
||
GITEA_REPOSITORY,
|
||
PR_NUMBER,
|
||
getInsecureHttpsAgent,
|
||
getLLMConfig,
|
||
} from './config.js';
|
||
import { verifyRemoteAccess } from './git.js';
|
||
import { step, line, ok, error, result } from './log.js';
|
||
|
||
const httpsAgent = getInsecureHttpsAgent();
|
||
/**
|
||
* 組出 Gitea REST API v1 的完整網址。
|
||
*
|
||
* 會將模組層級的 GITEA_SERVER_URL 尾端斜線去除後串接 `/api/v1` 與傳入路徑。
|
||
* @param {string} path - 以 `/` 開頭的 API 子路徑,例如 `/repos/owner/name`。
|
||
* @returns {string} 完整可請求的 API URL。
|
||
* @remarks 依賴模組層級常數 GITEA_SERVER_URL;若該值為空會丟出 TypeError。
|
||
*/
|
||
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||
/**
|
||
* 產生呼叫 Gitea API 用的 HTTP headers。
|
||
*
|
||
* Authorization 採 Gitea 的 `token <token>` 認證格式。
|
||
* @param {string} token - Gitea 個人存取權杖(personal access token)。
|
||
* @returns {{Authorization: string, 'Content-Type': string}} 可直接交給 axios 的 headers 物件。
|
||
*/
|
||
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
||
/**
|
||
* 將(axios)錯誤格式化為易讀的訊息字串。
|
||
*
|
||
* 有 HTTP 回應狀態碼時輸出 `HTTP <status> <message>`,否則僅輸出 message。
|
||
* @param {Error & {response?: {status?: number}, message: string}} e - 捕捉到的錯誤物件。
|
||
* @returns {string} 格式化後的錯誤描述。
|
||
*/
|
||
function giteaErr(e) {
|
||
const status = e.response?.status;
|
||
return status ? `HTTP ${status} ${e.message}` : e.message;
|
||
}
|
||
|
||
/**
|
||
* 檢查 code review 所需的必要環境變數是否齊全。
|
||
*
|
||
* 用法:preflight 第一關,缺任何一項即視為不通過並列出缺少項目。
|
||
* @param {object} [opts] - 覆寫值,供測試注入;省略時各欄取模組層級常數預設值。
|
||
* @param {string} [opts.token=GITEA_TOKEN] - Gitea token。
|
||
* @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。
|
||
* @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。
|
||
* @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。
|
||
*/
|
||
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');
|
||
if (!(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API)) missing.push('CLI_PROXY_API');
|
||
return { ok: missing.length === 0, missing };
|
||
}
|
||
|
||
/**
|
||
* 驗證 Gitea token 有效且對指定 repo 有讀取權限。
|
||
*
|
||
* 透過唯讀的 `GET /repos/{repo}` 探測;任何錯誤都被攔截並轉為回傳值,不會 throw。
|
||
* 採用 rejectUnauthorized:false 的 httpsAgent(不驗證 TLS 憑證)。
|
||
* @param {string} [token=GITEA_TOKEN] - Gitea token,可注入供測試。
|
||
* @param {string} [repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo,可注入供測試。
|
||
* @returns {Promise<{ok: true}|{ok: false, error: string}>} 成功僅含 ok;失敗含格式化錯誤訊息。
|
||
*/
|
||
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(GITEA_COMMENT_TOKEN)是否可用。
|
||
*
|
||
* 未提供 token 時直接視為通過並標記 skipped:true(之後 comment 會沿用主 token);
|
||
* 有提供則以 `GET /user` 探測。錯誤被攔截轉為回傳值,不會 throw。
|
||
* @param {string} [token=GITEA_COMMENT_TOKEN] - 專用於發布 comment 的 token,可注入供測試。
|
||
* @returns {Promise<{ok: true, skipped?: true}|{ok: false, error: string}>} skipped 表示未提供而略過。
|
||
*/
|
||
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) };
|
||
}
|
||
}
|
||
|
||
function extractModelIds(data) {
|
||
if (!data || typeof data !== 'object') return [];
|
||
const source = Array.isArray(data.data) ? data.data : (Array.isArray(data.models) ? data.models : []);
|
||
return source
|
||
.map(model => {
|
||
if (typeof model === 'string') return model;
|
||
if (model && typeof model === 'object') return model.id || model.slug || model.name || '';
|
||
return '';
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
/**
|
||
* 呼叫 CLIProxyAPI 的 /v1/models,確認 proxy 可用與模型清單可讀。
|
||
*
|
||
* @param {object} [deps] - 可注入相依,供測試避免真的打網路。
|
||
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
|
||
* @param {string} [deps.baseURL=CLI_PROXY_API] - CLIProxyAPI 基底網址。
|
||
* @param {string} [deps.apiKey=CLI_PROXY_API_KEY] - CLIProxyAPI API key(可空白)。
|
||
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
|
||
*/
|
||
export async function fetchLLMModels({
|
||
fetchImpl = fetch,
|
||
baseURL = process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API,
|
||
apiKey = process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY,
|
||
} = {}) {
|
||
const root = String(baseURL || '').trim().replace(/\/$/, '');
|
||
if (!root) return { ok: false, error: '未設定 CLI_PROXY_API' };
|
||
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (String(apiKey || '').trim()) headers.Authorization = `Bearer ${apiKey.trim()}`;
|
||
|
||
let resp;
|
||
try {
|
||
resp = await fetchImpl(`${root}/v1/models`, { headers });
|
||
} catch (e) {
|
||
return { ok: false, error: `CLIProxyAPI 模型清單查詢連線錯誤: ${e.message}` };
|
||
}
|
||
if (resp.status === 401) {
|
||
return { ok: false, error: 'CLIProxyAPI 認證失效(HTTP 401)——API key 已被撤銷或過期,請更新 CLI_PROXY_API_KEY secret' };
|
||
}
|
||
if (!resp.ok) {
|
||
return { ok: false, error: `CLIProxyAPI 模型清單查詢失敗(HTTP ${resp.status})` };
|
||
}
|
||
let data;
|
||
try {
|
||
data = await resp.json();
|
||
} catch (e) {
|
||
return { ok: false, error: `CLIProxyAPI 模型清單回應解析失敗: ${e.message}` };
|
||
}
|
||
return { ok: true, slugs: extractModelIds(data) };
|
||
}
|
||
|
||
/**
|
||
* 驗證 LLM proxy 設定可用。
|
||
*
|
||
* 確認目前環境可偵測到 CLIProxyAPI 且已解析出 model;額外向模型清單端點確認
|
||
* proxy 可連線且設定的 model 在可用清單內(不送 prompt)。
|
||
* @param {object} [deps] - 可注入相依,供測試。
|
||
* @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。
|
||
* @returns {Promise<
|
||
* {ok: true, provider: string, command: null, model: string, models?: string[]} |
|
||
* {ok: false, provider?: string, command?: null, model?: string, error: string}
|
||
* >}
|
||
* 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。
|
||
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
||
*/
|
||
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
|
||
const { provider, command, model } = getLLMConfig();
|
||
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
|
||
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
|
||
|
||
if (provider === 'cliproxyapi') {
|
||
const models = await fetchLLMModelsFn();
|
||
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
|
||
if (!models.slugs.includes(model)) {
|
||
return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` };
|
||
}
|
||
return { ok: true, provider, command, model, models: models.slugs };
|
||
}
|
||
|
||
return { ok: true, provider, command, model };
|
||
}
|
||
|
||
/**
|
||
* 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM proxy。
|
||
*
|
||
* 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。
|
||
* 各檢查可經 deps 注入覆寫,方便單元測試。
|
||
* @param {string} [workspace=process.env.GITHUB_WORKSPACE||'/workspace'] - git 遠端驗證用的工作目錄。
|
||
* @param {object} [deps] - 依賴注入,覆寫各檢查函式(預設為本模組/ git.js 的實作)。
|
||
* @param {Function} [deps.checkEnv=checkRequiredEnv] - 環境變數檢查。
|
||
* @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。
|
||
* @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。
|
||
* @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。
|
||
* @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM proxy 驗證。
|
||
* @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。
|
||
* @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。
|
||
*/
|
||
export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) {
|
||
const {
|
||
checkEnv = checkRequiredEnv,
|
||
verifyToken = verifyGiteaToken,
|
||
verifyComment = verifyCommentToken,
|
||
verifyRemote = verifyRemoteAccess,
|
||
verifyLLMFn = verifyLLM,
|
||
} = deps;
|
||
step('Step2', '前置驗證(驗證相關設定)');
|
||
|
||
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;
|
||
}
|
||
ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model})`);
|
||
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
|
||
|
||
result(true, '前置驗證通過');
|
||
return true;
|
||
}
|