import { markdownToAdf } from "./adf.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; const ISSUE_FIELDS = ["summary", "issuetype", "status", "assignee", "created", "updated"] as const; type JiraClientOptions = { config: AtlassianConfig; fetchImpl?: FetchLike; }; type SearchInput = { jql: string; maxResults: number; startAt: number; }; type CreateInput = { project?: string; type: string; summary: string; description?: string; dryRun?: boolean; }; type UpdateInput = { issue: string; summary?: string; description?: string; dryRun?: boolean; }; type CommentInput = { issue: string; body: string; dryRun?: boolean; }; type TransitionInput = { issue: string; transition: string; dryRun?: boolean; }; function normalizeIssue(config: AtlassianConfig, issue: Record): JiraIssueSummary { const fields = (issue.fields ?? {}) as Record; const issueType = (fields.issuetype ?? {}) as Record; const status = (fields.status ?? {}) as Record; const assignee = (fields.assignee ?? {}) as Record; return { key: String(issue.key ?? ""), summary: String(fields.summary ?? ""), issueType: String(issueType.name ?? ""), status: String(status.name ?? ""), assignee: assignee.displayName ? String(assignee.displayName) : undefined, created: String(fields.created ?? ""), updated: String(fields.updated ?? ""), url: `${config.baseUrl}/browse/${issue.key ?? ""}`, }; } function createRequest(config: AtlassianConfig, method: "GET" | "POST" | "PUT", path: string, body?: unknown) { const url = new URL(path, `${config.jiraBaseUrl}/`); return { method, url: url.toString(), ...(body === undefined ? {} : { body }), }; } export function createJiraClient(options: JiraClientOptions) { const fetchImpl = options.fetchImpl ?? globalThis.fetch; if (!fetchImpl) { throw new Error("Fetch API is not available in this runtime"); } async function send(method: "GET" | "POST" | "PUT", path: string, body?: unknown) { const request = createRequest(options.config, method, path, body); return sendJsonRequest({ config: options.config, fetchImpl, url: request.url, method, body, errorPrefix: "Jira request failed", }); } return { async searchIssues(input: SearchInput): Promise> { const raw = (await send("POST", "/rest/api/3/search", { jql: input.jql, maxResults: input.maxResults, startAt: input.startAt, fields: [...ISSUE_FIELDS], })) as Record; const issues = Array.isArray(raw.issues) ? raw.issues : []; return { ok: true, data: { issues: issues.map((issue) => normalizeIssue(options.config, issue as Record)), startAt: Number(raw.startAt ?? input.startAt), maxResults: Number(raw.maxResults ?? input.maxResults), total: Number(raw.total ?? issues.length), }, }; }, async getIssue(issue: string): Promise> { const url = new URL(`/rest/api/3/issue/${issue}`, `${options.config.jiraBaseUrl}/`); url.searchParams.set("fields", ISSUE_FIELDS.join(",")); const raw = (await send("GET", `${url.pathname}${url.search}`)) as Record; return { ok: true, data: { issue: normalizeIssue(options.config, raw), }, raw, }; }, async getTransitions(issue: string): Promise> { const raw = (await send( "GET", `/rest/api/3/issue/${issue}/transitions`, )) as { transitions?: Array> }; return { ok: true, data: { transitions: (raw.transitions ?? []).map((transition) => ({ id: String(transition.id ?? ""), name: String(transition.name ?? ""), toStatus: String(((transition.to ?? {}) as Record).name ?? ""), hasScreen: Boolean(transition.hasScreen), })), }, }; }, async createIssue(input: CreateInput): Promise> { const project = input.project || options.config.defaultProject; if (!project) { throw new Error("jira-create requires --project or ATLASSIAN_DEFAULT_PROJECT"); } const request = createRequest(options.config, "POST", "/rest/api/3/issue", { fields: { project: { key: project }, issuetype: { name: input.type }, summary: input.summary, ...(input.description ? { description: markdownToAdf(input.description) } : {}), }, }); if (input.dryRun) { return { ok: true, dryRun: true, data: request, }; } const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; }, async updateIssue(input: UpdateInput): Promise> { const fields: Record = {}; if (input.summary) { fields.summary = input.summary; } if (input.description) { fields.description = markdownToAdf(input.description); } if (Object.keys(fields).length === 0) { throw new Error("jira-update requires --summary and/or --description-file"); } const request = createRequest(options.config, "PUT", `/rest/api/3/issue/${input.issue}`, { fields, }); if (input.dryRun) { return { ok: true, dryRun: true, data: request, }; } await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { ok: true, data: { issue: input.issue, updated: true, }, }; }, async commentIssue(input: CommentInput): Promise> { const request = createRequest(options.config, "POST", `/rest/api/3/issue/${input.issue}/comment`, { body: markdownToAdf(input.body), }); if (input.dryRun) { return { ok: true, dryRun: true, data: request, }; } const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { ok: true, data: raw, }; }, async transitionIssue(input: TransitionInput): Promise> { const request = createRequest( options.config, "POST", `/rest/api/3/issue/${input.issue}/transitions`, { transition: { id: input.transition, }, }, ); if (input.dryRun) { return { ok: true, dryRun: true, data: request, }; } await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { ok: true, data: { issue: input.issue, transitioned: true, transition: input.transition, }, }; }, }; }