From 972789c240af5395c842fee84544ad6d7b995678 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Fri, 6 Mar 2026 00:53:13 -0600 Subject: [PATCH] feat(atlassian): implement milestone M3 - confluence and safety controls --- skills/atlassian/shared/scripts/src/cli.ts | 127 +++++++ .../shared/scripts/src/confluence.ts | 292 +++++++++++++++ skills/atlassian/shared/scripts/src/http.ts | 65 ++++ skills/atlassian/shared/scripts/src/jira.ts | 44 +-- skills/atlassian/shared/scripts/src/raw.ts | 85 +++++ .../shared/scripts/tests/confluence.test.ts | 351 ++++++++++++++++++ .../atlassian/shared/scripts/tests/helpers.ts | 3 +- .../shared/scripts/tests/raw.test.ts | 129 +++++++ 8 files changed, 1058 insertions(+), 38 deletions(-) create mode 100644 skills/atlassian/shared/scripts/src/confluence.ts create mode 100644 skills/atlassian/shared/scripts/src/http.ts create mode 100644 skills/atlassian/shared/scripts/src/raw.ts create mode 100644 skills/atlassian/shared/scripts/tests/confluence.test.ts create mode 100644 skills/atlassian/shared/scripts/tests/raw.test.ts diff --git a/skills/atlassian/shared/scripts/src/cli.ts b/skills/atlassian/shared/scripts/src/cli.ts index 9784800..4df932b 100644 --- a/skills/atlassian/shared/scripts/src/cli.ts +++ b/skills/atlassian/shared/scripts/src/cli.ts @@ -3,10 +3,12 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; +import { runRawCommand } from "./raw.js"; import type { FetchLike, OutputFormat, Writer } from "./types.js"; type CliContext = { @@ -28,6 +30,7 @@ function createRuntime(context: CliContext) { const stderr = context.stderr ?? process.stderr; let configCache: ReturnType | undefined; let jiraCache: ReturnType | undefined; + let confluenceCache: ReturnType | undefined; function getConfig() { configCache ??= loadConfig(env, { cwd }); @@ -42,6 +45,14 @@ function createRuntime(context: CliContext) { return jiraCache; } + function getConfluenceClient() { + confluenceCache ??= createConfluenceClient({ + config: getConfig(), + fetchImpl: context.fetchImpl, + }); + return confluenceCache; + } + async function readBodyFile(filePath: string | undefined) { if (!filePath) { return undefined; @@ -57,6 +68,8 @@ function createRuntime(context: CliContext) { readBodyFile, getConfig, getJiraClient, + getConfluenceClient, + fetchImpl: context.fetchImpl, }; } @@ -88,6 +101,120 @@ export function buildProgram(context: CliContext = {}) { ); }); + program + .command("conf-search") + .requiredOption("--query ", "CQL search query") + .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.getConfluenceClient().searchPages({ + query: options.query, + maxResults: Number(options.maxResults), + startAt: Number(options.startAt), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-get") + .requiredOption("--page ", "Confluence page ID") + .option("--format ", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().getPage(options.page); + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-create") + .requiredOption("--title ", "Confluence page title") + .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") + .option("--space <space>", "Confluence space ID") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().createPage({ + space: options.space, + title: options.title, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-update") + .requiredOption("--page <page>", "Confluence page ID") + .requiredOption("--title <title>", "Confluence page title") + .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().updatePage({ + pageId: options.page, + title: options.title, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-comment") + .requiredOption("--page <page>", "Confluence page ID") + .requiredOption("--body-file <path>", "Workspace-relative storage-format body file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().commentPage({ + pageId: options.page, + body: (await runtime.readBodyFile(options.bodyFile)) as string, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("conf-children") + .requiredOption("--page <page>", "Confluence page ID") + .option("--max-results <number>", "Maximum results to return", "50") + .option("--start-at <number>", "Cursor/start token", "0") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runtime.getConfluenceClient().listChildren( + options.page, + Number(options.maxResults), + Number(options.startAt), + ); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + + program + .command("raw") + .requiredOption("--product <product>", "jira or confluence") + .requiredOption("--method <method>", "GET, POST, or PUT") + .requiredOption("--path <path>", "Validated API path") + .option("--body-file <path>", "Workspace-relative JSON file") + .option("--dry-run", "Print the request without sending it") + .option("--format <format>", "Output format", "json") + .action(async (options) => { + const payload = await runRawCommand(runtime.getConfig(), runtime.fetchImpl, { + product: options.product, + method: String(options.method).toUpperCase(), + path: options.path, + bodyFile: options.bodyFile, + cwd: runtime.cwd, + dryRun: Boolean(options.dryRun), + }); + + writeOutput(runtime.stdout, payload, resolveFormat(options.format)); + }); + program .command("jira-search") .requiredOption("--jql <jql>", "JQL expression to execute") diff --git a/skills/atlassian/shared/scripts/src/confluence.ts b/skills/atlassian/shared/scripts/src/confluence.ts new file mode 100644 index 0000000..f22d66d --- /dev/null +++ b/skills/atlassian/shared/scripts/src/confluence.ts @@ -0,0 +1,292 @@ +import { sendJsonRequest } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ConfluenceClientOptions = { + config: AtlassianConfig; + fetchImpl?: FetchLike; +}; + +type SearchInput = { + query: string; + maxResults: number; + startAt: number; +}; + +type CreateInput = { + space?: string; + title: string; + body: string; + dryRun?: boolean; +}; + +type UpdateInput = { + pageId: string; + title: string; + body: string; + dryRun?: boolean; +}; + +type CommentInput = { + pageId: string; + body: string; + dryRun?: boolean; +}; + +type PageSummary = { + id: string; + title: string; + type: string; + status?: string; + spaceId?: string; + url?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +function normalizePage(baseUrl: string, page: Record<string, unknown>, excerpt?: string) { + const links = (page._links ?? {}) as Record<string, unknown>; + + return { + id: String(page.id ?? ""), + title: String(page.title ?? ""), + type: String(page.type ?? "page"), + ...(page.status ? { status: String(page.status) } : {}), + ...(page.spaceId ? { spaceId: String(page.spaceId) } : {}), + ...(excerpt ? { excerpt } : {}), + ...(links.webui ? { url: `${baseUrl}${String(links.webui)}` } : {}), + }; +} + +export function createConfluenceClient(options: ConfluenceClientOptions) { + const config = options.config; + + async function getPageForUpdate(pageId: string) { + return (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + } + + return { + async searchPages(input: SearchInput): Promise<CommandOutput<unknown>> { + const url = new URL("/wiki/rest/api/search", `${config.confluenceBaseUrl}/`); + url.searchParams.set("cql", input.query); + url.searchParams.set("limit", String(input.maxResults)); + url.searchParams.set("start", String(input.startAt)); + + const raw = (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: url.toString(), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + + const results = Array.isArray(raw.results) ? raw.results : []; + + return { + ok: true, + data: { + pages: results.map((entry) => { + const result = entry as Record<string, unknown>; + return normalizePage( + config.baseUrl, + (result.content ?? {}) as Record<string, unknown>, + result.excerpt ? String(result.excerpt) : undefined, + ); + }), + startAt: Number(raw.start ?? input.startAt), + maxResults: Number(raw.limit ?? input.maxResults), + total: Number(raw.totalSize ?? raw.size ?? results.length), + }, + }; + }, + + async getPage(pageId: string): Promise<CommandOutput<unknown>> { + const raw = (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + + const body = ((raw.body ?? {}) as Record<string, unknown>).storage as Record<string, unknown> | undefined; + + return { + ok: true, + data: { + page: { + ...normalizePage(config.baseUrl, raw), + version: Number((((raw.version ?? {}) as Record<string, unknown>).number ?? 0)), + body: body?.value ? String(body.value) : "", + }, + }, + raw, + }; + }, + + async listChildren(pageId: string, maxResults: number, startAt: number): Promise<CommandOutput<unknown>> { + const url = new URL(`/wiki/api/v2/pages/${pageId}/direct-children`, `${config.confluenceBaseUrl}/`); + url.searchParams.set("limit", String(maxResults)); + url.searchParams.set("cursor", String(startAt)); + + const raw = (await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: url.toString(), + method: "GET", + errorPrefix: "Confluence request failed", + })) as Record<string, unknown>; + + const results = Array.isArray(raw.results) ? raw.results : []; + const links = (raw._links ?? {}) as Record<string, unknown>; + + return { + ok: true, + data: { + pages: results.map((page) => normalizePage(config.baseUrl, page as Record<string, unknown>)), + nextCursor: links.next ? String(links.next) : null, + }, + }; + }, + + async createPage(input: CreateInput): Promise<CommandOutput<unknown>> { + const spaceId = input.space || config.defaultSpace; + + if (!spaceId) { + throw new Error("conf-create requires --space or ATLASSIAN_DEFAULT_SPACE"); + } + + const request = { + method: "POST" as const, + url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/pages"), + body: { + spaceId, + title: input.title, + status: "current", + body: { + representation: "storage", + value: input.body, + }, + }, + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: request.url, + method: request.method, + body: request.body, + errorPrefix: "Confluence request failed", + }); + + return { + ok: true, + data: raw, + }; + }, + + async updatePage(input: UpdateInput): Promise<CommandOutput<unknown>> { + const currentPage = await getPageForUpdate(input.pageId); + const version = (((currentPage.version ?? {}) as Record<string, unknown>).number ?? 0) as number; + const spaceId = String(currentPage.spaceId ?? ""); + + const request = { + method: "PUT" as const, + url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${input.pageId}`), + body: { + id: input.pageId, + status: String(currentPage.status ?? "current"), + title: input.title, + spaceId, + version: { + number: Number(version) + 1, + }, + body: { + representation: "storage", + value: input.body, + }, + }, + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: request.url, + method: request.method, + body: request.body, + errorPrefix: "Confluence request failed", + handleResponseError(response) { + if (response.status === 409) { + return new Error(`Confluence update conflict: page ${input.pageId} was updated by someone else`); + } + + return undefined; + }, + }); + + return { + ok: true, + data: raw, + }; + }, + + async commentPage(input: CommentInput): Promise<CommandOutput<unknown>> { + const request = { + method: "POST" as const, + url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/footer-comments"), + body: { + pageId: input.pageId, + body: { + representation: "storage", + value: input.body, + }, + }, + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const raw = await sendJsonRequest({ + config, + fetchImpl: options.fetchImpl, + url: request.url, + method: request.method, + body: request.body, + errorPrefix: "Confluence request failed", + }); + + return { + ok: true, + data: raw, + }; + }, + }; +} diff --git a/skills/atlassian/shared/scripts/src/http.ts b/skills/atlassian/shared/scripts/src/http.ts new file mode 100644 index 0000000..2ee6fc7 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/http.ts @@ -0,0 +1,65 @@ +import { createBasicAuthHeader } from "./config.js"; +import type { AtlassianConfig, FetchLike } from "./types.js"; + +export type HttpMethod = "GET" | "POST" | "PUT"; + +export function createJsonHeaders(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; +} + +export 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(); +} + +export async function sendJsonRequest(options: { + config: AtlassianConfig; + fetchImpl?: FetchLike; + url: string; + method: HttpMethod; + body?: unknown; + errorPrefix: string; + handleResponseError?: (response: Response) => Error | undefined; +}) { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + + if (!fetchImpl) { + throw new Error("Fetch API is not available in this runtime"); + } + + const response = await fetchImpl(options.url, { + method: options.method, + headers: createJsonHeaders(options.config, options.body !== undefined), + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + }); + + if (!response.ok) { + const customError = options.handleResponseError?.(response); + + if (customError) { + throw customError; + } + + throw new Error(`${options.errorPrefix}: ${response.status} ${response.statusText}`); + } + + return parseResponse(response); +} diff --git a/skills/atlassian/shared/scripts/src/jira.ts b/skills/atlassian/shared/scripts/src/jira.ts index aaec3f5..5cf3a6e 100644 --- a/skills/atlassian/shared/scripts/src/jira.ts +++ b/skills/atlassian/shared/scripts/src/jira.ts @@ -1,5 +1,5 @@ import { markdownToAdf } from "./adf.js"; -import { createBasicAuthHeader } from "./config.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; @@ -60,33 +60,6 @@ function normalizeIssue(config: AtlassianConfig, issue: Record<string, unknown>) }; } -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}/`); @@ -106,17 +79,14 @@ export function createJiraClient(options: JiraClientOptions) { 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, { + return sendJsonRequest({ + config: options.config, + fetchImpl, + url: request.url, method, - headers: createHeaders(options.config, body !== undefined), - ...(body === undefined ? {} : { body: JSON.stringify(body) }), + body, + errorPrefix: "Jira request failed", }); - - if (!response.ok) { - throw new Error(`Jira request failed: ${response.status} ${response.statusText}`); - } - - return parseResponse(response); } return { diff --git a/skills/atlassian/shared/scripts/src/raw.ts b/skills/atlassian/shared/scripts/src/raw.ts new file mode 100644 index 0000000..8e11793 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/raw.ts @@ -0,0 +1,85 @@ +import { readWorkspaceFile } from "./files.js"; +import { sendJsonRequest } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +const JIRA_ALLOWED_PREFIXES = ["/rest/api/3/"] as const; +const CONFLUENCE_ALLOWED_PREFIXES = ["/wiki/api/v2/", "/wiki/rest/api/"] as const; + +type RawInput = { + product: "jira" | "confluence"; + method: string; + path: string; + bodyFile?: string; + cwd: string; + dryRun?: boolean; +}; + +function getAllowedPrefixes(product: RawInput["product"]) { + return product === "jira" ? JIRA_ALLOWED_PREFIXES : CONFLUENCE_ALLOWED_PREFIXES; +} + +function buildUrl(config: AtlassianConfig, product: RawInput["product"], path: string) { + const baseUrl = product === "jira" ? config.jiraBaseUrl : config.confluenceBaseUrl; + return new URL(path, `${baseUrl}/`).toString(); +} + +function validateMethod(method: string): asserts method is "GET" | "POST" | "PUT" { + if (!["GET", "POST", "PUT"].includes(method)) { + throw new Error("raw only allows GET, POST, and PUT"); + } +} + +function validatePath(product: RawInput["product"], path: string) { + const allowedPrefixes = getAllowedPrefixes(product); + + if (!allowedPrefixes.some((prefix) => path.startsWith(prefix))) { + throw new Error(`raw path is not allowed for ${product}: ${path}`); + } +} + +async function readRawBody(bodyFile: string | undefined, cwd: string) { + if (!bodyFile) { + return undefined; + } + + const contents = await readWorkspaceFile(bodyFile, cwd); + return JSON.parse(contents) as unknown; +} + +export async function runRawCommand( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, + input: RawInput, +): Promise<CommandOutput<unknown>> { + validateMethod(input.method); + validatePath(input.product, input.path); + + const body = await readRawBody(input.bodyFile, input.cwd); + const request = { + method: input.method, + url: buildUrl(config, input.product, input.path), + ...(body === undefined ? {} : { body }), + }; + + if (input.dryRun) { + return { + ok: true, + dryRun: true, + data: request, + }; + } + + const data = await sendJsonRequest({ + config, + fetchImpl, + url: request.url, + method: input.method, + body, + errorPrefix: "Raw request failed", + }); + + return { + ok: true, + data, + }; +} diff --git a/skills/atlassian/shared/scripts/tests/confluence.test.ts b/skills/atlassian/shared/scripts/tests/confluence.test.ts new file mode 100644 index 0000000..11ab554 --- /dev/null +++ b/skills/atlassian/shared/scripts/tests/confluence.test.ts @@ -0,0 +1,351 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +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("conf-search uses CQL search and normalizes page results", async () => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + calls.push({ url: typeof input === "string" ? input : input.toString(), init }); + + return jsonResponse({ + results: [ + { + content: { + id: "123", + type: "page", + title: "Runbook", + _links: { webui: "/spaces/OPS/pages/123/Runbook" }, + }, + excerpt: "Operational runbook", + }, + ], + start: 5, + limit: 1, + size: 1, + totalSize: 7, + }); + }; + + const result = await runCli({ + args: ["conf-search", "--query", "title ~ \"Runbook\"", "--max-results", "1", "--start-at", "5"], + env: baseEnv, + fetchImpl, + }); + + assert.equal(calls.length, 1); + assert.equal( + calls[0]?.url, + "https://example.atlassian.net/wiki/rest/api/search?cql=title+%7E+%22Runbook%22&limit=1&start=5", + ); + assert.equal(calls[0]?.init?.method, "GET"); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + data: { + pages: [ + { + id: "123", + title: "Runbook", + type: "page", + excerpt: "Operational runbook", + url: "https://example.atlassian.net/spaces/OPS/pages/123/Runbook", + }, + ], + startAt: 5, + maxResults: 1, + total: 7, + }, + }); +}); + +test("conf-get returns normalized page details plus raw payload", async () => { + const rawPage = { + id: "123", + status: "current", + title: "Runbook", + spaceId: "OPS", + version: { number: 4 }, + body: { + storage: { + value: "<p>Runbook</p>", + representation: "storage", + }, + }, + _links: { + webui: "/spaces/OPS/pages/123/Runbook", + }, + }; + + const result = await runCli({ + args: ["conf-get", "--page", "123"], + env: baseEnv, + fetchImpl: async () => jsonResponse(rawPage), + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + data: { + page: { + id: "123", + title: "Runbook", + type: "page", + status: "current", + spaceId: "OPS", + version: 4, + body: "<p>Runbook</p>", + url: "https://example.atlassian.net/spaces/OPS/pages/123/Runbook", + }, + }, + raw: rawPage, + }); +}); + +test("conf-children returns normalized direct children with pagination", async () => { + const result = await runCli({ + args: ["conf-children", "--page", "123", "--max-results", "2", "--start-at", "1"], + env: baseEnv, + fetchImpl: async (input) => { + const url = typeof input === "string" ? input : input.toString(); + assert.equal( + url, + "https://example.atlassian.net/wiki/api/v2/pages/123/direct-children?limit=2&cursor=1", + ); + + return jsonResponse({ + results: [ + { + id: "124", + title: "Child page", + type: "page", + status: "current", + spaceId: "OPS", + _links: { webui: "/spaces/OPS/pages/124/Child+page" }, + }, + ], + }); + }, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + data: { + pages: [ + { + id: "124", + title: "Child page", + type: "page", + status: "current", + spaceId: "OPS", + url: "https://example.atlassian.net/spaces/OPS/pages/124/Child+page", + }, + ], + nextCursor: null, + }, + }); +}); + +test("conf-create dry-run emits a storage-format request body", async () => { + const workspace = createTempWorkspace(); + + try { + workspace.write("page.storage.html", "<p>Runbook</p>"); + + const result = await runCli({ + args: [ + "conf-create", + "--title", + "Runbook", + "--body-file", + "page.storage.html", + "--dry-run", + ], + cwd: workspace.cwd, + env: { + ...baseEnv, + ATLASSIAN_DEFAULT_SPACE: "OPS", + }, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + dryRun: true, + data: { + method: "POST", + url: "https://example.atlassian.net/wiki/api/v2/pages", + body: { + spaceId: "OPS", + title: "Runbook", + status: "current", + body: { + representation: "storage", + value: "<p>Runbook</p>", + }, + }, + }, + }); + } finally { + workspace.cleanup(); + } +}); + +test("conf-update fetches the current page version and increments it for dry-run", async () => { + const workspace = createTempWorkspace(); + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + + try { + workspace.write("page.storage.html", "<p>Updated</p>"); + + const result = await runCli({ + args: [ + "conf-update", + "--page", + "123", + "--title", + "Runbook", + "--body-file", + "page.storage.html", + "--dry-run", + ], + cwd: workspace.cwd, + env: baseEnv, + fetchImpl: async (input, init) => { + calls.push({ url: typeof input === "string" ? input : input.toString(), init }); + return jsonResponse({ + id: "123", + spaceId: "OPS", + status: "current", + title: "Runbook", + version: { number: 4 }, + body: { + storage: { + value: "<p>Old</p>", + representation: "storage", + }, + }, + }); + }, + }); + + assert.equal(calls.length, 1); + assert.equal( + calls[0]?.url, + "https://example.atlassian.net/wiki/api/v2/pages/123?body-format=storage", + ); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + dryRun: true, + data: { + method: "PUT", + url: "https://example.atlassian.net/wiki/api/v2/pages/123", + body: { + id: "123", + status: "current", + title: "Runbook", + spaceId: "OPS", + version: { + number: 5, + }, + body: { + representation: "storage", + value: "<p>Updated</p>", + }, + }, + }, + }); + } finally { + workspace.cleanup(); + } +}); + +test("conf-comment dry-run targets footer comments", async () => { + const workspace = createTempWorkspace(); + + try { + workspace.write("comment.storage.html", "<p>Looks good</p>"); + + const result = await runCli({ + args: ["conf-comment", "--page", "123", "--body-file", "comment.storage.html", "--dry-run"], + cwd: workspace.cwd, + env: baseEnv, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + dryRun: true, + data: { + method: "POST", + url: "https://example.atlassian.net/wiki/api/v2/footer-comments", + body: { + pageId: "123", + body: { + representation: "storage", + value: "<p>Looks good</p>", + }, + }, + }, + }); + } finally { + workspace.cleanup(); + } +}); + +test("conf-update surfaces version conflicts clearly", async () => { + const workspace = createTempWorkspace(); + + try { + workspace.write("page.storage.html", "<p>Updated</p>"); + + await assert.rejects( + runCli({ + args: [ + "conf-update", + "--page", + "123", + "--title", + "Runbook", + "--body-file", + "page.storage.html", + ], + cwd: workspace.cwd, + env: baseEnv, + fetchImpl: async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + + if (url.endsWith("?body-format=storage")) { + return jsonResponse({ + id: "123", + spaceId: "OPS", + status: "current", + title: "Runbook", + version: { number: 4 }, + body: { + storage: { + value: "<p>Old</p>", + representation: "storage", + }, + }, + }); + } + + assert.equal(init?.method, "PUT"); + return new Response(JSON.stringify({ message: "Conflict" }), { + status: 409, + statusText: "Conflict", + headers: { "content-type": "application/json" }, + }); + }, + }), + /Confluence update conflict: page 123 was updated by someone else/, + ); + } finally { + workspace.cleanup(); + } +}); diff --git a/skills/atlassian/shared/scripts/tests/helpers.ts b/skills/atlassian/shared/scripts/tests/helpers.ts index dc04d1b..5939f83 100644 --- a/skills/atlassian/shared/scripts/tests/helpers.ts +++ b/skills/atlassian/shared/scripts/tests/helpers.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -53,6 +53,7 @@ export function createTempWorkspace() { }, write(relativePath: string, contents: string) { const target = path.join(cwd, relativePath); + mkdirSync(path.dirname(target), { recursive: true }); writeFileSync(target, contents, "utf8"); return target; }, diff --git a/skills/atlassian/shared/scripts/tests/raw.test.ts b/skills/atlassian/shared/scripts/tests/raw.test.ts new file mode 100644 index 0000000..290ff26 --- /dev/null +++ b/skills/atlassian/shared/scripts/tests/raw.test.ts @@ -0,0 +1,129 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +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("raw rejects DELETE requests", async () => { + await assert.rejects( + runCli({ + args: ["raw", "--product", "jira", "--method", "DELETE", "--path", "/rest/api/3/issue/ENG-1"], + env: baseEnv, + }), + /raw only allows GET, POST, and PUT/, + ); +}); + +test("raw rejects unsupported Jira and Confluence prefixes", async () => { + await assert.rejects( + runCli({ + args: ["raw", "--product", "jira", "--method", "GET", "--path", "/wiki/api/v2/pages"], + env: baseEnv, + }), + /raw path is not allowed for jira/, + ); + + await assert.rejects( + runCli({ + args: ["raw", "--product", "confluence", "--method", "GET", "--path", "/rest/api/3/issue/ENG-1"], + env: baseEnv, + }), + /raw path is not allowed for confluence/, + ); +}); + +test("raw GET executes against the validated Jira endpoint", async () => { + let call: { url: string; init: RequestInit | undefined } | undefined; + + const result = await runCli({ + args: ["raw", "--product", "jira", "--method", "GET", "--path", "/rest/api/3/issue/ENG-1"], + env: baseEnv, + fetchImpl: async (input, init) => { + call = { url: typeof input === "string" ? input : input.toString(), init }; + return jsonResponse({ id: "10001", key: "ENG-1" }); + }, + }); + + assert.deepEqual(call, { + url: "https://example.atlassian.net/rest/api/3/issue/ENG-1", + init: { + method: "GET", + headers: [ + ["Accept", "application/json"], + ["Authorization", `Basic ${Buffer.from("dev@example.com:secret-token").toString("base64")}`], + ], + }, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + data: { + id: "10001", + key: "ENG-1", + }, + }); +}); + +test("raw POST dry-run reads only workspace-scoped body files", async () => { + const workspace = createTempWorkspace(); + + try { + workspace.write("payload.json", "{\"title\":\"Runbook\"}"); + + const result = await runCli({ + args: [ + "raw", + "--product", + "confluence", + "--method", + "POST", + "--path", + "/wiki/api/v2/pages", + "--body-file", + "payload.json", + "--dry-run", + ], + cwd: workspace.cwd, + env: baseEnv, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: true, + dryRun: true, + data: { + method: "POST", + url: "https://example.atlassian.net/wiki/api/v2/pages", + body: { + title: "Runbook", + }, + }, + }); + + await assert.rejects( + runCli({ + args: [ + "raw", + "--product", + "confluence", + "--method", + "POST", + "--path", + "/wiki/api/v2/pages", + "--body-file", + "../outside.json", + "--dry-run", + ], + cwd: workspace.cwd, + env: baseEnv, + }), + /--body-file must stay within the active workspace/, + ); + } finally { + workspace.cleanup(); + } +});