import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Input, truncateToWidth } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { JiraClient, loadJiraConfig, textToAdf } from "./client.ts"; import { deleteJiraCredential, jiraConfigPath, loadEffectiveJiraConfig, loadJiraCredential, loadJiraPreferences, saveJiraSetup, type JiraPreferences, } from "./settings.ts"; type JsonObject = Record; type ToolResult = { content: Array<{ type: "text"; text: string }>; details: JsonObject }; const DEFAULT_FIELDS = ["summary", "status", "issuetype", "priority", "assignee", "reporter", "labels", "updated"]; const WRITE_OVERRIDE = "JIRA_ALLOW_NONINTERACTIVE_WRITES"; const MAX_CONFIRMATION_CHARS = 32_767; function isTrue(value: string | undefined): boolean { return /^(1|true|yes)$/i.test(value?.trim() ?? ""); } function issueKey(value: string): string { const key = value.trim().toUpperCase(); if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(key)) throw new Error(`Invalid Jira issue key: ${value}`); return key; } function projectKey(value: string | undefined): string { const key = (value ?? process.env.JIRA_PROJECT_KEY ?? loadJiraPreferences()?.projectKey ?? "").trim().toUpperCase(); if (!key) throw new Error("Missing project key: specify projectKey or JIRA_PROJECT_KEY"); if (!/^[A-Z][A-Z0-9_]*$/.test(key)) throw new Error(`Invalid Jira project key: ${key}`); return key; } function client(): JiraClient { return new JiraClient(loadEffectiveJiraConfig()); } function readOnlyEnabled(preferences?: JiraPreferences): boolean { const value = process.env.JIRA_READ_ONLY?.trim(); if (!value) return (preferences ?? loadJiraPreferences())?.readOnly === true; if (/^(1|true|yes)$/i.test(value)) return true; if (/^(0|false|no)$/i.test(value)) return false; throw new Error("JIRA_READ_ONLY must be true or false"); } function displayPreferences(): JiraPreferences | undefined { const environmentOnly = process.env.JIRA_BASE_URL?.trim() && process.env.JIRA_EMAIL?.trim(); let saved: JiraPreferences | undefined; try { saved = loadJiraPreferences(); } catch (error) { if (!environmentOnly) throw error; } const baseUrl = process.env.JIRA_BASE_URL?.trim().replace(/\/+$/, "") || saved?.baseUrl; const email = process.env.JIRA_EMAIL?.trim() || saved?.email; if (!baseUrl || !email) return undefined; return { baseUrl, email, projectKey: process.env.JIRA_PROJECT_KEY?.trim().toUpperCase() || saved?.projectKey, defaultIssueType: process.env.JIRA_DEFAULT_ISSUE_TYPE?.trim() || saved?.defaultIssueType, readOnly: readOnlyEnabled(saved), }; } async function maskedInput(ctx: ExtensionContext, title: string, hasExistingValue: boolean): Promise { if (ctx.mode !== "tui") throw new Error("/jira-setup requires Pi's interactive TUI mode"); return ctx.ui.custom((tui, theme, _keybindings, done) => { const input = new Input(); input.onSubmit = (value) => done(value); input.onEscape = () => done(undefined); return { render(width: number) { const value = input.getValue(); const masked = value ? "•".repeat([...value].length) : hasExistingValue ? "(press Enter to keep the saved token)" : "(paste the token)"; return [ truncateToWidth(theme.fg("accent", theme.bold(title)), width), truncateToWidth(masked, width), truncateToWidth(theme.fg("dim", "Enter confirms • Esc cancels"), width), ]; }, handleInput(data: string) { input.handleInput(data); tui.requestRender(); }, invalidate() { input.invalidate(); }, }; }); } function result(data: unknown, details: JsonObject = {}): ToolResult { const raw = JSON.stringify(data, null, 2); const truncated = truncateHead(raw, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES }); let text = truncated.content; if (truncated.truncated) { text += `\n\n[Jira output truncated: ${formatSize(truncated.outputBytes)} of ${formatSize(truncated.totalBytes)}. Narrow the fields or query.]`; } return { content: [{ type: "text", text }], details: { ...details, truncated: truncated.truncated } }; } async function confirmWrite(ctx: ExtensionContext, title: string, summary: string): Promise { if (readOnlyEnabled()) throw new Error("Jira writes are blocked by the read-only configuration"); if (!ctx.hasUI) { if (isTrue(process.env[WRITE_OVERRIDE])) return; throw new Error(`Jira write blocked without a UI. Set ${WRITE_OVERRIDE}=true only for controlled automation.`); } if (summary.length > MAX_CONFIRMATION_CHARS) { throw new Error(`Jira write blocked: the content exceeds ${MAX_CONFIRMATION_CHARS} characters and cannot be shown in full in the confirmation prompt.`); } const accepted = await ctx.ui.confirm(title, summary); if (!accepted) throw new Error("Jira operation cancelled by the user"); } function displayValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(displayValue); if (!value || typeof value !== "object") return value; const object = value as JsonObject; for (const key of ["displayName", "name", "value", "key"]) { if (typeof object[key] === "string") return object[key]; } return value; } function summarizeIssue(issue: JsonObject): JsonObject { const fields = (issue.fields ?? {}) as JsonObject; const output: JsonObject = { key: issue.key, id: issue.id, summary: fields.summary, status: displayValue(fields.status), issueType: displayValue(fields.issuetype), priority: displayValue(fields.priority), assignee: displayValue(fields.assignee), updated: fields.updated, }; for (const [key, value] of Object.entries(output)) if (value === undefined) delete output[key]; return output; } function optionalFields(fields: string[] | undefined): string[] { return fields?.length ? [...new Set(fields.map((field) => field.trim()).filter(Boolean))].slice(0, 30) : DEFAULT_FIELDS; } function additionalFields(value: unknown): JsonObject { if (value === undefined) return {}; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("additionalFields must be a JSON object"); const fields = { ...(value as JsonObject) }; delete fields.key; delete fields.id; delete fields.project; return fields; } export default function jiraExtension(pi: ExtensionAPI): void { pi.registerTool({ name: "jira_status", label: "Jira Status", description: "Verify Jira configuration and return the authenticated Jira user. Read-only.", promptSnippet: "Verify Jira connectivity and authenticated user", parameters: Type.Object({}), async execute(_id, _params, signal) { const myself = await client().request("/rest/api/3/myself", { signal }); return result({ connected: true, accountId: myself.accountId, displayName: myself.displayName, emailAddress: myself.emailAddress }); }, }); pi.registerTool({ name: "jira_search", label: "Jira Search", description: "Search Jira issues with JQL. Returns compact issue summaries, at most 100 results.", promptSnippet: "Search Jira issues using JQL", parameters: Type.Object({ jql: Type.String({ minLength: 1, description: "Jira Query Language expression" }), maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), fields: Type.Optional(Type.Array(Type.String(), { maxItems: 30 })), nextPageToken: Type.Optional(Type.String()), }), async execute(_id, params, signal) { const fields = optionalFields(params.fields); const query = new URLSearchParams({ jql: params.jql, maxResults: String(params.maxResults ?? 25), fields: fields.join(","), }); if (params.nextPageToken) query.set("nextPageToken", params.nextPageToken); const data = await client().request<{ issues?: JsonObject[]; nextPageToken?: string; isLast?: boolean }>( `/rest/api/3/search/jql?${query}`, { signal }, ); return result({ issues: (data.issues ?? []).map(summarizeIssue), nextPageToken: data.nextPageToken, isLast: data.isLast, fields, }); }, }); pi.registerTool({ name: "jira_get_issue", label: "Jira Get Issue", description: "Read one Jira issue. Optionally select up to 30 fields to control output size.", promptSnippet: "Read a Jira issue by key", parameters: Type.Object({ issueKey: Type.String({ description: "For example PRX-123" }), fields: Type.Optional(Type.Array(Type.String(), { maxItems: 30 })), }), async execute(_id, params, signal) { const key = issueKey(params.issueKey); const fields = optionalFields(params.fields); const query = new URLSearchParams({ fields: fields.join(",") }); const data = await client().request(`/rest/api/3/issue/${encodeURIComponent(key)}?${query}`, { signal }); return result(data, { issueKey: key, fields }); }, }); pi.registerTool({ name: "jira_create_issue", label: "Jira Create Issue", description: "Create a Jira issue after interactive confirmation. Uses JIRA_PROJECT_KEY and JIRA_DEFAULT_ISSUE_TYPE when omitted.", promptSnippet: "Create a Jira issue with confirmation", promptGuidelines: ["Use jira_create_issue only after the user has supplied or approved the issue content; the tool performs a final confirmation."], parameters: Type.Object({ projectKey: Type.Optional(Type.String()), issueType: Type.Optional(Type.String()), summary: Type.String({ minLength: 1, maxLength: 255 }), description: Type.Optional(Type.String()), priority: Type.Optional(Type.String()), assigneeAccountId: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })), additionalFields: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }), async execute(_id, params, signal, _onUpdate, ctx) { const project = projectKey(params.projectKey); const issueType = params.issueType?.trim() || process.env.JIRA_DEFAULT_ISSUE_TYPE?.trim() || loadJiraPreferences()?.defaultIssueType || "Task"; const fields: JsonObject = { ...additionalFields(params.additionalFields), project: { key: project }, issuetype: { name: issueType }, summary: params.summary.trim(), }; if (params.description !== undefined) fields.description = textToAdf(params.description); if (params.priority) fields.priority = { name: params.priority }; if (params.assigneeAccountId) fields.assignee = { accountId: params.assigneeAccountId }; if (params.labels) fields.labels = params.labels; await confirmWrite(ctx, "Create Jira issue?", JSON.stringify(fields, null, 2)); const created = await client().request("/rest/api/3/issue", { method: "POST", body: { fields }, signal }); return result(created, { operation: "create" }); }, }); pi.registerTool({ name: "jira_update_issue", label: "Jira Update Issue", description: "Update selected fields on a Jira issue after interactive confirmation.", promptSnippet: "Update Jira issue fields with confirmation", promptGuidelines: ["Use jira_update_issue only for fields requested or approved by the user; the tool performs a final confirmation."], parameters: Type.Object({ issueKey: Type.String(), summary: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), description: Type.Optional(Type.String()), priority: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })), additionalFields: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }), async execute(_id, params, signal, _onUpdate, ctx) { const key = issueKey(params.issueKey); const fields = additionalFields(params.additionalFields); if (params.summary !== undefined) fields.summary = params.summary.trim(); if (params.description !== undefined) fields.description = textToAdf(params.description); if (params.priority !== undefined) fields.priority = { name: params.priority }; if (params.labels !== undefined) fields.labels = params.labels; if (Object.keys(fields).length === 0) throw new Error("No fields to update"); await confirmWrite(ctx, `Update ${key}?`, JSON.stringify(fields, null, 2)); await client().request(`/rest/api/3/issue/${encodeURIComponent(key)}`, { method: "PUT", body: { fields }, signal }); return result({ updated: true, key, fields: Object.keys(fields) }, { operation: "update", issueKey: key }); }, }); pi.registerTool({ name: "jira_comment", label: "Jira Comment", description: "Add a plain-text comment to a Jira issue after interactive confirmation.", promptSnippet: "Add a Jira comment with confirmation", parameters: Type.Object({ issueKey: Type.String(), comment: Type.String({ minLength: 1, maxLength: 32_767 }) }), async execute(_id, params, signal, _onUpdate, ctx) { const key = issueKey(params.issueKey); await confirmWrite(ctx, `Comment on ${key}?`, params.comment); const comment = await client().request(`/rest/api/3/issue/${encodeURIComponent(key)}/comment`, { method: "POST", body: { body: textToAdf(params.comment) }, signal, }); return result({ id: comment.id, created: comment.created, author: displayValue(comment.author) }, { operation: "comment", issueKey: key }); }, }); pi.registerTool({ name: "jira_transition", label: "Jira Transition", description: "List available transitions or move an issue using a transition ID/name after confirmation.", promptSnippet: "List or apply Jira workflow transitions", parameters: Type.Object({ issueKey: Type.String(), action: StringEnum(["list", "apply"] as const), transitionId: Type.Optional(Type.String()), transitionName: Type.Optional(Type.String()), }), async execute(_id, params, signal, _onUpdate, ctx) { const key = issueKey(params.issueKey); const available = await client().request<{ transitions?: Array<{ id: string; name: string; to?: JsonObject }> }>( `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, { signal }, ); const transitions = available.transitions ?? []; if (params.action === "list") return result({ key, transitions: transitions.map((t) => ({ id: t.id, name: t.name, to: displayValue(t.to) })) }); let transition = params.transitionId ? transitions.find((item) => item.id === params.transitionId) : undefined; if (!transition && params.transitionName) { const name = params.transitionName.trim().toLocaleLowerCase(); transition = transitions.find((item) => item.name.toLocaleLowerCase() === name); } if (!transition) throw new Error("Transition not found. Use action=list to see the available transitions."); await confirmWrite(ctx, `Change the status of ${key}?`, `${transition.name} → ${String(displayValue(transition.to) ?? "new status")}`); await client().request(`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, { method: "POST", body: { transition: { id: transition.id } }, signal, }); return result({ transitioned: true, key, transition: { id: transition.id, name: transition.name } }, { operation: "transition", issueKey: key }); }, }); pi.registerTool({ name: "jira_assign", label: "Jira Assign", description: "Assign or unassign a Jira issue after interactive confirmation. Jira Cloud uses accountId.", promptSnippet: "Assign or unassign Jira issues with confirmation", parameters: Type.Object({ issueKey: Type.String(), accountId: Type.Optional(Type.String()), unassign: Type.Optional(Type.Boolean()), }), async execute(_id, params, signal, _onUpdate, ctx) { const key = issueKey(params.issueKey); if (!params.unassign && !params.accountId) throw new Error("Specify accountId or unassign=true"); if (params.unassign && params.accountId) throw new Error("accountId and unassign=true are incompatible"); const accountId = params.unassign ? null : params.accountId!; await confirmWrite(ctx, `${params.unassign ? "Unassign" : "Assign"} ${key}?`, params.unassign ? "Unassigned issue" : `Account ID: ${accountId}`); await client().request(`/rest/api/3/issue/${encodeURIComponent(key)}/assignee`, { method: "PUT", body: { accountId }, signal, }); return result({ updated: true, key, assigned: !params.unassign, accountId }, { operation: "assign", issueKey: key }); }, }); pi.registerCommand("jira-setup", { description: "Configure Jira and save the API token in the system keychain", handler: async (_args, ctx) => { try { const current = loadJiraPreferences(); const baseUrlInput = await ctx.ui.input("Jira URL", current?.baseUrl ?? "https://company.atlassian.net"); if (baseUrlInput === undefined) return; const emailInput = await ctx.ui.input("Jira email", current?.email ?? "name@company.example"); if (emailInput === undefined) return; const existingToken = loadJiraCredential(); const tokenInput = await maskedInput(ctx, "Jira API token", Boolean(existingToken)); if (tokenInput === undefined) return; const token = tokenInput.trim() || existingToken; if (!token) throw new Error("Missing Jira API token"); const projectInput = await ctx.ui.input("Default project (optional)", current?.projectKey ?? "PRX"); if (projectInput === undefined) return; const issueTypeInput = await ctx.ui.input("Default issue type", current?.defaultIssueType ?? "Task"); if (issueTypeInput === undefined) return; const mode = await ctx.ui.select("Jira mode", ["Read and write with confirmation", "Read only"]); if (!mode) return; const preferences: JiraPreferences = { baseUrl: baseUrlInput.trim() || current?.baseUrl || "", email: emailInput.trim() || current?.email || "", projectKey: projectInput.trim() || undefined, defaultIssueType: issueTypeInput.trim() || "Task", readOnly: mode === "Read only", }; const testClient = new JiraClient(loadJiraConfig({ JIRA_BASE_URL: preferences.baseUrl, JIRA_EMAIL: preferences.email, JIRA_API_TOKEN: token, })); const myself = await testClient.request("/rest/api/3/myself"); saveJiraSetup(preferences, token); ctx.ui.notify(`Jira configured: ${String(myself.displayName ?? myself.accountId ?? "authenticated user")}`, "info"); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("jira-config", { description: "Show the active Jira configuration without secrets", handler: async (_args, ctx) => { try { const preferences = displayPreferences(); ctx.ui.notify(preferences ? `Jira: ${preferences.baseUrl} • ${preferences.email} • project ${preferences.projectKey ?? "not set"} • ${preferences.readOnly ? "read only" : "writes with confirmation"}` : "Jira is not configured. Run /jira-setup.", preferences ? "info" : "warning"); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("jira-logout", { description: "Remove the Jira API token from the system keychain", handler: async (_args, ctx) => { if (!await ctx.ui.confirm("Log out of Jira?", "The API token will be removed from the system keychain.")) return; deleteJiraCredential(); ctx.ui.notify("Jira token removed", "info"); }, }); pi.registerCommand("jira-status", { description: "Verify Jira configuration and connectivity", handler: async (_args, ctx) => { try { const config = loadEffectiveJiraConfig(); const preferences = displayPreferences(); const myself = await new JiraClient(config).request("/rest/api/3/myself"); ctx.ui.notify(`Jira connected: ${String(myself.displayName ?? myself.accountId ?? "authenticated user")} • project ${preferences?.projectKey ?? "not set"} • ${preferences?.readOnly ? "read only" : "writes with confirmation"}`, "info"); } catch (error) { ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}. Run /jira-setup.`, "error"); } }, }); pi.on("session_start", (_event, ctx) => { try { if (!displayPreferences()) ctx.ui.notify(`Jira is not configured — run /jira-setup (config: ${jiraConfigPath()})`, "warning"); } catch (error) { ctx.ui.notify(`Invalid Jira configuration: ${error instanceof Error ? error.message : String(error)}. Run /jira-setup.`, "error"); } }); }