export type JiraConfig = { baseUrl: string; email: string; apiToken: string; }; export type JiraRequestOptions = { method?: "GET" | "POST" | "PUT" | "DELETE"; body?: unknown; signal?: AbortSignal; }; export class JiraApiError extends Error { readonly status: number; readonly details?: unknown; constructor(message: string, status: number, details?: unknown) { super(message); this.name = "JiraApiError"; this.status = status; this.details = details; } } export function loadJiraConfig(env: NodeJS.ProcessEnv = process.env): JiraConfig { const baseUrl = env.JIRA_BASE_URL?.trim().replace(/\/+$/, ""); const email = env.JIRA_EMAIL?.trim(); const apiToken = env.JIRA_API_TOKEN?.trim(); const missing = [ !baseUrl && "JIRA_BASE_URL", !email && "JIRA_EMAIL", !apiToken && "JIRA_API_TOKEN", ].filter(Boolean); if (missing.length > 0) { throw new Error(`Jira is not configured. Missing variables: ${missing.join(", ")}`); } let parsed: URL; try { parsed = new URL(baseUrl!); } catch { throw new Error("JIRA_BASE_URL is not a valid URL"); } const localHttp = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname); if (parsed.protocol !== "https:" && !localHttp) { throw new Error("JIRA_BASE_URL must use HTTPS (HTTP is allowed only for localhost)"); } return { baseUrl: baseUrl!, email: email!, apiToken: apiToken! }; } export function textToAdf(text: string) { const paragraphs = text.split(/\r?\n/).map((line) => ({ type: "paragraph", content: line ? [{ type: "text", text: line }] : [], })); return { version: 1, type: "doc", content: paragraphs }; } function extractErrorMessage(payload: unknown, status: number): string { if (payload && typeof payload === "object") { const data = payload as { errorMessages?: unknown; errors?: unknown; message?: unknown }; const messages = Array.isArray(data.errorMessages) ? data.errorMessages.filter((v) => typeof v === "string") : []; const fieldErrors = data.errors && typeof data.errors === "object" ? Object.entries(data.errors as Record).map(([key, value]) => `${key}: ${String(value)}`) : []; if (messages.length || fieldErrors.length) return [...messages, ...fieldErrors].join("; "); if (typeof data.message === "string") return data.message; } return `Jira request failed (HTTP ${status})`; } export class JiraClient { private readonly config: JiraConfig; constructor(config: JiraConfig = loadJiraConfig()) { this.config = config; } async request(path: string, options: JiraRequestOptions = {}): Promise { if (!path.startsWith("/")) throw new Error("The Jira path must start with /"); const timeoutSignal = AbortSignal.timeout(30_000); const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; const response = await fetch(`${this.config.baseUrl}${path}`, { method: options.method ?? "GET", headers: { Accept: "application/json", Authorization: `Basic ${Buffer.from(`${this.config.email}:${this.config.apiToken}`).toString("base64")}`, ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), }, body: options.body === undefined ? undefined : JSON.stringify(options.body), signal, }); const text = await response.text(); let payload: unknown; if (text) { try { payload = JSON.parse(text); } catch { payload = text.slice(0, 2_000); } } if (!response.ok) { throw new JiraApiError(extractErrorMessage(payload, response.status), response.status, payload); } return payload as T; } }