Convert confluence + jira on the tools-only template #11

Merged
jschoubben merged 1 commits from feat/convert-atlassian into main 2026-09-05 23:54:52 +00:00
10 changed files with 679 additions and 0 deletions
Showing only changes of commit db8b60f4e4 - Show all commits
+117
View File
@@ -0,0 +1,117 @@
// confluence's own Confluence API client — its own code, living in the module (novox/hq ADR 0039).
// Ported from the hal sdk's shared ConfluenceClient, where a change to the Atlassian API rebuilt
// everything; here it rebuilds only confluence. This module's tools import it, and nothing outside
// confluence does.
//
// confluence 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 Confluence instance
// over its REST 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 ConfluenceClient {
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 — confluence's one secret (own-secret). */
private readonly token: string | undefined,
) {}
static fromEnv(env: NodeJS.ProcessEnv = process.env): ConfluenceClient {
// 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_CONFLUENCE_CONFIG_FILE);
const url = config.ATLASSIAN_URL ?? env.MESH_CONFLUENCE_URL ?? env.ATLASSIAN_URL;
const email = config.ATLASSIAN_EMAIL ?? env.MESH_CONFLUENCE_EMAIL ?? env.ATLASSIAN_EMAIL;
const token = env.MESH_CONFLUENCE_TOKEN ?? readSecret(env.MESH_CONFLUENCE_TOKEN_FILE) ?? env.ATLASSIAN_TOKEN;
return new ConfluenceClient(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(
"confluence 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(`Confluence API error ${res.status}: ${await res.text()}`);
}
if (res.status === 204) return null as T;
return res.json() as Promise<T>;
}
async search(cql: string, limit = 25): Promise<unknown> {
const params = new URLSearchParams({ cql, limit: String(limit) });
return this.request(`/wiki/rest/api/content/search?${params}`);
}
async getPage(pageId: string, expand?: string): Promise<unknown> {
const params = expand ? `?body-format=${encodeURIComponent(expand)}` : "";
return this.request(`/wiki/api/v2/pages/${pageId}${params}`);
}
async listSpaces(params: Record<string, string> = {}): Promise<unknown> {
const qs = new URLSearchParams(params).toString();
return this.request(`/wiki/api/v2/spaces?${qs}`);
}
}
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 {};
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"module": "confluence",
"version": "1",
"own-secrets": {
"token": "/var/lib/confluence/token",
"broker": "/var/lib/mesh/confluence/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/confluence",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/confluence",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "/var/lib/confluence/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-confluence",
"image": "mesh-runtime-confluence@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/confluence/config.json:/run/config/config.json:ro",
"/var/lib/confluence/token:/run/secrets/token:ro",
"/var/lib/mesh/confluence/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
"MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
}
}
],
"capabilities": [
"container-runtime"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-confluence",
"version": "0.1.0",
"description": "confluence — a tools-only, outbound-only Atlassian Confluence SaaS integration (novox/hq ADR 0039): its API client and tools live here.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
// confluence's tools — confluence's own code (novox/hq ADR 0039), importing confluence's own
// Confluence API client. They return structured data; the mesh serves them through the sdk's tool
// harness. confluence is tools-only and outbound-only: no service, no events, no listener — it
// reaches out to an Atlassian Confluence instance and exposes its content search, pages and spaces.
//
// Every tool is registered unconditionally, even with no credentials configured (the Servarr
// lesson): the client is built lazily and never throws, so the runtime always comes up and serves
// the full tool surface — a call made before the URL/email/token are set fails with a clear error,
// but the runtime does not refuse to serve. The install proof is the runtime logging
// `[mesh-tools] serving N tool(s)`.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { ConfluenceClient } from "../client.js";
function str(value: unknown): string {
return String(value ?? "");
}
function num(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function getConfluenceTools(confluence: ConfluenceClient): ToolDefinition[] {
return [
{
name: "confluence_search",
description: "Search Confluence content using CQL.",
input: {
cql: { type: "string", description: "CQL query string" },
limit: { type: "number", description: "max results (default 25)" },
},
run: async (args) => ({ results: await confluence.search(str(args.cql), num(args.limit, 25)) }),
},
{
name: "confluence_get_page",
description: "Get a Confluence page by ID.",
input: {
page_id: { type: "string", description: "Page ID" },
},
run: async (args) => ({ page: await confluence.getPage(str(args.page_id)) }),
},
{
name: "confluence_list_spaces",
description: "List Confluence spaces.",
input: {
page: { type: "number", description: "page number, 1-based (default 1)" },
limit: { type: "number", description: "results per page (default 25)" },
},
run: async (args) => {
const page = num(args.page, 1);
const limit = num(args.limit, 25);
const cursor = String((page - 1) * limit);
return { spaces: await confluence.listSpaces({ cursor, limit: String(limit) }) };
},
},
];
}
// confluence always registers its full tool surface: the client is built lazily and never throws, so
// the runtime comes up and serves every tool even before the URL/email/token are configured (the lab
// has no real Atlassian). A tool called before the module is configured fails with a clear error from
// that call.
registerModuleTools("confluence", (env) => {
try {
return getConfluenceTools(ConfluenceClient.fromEnv(env));
} catch {
return [];
}
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"tools/index.ts"
]
}
+178
View File
@@ -0,0 +1,178 @@
// 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 {};
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"module": "jira",
"version": "1",
"own-secrets": {
"token": "/var/lib/jira/token",
"broker": "/var/lib/mesh/jira/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/jira",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/jira",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "/var/lib/jira/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-jira",
"image": "mesh-runtime-jira@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/jira/config.json:/run/config/config.json:ro",
"/var/lib/jira/token:/run/secrets/token:ro",
"/var/lib/mesh/jira/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_JIRA_TOKEN_FILE": "/run/secrets/token",
"MESH_JIRA_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
}
}
],
"capabilities": [
"container-runtime"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-jira",
"version": "0.1.0",
"description": "jira — a tools-only, outbound-only Atlassian Jira SaaS integration (novox/hq ADR 0039): its API client and tools live here.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+156
View File
@@ -0,0 +1,156 @@
// jira's tools — jira's own code (novox/hq ADR 0039), importing jira's own Jira API client. They
// return structured data; the mesh serves them through the sdk's tool harness. jira is tools-only and
// outbound-only: no service, no events, no listener — it reaches out to an Atlassian Jira instance
// and exposes its issues, comments, transitions and projects.
//
// NOTE — the HAL jira module also shipped a periodic ticket-poller (update-tickets.service/.timer)
// that pulled tickets on a schedule. It is deliberately NOT ported: the mesh has no scheduled-task
// primitive yet (a pending decision). Only jira's TOOLS are ported here; the poller is deferred until
// the scheduled-task capability lands.
//
// Every tool is registered unconditionally, even with no credentials configured (the Servarr
// lesson): the client is built lazily and never throws, so the runtime always comes up and serves the
// full tool surface — a call made before the URL/email/token are set fails with a clear error, but
// the runtime does not refuse to serve. The install proof is the runtime logging
// `[mesh-tools] serving N tool(s)`.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { JiraClient } from "../client.js";
function str(value: unknown): string {
return String(value ?? "");
}
function num(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function getJiraTools(jira: JiraClient): ToolDefinition[] {
return [
// --- Issues ---
{
name: "jira_search_issues",
description: "Search Jira issues using JQL.",
input: {
jql: { type: "string", description: "JQL query string" },
fields: { type: "string", description: "comma-separated field names to return (optional)" },
max_results: { type: "number", description: "max results (default 50)" },
},
run: async (args) => ({
results: await jira.searchIssues(
str(args.jql),
args.fields !== undefined ? str(args.fields) : undefined,
num(args.max_results, 50),
),
}),
},
{
name: "jira_get_issue",
description: "Get a single Jira issue by key (e.g. PROJ-123).",
input: {
issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" },
fields: { type: "string", description: "comma-separated field names to return (optional)" },
},
run: async (args) => ({
issue: await jira.getIssue(str(args.issue_key), args.fields !== undefined ? str(args.fields) : undefined),
}),
},
{
name: "jira_create_issue",
description: "Create a new Jira issue.",
input: {
project_key: { type: "string", description: "Project key, e.g. PROJ" },
issue_type: { type: "string", description: "Issue type name, e.g. Task, Bug, Story" },
summary: { type: "string", description: "Issue summary" },
description: { type: "string", description: "Plain text description, converted to ADF internally (optional)" },
},
run: async (args) => ({
issue: await jira.createIssue({
projectKey: str(args.project_key),
issueType: str(args.issue_type),
summary: str(args.summary),
description: args.description !== undefined ? str(args.description) : undefined,
}),
}),
},
{
name: "jira_update_issue",
description: "Update an existing Jira issue.",
input: {
issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" },
summary: { type: "string", description: "new summary (optional)" },
description: { type: "string", description: "new plain text description, converted to ADF internally (optional)" },
},
run: async (args) => {
await jira.updateIssue(str(args.issue_key), {
summary: args.summary !== undefined ? str(args.summary) : undefined,
description: args.description !== undefined ? str(args.description) : undefined,
});
return { updated: true, issue_key: str(args.issue_key) };
},
},
{
name: "jira_add_comment",
description: "Add a comment to a Jira issue.",
input: {
issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" },
body: { type: "string", description: "comment text" },
},
run: async (args) => ({ comment: await jira.addComment(str(args.issue_key), str(args.body)) }),
},
{
name: "jira_list_transitions",
description: "List available transitions for a Jira issue.",
input: {
issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" },
},
run: async (args) => ({ transitions: await jira.listTransitions(str(args.issue_key)) }),
},
{
name: "jira_transition_issue",
description: "Transition a Jira issue to a new status.",
input: {
issue_key: { type: "string", description: "Issue key, e.g. PROJ-123" },
transition_id: { type: "string", description: "Transition ID (get from jira_list_transitions)" },
},
run: async (args) => {
await jira.transitionIssue(str(args.issue_key), str(args.transition_id));
return { transitioned: true, issue_key: str(args.issue_key) };
},
},
// --- Projects ---
{
name: "jira_list_projects",
description: "List Jira projects.",
input: {
query: { type: "string", description: "search query for project name (optional)" },
page: { type: "number", description: "page index, 0-based (default 0)" },
max_results: { type: "number", description: "results per page (default 50)" },
},
run: async (args) => {
const page = num(args.page, 0);
const maxResults = num(args.max_results, 50);
const params: Record<string, string> = {
startAt: String(page * maxResults),
maxResults: String(maxResults),
};
if (args.query) params.query = str(args.query);
return { projects: await jira.listProjects(params) };
},
},
];
}
// jira always registers its full tool surface: the client is built lazily and never throws, so the
// runtime comes up and serves every tool even before the URL/email/token are configured (the lab has
// no real Atlassian). A tool called before the module is configured fails with a clear error from
// that call.
registerModuleTools("jira", (env) => {
try {
return getJiraTools(JiraClient.fromEnv(env));
} catch {
return [];
}
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"tools/index.ts"
]
}