feat(atlassian): implement milestone M2 - jira command surface
This commit is contained in:
38
skills/atlassian/shared/scripts/tests/config.test.ts
Normal file
38
skills/atlassian/shared/scripts/tests/config.test.ts
Normal file
@@ -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")}`,
|
||||
);
|
||||
});
|
||||
71
skills/atlassian/shared/scripts/tests/helpers.ts
Normal file
71
skills/atlassian/shared/scripts/tests/helpers.ts
Normal file
@@ -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 ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
321
skills/atlassian/shared/scripts/tests/jira.test.ts
Normal file
321
skills/atlassian/shared/scripts/tests/jira.test.ts
Normal file
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user