Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/jira

src/client.js

343 lines · 10.4 KB · JavaScript
import { createAuthenticatedFetch, secretHeaders } from 'kody:runtime'
import {
	API_BASE_URL,
	authSetupMessage,
	assertSafeSecretName,
	normalizeSite,
	resolveAccount,
	secretNamesForAccount,
	siteHost,
} from './setup.js'

export function isRecord(value) {
	return value !== null && typeof value === 'object' && !Array.isArray(value)
}

export function appendQuery(url, query = {}) {
	const search = new URLSearchParams()
	for (const [key, value] of Object.entries(query ?? {})) {
		if (value === undefined || value === null || value === '') continue
		if (Array.isArray(value)) {
			for (const item of value) {
				if (item === undefined || item === null || item === '') continue
				search.append(key, String(item))
			}
			continue
		}
		search.append(key, String(value))
	}
	const qs = search.toString()
	return qs ? `${url}?${qs}` : url
}

function mergeHeaders(userHeaders, authHeaders) {
	const merged = { ...(userHeaders ?? {}) }
	for (const [key, value] of Object.entries(authHeaders)) {
		const lower = key.toLowerCase()
		for (const existing of Object.keys(merged)) {
			if (existing.toLowerCase() === lower) delete merged[existing]
		}
		merged[key] = value
	}
	return merged
}

function hasHeader(headers, name) {
	const lower = name.toLowerCase()
	return Object.keys(headers).some((key) => key.toLowerCase() === lower)
}

function parseAuthMode(input = {}) {
	if (input.auth === undefined || input.auth === null || input.auth === '') return null
	const auth = String(input.auth).trim().toLowerCase()
	if (auth === 'oauth' || auth === 'token') return auth
	throw new Error('Jira auth must be "oauth" or "token".')
}

function tokenSecrets(input, account) {
	const defaults = secretNamesForAccount(account)
	const emailSecret =
		typeof input.emailSecretName === 'string' && input.emailSecretName.trim()
			? assertSafeSecretName(input.emailSecretName.trim(), 'emailSecretName')
			: defaults.emailSecret
	const tokenSecret =
		typeof input.tokenSecretName === 'string' && input.tokenSecretName.trim()
			? assertSafeSecretName(input.tokenSecretName.trim(), 'tokenSecretName')
			: defaults.tokenSecret
	return { emailSecret, tokenSecret }
}

async function oauthTransport(account) {
	try {
		return {
			auth: 'oauth',
			account,
			fetchImpl: await createAuthenticatedFetch(account),
			authHeaders: {},
		}
	} catch (error) {
		const message = error instanceof Error ? error.message : String(error)
		throw new Error(authSetupMessage(`Jira OAuth integration "${account}" is not connected. ${message}`))
	}
}

function tokenTransport(input, account) {
	const { emailSecret, tokenSecret } = tokenSecrets(input, account)
	return {
		auth: 'token',
		account,
		fetchImpl: fetch,
		authHeaders: {
			Authorization: secretHeaders.basic({
				usernameSecret: emailSecret,
				passwordSecret: tokenSecret,
				scope: 'user',
			}),
		},
	}
}

export async function resolveTransport(input = {}) {
	const account = resolveAccount(input)
	const mode = parseAuthMode(input)
	const explicitIntegration =
		typeof input.integrationName === 'string' && input.integrationName.trim() !== ''
	if (mode === 'oauth' || (explicitIntegration && mode !== 'token')) {
		return oauthTransport(account)
	}
	if (mode === 'token') return tokenTransport(input, account)

	try {
		return await oauthTransport(account)
	} catch (oauthError) {
		if (input.site || input.emailSecretName || input.tokenSecretName) {
			return tokenTransport(input, account)
		}
		throw oauthError
	}
}

export async function listAccessibleResources(transport) {
	if (transport.auth !== 'oauth') return []
	const response = await transport.fetchImpl(`${API_BASE_URL}/oauth/token/accessible-resources`, {
		headers: { Accept: 'application/json' },
	})
	const data = await parseBody(response)
	if (!response.ok) {
		throw jiraError(response, data, '/oauth/token/accessible-resources')
	}
	return Array.isArray(data) ? data : []
}

function slimSite(resource) {
	return {
		id: resource?.id ?? null,
		name: resource?.name ?? null,
		url: resource?.url ?? null,
		scopes: Array.isArray(resource?.scopes) ? resource.scopes : [],
	}
}

export function matchSite(resources, { site, cloudId } = {}) {
	const wantedId = typeof cloudId === 'string' ? cloudId.trim() : ''
	if (wantedId) {
		const match = resources.find((resource) => resource?.id === wantedId)
		if (!match) {
			throw new Error(
				`No Jira Cloud site with cloudId "${wantedId}" is granted to this OAuth token. Call list-sites and pass a granted cloudId or site.`,
			)
		}
		return slimSite(match)
	}

	const handle = normalizeSite(site)
	if (handle) {
		const expected = `https://${handle}.atlassian.net`
		const match = resources.find((resource) => {
			const url = String(resource?.url ?? '')
				.replace(/\/$/, '')
				.toLowerCase()
			return url === expected || url === `https://${handle}.atlassian.net`
		})
		if (!match) {
			throw new Error(
				`No granted Jira Cloud site matches "${handle}". Call list-sites and pass site or cloudId from that list.`,
			)
		}
		return slimSite(match)
	}

	if (resources.length === 1) return slimSite(resources[0])
	if (resources.length === 0) {
		throw new Error(
			'This Jira OAuth token has no accessible Jira Cloud sites. Reconnect and grant a site.',
		)
	}
	throw new Error(
		`This Jira OAuth token can access ${resources.length} sites. Pass site or cloudId. Do not guess a default project or site.`,
	)
}

export async function resolveBaseUrl(input, transport) {
	if (transport.auth === 'token') {
		return `https://${siteHost(input.site)}/rest/api/3`
	}
	const resources = await listAccessibleResources(transport)
	const selected = matchSite(resources, input)
	return {
		baseUrl: `${API_BASE_URL}/ex/jira/${selected.id}/rest/api/3`,
		site: selected,
	}
}

export async function parseBody(response) {
	const contentType = response.headers.get('content-type') ?? ''
	if (contentType.includes('application/json')) {
		try {
			return await response.json()
		} catch {
			return null
		}
	}
	const text = await response.text()
	return text ? { text } : null
}

export function jiraError(response, data, pathHint) {
	const messageParts = [`Jira API request failed with ${response.status} ${response.statusText}`]
	if (isRecord(data) && typeof data.errorMessages?.[0] === 'string') {
		messageParts.push(data.errorMessages[0])
	} else if (isRecord(data) && isRecord(data.errors)) {
		const first = Object.values(data.errors)[0]
		if (typeof first === 'string') messageParts.push(first)
	} else if (isRecord(data) && typeof data.message === 'string') {
		messageParts.push(data.message)
	}
	const error = new Error(messageParts.join(': '))
	error.status = response.status
	error.statusText = response.statusText
	error.path = pathHint
	error.data = data
	return error
}

export function requireMutationPreview(input, action, method, path, body) {
	if (input?.dryRun) {
		return { dryRun: true, action, method, path, body: body ?? null }
	}
	if (input?.confirm !== true) {
		throw new Error(
			`${action} writes live Jira state. Pass dryRun: true to preview, or confirm: true after the user approved the exact mutation.`,
		)
	}
	return null
}

export async function jiraFetch(input, path, options = {}) {
	if (typeof path !== 'string' || path.trim() === '') {
		throw new Error('Jira request requires a non-empty path string.')
	}
	if (/^https?:\/\//i.test(path)) {
		throw new Error('Jira request path must be relative, for example /myself or /search/jql.')
	}

	const transport = options.transport ?? (await resolveTransport(input))
	const resolved = await resolveBaseUrl(input, transport)
	const baseUrl = typeof resolved === 'string' ? resolved : resolved.baseUrl
	const site = typeof resolved === 'string' ? { url: `https://${siteHost(input.site)}` } : resolved.site
	const normalized = path.startsWith('/') ? path : `/${path}`
	const url = appendQuery(`${baseUrl}${normalized}`, options.query)
	const headers = mergeHeaders(
		{
			Accept: 'application/json',
			...(options.headers ?? {}),
		},
		transport.authHeaders,
	)
	let body = options.body
	if (body !== undefined && typeof body !== 'string') {
		body = JSON.stringify(body)
		if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/json'
	}
	const response = await transport.fetchImpl(url, {
		method: options.method ?? 'GET',
		headers,
		body,
	})
	const data = await parseBody(response)
	if (!response.ok) throw jiraError(response, data, normalized)
	return {
		status: response.status,
		data,
		auth: transport.auth,
		account: transport.account,
		site,
	}
}

export function isAuthSetupFailure(error) {
	const status = error?.status
	if (status === 401 || status === 403) return true
	const message = String(error?.message ?? error)
	return /secret|host approval|not connected|not approved|placeholder|jiraEmail|jiraApiToken|unresolved|integration/i.test(
		message,
	)
}

export function issueUrl(siteUrl, key) {
	if (!siteUrl || !key) return null
	return `${String(siteUrl).replace(/\/$/, '')}/browse/${key}`
}

export function slimIssue(issue, siteUrl) {
	const fields = isRecord(issue?.fields) ? issue.fields : {}
	const status = isRecord(fields.status) ? fields.status : null
	const assignee = isRecord(fields.assignee) ? fields.assignee : null
	const project = isRecord(fields.project) ? fields.project : null
	const issuetype = isRecord(fields.issuetype) ? fields.issuetype : null
	return {
		id: issue?.id ?? null,
		key: issue?.key ?? null,
		summary: fields.summary ?? null,
		status: status?.name ?? null,
		statusId: status?.id ?? null,
		assignee: assignee?.displayName ?? null,
		assigneeAccountId: assignee?.accountId ?? null,
		projectKey: project?.key ?? null,
		projectName: project?.name ?? null,
		issueType: issuetype?.name ?? null,
		updated: fields.updated ?? null,
		url: issueUrl(siteUrl, issue?.key),
	}
}

export function slimProject(project, siteUrl) {
	return {
		id: project?.id ?? null,
		key: project?.key ?? null,
		name: project?.name ?? null,
		style: project?.style ?? null,
		projectTypeKey: project?.projectTypeKey ?? null,
		url: project?.key && siteUrl ? `${String(siteUrl).replace(/\/$/, '')}/browse/${project.key}` : null,
	}
}

export function slimComment(comment) {
	return {
		id: comment?.id ?? null,
		created: comment?.created ?? null,
		updated: comment?.updated ?? null,
		author: comment?.author?.displayName ?? null,
		authorAccountId: comment?.author?.accountId ?? null,
		body: comment?.body ?? null,
	}
}

export function slimTransition(transition) {
	return {
		id: transition?.id ?? null,
		name: transition?.name ?? null,
		to: transition?.to?.name ?? null,
		toId: transition?.to?.id ?? null,
	}
}