diff --git a/skills/atlassian/shared/scripts/src/adf.ts b/skills/atlassian/shared/scripts/src/adf.ts new file mode 100644 index 0000000..638914c --- /dev/null +++ b/skills/atlassian/shared/scripts/src/adf.ts @@ -0,0 +1,92 @@ +const TEXT_NODE = "text"; + +function textNode(text: string) { + return { + type: TEXT_NODE, + text, + }; +} + +function paragraphNode(lines: string[]) { + const content: Array<{ type: string; text?: string }> = []; + + lines.forEach((line, index) => { + if (index > 0) { + content.push({ type: "hardBreak" }); + } + + if (line.length > 0) { + content.push(textNode(line)); + } + }); + + return { + type: "paragraph", + ...(content.length > 0 ? { content } : {}), + }; +} + +export function markdownToAdf(input: string) { + const lines = input.replace(/\r\n/g, "\n").split("\n"); + const content: Array> = []; + let index = 0; + + while (index < lines.length) { + const current = lines[index]?.trimEnd() ?? ""; + + if (current.trim().length === 0) { + index += 1; + continue; + } + + const heading = current.match(/^(#{1,6})\s+(.*)$/); + + if (heading) { + content.push({ + type: "heading", + attrs: { level: heading[1].length }, + content: [textNode(heading[2])], + }); + index += 1; + continue; + } + + if (/^[-*]\s+/.test(current)) { + const items: Array> = []; + + while (index < lines.length && /^[-*]\s+/.test(lines[index] ?? "")) { + items.push({ + type: "listItem", + content: [ + { + type: "paragraph", + content: [textNode((lines[index] ?? "").replace(/^[-*]\s+/, ""))], + }, + ], + }); + index += 1; + } + + content.push({ + type: "bulletList", + content: items, + }); + continue; + } + + const paragraphLines: string[] = []; + + while (index < lines.length && (lines[index]?.trim().length ?? 0) > 0) { + paragraphLines.push(lines[index] ?? ""); + index += 1; + } + + content.push(paragraphNode(paragraphLines)); + } + + return { + type: "doc", + version: 1, + content, + }; +} diff --git a/skills/atlassian/shared/scripts/src/cli.ts b/skills/atlassian/shared/scripts/src/cli.ts index 1d695a9..9784800 100644 --- a/skills/atlassian/shared/scripts/src/cli.ts +++ b/skills/atlassian/shared/scripts/src/cli.ts @@ -1,22 +1,212 @@ +import process from "node:process"; +import { pathToFileURL } from "node:url"; + import { Command } from "commander"; -const program = new Command() - .name("atlassian") - .description("Portable Atlassian CLI for multi-agent skills") - .version("0.1.0"); +import { loadConfig } from "./config.js"; +import { readWorkspaceFile } from "./files.js"; +import { createJiraClient } from "./jira.js"; +import { writeOutput } from "./output.js"; +import type { FetchLike, OutputFormat, Writer } from "./types.js"; -program - .command("health") - .description("Validate configuration and Atlassian connectivity") - .option("--format ", "Output format", "json") - .action((options) => { - const payload = { - ok: false, - message: "health is not implemented yet", - format: options.format, - }; +type CliContext = { + cwd?: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: FetchLike; + stdout?: Writer; + stderr?: Writer; +}; - console.log(JSON.stringify(payload, null, 2)); +function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} + +function createRuntime(context: CliContext) { + const cwd = context.cwd ?? process.cwd(); + const env = context.env ?? process.env; + const stdout = context.stdout ?? process.stdout; + const stderr = context.stderr ?? process.stderr; + let configCache: ReturnType | undefined; + let jiraCache: ReturnType | undefined; + + function getConfig() { + configCache ??= loadConfig(env, { cwd }); + return configCache; + } + + function getJiraClient() { + jiraCache ??= createJiraClient({ + config: getConfig(), + fetchImpl: context.fetchImpl, + }); + return jiraCache; + } + + async function readBodyFile(filePath: string | undefined) { + if (!filePath) { + return undefined; + } + + return readWorkspaceFile(filePath, cwd); + } + + return { + cwd, + stdout, + stderr, + readBodyFile, + getConfig, + getJiraClient, + }; +} + +export function buildProgram(context: CliContext = {}) { + const runtime = createRuntime(context); + const program = new Command() + .name("atlassian") + .description("Portable Atlassian CLI for multi-agent skills") + .version("0.1.0"); + + program + .command("health") + .description("Validate configuration and Atlassian connectivity") + .option("--format ", "Output format", "json") + .action((options) => { + writeOutput( + runtime.stdout, + { + ok: true, + data: { + baseUrl: runtime.getConfig().baseUrl, + jiraBaseUrl: runtime.getConfig().jiraBaseUrl, + confluenceBaseUrl: runtime.getConfig().confluenceBaseUrl, + defaultProject: runtime.getConfig().defaultProject, + defaultSpace: runtime.getConfig().defaultSpace, + }, + }, + resolveFormat(options.format), + ); + }); + + program + .command("jira-search") + .requiredOption("--jql ", "JQL expression to execute") + .option("--max-results ", "Maximum results to return", "50") + .option("--start-at ", "Result offset", "0") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().searchIssues({ + jql: options.jql, + maxResults: Number(options.maxResults), + startAt: Number(options.startAt), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-get") + .requiredOption("--issue ", "Issue key") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().getIssue(options.issue); + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-create") + .requiredOption("--type ", "Issue type name") + .requiredOption("--summary ", "Issue summary") + .option("--project ", "Project key") + .option("--description-file ", "Workspace-relative markdown/text file") + .option("--dry-run", "Print the request without sending it") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().createIssue({ + project: options.project, + type: options.type, + summary: options.summary, + description: await runtime.readBodyFile(options.descriptionFile), + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-update") + .requiredOption("--issue ", "Issue key") + .option("--summary ", "Updated summary") + .option("--description-file ", "Workspace-relative markdown/text file") + .option("--dry-run", "Print the request without sending it") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().updateIssue({ + issue: options.issue, + summary: options.summary, + description: await runtime.readBodyFile(options.descriptionFile), + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-comment") + .requiredOption("--issue ", "Issue key") + .requiredOption("--body-file ", "Workspace-relative markdown/text file") + .option("--dry-run", "Print the request without sending it") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().commentIssue({ + issue: options.issue, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-transitions") + .requiredOption("--issue ", "Issue key") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().getTransitions(options.issue); + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("jira-transition") + .requiredOption("--issue ", "Issue key") + .requiredOption("--transition ", "Transition ID") + .option("--dry-run", "Print the request without sending it") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getJiraClient().transitionIssue({ + issue: options.issue, + transition: options.transition, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + return program; +} + +export async function runCli(argv = process.argv, context: CliContext = {}) { + const program = buildProgram(context); + await program.parseAsync(argv); +} + +const isDirectExecution = + Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectExecution) { + runCli().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; }); - -program.parse(process.argv); +} diff --git a/skills/atlassian/shared/scripts/src/config.ts b/skills/atlassian/shared/scripts/src/config.ts new file mode 100644 index 0000000..eb34a39 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/config.ts @@ -0,0 +1,52 @@ +import path from "node:path"; + +import { config as loadDotEnv } from "dotenv"; + +import type { AtlassianConfig } from "./types.js"; + +function normalizeBaseUrl(value: string) { + return value.replace(/\/+$/, ""); +} + +function readRequired(env: NodeJS.ProcessEnv, key: string) { + const value = env[key]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + + return value; +} + +export function loadConfig( + env: NodeJS.ProcessEnv = process.env, + options?: { + cwd?: string; + }, +): AtlassianConfig { + loadDotEnv({ + path: path.resolve(options?.cwd ?? process.cwd(), ".env"), + processEnv: env as Record, + override: false, + }); + + const baseUrl = normalizeBaseUrl(readRequired(env, "ATLASSIAN_BASE_URL")); + + return { + baseUrl, + jiraBaseUrl: normalizeBaseUrl(env.ATLASSIAN_JIRA_BASE_URL?.trim() || baseUrl), + confluenceBaseUrl: normalizeBaseUrl(env.ATLASSIAN_CONFLUENCE_BASE_URL?.trim() || baseUrl), + email: readRequired(env, "ATLASSIAN_EMAIL"), + apiToken: readRequired(env, "ATLASSIAN_API_TOKEN"), + defaultProject: env.ATLASSIAN_DEFAULT_PROJECT?.trim() || undefined, + defaultSpace: env.ATLASSIAN_DEFAULT_SPACE?.trim() || undefined, + }; +} + +export function createBasicAuthHeader(config: { + email: string; + apiToken: string; + [key: string]: unknown; +}) { + return `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString("base64")}`; +} diff --git a/skills/atlassian/shared/scripts/src/files.ts b/skills/atlassian/shared/scripts/src/files.ts new file mode 100644 index 0000000..8339109 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/files.ts @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export async function readWorkspaceFile(filePath: string, cwd: string) { + const resolved = path.resolve(cwd, filePath); + const relative = path.relative(cwd, resolved); + + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`--body-file must stay within the active workspace: ${filePath}`); + } + + return readFile(resolved, "utf8"); +} diff --git a/skills/atlassian/shared/scripts/src/jira.ts b/skills/atlassian/shared/scripts/src/jira.ts new file mode 100644 index 0000000..aaec3f5 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/jira.ts @@ -0,0 +1,294 @@ +import { markdownToAdf } from "./adf.js"; +import { createBasicAuthHeader } from "./config.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 createHeaders(config: AtlassianConfig, includeJsonBody: boolean) { + const headers: Array<[string, string]> = [ + ["Accept", "application/json"], + ["Authorization", createBasicAuthHeader(config)], + ]; + + if (includeJsonBody) { + headers.push(["Content-Type", "application/json"]); + } + + return headers; +} + +async function parseResponse(response: Response) { + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + return response.json(); + } + + return response.text(); +} + +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); + const response = await fetchImpl(request.url, { + method, + headers: createHeaders(options.config, body !== undefined), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + throw new Error(`Jira request failed: ${response.status} ${response.statusText}`); + } + + return parseResponse(response); + } + + 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, + }, + }; + }, + }; +} diff --git a/skills/atlassian/shared/scripts/src/output.ts b/skills/atlassian/shared/scripts/src/output.ts new file mode 100644 index 0000000..06b8a6e --- /dev/null +++ b/skills/atlassian/shared/scripts/src/output.ts @@ -0,0 +1,44 @@ +import type { CommandOutput, OutputFormat, Writer } from "./types.js"; + +function renderText(payload: CommandOutput) { + const data = payload.data as Record; + + if (Array.isArray(data?.issues)) { + return data.issues + .map((issue) => { + const item = issue as Record; + return `${item.key} [${item.status}] ${item.issueType} - ${item.summary}`; + }) + .join("\n"); + } + + if (data?.issue && typeof data.issue === "object") { + const issue = data.issue as Record; + return [ + issue.key, + `${issue.issueType} | ${issue.status}`, + issue.summary, + issue.url, + ].join("\n"); + } + + if (Array.isArray(data?.transitions)) { + return data.transitions + .map((transition) => { + const item = transition as Record; + return `${item.id} ${item.name} -> ${item.toStatus}`; + }) + .join("\n"); + } + + return JSON.stringify(payload, null, 2); +} + +export function writeOutput( + writer: Writer, + payload: CommandOutput, + format: OutputFormat = "json", +) { + const body = format === "text" ? renderText(payload) : JSON.stringify(payload, null, 2); + writer.write(`${body}\n`); +} diff --git a/skills/atlassian/shared/scripts/src/types.ts b/skills/atlassian/shared/scripts/src/types.ts new file mode 100644 index 0000000..3072ee1 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/types.ts @@ -0,0 +1,35 @@ +export type AtlassianConfig = { + baseUrl: string; + jiraBaseUrl: string; + confluenceBaseUrl: string; + email: string; + apiToken: string; + defaultProject?: string; + defaultSpace?: string; +}; + +export type CommandOutput = { + ok: true; + data: T; + dryRun?: boolean; + raw?: unknown; +}; + +export type JiraIssueSummary = { + key: string; + summary: string; + issueType: string; + status: string; + assignee?: string; + created: string; + updated: string; + url: string; +}; + +export type Writer = { + write(chunk: string | Uint8Array): unknown; +}; + +export type FetchLike = typeof fetch; + +export type OutputFormat = "json" | "text"; diff --git a/skills/atlassian/shared/scripts/tests/config.test.ts b/skills/atlassian/shared/scripts/tests/config.test.ts new file mode 100644 index 0000000..b45741c --- /dev/null +++ b/skills/atlassian/shared/scripts/tests/config.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createBasicAuthHeader, loadConfig } from "../src/config.js"; + +test("loadConfig derives Jira and Confluence base URLs from ATLASSIAN_BASE_URL", () => { + const config = loadConfig({ + ATLASSIAN_BASE_URL: "https://example.atlassian.net/", + ATLASSIAN_EMAIL: "dev@example.com", + ATLASSIAN_API_TOKEN: "secret-token", + ATLASSIAN_DEFAULT_PROJECT: "ENG", + }); + + assert.deepEqual(config, { + baseUrl: "https://example.atlassian.net", + jiraBaseUrl: "https://example.atlassian.net", + confluenceBaseUrl: "https://example.atlassian.net", + email: "dev@example.com", + apiToken: "secret-token", + defaultProject: "ENG", + defaultSpace: undefined, + }); +}); + +test("createBasicAuthHeader encodes email and API token for Atlassian Cloud", () => { + const header = createBasicAuthHeader({ + baseUrl: "https://example.atlassian.net", + jiraBaseUrl: "https://example.atlassian.net", + confluenceBaseUrl: "https://example.atlassian.net", + email: "dev@example.com", + apiToken: "secret-token", + }); + + assert.equal( + header, + `Basic ${Buffer.from("dev@example.com:secret-token").toString("base64")}`, + ); +}); diff --git a/skills/atlassian/shared/scripts/tests/helpers.ts b/skills/atlassian/shared/scripts/tests/helpers.ts new file mode 100644 index 0000000..dc04d1b --- /dev/null +++ b/skills/atlassian/shared/scripts/tests/helpers.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { buildProgram } from "../src/cli.js"; + +type RunCliOptions = { + args: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; +}; + +class MemoryWriter { + private readonly chunks: string[] = []; + + write(chunk: string | Uint8Array) { + this.chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + } + + toString() { + return this.chunks.join(""); + } +} + +export async function runCli(options: RunCliOptions) { + const stdout = new MemoryWriter(); + const stderr = new MemoryWriter(); + const program = buildProgram({ + cwd: options.cwd, + env: options.env, + fetchImpl: options.fetchImpl, + stdout, + stderr, + }); + + await program.parseAsync(options.args, { from: "user" }); + + return { + stdout: stdout.toString(), + stderr: stderr.toString(), + }; +} + +export function createTempWorkspace() { + const cwd = mkdtempSync(path.join(tmpdir(), "atlassian-skill-")); + + return { + cwd, + cleanup() { + rmSync(cwd, { recursive: true, force: true }); + }, + write(relativePath: string, contents: string) { + const target = path.join(cwd, relativePath); + writeFileSync(target, contents, "utf8"); + return target; + }, + }; +} + +export function jsonResponse(payload: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(payload), { + status: init?.status ?? 200, + statusText: init?.statusText, + headers: { + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); +} diff --git a/skills/atlassian/shared/scripts/tests/jira.test.ts b/skills/atlassian/shared/scripts/tests/jira.test.ts new file mode 100644 index 0000000..e14d643 --- /dev/null +++ b/skills/atlassian/shared/scripts/tests/jira.test.ts @@ -0,0 +1,321 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { markdownToAdf } from "../src/adf.js"; +import { createTempWorkspace, jsonResponse, runCli } from "./helpers.js"; + +const baseEnv = { + ATLASSIAN_BASE_URL: "https://example.atlassian.net", + ATLASSIAN_EMAIL: "dev@example.com", + ATLASSIAN_API_TOKEN: "secret-token", +}; + +test("jira-search emits normalized results and uses pagination inputs", async () => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ url, init }); + + return jsonResponse({ + startAt: 10, + maxResults: 2, + total: 25, + issues: [ + { + key: "ENG-1", + fields: { + summary: "Add Jira search command", + issuetype: { name: "Story" }, + status: { name: "In Progress" }, + assignee: { displayName: "Ada Lovelace" }, + created: "2026-03-01T00:00:00.000Z", + updated: "2026-03-02T00:00:00.000Z", + }, + }, + ], + }); + }; + + const result = await runCli({ + args: ["jira-search", "--jql", "project = ENG", "--max-results", "2", "--start-at", "10"], + env: baseEnv, + fetchImpl, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.url, "https://example.atlassian.net/rest/api/3/search"); + assert.equal(calls[0]?.init?.method, "POST"); + assert.match(String(calls[0]?.init?.headers), /Authorization/); + assert.deepEqual(JSON.parse(String(calls[0]?.init?.body)), { + jql: "project = ENG", + maxResults: 2, + startAt: 10, + fields: ["summary", "issuetype", "status", "assignee", "created", "updated"], + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + data: { + issues: [ + { + key: "ENG-1", + summary: "Add Jira search command", + issueType: "Story", + status: "In Progress", + assignee: "Ada Lovelace", + created: "2026-03-01T00:00:00.000Z", + updated: "2026-03-02T00:00:00.000Z", + url: "https://example.atlassian.net/browse/ENG-1", + }, + ], + startAt: 10, + maxResults: 2, + total: 25, + }, + }); +}); + +test("jira-get returns normalized fields plus the raw Jira payload", async () => { + const rawIssue = { + key: "ENG-42", + fields: { + summary: "Ship v1", + issuetype: { name: "Task" }, + status: { name: "Done" }, + assignee: { displayName: "Grace Hopper" }, + created: "2026-03-03T00:00:00.000Z", + updated: "2026-03-04T00:00:00.000Z", + }, + }; + + const fetchImpl: typeof fetch = async () => jsonResponse(rawIssue); + + const result = await runCli({ + args: ["jira-get", "--issue", "ENG-42"], + env: baseEnv, + fetchImpl, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + data: { + issue: { + key: "ENG-42", + summary: "Ship v1", + issueType: "Task", + status: "Done", + assignee: "Grace Hopper", + created: "2026-03-03T00:00:00.000Z", + updated: "2026-03-04T00:00:00.000Z", + url: "https://example.atlassian.net/browse/ENG-42", + }, + }, + raw: rawIssue, + }); +}); + +test("markdownToAdf converts headings, paragraphs, and bullet lists", () => { + assert.deepEqual(markdownToAdf("# Summary\n\nBuild the Jira skill.\n\n- Search\n- Comment"), { + type: "doc", + version: 1, + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Summary" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Build the Jira skill." }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Search" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Comment" }], + }, + ], + }, + ], + }, + ], + }); +}); + +test("jira-create dry-run emits an ADF request body without calling Jira", async () => { + const workspace = createTempWorkspace(); + + try { + workspace.write("description.md", "# New story\n\n- one\n- two"); + let called = false; + + const result = await runCli({ + args: [ + "jira-create", + "--type", + "Story", + "--summary", + "Create the Atlassian skill", + "--description-file", + "description.md", + "--dry-run", + ], + cwd: workspace.cwd, + env: { + ...baseEnv, + ATLASSIAN_DEFAULT_PROJECT: "ENG", + }, + fetchImpl: async () => { + called = true; + return jsonResponse({}); + }, + }); + + assert.equal(called, false); + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + dryRun: true, + data: { + method: "POST", + url: "https://example.atlassian.net/rest/api/3/issue", + body: { + fields: { + project: { key: "ENG" }, + issuetype: { name: "Story" }, + summary: "Create the Atlassian skill", + description: markdownToAdf("# New story\n\n- one\n- two"), + }, + }, + }, + }); + } finally { + workspace.cleanup(); + } +}); + +test("jira-update, jira-comment, and jira-transition dry-runs build the expected Jira requests", async () => { + const workspace = createTempWorkspace(); + + try { + workspace.write("issue.md", "Updated description"); + workspace.write("comment.md", "Comment body"); + + const update = await runCli({ + args: [ + "jira-update", + "--issue", + "ENG-9", + "--summary", + "Updated summary", + "--description-file", + "issue.md", + "--dry-run", + ], + cwd: workspace.cwd, + env: baseEnv, + }); + + const comment = await runCli({ + args: ["jira-comment", "--issue", "ENG-9", "--body-file", "comment.md", "--dry-run"], + cwd: workspace.cwd, + env: baseEnv, + }); + + const transition = await runCli({ + args: ["jira-transition", "--issue", "ENG-9", "--transition", "31", "--dry-run"], + cwd: workspace.cwd, + env: baseEnv, + }); + + assert.deepEqual(JSON.parse(update.stdout), { + ok: true, + dryRun: true, + data: { + method: "PUT", + url: "https://example.atlassian.net/rest/api/3/issue/ENG-9", + body: { + fields: { + summary: "Updated summary", + description: markdownToAdf("Updated description"), + }, + }, + }, + }); + + assert.deepEqual(JSON.parse(comment.stdout), { + ok: true, + dryRun: true, + data: { + method: "POST", + url: "https://example.atlassian.net/rest/api/3/issue/ENG-9/comment", + body: { + body: markdownToAdf("Comment body"), + }, + }, + }); + + assert.deepEqual(JSON.parse(transition.stdout), { + ok: true, + dryRun: true, + data: { + method: "POST", + url: "https://example.atlassian.net/rest/api/3/issue/ENG-9/transitions", + body: { + transition: { + id: "31", + }, + }, + }, + }); + } finally { + workspace.cleanup(); + } +}); + +test("jira-transitions returns normalized transition options", async () => { + const fetchImpl: typeof fetch = async () => + jsonResponse({ + transitions: [ + { + id: "21", + name: "Start Progress", + to: { name: "In Progress" }, + hasScreen: false, + }, + ], + }); + + const result = await runCli({ + args: ["jira-transitions", "--issue", "ENG-9"], + env: baseEnv, + fetchImpl, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + data: { + transitions: [ + { + id: "21", + name: "Start Progress", + toStatus: "In Progress", + hasScreen: false, + }, + ], + }, + }); +});