feat(atlassian): implement milestone M2 - jira command surface

This commit is contained in:
Stefano Fiorini
2026-03-06 00:39:54 -06:00
parent e56f0c9941
commit 73c4bff901
10 changed files with 1167 additions and 17 deletions

View File

@@ -0,0 +1,92 @@
const TEXT_NODE = "text";
function textNode(text: string) {
return {
type: TEXT_NODE,
text,
};
}
function paragraphNode(lines: string[]) {
const content: Array<{ type: string; text?: string }> = [];
lines.forEach((line, index) => {
if (index > 0) {
content.push({ type: "hardBreak" });
}
if (line.length > 0) {
content.push(textNode(line));
}
});
return {
type: "paragraph",
...(content.length > 0 ? { content } : {}),
};
}
export function markdownToAdf(input: string) {
const lines = input.replace(/\r\n/g, "\n").split("\n");
const content: Array<Record<string, unknown>> = [];
let index = 0;
while (index < lines.length) {
const current = lines[index]?.trimEnd() ?? "";
if (current.trim().length === 0) {
index += 1;
continue;
}
const heading = current.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
content.push({
type: "heading",
attrs: { level: heading[1].length },
content: [textNode(heading[2])],
});
index += 1;
continue;
}
if (/^[-*]\s+/.test(current)) {
const items: Array<Record<string, unknown>> = [];
while (index < lines.length && /^[-*]\s+/.test(lines[index] ?? "")) {
items.push({
type: "listItem",
content: [
{
type: "paragraph",
content: [textNode((lines[index] ?? "").replace(/^[-*]\s+/, ""))],
},
],
});
index += 1;
}
content.push({
type: "bulletList",
content: items,
});
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length && (lines[index]?.trim().length ?? 0) > 0) {
paragraphLines.push(lines[index] ?? "");
index += 1;
}
content.push(paragraphNode(paragraphLines));
}
return {
type: "doc",
version: 1,
content,
};
}

View File

@@ -1,22 +1,212 @@
import process from "node:process";
import { pathToFileURL } from "node:url";
import { Command } from "commander";
const program = new Command()
.name("atlassian")
.description("Portable Atlassian CLI for multi-agent skills")
.version("0.1.0");
import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js";
import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js";
program
.command("health")
.description("Validate configuration and Atlassian connectivity")
.option("--format <format>", "Output format", "json")
.action((options) => {
const payload = {
ok: false,
message: "health is not implemented yet",
format: options.format,
};
type CliContext = {
cwd?: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: FetchLike;
stdout?: Writer;
stderr?: Writer;
};
console.log(JSON.stringify(payload, null, 2));
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env;
const stdout = context.stdout ?? process.stdout;
const stderr = context.stderr ?? process.stderr;
let configCache: ReturnType<typeof loadConfig> | undefined;
let jiraCache: ReturnType<typeof createJiraClient> | undefined;
function getConfig() {
configCache ??= loadConfig(env, { cwd });
return configCache;
}
function getJiraClient() {
jiraCache ??= createJiraClient({
config: getConfig(),
fetchImpl: context.fetchImpl,
});
return jiraCache;
}
async function readBodyFile(filePath: string | undefined) {
if (!filePath) {
return undefined;
}
return readWorkspaceFile(filePath, cwd);
}
return {
cwd,
stdout,
stderr,
readBodyFile,
getConfig,
getJiraClient,
};
}
export function buildProgram(context: CliContext = {}) {
const runtime = createRuntime(context);
const program = new Command()
.name("atlassian")
.description("Portable Atlassian CLI for multi-agent skills")
.version("0.1.0");
program
.command("health")
.description("Validate configuration and Atlassian connectivity")
.option("--format <format>", "Output format", "json")
.action((options) => {
writeOutput(
runtime.stdout,
{
ok: true,
data: {
baseUrl: runtime.getConfig().baseUrl,
jiraBaseUrl: runtime.getConfig().jiraBaseUrl,
confluenceBaseUrl: runtime.getConfig().confluenceBaseUrl,
defaultProject: runtime.getConfig().defaultProject,
defaultSpace: runtime.getConfig().defaultSpace,
},
},
resolveFormat(options.format),
);
});
program
.command("jira-search")
.requiredOption("--jql <jql>", "JQL expression to execute")
.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.getJiraClient().searchIssues({
jql: options.jql,
maxResults: Number(options.maxResults),
startAt: Number(options.startAt),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-get")
.requiredOption("--issue <issue>", "Issue key")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().getIssue(options.issue);
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-create")
.requiredOption("--type <type>", "Issue type name")
.requiredOption("--summary <summary>", "Issue summary")
.option("--project <project>", "Project key")
.option("--description-file <path>", "Workspace-relative markdown/text file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().createIssue({
project: options.project,
type: options.type,
summary: options.summary,
description: await runtime.readBodyFile(options.descriptionFile),
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-update")
.requiredOption("--issue <issue>", "Issue key")
.option("--summary <summary>", "Updated summary")
.option("--description-file <path>", "Workspace-relative markdown/text file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().updateIssue({
issue: options.issue,
summary: options.summary,
description: await runtime.readBodyFile(options.descriptionFile),
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-comment")
.requiredOption("--issue <issue>", "Issue key")
.requiredOption("--body-file <path>", "Workspace-relative markdown/text file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().commentIssue({
issue: options.issue,
body: (await runtime.readBodyFile(options.bodyFile)) as string,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-transitions")
.requiredOption("--issue <issue>", "Issue key")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().getTransitions(options.issue);
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-transition")
.requiredOption("--issue <issue>", "Issue key")
.requiredOption("--transition <transition>", "Transition ID")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().transitionIssue({
issue: options.issue,
transition: options.transition,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
return program;
}
export async function runCli(argv = process.argv, context: CliContext = {}) {
const program = buildProgram(context);
await program.parseAsync(argv);
}
const isDirectExecution =
Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectExecution) {
runCli().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
program.parse(process.argv);
}

View File

@@ -0,0 +1,52 @@
import path from "node:path";
import { config as loadDotEnv } from "dotenv";
import type { AtlassianConfig } from "./types.js";
function normalizeBaseUrl(value: string) {
return value.replace(/\/+$/, "");
}
function readRequired(env: NodeJS.ProcessEnv, key: string) {
const value = env[key]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export function loadConfig(
env: NodeJS.ProcessEnv = process.env,
options?: {
cwd?: string;
},
): AtlassianConfig {
loadDotEnv({
path: path.resolve(options?.cwd ?? process.cwd(), ".env"),
processEnv: env as Record<string, string>,
override: false,
});
const baseUrl = normalizeBaseUrl(readRequired(env, "ATLASSIAN_BASE_URL"));
return {
baseUrl,
jiraBaseUrl: normalizeBaseUrl(env.ATLASSIAN_JIRA_BASE_URL?.trim() || baseUrl),
confluenceBaseUrl: normalizeBaseUrl(env.ATLASSIAN_CONFLUENCE_BASE_URL?.trim() || baseUrl),
email: readRequired(env, "ATLASSIAN_EMAIL"),
apiToken: readRequired(env, "ATLASSIAN_API_TOKEN"),
defaultProject: env.ATLASSIAN_DEFAULT_PROJECT?.trim() || undefined,
defaultSpace: env.ATLASSIAN_DEFAULT_SPACE?.trim() || undefined,
};
}
export function createBasicAuthHeader(config: {
email: string;
apiToken: string;
[key: string]: unknown;
}) {
return `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString("base64")}`;
}

View File

@@ -0,0 +1,13 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
export async function readWorkspaceFile(filePath: string, cwd: string) {
const resolved = path.resolve(cwd, filePath);
const relative = path.relative(cwd, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`--body-file must stay within the active workspace: ${filePath}`);
}
return readFile(resolved, "utf8");
}

View File

@@ -0,0 +1,294 @@
import { markdownToAdf } from "./adf.js";
import { createBasicAuthHeader } from "./config.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
const ISSUE_FIELDS = ["summary", "issuetype", "status", "assignee", "created", "updated"] as const;
type JiraClientOptions = {
config: AtlassianConfig;
fetchImpl?: FetchLike;
};
type SearchInput = {
jql: string;
maxResults: number;
startAt: number;
};
type CreateInput = {
project?: string;
type: string;
summary: string;
description?: string;
dryRun?: boolean;
};
type UpdateInput = {
issue: string;
summary?: string;
description?: string;
dryRun?: boolean;
};
type CommentInput = {
issue: string;
body: string;
dryRun?: boolean;
};
type TransitionInput = {
issue: string;
transition: string;
dryRun?: boolean;
};
function normalizeIssue(config: AtlassianConfig, issue: Record<string, unknown>): JiraIssueSummary {
const fields = (issue.fields ?? {}) as Record<string, unknown>;
const issueType = (fields.issuetype ?? {}) as Record<string, unknown>;
const status = (fields.status ?? {}) as Record<string, unknown>;
const assignee = (fields.assignee ?? {}) as Record<string, unknown>;
return {
key: String(issue.key ?? ""),
summary: String(fields.summary ?? ""),
issueType: String(issueType.name ?? ""),
status: String(status.name ?? ""),
assignee: assignee.displayName ? String(assignee.displayName) : undefined,
created: String(fields.created ?? ""),
updated: String(fields.updated ?? ""),
url: `${config.baseUrl}/browse/${issue.key ?? ""}`,
};
}
function createHeaders(config: AtlassianConfig, includeJsonBody: boolean) {
const headers: Array<[string, string]> = [
["Accept", "application/json"],
["Authorization", createBasicAuthHeader(config)],
];
if (includeJsonBody) {
headers.push(["Content-Type", "application/json"]);
}
return headers;
}
async function parseResponse(response: Response) {
if (response.status === 204) {
return null;
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return response.json();
}
return response.text();
}
function createRequest(config: AtlassianConfig, method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
const url = new URL(path, `${config.jiraBaseUrl}/`);
return {
method,
url: url.toString(),
...(body === undefined ? {} : { body }),
};
}
export function createJiraClient(options: JiraClientOptions) {
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
if (!fetchImpl) {
throw new Error("Fetch API is not available in this runtime");
}
async function send(method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
const request = createRequest(options.config, method, path, body);
const response = await fetchImpl(request.url, {
method,
headers: createHeaders(options.config, body !== undefined),
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) {
throw new Error(`Jira request failed: ${response.status} ${response.statusText}`);
}
return parseResponse(response);
}
return {
async searchIssues(input: SearchInput): Promise<CommandOutput<unknown>> {
const raw = (await send("POST", "/rest/api/3/search", {
jql: input.jql,
maxResults: input.maxResults,
startAt: input.startAt,
fields: [...ISSUE_FIELDS],
})) as Record<string, unknown>;
const issues = Array.isArray(raw.issues) ? raw.issues : [];
return {
ok: true,
data: {
issues: issues.map((issue) => normalizeIssue(options.config, issue as Record<string, unknown>)),
startAt: Number(raw.startAt ?? input.startAt),
maxResults: Number(raw.maxResults ?? input.maxResults),
total: Number(raw.total ?? issues.length),
},
};
},
async getIssue(issue: string): Promise<CommandOutput<unknown>> {
const url = new URL(`/rest/api/3/issue/${issue}`, `${options.config.jiraBaseUrl}/`);
url.searchParams.set("fields", ISSUE_FIELDS.join(","));
const raw = (await send("GET", `${url.pathname}${url.search}`)) as Record<string, unknown>;
return {
ok: true,
data: {
issue: normalizeIssue(options.config, raw),
},
raw,
};
},
async getTransitions(issue: string): Promise<CommandOutput<unknown>> {
const raw = (await send(
"GET",
`/rest/api/3/issue/${issue}/transitions`,
)) as { transitions?: Array<Record<string, unknown>> };
return {
ok: true,
data: {
transitions: (raw.transitions ?? []).map((transition) => ({
id: String(transition.id ?? ""),
name: String(transition.name ?? ""),
toStatus: String(((transition.to ?? {}) as Record<string, unknown>).name ?? ""),
hasScreen: Boolean(transition.hasScreen),
})),
},
};
},
async createIssue(input: CreateInput): Promise<CommandOutput<unknown>> {
const project = input.project || options.config.defaultProject;
if (!project) {
throw new Error("jira-create requires --project or ATLASSIAN_DEFAULT_PROJECT");
}
const request = createRequest(options.config, "POST", "/rest/api/3/issue", {
fields: {
project: { key: project },
issuetype: { name: input.type },
summary: input.summary,
...(input.description ? { description: markdownToAdf(input.description) } : {}),
},
});
if (input.dryRun) {
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw };
},
async updateIssue(input: UpdateInput): Promise<CommandOutput<unknown>> {
const fields: Record<string, unknown> = {};
if (input.summary) {
fields.summary = input.summary;
}
if (input.description) {
fields.description = markdownToAdf(input.description);
}
if (Object.keys(fields).length === 0) {
throw new Error("jira-update requires --summary and/or --description-file");
}
const request = createRequest(options.config, "PUT", `/rest/api/3/issue/${input.issue}`, {
fields,
});
if (input.dryRun) {
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return {
ok: true,
data: {
issue: input.issue,
updated: true,
},
};
},
async commentIssue(input: CommentInput): Promise<CommandOutput<unknown>> {
const request = createRequest(options.config, "POST", `/rest/api/3/issue/${input.issue}/comment`, {
body: markdownToAdf(input.body),
});
if (input.dryRun) {
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return {
ok: true,
data: raw,
};
},
async transitionIssue(input: TransitionInput): Promise<CommandOutput<unknown>> {
const request = createRequest(
options.config,
"POST",
`/rest/api/3/issue/${input.issue}/transitions`,
{
transition: {
id: input.transition,
},
},
);
if (input.dryRun) {
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return {
ok: true,
data: {
issue: input.issue,
transitioned: true,
transition: input.transition,
},
};
},
};
}

View File

@@ -0,0 +1,44 @@
import type { CommandOutput, OutputFormat, Writer } from "./types.js";
function renderText(payload: CommandOutput<unknown>) {
const data = payload.data as Record<string, unknown>;
if (Array.isArray(data?.issues)) {
return data.issues
.map((issue) => {
const item = issue as Record<string, string>;
return `${item.key} [${item.status}] ${item.issueType} - ${item.summary}`;
})
.join("\n");
}
if (data?.issue && typeof data.issue === "object") {
const issue = data.issue as Record<string, string>;
return [
issue.key,
`${issue.issueType} | ${issue.status}`,
issue.summary,
issue.url,
].join("\n");
}
if (Array.isArray(data?.transitions)) {
return data.transitions
.map((transition) => {
const item = transition as Record<string, string>;
return `${item.id} ${item.name} -> ${item.toStatus}`;
})
.join("\n");
}
return JSON.stringify(payload, null, 2);
}
export function writeOutput(
writer: Writer,
payload: CommandOutput<unknown>,
format: OutputFormat = "json",
) {
const body = format === "text" ? renderText(payload) : JSON.stringify(payload, null, 2);
writer.write(`${body}\n`);
}

View File

@@ -0,0 +1,35 @@
export type AtlassianConfig = {
baseUrl: string;
jiraBaseUrl: string;
confluenceBaseUrl: string;
email: string;
apiToken: string;
defaultProject?: string;
defaultSpace?: string;
};
export type CommandOutput<T> = {
ok: true;
data: T;
dryRun?: boolean;
raw?: unknown;
};
export type JiraIssueSummary = {
key: string;
summary: string;
issueType: string;
status: string;
assignee?: string;
created: string;
updated: string;
url: string;
};
export type Writer = {
write(chunk: string | Uint8Array): unknown;
};
export type FetchLike = typeof fetch;
export type OutputFormat = "json" | "text";