Files
mesh-catalog/modules/jira/tools/index.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

157 lines
6.5 KiB
TypeScript

// 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 [];
}
});