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, } }