Files
mesh-catalog/modules/jira/client.ts
T
jschoubben db8b60f4e4 Convert confluence and jira into nox catalog modules
Port the HAL confluence and jira integrations to the tools-only,
outbound-only external-SaaS pattern proven by the merged gitlab module:
runtime-only containers (container-runtime capability), own-secret token +
broker, a settings-managed config.json for public config, and a
per-module runtime image.

Each module carries its own Atlassian API client and tools (ADR 0039),
translated from HAL's @hal/sdk zod-schema/MCP-content shape into
mesh-sdk's input/run-returns-data shape. Public config (ATLASSIAN_URL,
ATLASSIAN_EMAIL) lives in config.json; the API token is the one
own-secret. Clients are built lazily and never throw at registration, so
each runtime serves its full tool surface with no credentials (the
Servarr lesson) — confluence serves 3 tools, jira serves 8.

jira's periodic ticket-poller (update-tickets.service/.timer) is NOT
ported: the mesh has no scheduled-task primitive yet (a pending
decision). Only jira's tools are ported; a top-of-file note records the
deferral.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 01:49:22 +02:00

179 lines
6.8 KiB
TypeScript

// 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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.baseUrl()}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authHeader(),
...(options.headers as Record<string, string>),
},
});
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<T>;
}
async searchIssues(jql: string, fields?: string, maxResults = 50): Promise<unknown> {
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<unknown> {
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<unknown> {
const body: Record<string, unknown> = {
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<unknown> {
const fields: Record<string, unknown> = {};
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<unknown> {
return this.request(`/rest/api/3/issue/${issueKey}/comment`, {
method: "POST",
body: JSON.stringify({ body: textToAdf(body) }),
});
}
async listTransitions(issueKey: string): Promise<unknown> {
return this.request(`/rest/api/3/issue/${issueKey}/transitions`);
}
async transitionIssue(issueKey: string, transitionId: string): Promise<unknown> {
return this.request(`/rest/api/3/issue/${issueKey}/transitions`, {
method: "POST",
body: JSON.stringify({ transition: { id: transitionId } }),
});
}
async listProjects(params: Record<string, string> = {}): Promise<unknown> {
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 {};
}
}