Skip to content

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

Package listing

@kody/jira

src/jira.js

386 lines · 11.4 KB · JavaScript
import { toAdf } from './adf.js'
import {
	isAuthSetupFailure,
	isRecord,
	issueUrl,
	jiraFetch,
	listAccessibleResources,
	matchSite,
	requireMutationPreview,
	resolveTransport,
	slimComment,
	slimIssue,
	slimProject,
	slimTransition,
} from './client.js'
import { DEFAULT_ACCOUNT, resolveAccount, setupUrls, siteHost } from './setup.js'

const ISSUE_FIELDS = ['summary', 'status', 'assignee', 'issuetype', 'priority', 'updated', 'project']

function requiredString(input, names, label) {
	for (const name of names) {
		const value = input?.[name]
		if (typeof value === 'string' && value.trim()) return value.trim()
	}
	throw new Error(`${label} is required.`)
}

function siteUrlFromResult(result, input) {
	if (result?.site?.url) return result.site.url
	if (input?.site) return `https://${siteHost(input.site)}`
	return null
}

export function setup(input = {}) {
	return setupUrls(input)
}

export async function listSites(input = {}) {
	const transport = await resolveTransport(input)
	if (transport.auth === 'oauth') {
		const resources = await listAccessibleResources(transport)
		return {
			auth: 'oauth',
			account: transport.account,
			sites: resources.map((resource) => ({
				id: resource?.id ?? null,
				name: resource?.name ?? null,
				url: resource?.url ?? null,
				scopes: Array.isArray(resource?.scopes) ? resource.scopes : [],
			})),
		}
	}
	const handle = siteHost(input.site).replace(/\.atlassian\.net$/, '')
	const url = `https://${handle}.atlassian.net`
	const result = await jiraFetch(input, '/serverInfo', { transport })
	return {
		auth: 'token',
		account: transport.account,
		sites: [
			{
				id: result.data?.serverTitle ?? null,
				name: result.data?.serverTitle ?? handle,
				url: result.data?.baseUrl ?? url,
				scopes: [],
			},
		],
	}
}

export async function listProjects(input = {}) {
	const maxResults = input.maxResults ?? 50
	const startAt = input.startAt ?? 0
	const query = {
		maxResults,
		startAt,
		query: input.query,
		orderBy: input.orderBy,
		typeKey: input.typeKey,
		action:
			input.projectAction === 'browse' ||
			input.projectAction === 'edit' ||
			input.projectAction === 'view'
				? input.projectAction
				: undefined,
	}
	const result = await jiraFetch(input, '/project/search', { query })
	const values = Array.isArray(result.data?.values) ? result.data.values : []
	const siteUrl = siteUrlFromResult(result, input)
	return {
		auth: result.auth,
		account: result.account,
		total: result.data?.total ?? values.length,
		isLast: result.data?.isLast ?? true,
		startAt: result.data?.startAt ?? startAt,
		projects: values.map((project) => slimProject(project, siteUrl)),
	}
}

export async function getIssue(input = {}) {
	const issueIdOrKey = requiredString(input, ['issueIdOrKey', 'issueKey', 'key'], 'getIssue requires issueIdOrKey')
	const result = await jiraFetch(input, `/issue/${encodeURIComponent(issueIdOrKey)}`, {
		query: { fields: ISSUE_FIELDS.join(',') },
	})
	return slimIssue(result.data, siteUrlFromResult(result, input))
}

export async function searchIssues(input = {}) {
	const jql = requiredString(input, ['jql'], 'searchIssues requires jql')
	const body = {
		jql,
		maxResults: input.maxResults ?? 50,
		fields: input.fields ?? ISSUE_FIELDS,
	}
	if (typeof input.nextPageToken === 'string' && input.nextPageToken) {
		body.nextPageToken = input.nextPageToken
	}
	if (input.dryRun) {
		return { dryRun: true, method: 'POST', path: '/search/jql', body }
	}

	let result
	try {
		result = await jiraFetch(input, '/search/jql', { method: 'POST', body })
	} catch (error) {
		if (error?.status === 404) {
			result = await jiraFetch(input, '/search', {
				method: 'POST',
				body: {
					jql,
					maxResults: body.maxResults,
					fields: body.fields,
					startAt: input.startAt ?? 0,
				},
			})
		} else {
			throw error
		}
	}

	const issues = Array.isArray(result.data?.issues) ? result.data.issues : []
	const siteUrl = siteUrlFromResult(result, input)
	return {
		jql,
		total: result.data?.total ?? null,
		isLast: result.data?.isLast ?? null,
		nextPageToken: result.data?.nextPageToken ?? null,
		issues: issues.map((issue) => slimIssue(issue, siteUrl)),
	}
}

export async function createIssue(input = {}) {
	const projectKey = requiredString(input, ['projectKey'], 'createIssue requires projectKey')
	const summary = requiredString(input, ['summary'], 'createIssue requires summary')
	const issueType = requiredString(input, ['issueType', 'issuetype'], 'createIssue requires issueType')
	const fields = {
		project: { key: projectKey },
		summary,
		issuetype: { name: issueType },
	}
	if (input.description !== undefined) fields.description = toAdf(input.description)
	if (isRecord(input.fields)) {
		for (const [key, value] of Object.entries(input.fields)) {
			if (value !== undefined) fields[key] = value
		}
	}
	const body = { fields }
	const preview = requireMutationPreview(input, 'createIssue', 'POST', '/issue', body)
	if (preview) return preview

	const result = await jiraFetch(input, '/issue', { method: 'POST', body })
	return {
		id: result.data?.id ?? null,
		key: result.data?.key ?? null,
		url: issueUrl(siteUrlFromResult(result, input), result.data?.key),
	}
}

export async function listComments(input = {}) {
	const issueIdOrKey = requiredString(input, ['issueIdOrKey', 'issueKey', 'key'], 'listComments requires issueIdOrKey')
	const result = await jiraFetch(input, `/issue/${encodeURIComponent(issueIdOrKey)}/comment`, {
		query: {
			maxResults: input.maxResults ?? 50,
			startAt: input.startAt ?? 0,
			orderBy: input.orderBy,
		},
	})
	const comments = Array.isArray(result.data?.comments) ? result.data.comments : []
	return {
		issueIdOrKey,
		total: result.data?.total ?? comments.length,
		startAt: result.data?.startAt ?? 0,
		comments: comments.map(slimComment),
	}
}

export async function addComment(input = {}) {
	const issueIdOrKey = requiredString(input, ['issueIdOrKey', 'issueKey', 'key'], 'addComment requires issueIdOrKey')
	const bodyText = input.body ?? input.comment
	if (bodyText === undefined) throw new Error('addComment requires body.')
	const body = { body: toAdf(bodyText) }
	const path = `/issue/${encodeURIComponent(issueIdOrKey)}/comment`
	const preview = requireMutationPreview(input, 'addComment', 'POST', path, body)
	if (preview) return preview

	const result = await jiraFetch(input, path, { method: 'POST', body })
	return slimComment(result.data)
}

export async function listTransitions(input = {}) {
	const issueIdOrKey = requiredString(input, ['issueIdOrKey', 'issueKey', 'key'], 'listTransitions requires issueIdOrKey')
	const result = await jiraFetch(input, `/issue/${encodeURIComponent(issueIdOrKey)}/transitions`)
	const transitions = Array.isArray(result.data?.transitions) ? result.data.transitions : []
	return {
		issueIdOrKey,
		transitions: transitions.map(slimTransition),
	}
}

export async function transitionIssue(input = {}) {
	const issueIdOrKey = requiredString(
		input,
		['issueIdOrKey', 'issueKey', 'key'],
		'transitionIssue requires issueIdOrKey',
	)
	const transitionId = requiredString(input, ['transitionId'], 'transitionIssue requires transitionId')
	const body = {
		transition: { id: transitionId },
	}
	if (input.comment !== undefined) {
		body.update = {
			comment: [{ add: { body: toAdf(input.comment) } }],
		}
	}
	if (isRecord(input.fields)) body.fields = input.fields
	const path = `/issue/${encodeURIComponent(issueIdOrKey)}/transitions`
	const preview = requireMutationPreview(input, 'transitionIssue', 'POST', path, body)
	if (preview) return preview

	await jiraFetch(input, path, { method: 'POST', body })
	return { ok: true, issueIdOrKey, transitionId }
}

export async function request(input = {}) {
	if (!isRecord(input)) throw new Error('Jira request input must be an object.')
	let path = input.path
	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.')
	}
	path = path.startsWith('/') ? path : `/${path}`
	const method = String(input.method ?? (input.body === undefined ? 'GET' : 'POST')).toUpperCase()
	const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
	if (mutating) {
		const preview = requireMutationPreview(input, 'request', method, path, input.body)
		if (preview) return { ...preview, query: input.query ?? null }
	} else if (input.dryRun) {
		return { dryRun: true, method, path, query: input.query ?? null, body: input.body ?? null }
	}

	const result = await jiraFetch(input, path, {
		method,
		query: input.query,
		headers: input.headers,
		body: input.body,
	})
	return { status: result.status, data: result.data, auth: result.auth, account: result.account }
}

function trimMyself(myself) {
	return {
		accountId: myself?.accountId ?? null,
		accountType: myself?.accountType ?? null,
		active: myself?.active ?? null,
	}
}

/**
 * Verify Jira credentials with a read-only `/myself` (and OAuth site list).
 * Without credentials, returns setup URLs instead of throwing.
 */
export async function smokeTest(input = {}) {
	const setup = setupUrls(input)
	if (input?.dryRun) {
		return { ok: true, live: false, dryRun: true, method: 'GET', path: '/myself', setup }
	}

	try {
		const transport = await resolveTransport(input)
		let selectedSite = null
		if (transport.auth === 'oauth') {
			const resources = await listAccessibleResources(transport)
			if (input.site || input.cloudId) {
				selectedSite = matchSite(resources, input)
			} else if (resources.length === 1) {
				selectedSite = {
					id: resources[0]?.id ?? null,
					name: resources[0]?.name ?? null,
					url: resources[0]?.url ?? null,
				}
			}
			if (!selectedSite && resources.length !== 1) {
				return {
					ok: true,
					live: true,
					auth: 'oauth',
					account: transport.account,
					siteCount: resources.length,
					sites: resources.map((resource) => ({
						id: resource?.id ?? null,
						name: resource?.name ?? null,
						url: resource?.url ?? null,
					})),
					setup,
				}
			}
		}

		const result = await jiraFetch(
			{ ...input, cloudId: input.cloudId ?? selectedSite?.id, site: input.site },
			'/myself',
			{ transport },
		)
		return {
			ok: true,
			live: true,
			auth: result.auth,
			account: result.account,
			site: selectedSite ?? (result.site ? { url: result.site.url } : null),
			myself: trimMyself(result.data),
			setup,
		}
	} catch (error) {
		if (isAuthSetupFailure(error)) {
			return {
				ok: true,
				live: false,
				setup,
				reason: error instanceof Error ? error.message : String(error),
				status: error?.status ?? null,
			}
		}
		throw error
	}
}

const actions = {
	setup,
	'smoke-test': smokeTest,
	smoke: smokeTest,
	'sites': listSites,
	'list-sites': listSites,
	projects: listProjects,
	'list-projects': listProjects,
	'get-issue': getIssue,
	issue: getIssue,
	search: searchIssues,
	'search-issues': searchIssues,
	jql: searchIssues,
	'create-issue': createIssue,
	'list-comments': listComments,
	comments: listComments,
	'add-comment': addComment,
	'list-transitions': listTransitions,
	transitions: listTransitions,
	'transition-issue': transitionIssue,
	request,
}

/**
 * Dispatch Jira Cloud helper actions such as `list-projects` or `smoke-test`.
 * Defaults to `smoke-test`.
 */
export default async function jira(input = {}) {
	const action = input.action ?? 'smoke-test'
	const handler = actions[action]
	if (!handler) {
		throw new Error(
			`Unsupported Jira action: ${action}. Use one of: ${Object.keys(actions).sort().join(', ')}.`,
		)
	}
	return await handler(input)
}

export { DEFAULT_ACCOUNT, resolveAccount }