Behavior-preserving cleanups: - extract shared resolveBaseUrl helper for jira/confluence base-URL logic - extract shared registerTool helper; port jira/gitlab/confluence tools to it - make scanSecrets declarative (filter/map) - collapse ok/warn pair into status() and drop double existsSync in kit-doctor Typecheck clean; npm test 71 PASS, 0 FAIL.
125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
/**
|
|
* 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<string, string | number | boolean | undefined>;
|
|
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<unknown> {
|
|
const { method = "GET", query, body, timeoutMs = 15000 } = opts;
|
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
|
|
const url = buildUrl(baseUrl, path, query);
|
|
const headers: Record<string, string> = {
|
|
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<string, unknown>;
|
|
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;
|
|
}
|