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

181 lines
7.7 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 {
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');
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) };
}
}
/**
* 驗證 LLM(AI 助理 CLI)設定可用。
*
* 確認目前環境可偵測到支援的 CLI,且已解析出 model。實際模型可用性由 CLI
* 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度。
* @returns {Promise<
* {ok: true, provider: string, command: string, model: string} |
* {ok: false, provider?: string, error: string}
* >}
* 通過時含 provider、command 與 model;未設定 provider 的失敗分支不含 provider 欄位。
* @remarks 設定來源為 config.js 的 getLLMConfig()。
*/
export async function verifyLLM() {
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' };
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}`);
result(true, '前置驗證通過');
return true;
}