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
157 lines
6.5 KiB
TypeScript
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 [];
|
|
}
|
|
});
|