feat(atlassian): implement milestone M3 - confluence and safety controls
This commit is contained in:
@@ -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<typeof loadConfig> | undefined;
|
||||
let jiraCache: ReturnType<typeof createJiraClient> | undefined;
|
||||
let confluenceCache: ReturnType<typeof createConfluenceClient> | 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 <query>", "CQL search query")
|
||||
.option("--max-results <number>", "Maximum results to return", "50")
|
||||
.option("--start-at <number>", "Result offset", "0")
|
||||
.option("--format <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 <page>", "Confluence page ID")
|
||||
.option("--format <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 <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")
|
||||
|
||||
292
skills/atlassian/shared/scripts/src/confluence.ts
Normal file
292
skills/atlassian/shared/scripts/src/confluence.ts
Normal file
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
65
skills/atlassian/shared/scripts/src/http.ts
Normal file
65
skills/atlassian/shared/scripts/src/http.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
85
skills/atlassian/shared/scripts/src/raw.ts
Normal file
85
skills/atlassian/shared/scripts/src/raw.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
351
skills/atlassian/shared/scripts/tests/confluence.test.ts
Normal file
351
skills/atlassian/shared/scripts/tests/confluence.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
129
skills/atlassian/shared/scripts/tests/raw.test.ts
Normal file
129
skills/atlassian/shared/scripts/tests/raw.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user