diff --git a/docs/ATLASSIAN.md b/docs/ATLASSIAN.md index 39cc41a..de5ede1 100644 --- a/docs/ATLASSIAN.md +++ b/docs/ATLASSIAN.md @@ -50,7 +50,7 @@ Optional: ## Command Notes -- `health` validates local configuration and shows the resolved Jira and Confluence base URLs. +- `health` validates local configuration, probes Jira and Confluence separately, and reports one product as unavailable without masking the other. - `jira-create` requires `--type`, `--summary`, and either `--project` or `ATLASSIAN_DEFAULT_PROJECT`. - `jira-update` requires `--issue` and at least one of `--summary` or `--description-file`. - `conf-create` requires `--title`, `--body-file`, and either `--space` or `ATLASSIAN_DEFAULT_SPACE`. diff --git a/skills/atlassian/claude-code/SKILL.md b/skills/atlassian/claude-code/SKILL.md index 3e4cd97..a79c876 100644 --- a/skills/atlassian/claude-code/SKILL.md +++ b/skills/atlassian/claude-code/SKILL.md @@ -58,6 +58,12 @@ If any check fails, stop and return: - `pnpm atlassian conf-children --page 12345` - `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...` +## Usage Examples + +- `pnpm atlassian jira-search --jql "project = ENG ORDER BY updated DESC" --max-results 10` +- `pnpm atlassian conf-comment --page 12345 --body-file comment.storage.html --dry-run` +- `pnpm atlassian raw --product jira --method GET --path "/rest/api/3/issue/ENG-123"` + ## Safety Rules - Default output is JSON; only switch to text output when the user needs a human-readable summary. diff --git a/skills/atlassian/claude-code/scripts/src/cli.ts b/skills/atlassian/claude-code/scripts/src/cli.ts index 4df932b..6012b99 100644 --- a/skills/atlassian/claude-code/scripts/src/cli.ts +++ b/skills/atlassian/claude-code/scripts/src/cli.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; +import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; @@ -84,19 +85,11 @@ export function buildProgram(context: CliContext = {}) { .command("health") .description("Validate configuration and Atlassian connectivity") .option("--format ", "Output format", "json") - .action((options) => { + .action(async (options) => { + const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl); 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, - }, - }, + payload, resolveFormat(options.format), ); }); diff --git a/skills/atlassian/claude-code/scripts/src/health.ts b/skills/atlassian/claude-code/scripts/src/health.ts new file mode 100644 index 0000000..b2d4d24 --- /dev/null +++ b/skills/atlassian/claude-code/scripts/src/health.ts @@ -0,0 +1,69 @@ +import { createJsonHeaders, createStatusError } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ProductHealth = { + ok: boolean; + status?: number; + message?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +export async function runHealthCheck( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, +): Promise> { + const client = fetchImpl ?? globalThis.fetch; + + if (!client) { + throw new Error("Fetch API is not available in this runtime"); + } + + async function probe(product: "Jira" | "Confluence", url: string): Promise { + try { + const response = await client(url, { + method: "GET", + headers: createJsonHeaders(config, false), + }); + + if (!response.ok) { + const error = createStatusError(`${product} health check failed`, response); + return { + ok: false, + status: response.status, + message: error.message, + }; + } + + return { + ok: true, + status: response.status, + }; + } catch (error: unknown) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself")); + const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1")); + + return { + ok: jira.ok && confluence.ok, + data: { + baseUrl: config.baseUrl, + jiraBaseUrl: config.jiraBaseUrl, + confluenceBaseUrl: config.confluenceBaseUrl, + defaultProject: config.defaultProject, + defaultSpace: config.defaultSpace, + products: { + jira, + confluence, + }, + }, + }; +} diff --git a/skills/atlassian/claude-code/scripts/src/http.ts b/skills/atlassian/claude-code/scripts/src/http.ts index 2ee6fc7..5791886 100644 --- a/skills/atlassian/claude-code/scripts/src/http.ts +++ b/skills/atlassian/claude-code/scripts/src/http.ts @@ -24,12 +24,33 @@ export async function parseResponse(response: Response) { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - return response.json(); + try { + return await response.json(); + } catch { + throw new Error("Malformed JSON response from Atlassian API"); + } } return response.text(); } +export function createStatusError(errorPrefix: string, response: Response) { + const base = `${errorPrefix}: ${response.status} ${response.statusText}`; + + switch (response.status) { + case 401: + return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`); + case 403: + return new Error(`${base} - verify product permissions for this account`); + case 404: + return new Error(`${base} - verify the resource identifier or API path`); + case 429: + return new Error(`${base} - retry later or reduce request rate`); + default: + return new Error(base); + } +} + export async function sendJsonRequest(options: { config: AtlassianConfig; fetchImpl?: FetchLike; @@ -58,7 +79,7 @@ export async function sendJsonRequest(options: { throw customError; } - throw new Error(`${options.errorPrefix}: ${response.status} ${response.statusText}`); + throw createStatusError(options.errorPrefix, response); } return parseResponse(response); diff --git a/skills/atlassian/claude-code/scripts/src/types.ts b/skills/atlassian/claude-code/scripts/src/types.ts index 3072ee1..7f48f56 100644 --- a/skills/atlassian/claude-code/scripts/src/types.ts +++ b/skills/atlassian/claude-code/scripts/src/types.ts @@ -9,7 +9,7 @@ export type AtlassianConfig = { }; export type CommandOutput = { - ok: true; + ok: boolean; data: T; dryRun?: boolean; raw?: unknown; diff --git a/skills/atlassian/codex/SKILL.md b/skills/atlassian/codex/SKILL.md index d0829ba..0444d02 100644 --- a/skills/atlassian/codex/SKILL.md +++ b/skills/atlassian/codex/SKILL.md @@ -60,6 +60,12 @@ If any check fails, stop and return: - `pnpm atlassian conf-children --page 12345` - `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...` +## Usage Examples + +- `pnpm atlassian jira-search --jql "project = ENG ORDER BY updated DESC" --max-results 10` +- `pnpm atlassian conf-update --page 12345 --title "Runbook" --body-file page.storage.html --dry-run` +- `pnpm atlassian raw --product confluence --method POST --path "/wiki/api/v2/pages" --body-file page.json --dry-run` + ## Safety Rules - Default output is JSON; prefer that for agent workflows. diff --git a/skills/atlassian/codex/scripts/src/cli.ts b/skills/atlassian/codex/scripts/src/cli.ts index 4df932b..6012b99 100644 --- a/skills/atlassian/codex/scripts/src/cli.ts +++ b/skills/atlassian/codex/scripts/src/cli.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; +import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; @@ -84,19 +85,11 @@ export function buildProgram(context: CliContext = {}) { .command("health") .description("Validate configuration and Atlassian connectivity") .option("--format ", "Output format", "json") - .action((options) => { + .action(async (options) => { + const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl); 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, - }, - }, + payload, resolveFormat(options.format), ); }); diff --git a/skills/atlassian/codex/scripts/src/health.ts b/skills/atlassian/codex/scripts/src/health.ts new file mode 100644 index 0000000..b2d4d24 --- /dev/null +++ b/skills/atlassian/codex/scripts/src/health.ts @@ -0,0 +1,69 @@ +import { createJsonHeaders, createStatusError } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ProductHealth = { + ok: boolean; + status?: number; + message?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +export async function runHealthCheck( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, +): Promise> { + const client = fetchImpl ?? globalThis.fetch; + + if (!client) { + throw new Error("Fetch API is not available in this runtime"); + } + + async function probe(product: "Jira" | "Confluence", url: string): Promise { + try { + const response = await client(url, { + method: "GET", + headers: createJsonHeaders(config, false), + }); + + if (!response.ok) { + const error = createStatusError(`${product} health check failed`, response); + return { + ok: false, + status: response.status, + message: error.message, + }; + } + + return { + ok: true, + status: response.status, + }; + } catch (error: unknown) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself")); + const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1")); + + return { + ok: jira.ok && confluence.ok, + data: { + baseUrl: config.baseUrl, + jiraBaseUrl: config.jiraBaseUrl, + confluenceBaseUrl: config.confluenceBaseUrl, + defaultProject: config.defaultProject, + defaultSpace: config.defaultSpace, + products: { + jira, + confluence, + }, + }, + }; +} diff --git a/skills/atlassian/codex/scripts/src/http.ts b/skills/atlassian/codex/scripts/src/http.ts index 2ee6fc7..5791886 100644 --- a/skills/atlassian/codex/scripts/src/http.ts +++ b/skills/atlassian/codex/scripts/src/http.ts @@ -24,12 +24,33 @@ export async function parseResponse(response: Response) { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - return response.json(); + try { + return await response.json(); + } catch { + throw new Error("Malformed JSON response from Atlassian API"); + } } return response.text(); } +export function createStatusError(errorPrefix: string, response: Response) { + const base = `${errorPrefix}: ${response.status} ${response.statusText}`; + + switch (response.status) { + case 401: + return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`); + case 403: + return new Error(`${base} - verify product permissions for this account`); + case 404: + return new Error(`${base} - verify the resource identifier or API path`); + case 429: + return new Error(`${base} - retry later or reduce request rate`); + default: + return new Error(base); + } +} + export async function sendJsonRequest(options: { config: AtlassianConfig; fetchImpl?: FetchLike; @@ -58,7 +79,7 @@ export async function sendJsonRequest(options: { throw customError; } - throw new Error(`${options.errorPrefix}: ${response.status} ${response.statusText}`); + throw createStatusError(options.errorPrefix, response); } return parseResponse(response); diff --git a/skills/atlassian/codex/scripts/src/types.ts b/skills/atlassian/codex/scripts/src/types.ts index 3072ee1..7f48f56 100644 --- a/skills/atlassian/codex/scripts/src/types.ts +++ b/skills/atlassian/codex/scripts/src/types.ts @@ -9,7 +9,7 @@ export type AtlassianConfig = { }; export type CommandOutput = { - ok: true; + ok: boolean; data: T; dryRun?: boolean; raw?: unknown; diff --git a/skills/atlassian/cursor/SKILL.md b/skills/atlassian/cursor/SKILL.md index accb8cf..477807c 100644 --- a/skills/atlassian/cursor/SKILL.md +++ b/skills/atlassian/cursor/SKILL.md @@ -73,6 +73,12 @@ If any check fails, stop and return: - `pnpm atlassian conf-children --page 12345` - `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...` +## Usage Examples + +- `pnpm atlassian jira-get --issue ENG-123` +- `pnpm atlassian conf-search --query "title ~ \\\"Runbook\\\"" --max-results 10 --start-at 0` +- `pnpm atlassian raw --product confluence --method POST --path "/wiki/api/v2/pages" --body-file page.json --dry-run` + ## Safety Rules - Prefer JSON output for agent use. diff --git a/skills/atlassian/cursor/scripts/src/cli.ts b/skills/atlassian/cursor/scripts/src/cli.ts index 4df932b..6012b99 100644 --- a/skills/atlassian/cursor/scripts/src/cli.ts +++ b/skills/atlassian/cursor/scripts/src/cli.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; +import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; @@ -84,19 +85,11 @@ export function buildProgram(context: CliContext = {}) { .command("health") .description("Validate configuration and Atlassian connectivity") .option("--format ", "Output format", "json") - .action((options) => { + .action(async (options) => { + const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl); 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, - }, - }, + payload, resolveFormat(options.format), ); }); diff --git a/skills/atlassian/cursor/scripts/src/health.ts b/skills/atlassian/cursor/scripts/src/health.ts new file mode 100644 index 0000000..b2d4d24 --- /dev/null +++ b/skills/atlassian/cursor/scripts/src/health.ts @@ -0,0 +1,69 @@ +import { createJsonHeaders, createStatusError } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ProductHealth = { + ok: boolean; + status?: number; + message?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +export async function runHealthCheck( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, +): Promise> { + const client = fetchImpl ?? globalThis.fetch; + + if (!client) { + throw new Error("Fetch API is not available in this runtime"); + } + + async function probe(product: "Jira" | "Confluence", url: string): Promise { + try { + const response = await client(url, { + method: "GET", + headers: createJsonHeaders(config, false), + }); + + if (!response.ok) { + const error = createStatusError(`${product} health check failed`, response); + return { + ok: false, + status: response.status, + message: error.message, + }; + } + + return { + ok: true, + status: response.status, + }; + } catch (error: unknown) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself")); + const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1")); + + return { + ok: jira.ok && confluence.ok, + data: { + baseUrl: config.baseUrl, + jiraBaseUrl: config.jiraBaseUrl, + confluenceBaseUrl: config.confluenceBaseUrl, + defaultProject: config.defaultProject, + defaultSpace: config.defaultSpace, + products: { + jira, + confluence, + }, + }, + }; +} diff --git a/skills/atlassian/cursor/scripts/src/http.ts b/skills/atlassian/cursor/scripts/src/http.ts index 2ee6fc7..5791886 100644 --- a/skills/atlassian/cursor/scripts/src/http.ts +++ b/skills/atlassian/cursor/scripts/src/http.ts @@ -24,12 +24,33 @@ export async function parseResponse(response: Response) { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - return response.json(); + try { + return await response.json(); + } catch { + throw new Error("Malformed JSON response from Atlassian API"); + } } return response.text(); } +export function createStatusError(errorPrefix: string, response: Response) { + const base = `${errorPrefix}: ${response.status} ${response.statusText}`; + + switch (response.status) { + case 401: + return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`); + case 403: + return new Error(`${base} - verify product permissions for this account`); + case 404: + return new Error(`${base} - verify the resource identifier or API path`); + case 429: + return new Error(`${base} - retry later or reduce request rate`); + default: + return new Error(base); + } +} + export async function sendJsonRequest(options: { config: AtlassianConfig; fetchImpl?: FetchLike; @@ -58,7 +79,7 @@ export async function sendJsonRequest(options: { throw customError; } - throw new Error(`${options.errorPrefix}: ${response.status} ${response.statusText}`); + throw createStatusError(options.errorPrefix, response); } return parseResponse(response); diff --git a/skills/atlassian/cursor/scripts/src/types.ts b/skills/atlassian/cursor/scripts/src/types.ts index 3072ee1..7f48f56 100644 --- a/skills/atlassian/cursor/scripts/src/types.ts +++ b/skills/atlassian/cursor/scripts/src/types.ts @@ -9,7 +9,7 @@ export type AtlassianConfig = { }; export type CommandOutput = { - ok: true; + ok: boolean; data: T; dryRun?: boolean; raw?: unknown; diff --git a/skills/atlassian/opencode/SKILL.md b/skills/atlassian/opencode/SKILL.md index 141e2ef..f2a33be 100644 --- a/skills/atlassian/opencode/SKILL.md +++ b/skills/atlassian/opencode/SKILL.md @@ -58,6 +58,12 @@ If any check fails, stop and return: - `pnpm atlassian conf-children --page 12345` - `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...` +## Usage Examples + +- `pnpm atlassian jira-transition --issue ENG-123 --transition 31 --dry-run` +- `pnpm atlassian conf-create --space OPS --title "Runbook" --body-file page.storage.html --dry-run` +- `pnpm atlassian raw --product jira --method GET --path "/rest/api/3/issue/ENG-123"` + ## Safety Rules - Prefer JSON output for machine consumption. diff --git a/skills/atlassian/opencode/scripts/src/cli.ts b/skills/atlassian/opencode/scripts/src/cli.ts index 4df932b..6012b99 100644 --- a/skills/atlassian/opencode/scripts/src/cli.ts +++ b/skills/atlassian/opencode/scripts/src/cli.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; +import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; @@ -84,19 +85,11 @@ export function buildProgram(context: CliContext = {}) { .command("health") .description("Validate configuration and Atlassian connectivity") .option("--format ", "Output format", "json") - .action((options) => { + .action(async (options) => { + const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl); 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, - }, - }, + payload, resolveFormat(options.format), ); }); diff --git a/skills/atlassian/opencode/scripts/src/health.ts b/skills/atlassian/opencode/scripts/src/health.ts new file mode 100644 index 0000000..b2d4d24 --- /dev/null +++ b/skills/atlassian/opencode/scripts/src/health.ts @@ -0,0 +1,69 @@ +import { createJsonHeaders, createStatusError } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ProductHealth = { + ok: boolean; + status?: number; + message?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +export async function runHealthCheck( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, +): Promise> { + const client = fetchImpl ?? globalThis.fetch; + + if (!client) { + throw new Error("Fetch API is not available in this runtime"); + } + + async function probe(product: "Jira" | "Confluence", url: string): Promise { + try { + const response = await client(url, { + method: "GET", + headers: createJsonHeaders(config, false), + }); + + if (!response.ok) { + const error = createStatusError(`${product} health check failed`, response); + return { + ok: false, + status: response.status, + message: error.message, + }; + } + + return { + ok: true, + status: response.status, + }; + } catch (error: unknown) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself")); + const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1")); + + return { + ok: jira.ok && confluence.ok, + data: { + baseUrl: config.baseUrl, + jiraBaseUrl: config.jiraBaseUrl, + confluenceBaseUrl: config.confluenceBaseUrl, + defaultProject: config.defaultProject, + defaultSpace: config.defaultSpace, + products: { + jira, + confluence, + }, + }, + }; +} diff --git a/skills/atlassian/opencode/scripts/src/http.ts b/skills/atlassian/opencode/scripts/src/http.ts index 2ee6fc7..5791886 100644 --- a/skills/atlassian/opencode/scripts/src/http.ts +++ b/skills/atlassian/opencode/scripts/src/http.ts @@ -24,12 +24,33 @@ export async function parseResponse(response: Response) { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - return response.json(); + try { + return await response.json(); + } catch { + throw new Error("Malformed JSON response from Atlassian API"); + } } return response.text(); } +export function createStatusError(errorPrefix: string, response: Response) { + const base = `${errorPrefix}: ${response.status} ${response.statusText}`; + + switch (response.status) { + case 401: + return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`); + case 403: + return new Error(`${base} - verify product permissions for this account`); + case 404: + return new Error(`${base} - verify the resource identifier or API path`); + case 429: + return new Error(`${base} - retry later or reduce request rate`); + default: + return new Error(base); + } +} + export async function sendJsonRequest(options: { config: AtlassianConfig; fetchImpl?: FetchLike; @@ -58,7 +79,7 @@ export async function sendJsonRequest(options: { throw customError; } - throw new Error(`${options.errorPrefix}: ${response.status} ${response.statusText}`); + throw createStatusError(options.errorPrefix, response); } return parseResponse(response); diff --git a/skills/atlassian/opencode/scripts/src/types.ts b/skills/atlassian/opencode/scripts/src/types.ts index 3072ee1..7f48f56 100644 --- a/skills/atlassian/opencode/scripts/src/types.ts +++ b/skills/atlassian/opencode/scripts/src/types.ts @@ -9,7 +9,7 @@ export type AtlassianConfig = { }; export type CommandOutput = { - ok: true; + ok: boolean; data: T; dryRun?: boolean; raw?: unknown; diff --git a/skills/atlassian/shared/scripts/src/cli.ts b/skills/atlassian/shared/scripts/src/cli.ts index 4df932b..6012b99 100644 --- a/skills/atlassian/shared/scripts/src/cli.ts +++ b/skills/atlassian/shared/scripts/src/cli.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; +import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; @@ -84,19 +85,11 @@ export function buildProgram(context: CliContext = {}) { .command("health") .description("Validate configuration and Atlassian connectivity") .option("--format ", "Output format", "json") - .action((options) => { + .action(async (options) => { + const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl); 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, - }, - }, + payload, resolveFormat(options.format), ); }); diff --git a/skills/atlassian/shared/scripts/src/health.ts b/skills/atlassian/shared/scripts/src/health.ts new file mode 100644 index 0000000..b2d4d24 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/health.ts @@ -0,0 +1,69 @@ +import { createJsonHeaders, createStatusError } from "./http.js"; +import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; + +type ProductHealth = { + ok: boolean; + status?: number; + message?: string; +}; + +function buildUrl(baseUrl: string, path: string) { + return new URL(path, `${baseUrl}/`).toString(); +} + +export async function runHealthCheck( + config: AtlassianConfig, + fetchImpl: FetchLike | undefined, +): Promise> { + const client = fetchImpl ?? globalThis.fetch; + + if (!client) { + throw new Error("Fetch API is not available in this runtime"); + } + + async function probe(product: "Jira" | "Confluence", url: string): Promise { + try { + const response = await client(url, { + method: "GET", + headers: createJsonHeaders(config, false), + }); + + if (!response.ok) { + const error = createStatusError(`${product} health check failed`, response); + return { + ok: false, + status: response.status, + message: error.message, + }; + } + + return { + ok: true, + status: response.status, + }; + } catch (error: unknown) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself")); + const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1")); + + return { + ok: jira.ok && confluence.ok, + data: { + baseUrl: config.baseUrl, + jiraBaseUrl: config.jiraBaseUrl, + confluenceBaseUrl: config.confluenceBaseUrl, + defaultProject: config.defaultProject, + defaultSpace: config.defaultSpace, + products: { + jira, + confluence, + }, + }, + }; +} diff --git a/skills/atlassian/shared/scripts/src/http.ts b/skills/atlassian/shared/scripts/src/http.ts index 2ee6fc7..5791886 100644 --- a/skills/atlassian/shared/scripts/src/http.ts +++ b/skills/atlassian/shared/scripts/src/http.ts @@ -24,12 +24,33 @@ export async function parseResponse(response: Response) { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - return response.json(); + try { + return await response.json(); + } catch { + throw new Error("Malformed JSON response from Atlassian API"); + } } return response.text(); } +export function createStatusError(errorPrefix: string, response: Response) { + const base = `${errorPrefix}: ${response.status} ${response.statusText}`; + + switch (response.status) { + case 401: + return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`); + case 403: + return new Error(`${base} - verify product permissions for this account`); + case 404: + return new Error(`${base} - verify the resource identifier or API path`); + case 429: + return new Error(`${base} - retry later or reduce request rate`); + default: + return new Error(base); + } +} + export async function sendJsonRequest(options: { config: AtlassianConfig; fetchImpl?: FetchLike; @@ -58,7 +79,7 @@ export async function sendJsonRequest(options: { throw customError; } - throw new Error(`${options.errorPrefix}: ${response.status} ${response.statusText}`); + throw createStatusError(options.errorPrefix, response); } return parseResponse(response); diff --git a/skills/atlassian/shared/scripts/src/types.ts b/skills/atlassian/shared/scripts/src/types.ts index 3072ee1..7f48f56 100644 --- a/skills/atlassian/shared/scripts/src/types.ts +++ b/skills/atlassian/shared/scripts/src/types.ts @@ -9,7 +9,7 @@ export type AtlassianConfig = { }; export type CommandOutput = { - ok: true; + ok: boolean; data: T; dryRun?: boolean; raw?: unknown; diff --git a/skills/atlassian/shared/scripts/tests/http.test.ts b/skills/atlassian/shared/scripts/tests/http.test.ts new file mode 100644 index 0000000..e893c36 --- /dev/null +++ b/skills/atlassian/shared/scripts/tests/http.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { sendJsonRequest } from "../src/http.js"; +import { runCli } from "./helpers.js"; + +const baseConfig = { + baseUrl: "https://example.atlassian.net", + jiraBaseUrl: "https://example.atlassian.net", + confluenceBaseUrl: "https://example.atlassian.net", + email: "dev@example.com", + apiToken: "secret-token", +}; + +const baseEnv = { + ATLASSIAN_BASE_URL: "https://example.atlassian.net", + ATLASSIAN_EMAIL: "dev@example.com", + ATLASSIAN_API_TOKEN: "secret-token", +}; + +test("health probes Jira and Confluence independently", async () => { + const calls: string[] = []; + + const result = await runCli({ + args: ["health"], + env: baseEnv, + fetchImpl: async (input) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push(url); + + if (url.endsWith("/rest/api/3/myself")) { + return new Response(JSON.stringify({ accountId: "1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ message: "Forbidden" }), { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "application/json" }, + }); + }, + }); + + assert.deepEqual(calls, [ + "https://example.atlassian.net/rest/api/3/myself", + "https://example.atlassian.net/wiki/api/v2/spaces?limit=1", + ]); + + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + data: { + baseUrl: "https://example.atlassian.net", + jiraBaseUrl: "https://example.atlassian.net", + confluenceBaseUrl: "https://example.atlassian.net", + products: { + jira: { + ok: true, + status: 200, + }, + confluence: { + ok: false, + status: 403, + message: + "Confluence health check failed: 403 Forbidden - verify product permissions for this account", + }, + }, + }, + }); +}); + +test("sendJsonRequest maps 401, 403, 404, and 429 to actionable messages", async () => { + const cases = [ + { + status: 401, + statusText: "Unauthorized", + expected: /401 Unauthorized - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN/, + }, + { + status: 403, + statusText: "Forbidden", + expected: /403 Forbidden - verify product permissions for this account/, + }, + { + status: 404, + statusText: "Not Found", + expected: /404 Not Found - verify the resource identifier or API path/, + }, + { + status: 429, + statusText: "Too Many Requests", + expected: /429 Too Many Requests - retry later or reduce request rate/, + }, + ] as const; + + for (const entry of cases) { + await assert.rejects( + sendJsonRequest({ + config: baseConfig, + url: "https://example.atlassian.net/rest/api/3/issue/ENG-1", + method: "GET", + errorPrefix: "Jira request failed", + fetchImpl: async () => + new Response(JSON.stringify({ message: entry.statusText }), { + status: entry.status, + statusText: entry.statusText, + headers: { "content-type": "application/json" }, + }), + }), + entry.expected, + ); + } +}); + +test("sendJsonRequest reports malformed JSON responses clearly", async () => { + await assert.rejects( + sendJsonRequest({ + config: baseConfig, + url: "https://example.atlassian.net/rest/api/3/issue/ENG-1", + method: "GET", + errorPrefix: "Jira request failed", + fetchImpl: async () => + new Response("{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + }), + /Malformed JSON response from Atlassian API/, + ); +});