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 }