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 }