/** * Shared HTTP helpers for the Jira / Confluence / GitLab REST API tools. * * Everything here is pure (no pi API) so it can be unit-tested directly * (see test/api.test.ts). URLs come from config/company.json — the single * source of truth — and tokens from the environment. Nothing is hardcoded. * * If a required URL/token is missing the request throws a typed ERR with a * clear, human message instead of guessing values. * * CONFIGURE: edit config/company.json (trackerUrl, docsUrl, gitHost) and set * env vars JIRA_TOKEN / CONFLUENCE_TOKEN / GITLAB_TOKEN. */ /** A network/config failure carrying a message fit for the user. */ export class ApiError extends Error { status?: number; constructor(message: string, status?: number) { super(message); this.name = "ApiError"; this.status = status; } } export interface RequestOptions { method?: "GET" | "POST" | "PUT" | "DELETE"; query?: Record; body?: unknown; /** Override the injectable fetch (tests). Defaults to global fetch. */ fetchImpl?: typeof fetch; timeoutMs?: number; } /** Trim a base URL so it has no trailing slash. */ function base(url: string): string { return url.replace(/\/+$/, ""); } /** * Resolve an effective base URL from company.json + an env override. * The env var wins when set (and not "TODO"); otherwise the config value * (if set and not "TODO") is used. Trailing slashes are trimmed. Returns "" * when neither is configured; callers decide whether to throw. */ export function resolveBaseUrl(envName: string, cfgValue: string | null | undefined): string { const fromCfg = cfgValue && cfgValue !== "TODO" ? cfgValue : ""; const env = process.env[envName]; return (env && env !== "TODO" ? env : fromCfg).replace(/\/+$/, ""); } /** Build a URL with query params; undefined/empty values are dropped. */ export function buildUrl( baseUrl: string, path: string, query?: RequestOptions["query"], ): string { const q = Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== ""); const qs = q.length ? "?" + q.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&") : ""; return `${base(baseUrl)}${path}${qs}`; } /** * Perform an authenticated JSON request against a corporate API. * `baseUrl` and `token` are resolved by the caller per service. */ export async function apiRequest( baseUrl: string, token: string, path: string, opts: RequestOptions = {}, ): Promise { const { method = "GET", query, body, timeoutMs = 15000 } = opts; const fetchImpl = opts.fetchImpl ?? fetch; const url = buildUrl(baseUrl, path, query); const headers: Record = { Accept: "application/json", Authorization: `Bearer ${token}`, }; if (body !== undefined) headers["Content-Type"] = "application/json"; let res: Response; try { res = await fetchImpl(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs), }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new ApiError(`Сетевой сбой при обращении к ${baseUrl}: ${msg}`, 0); } if (!res.ok) { // Try to surface a short API-provided error message. let detail = `HTTP ${res.status}`; try { const data = (await res.json()) as Record; const m = data?.message ?? data?.error_description ?? data?.error; if (typeof m === "string") detail += `: ${m}`; } catch { // non-JSON error body — keep the HTTP status only. } if (res.status === 401 || res.status === 403) { detail += " — проверь токен и права доступа (не логируй сам токен)."; } throw new ApiError(`Запрос к ${baseUrl}${path} вернул ${detail}`, res.status); } if (res.status === 204) return null; return res.json().catch(() => null); } /** Resolve a required token from the environment, throwing a helpful error. */ export function requireToken(name: string, hint: string): string { const v = process.env[name]; if (!v || v === "" || v === "TODO") { throw new ApiError( `Не задан $${name} — ${hint}. Задай его в ~/.config/pi-kit/env.sh (или в окружении) и перезапусти pi.`, ); } return v; }