feat(atlassian): implement milestone M3 - confluence and safety controls
This commit is contained in:
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