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 ` 認證格式。 * @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 `,否則僅輸出 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 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) }; } } /** * 驗證 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] - LLM(AI 助理 CLI)驗證。 * @returns {Promise} 全部通過為 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 CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model})`); result(true, '前置驗證通過'); return true; }