// jira's own Jira API client — its own code, living in the module (novox/hq ADR 0039). Ported from // the hal sdk's shared JiraClient, where a change to the Atlassian API rebuilt everything; here it // rebuilds only jira. This module's tools import it, and nothing outside jira does. // // jira is a tools-only, outbound-only integration with an external SaaS (Atlassian Cloud): it holds // no service of its own, listens for nothing, and only ever calls out to a Jira instance over its // REST v3 API, authenticated with HTTP Basic (email + API token) against the Atlassian site. // // The client is built lazily and NEVER throws at construction (the Servarr lesson): the runtime must // come up and register every tool even with no valid credentials — the lab has no real Atlassian. A // missing URL, email or token surfaces only when a tool is actually invoked, as a clear error from // that one call, not as a runtime that refuses to serve. import { readFileSync } from "node:fs"; export class JiraClient { constructor( /** The Atlassian site URL, e.g. "https://acme.atlassian.net". A public setting (config file). */ private readonly url: string | undefined, /** The Atlassian account email — a public setting (config file). */ private readonly email: string | undefined, /** The Atlassian API token — jira's one secret (own-secret). */ private readonly token: string | undefined, ) {} static fromEnv(env: NodeJS.ProcessEnv = process.env): JiraClient { // The site URL and account email are a mesh's own facts, not this module's — settings merged into // a config file the mesh manages (novox/hq ADR 0046) under ATLASSIAN_URL / ATLASSIAN_EMAIL, read // here. The API token is the one secret and stays an own-secret, read from its file. Env is // honoured as a fallback for a hand-run instance. No absence throws: the client still constructs, // so every tool still registers and serves. const config = readConfig(env.MESH_JIRA_CONFIG_FILE); const url = config.ATLASSIAN_URL ?? env.MESH_JIRA_URL ?? env.ATLASSIAN_URL; const email = config.ATLASSIAN_EMAIL ?? env.MESH_JIRA_EMAIL ?? env.ATLASSIAN_EMAIL; const token = env.MESH_JIRA_TOKEN ?? readSecret(env.MESH_JIRA_TOKEN_FILE) ?? env.ATLASSIAN_TOKEN; return new JiraClient(url, email, token); } /** Whether the module is configured enough to make a call. */ configured(): boolean { return Boolean(this.url && this.email && this.token); } private baseUrl(): string { if (!this.url || !this.email || !this.token) { throw new Error( "jira is not configured — set its site URL and account email in settings " + "(ATLASSIAN_URL, ATLASSIAN_EMAIL) and its API token as its own-secret; until then it " + "answers no calls", ); } return this.url.replace(/\/+$/, ""); } private authHeader(): string { return "Basic " + Buffer.from(`${this.email}:${this.token}`).toString("base64"); } private async request(path: string, options: RequestInit = {}): Promise { const res = await fetch(`${this.baseUrl()}${path}`, { ...options, headers: { "Content-Type": "application/json", Authorization: this.authHeader(), ...(options.headers as Record), }, }); if (!res.ok) { throw new Error(`Jira API error ${res.status}: ${await res.text()}`); } if (res.status === 204) return null as T; return res.json() as Promise; } async searchIssues(jql: string, fields?: string, maxResults = 50): Promise { const params = new URLSearchParams({ jql, maxResults: String(maxResults) }); if (fields) params.set("fields", fields); return this.request(`/rest/api/3/search/jql?${params}`); } async getIssue(issueKey: string, fields?: string): Promise { const params = fields ? `?fields=${encodeURIComponent(fields)}` : ""; return this.request(`/rest/api/3/issue/${issueKey}${params}`); } async createIssue(data: { projectKey: string; issueType: string; summary: string; description?: string; }): Promise { const body: Record = { fields: { project: { key: data.projectKey }, issuetype: { name: data.issueType }, summary: data.summary, ...(data.description ? { description: textToAdf(data.description) } : {}), }, }; return this.request("/rest/api/3/issue", { method: "POST", body: JSON.stringify(body), }); } async updateIssue(issueKey: string, data: { summary?: string; description?: string }): Promise { const fields: Record = {}; if (data.summary) fields.summary = data.summary; if (data.description) fields.description = textToAdf(data.description); return this.request(`/rest/api/3/issue/${issueKey}`, { method: "PUT", body: JSON.stringify({ fields }), }); } async addComment(issueKey: string, body: string): Promise { return this.request(`/rest/api/3/issue/${issueKey}/comment`, { method: "POST", body: JSON.stringify({ body: textToAdf(body) }), }); } async listTransitions(issueKey: string): Promise { return this.request(`/rest/api/3/issue/${issueKey}/transitions`); } async transitionIssue(issueKey: string, transitionId: string): Promise { return this.request(`/rest/api/3/issue/${issueKey}/transitions`, { method: "POST", body: JSON.stringify({ transition: { id: transitionId } }), }); } async listProjects(params: Record = {}): Promise { const qs = new URLSearchParams(params).toString(); return this.request(`/rest/api/3/project/search?${qs}`); } } /** Convert plain text into Atlassian Document Format — Jira v3 takes rich-text fields as ADF, not * plain strings. Blank-line-separated blocks become paragraphs. */ function textToAdf(text: string): object { return { version: 1, type: "doc", content: text.split("\n\n").map((paragraph) => ({ type: "paragraph", content: [{ type: "text", text: paragraph }], })), }; } function readSecret(path: string | undefined): string | undefined { if (!path) return undefined; try { return readFileSync(path, "utf8").trim(); } catch { return undefined; } } interface Config { ATLASSIAN_URL?: string; ATLASSIAN_EMAIL?: string; } /** The settings-managed config file (a JSON document the mesh merges settings into), holding the * public ATLASSIAN_URL and ATLASSIAN_EMAIL settings. Absent or unparseable yields an empty config — * the module then answers no calls until its URL, email and token are set, but still registers and * serves every tool. */ function readConfig(path: string | undefined): Config { if (!path) return {}; try { return JSON.parse(readFileSync(path, "utf8")) as Config; } catch { return {}; } }