import type { JiraRetrieveBulkParams, JiraRetrieveResponseBulk } from '@/tools/jira/types' import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' import { extractAdfText, normalizeDomain } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' export const jiraBulkRetrieveTool: ToolConfig = { id: 'jira_bulk_read', name: 'Jira Bulk Read', description: 'Retrieve multiple Jira issues from a project in bulk', version: '1.0.0', oauth: { required: true, provider: 'jira', }, params: { accessToken: { type: 'string', required: true, visibility: 'hidden', description: 'OAuth access token for Jira', }, domain: { type: 'string', required: true, visibility: 'user-only', description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', }, projectId: { type: 'string', required: true, visibility: 'user-or-llm', description: 'Jira project key (e.g., PROJ)', }, cloudId: { type: 'string', required: false, visibility: 'hidden', description: 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', }, }, request: { url: () => 'https://api.atlassian.com/oauth/token/accessible-resources', method: 'GET', headers: (params: JiraRetrieveBulkParams) => ({ Authorization: `Bearer ${params.accessToken}`, Accept: 'application/json', }), }, transformResponse: async (response: Response, params?: JiraRetrieveBulkParams) => { const MAX_TOTAL = 1000 const PAGE_SIZE = 100 const resolveProjectKey = async (cloudId: string, accessToken: string, ref: string) => { const refTrimmed = (ref || '').trim() if (!refTrimmed) return refTrimmed const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${encodeURIComponent(refTrimmed)}` const resp = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, }) if (!resp.ok) return refTrimmed const project = await resp.json() return project?.key || refTrimmed } const resolveCloudId = async () => { if (params?.cloudId) return params.cloudId const accessibleResources = await response.json() if (!Array.isArray(accessibleResources) || accessibleResources.length === 0) { throw new Error('No Jira resources found') } const normalizedInput = normalizeDomain(params?.domain ?? '') const matchedResource = accessibleResources.find( (r: { url: string }) => r.url.toLowerCase().replace(/\/+$/, '') === normalizedInput ) if (matchedResource) return matchedResource.id if (accessibleResources.length === 1) return accessibleResources[0].id throw new Error( `Could not match Jira domain "${params?.domain}" to any accessible resource. ` + `Available sites: ${accessibleResources.map((r: { url: string }) => r.url).join(', ')}` ) } const cloudId = await resolveCloudId() const projectKey = await resolveProjectKey(cloudId, params!.accessToken, params!.projectId) if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(projectKey)) { throw new Error( `Invalid Jira project key "${projectKey}". Expected an alphanumeric project key (e.g., PROJ).` ) } const jql = `project = "${projectKey}" ORDER BY updated DESC` let collected: any[] = [] let nextPageToken: string | undefined let total: number | null = null while (collected.length < MAX_TOTAL) { const queryParams = new URLSearchParams({ jql, fields: 'summary,description,status,issuetype,priority,assignee,created,updated', maxResults: String(PAGE_SIZE), }) if (nextPageToken) queryParams.set('nextPageToken', nextPageToken) const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${queryParams.toString()}` const pageResponse = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${params!.accessToken}`, Accept: 'application/json', }, }) if (!pageResponse.ok) { let message = `Failed to bulk read Jira issues (${pageResponse.status})` try { const err = await pageResponse.json() message = err?.errorMessages?.join(', ') || err?.message || message } catch (_e) {} throw new Error(message) } const pageData = await pageResponse.json() const issues = pageData.issues || [] if (pageData.total != null) total = pageData.total collected = collected.concat(issues) if (pageData.isLast || !pageData.nextPageToken || issues.length === 0) break nextPageToken = pageData.nextPageToken } return { success: true, output: { ts: new Date().toISOString(), total, issues: collected.slice(0, MAX_TOTAL).map((issue: any) => ({ id: issue.id ?? '', key: issue.key ?? '', self: issue.self ?? '', summary: issue.fields?.summary ?? '', description: extractAdfText(issue.fields?.description), status: { id: issue.fields?.status?.id ?? '', name: issue.fields?.status?.name ?? '', }, issuetype: { id: issue.fields?.issuetype?.id ?? '', name: issue.fields?.issuetype?.name ?? '', }, priority: issue.fields?.priority ? { id: issue.fields.priority.id ?? '', name: issue.fields.priority.name ?? '' } : null, assignee: issue.fields?.assignee ? { accountId: issue.fields.assignee.accountId ?? '', displayName: issue.fields.assignee.displayName ?? '', } : null, created: issue.fields?.created ?? '', updated: issue.fields?.updated ?? '', })), nextPageToken: nextPageToken ?? null, isLast: !nextPageToken || collected.length >= MAX_TOTAL, }, } }, outputs: { ts: TIMESTAMP_OUTPUT, total: { type: 'number', description: 'Total number of issues in the project (may not always be available)', optional: true, }, issues: { type: 'array', description: 'Array of Jira issues', items: { type: 'object', properties: { id: { type: 'string', description: 'Issue ID' }, key: { type: 'string', description: 'Issue key (e.g., PROJ-123)' }, self: { type: 'string', description: 'REST API URL for this issue' }, summary: { type: 'string', description: 'Issue summary' }, description: { type: 'string', description: 'Issue description text', optional: true }, status: { type: 'object', description: 'Issue status', properties: { id: { type: 'string', description: 'Status ID' }, name: { type: 'string', description: 'Status name' }, }, }, issuetype: { type: 'object', description: 'Issue type', properties: { id: { type: 'string', description: 'Issue type ID' }, name: { type: 'string', description: 'Issue type name' }, }, }, priority: { type: 'object', description: 'Issue priority', properties: { id: { type: 'string', description: 'Priority ID' }, name: { type: 'string', description: 'Priority name' }, }, optional: true, }, assignee: { type: 'object', description: 'Assigned user', properties: { accountId: { type: 'string', description: 'Atlassian account ID' }, displayName: { type: 'string', description: 'Display name' }, }, optional: true, }, created: { type: 'string', description: 'ISO 8601 creation timestamp' }, updated: { type: 'string', description: 'ISO 8601 last updated timestamp' }, }, }, }, nextPageToken: { type: 'string', description: 'Cursor token for the next page. Null when no more results.', optional: true, }, isLast: { type: 'boolean', description: 'Whether this is the last page of results' }, }, }