Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

pi/agent/skillz/ims-process-review/scripts/atlassian-request.mjs

Raw
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { realpathSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";

const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const MUTATIONS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

export function parseArgs(argv) {
	const args = [...argv];
	let method = "GET";
	if (METHODS.has(args[0]?.toUpperCase())) method = args.shift().toUpperCase();

	const options = {
		method,
		path: undefined,
		bodyFile: undefined,
		profile: undefined,
		paginate: false,
		maxPages: 100,
		allowMutation: false,
	};

	while (args.length) {
		const arg = args.shift();
		switch (arg) {
			case "--body":
				options.bodyFile = requiredValue(arg, args.shift());
				break;
			case "--profile":
				options.profile = requiredValue(arg, args.shift());
				break;
			case "--paginate":
				options.paginate = true;
				break;
			case "--max-pages": {
				const value = Number(requiredValue(arg, args.shift()));
				if (!Number.isSafeInteger(value) || value < 1) throw new Error("--max-pages must be a positive integer");
				options.maxPages = value;
				break;
			}
			case "--allow-mutation":
				options.allowMutation = true;
				break;
			case "--help":
				options.help = true;
				break;
			default:
				if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
				if (options.path) throw new Error(`Unexpected argument: ${arg}`);
				options.path = arg;
		}
	}

	if (options.help) return options;
	if (!options.path) throw new Error("Request path is required");
	if (MUTATIONS.has(method) && !options.allowMutation) throw new Error(`${method} requires --allow-mutation`);
	if (options.bodyFile && method === "GET") throw new Error("GET does not accept --body");
	if (options.paginate && method !== "GET") throw new Error("--paginate requires GET");
	return options;
}

function requiredValue(option, value) {
	if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`);
	return value;
}

export function selectConnection(profiles, profileName, env = process.env) {
	const selected = profileName
		? profiles.find((profile) => profile.name === profileName)
		: profiles.find((profile) => profile.is_default) ?? profiles[0];
	if (profileName && !selected && (!env.ATLASSIAN_BASE_URL || !env.ATLASSIAN_EMAIL)) {
		throw new Error(`Unknown Atlassian profile: ${profileName}`);
	}
	const baseUrl = env.ATLASSIAN_BASE_URL ?? selected?.base_url;
	const email = env.ATLASSIAN_EMAIL ?? selected?.email;
	if (!baseUrl || !email) throw new Error("Atlassian base URL and email are unavailable");
	return { baseUrl, email };
}

export function resolveRequestUrl(path, baseUrl) {
	const base = new URL(baseUrl);
	if (base.protocol !== "https:" && !["localhost", "127.0.0.1", "::1"].includes(base.hostname)) {
		throw new Error(`Refusing non-HTTPS Atlassian base URL: ${base.origin}`);
	}
	const url = new URL(path, base);
	if (url.origin !== base.origin) throw new Error(`Refusing to send Atlassian credentials to ${url.origin}`);
	return url;
}

export function nextPageUrl(body, currentUrl) {
	const linked = body?._links?.next ?? body?.links?.next ?? body?.nextPage ?? (typeof body?.next === "string" ? body.next : undefined);
	if (linked) return new URL(linked, currentUrl);

	if (Number.isFinite(body?.startAt) && Number.isFinite(body?.maxResults) && Number.isFinite(body?.total)) {
		const startAt = body.startAt + body.maxResults;
		if (startAt < body.total) {
			const next = new URL(currentUrl);
			next.searchParams.set("startAt", String(startAt));
			return next;
		}
	}

	if (body?.nextPageToken && body?.isLast !== true) {
		const next = new URL(currentUrl);
		next.searchParams.set("nextPageToken", body.nextPageToken);
		return next;
	}
	return undefined;
}

export async function requestJson(url, init, fetchImpl = fetch) {
	const response = await fetchImpl(url, { ...init, redirect: "manual" });
	const text = await response.text();
	if (response.status >= 300 && response.status < 400) {
		throw new Error(`${init.method} ${url}: redirect ${response.status} refused`);
	}
	if (!response.ok) throw new Error(`${init.method} ${url}: HTTP ${response.status}${text ? `\n${text}` : ""}`);
	if (!text) return null;
	try {
		return JSON.parse(text);
	} catch {
		return text;
	}
}

export async function runRequest(options, connection, token, fetchImpl = fetch) {
	if (!token) throw new Error("ATLASSIAN_API_TOKEN or JIRA_API_TOKEN is required");
	const firstUrl = resolveRequestUrl(options.path, connection.baseUrl);
	const headers = {
		Accept: "application/json",
		Authorization: `Basic ${Buffer.from(`${connection.email}:${token}`).toString("base64")}`,
	};
	let body;
	if (options.bodyFile) {
		const input = await readFile(options.bodyFile, "utf8");
		body = JSON.stringify(JSON.parse(input));
		headers["Content-Type"] = "application/json";
	}
	const init = { method: options.method, headers, body };
	if (!options.paginate) return requestJson(firstUrl, init, fetchImpl);

	const pages = [];
	let url = firstUrl;
	for (let page = 1; ; page++) {
		pages.push(await requestJson(url, init, fetchImpl));
		const next = nextPageUrl(pages.at(-1), url);
		if (!next) return pages;
		if (page >= options.maxPages) throw new Error(`Pagination exceeded --max-pages ${options.maxPages}`);
		url = resolveRequestUrl(next.href, connection.baseUrl);
	}
}

function loadProfiles() {
	const result = spawnSync("atlassian-cli", ["auth", "list", "--format", "json"], {
		encoding: "utf8",
		windowsHide: true,
	});
	if (result.status !== 0) throw new Error(result.stderr.trim() || "atlassian-cli auth list failed");
	return JSON.parse(result.stdout);
}

function usage() {
	return `Usage: node atlassian-request.mjs [METHOD] PATH [options]

METHOD defaults to GET. PATH must resolve to the configured Atlassian origin.
On Windows Bash, use relative paths without a leading slash.

Options:
  --body FILE          JSON request body
  --profile NAME       atlassian-cli profile
  --paginate           fetch all GET pages and output an array of responses
  --max-pages NUMBER   pagination limit, default 100
  --allow-mutation     required for POST, PUT, PATCH, and DELETE
  --help               show this help`;
}

async function main() {
	const options = parseArgs(process.argv.slice(2));
	if (options.help) {
		console.log(usage());
		return;
	}
	const profiles = process.env.ATLASSIAN_BASE_URL && process.env.ATLASSIAN_EMAIL ? [] : loadProfiles();
	const connection = selectConnection(profiles, options.profile);
	const token = process.env.ATLASSIAN_API_TOKEN ?? process.env.JIRA_API_TOKEN;
	const result = await runRequest(options, connection, token);
	console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
}

if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
	main().catch((error) => {
		console.error(error.message);
		process.exit(1);
	});
}