Files
ai-code-review/src/preflight.js
T

253 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios';
import fs from 'fs';
import os from 'os';
import { join } from 'path';
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';
// codex 內部用來取得帳號可用模型清單的端點;auth 失效時會回 HTTP 401。
const CODEX_MODELS_ENDPOINT = 'https://chatgpt.com/backend-api/codex/models';
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');
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 tokenGITEA_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) };
}
}
/**
* 讀取本機 codex 認證檔,向模型清單端點確認帳號目前可用的模型 slug。
*
* 用途:preflight 期即時分辨「auth 失效(HTTP 401)」與「模型無權限(不在清單)」,
* 不必等到 Step5 每個角色送 prompt 才神秘失敗。只讀清單、不送 prompt,不消耗生成額度。
* 所有錯誤都被攔截並轉為回傳值,不會 throw。
*
* @param {object} [deps] - 可注入相依,供測試避免真的讀檔/打網路。
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
* @param {string} [deps.authPath=~/.codex/auth.json] - codex 認證檔路徑。
* @param {string} [deps.clientVersion] - 帶給端點的 client_version 查詢參數。
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
* 成功回傳可用模型 slug 陣列;失敗回傳格式化錯誤訊息。
*/
export async function fetchCodexModels({
fetchImpl = fetch,
authPath = join(os.homedir(), '.codex', 'auth.json'),
clientVersion = '0.142.5',
} = {}) {
let auth;
try {
auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
} catch (e) {
return { ok: false, error: `無法讀取 codex 認證檔(${authPath}: ${e.message}` };
}
const tokens = auth.tokens || {};
if (!tokens.access_token) return { ok: false, error: 'codex 認證檔缺少 tokens.access_token' };
const headers = { Authorization: `Bearer ${tokens.access_token}` };
if (tokens.account_id) headers['chatgpt-account-id'] = tokens.account_id;
let resp;
try {
resp = await fetchImpl(`${CODEX_MODELS_ENDPOINT}?client_version=${clientVersion}`, { headers });
} catch (e) {
return { ok: false, error: `codex 模型清單查詢連線錯誤: ${e.message}` };
}
if (resp.status === 401) {
return { ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' };
}
if (!resp.ok) {
return { ok: false, error: `codex 模型清單查詢失敗(HTTP ${resp.status}` };
}
let data;
try {
data = await resp.json();
} catch (e) {
return { ok: false, error: `codex 模型清單回應解析失敗: ${e.message}` };
}
const slugs = Array.isArray(data.models) ? data.models.map(m => m.slug).filter(Boolean) : [];
return { ok: true, slugs };
}
/**
* 驗證 LLM(AI 助理 CLI)設定可用。
*
* 確認目前環境可偵測到支援的 CLI 且已解析出 modelprovider 為 codex 時,
* 額外向模型清單端點確認 auth 有效且設定的 model 在可用清單內(不送 prompt)。
* @param {object} [deps] - 可注入相依,供測試。
* @param {Function} [deps.fetchCodexModelsFn=fetchCodexModels] - codex 模型清單取得函式。
* @returns {Promise<
* {ok: true, provider: string, command: string, model: string, models?: string[]} |
* {ok: false, provider?: string, command?: string, model?: string, error: string}
* >}
* 通過時含 provider、command、modelcodex 另含 models 清單);未設定 provider 的失敗分支不含 provider。
* @remarks 設定來源為 config.js 的 getLLMConfig()。
*/
export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {}) {
const { provider, command, model } = getLLMConfig();
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' };
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
if (provider === 'codex') {
const models = await fetchCodexModelsFn();
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} 不在 codex 可用清單: [${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 CLI。
*
* 全程唯讀,不發布任何 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] - LLMAI 助理 CLI)驗證。
* @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_TOKENcomment 將沿用 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 CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model}`);
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
result(true, '前置驗證通過');
return true;
}