feat(api-tools): native TS tools for Jira/Confluence/GitLab, drop MCP bridge

This commit is contained in:
Aleksey Shakhmatov
2026-08-06 14:43:10 +03:00
parent 035bededb0
commit 8b273c9a72
16 changed files with 925 additions and 469 deletions
+112
View File
@@ -0,0 +1,112 @@
/**
* 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(/\/+$/, "");
}
/** 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;
}