fix(atlassian): tighten health checks and review coverage

This commit is contained in:
Stefano Fiorini
2026-03-06 07:06:38 -06:00
parent c3afee091b
commit 783bcfa037
26 changed files with 641 additions and 71 deletions

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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 <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),
);
});

View File

@@ -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<CommandOutput<unknown>> {
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<ProductHealth> {
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,
},
},
};
}

View File

@@ -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);

View File

@@ -9,7 +9,7 @@ export type AtlassianConfig = {
};
export type CommandOutput<T> = {
ok: true;
ok: boolean;
data: T;
dryRun?: boolean;
raw?: unknown;

View File

@@ -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.

View File

@@ -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 <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),
);
});

View File

@@ -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<CommandOutput<unknown>> {
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<ProductHealth> {
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,
},
},
};
}

View File

@@ -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);

View File

@@ -9,7 +9,7 @@ export type AtlassianConfig = {
};
export type CommandOutput<T> = {
ok: true;
ok: boolean;
data: T;
dryRun?: boolean;
raw?: unknown;

View File

@@ -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.

View File

@@ -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 <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),
);
});

View File

@@ -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<CommandOutput<unknown>> {
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<ProductHealth> {
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,
},
},
};
}

View File

@@ -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);

View File

@@ -9,7 +9,7 @@ export type AtlassianConfig = {
};
export type CommandOutput<T> = {
ok: true;
ok: boolean;
data: T;
dryRun?: boolean;
raw?: unknown;

View File

@@ -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.

View File

@@ -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 <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),
);
});

View File

@@ -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<CommandOutput<unknown>> {
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<ProductHealth> {
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,
},
},
};
}

View File

@@ -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);

View File

@@ -9,7 +9,7 @@ export type AtlassianConfig = {
};
export type CommandOutput<T> = {
ok: true;
ok: boolean;
data: T;
dryRun?: boolean;
raw?: unknown;

View File

@@ -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 <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),
);
});

View File

@@ -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<CommandOutput<unknown>> {
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<ProductHealth> {
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,
},
},
};
}

View File

@@ -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);

View File

@@ -9,7 +9,7 @@ export type AtlassianConfig = {
};
export type CommandOutput<T> = {
ok: true;
ok: boolean;
data: T;
dryRun?: boolean;
raw?: unknown;

View File

@@ -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/,
);
});