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