From 07ba17422b0a9af84707189d112a59e9305d3c7d Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Sat, 6 Sep 2025 20:48:49 -0700 Subject: [PATCH] Fix(jira): reading multiple issues and write fixed the read and write tools in jira --- apps/docs/content/docs/tools/jira.mdx | 2 +- apps/sim/app/api/tools/jira/issues/route.ts | 203 +++++++++----------- apps/sim/app/api/tools/jira/write/route.ts | 7 +- apps/sim/blocks/blocks/jira.ts | 29 ++- apps/sim/tools/jira/bulk_read.ts | 194 +++++++++++-------- apps/sim/tools/jira/retrieve.ts | 56 ++++-- apps/sim/tools/jira/types.ts | 4 +- 7 files changed, 268 insertions(+), 227 deletions(-) diff --git a/apps/docs/content/docs/tools/jira.mdx b/apps/docs/content/docs/tools/jira.mdx index 81fabb91cb..ed729b2c70 100644 --- a/apps/docs/content/docs/tools/jira.mdx +++ b/apps/docs/content/docs/tools/jira.mdx @@ -58,7 +58,7 @@ Retrieve detailed information about a specific Jira issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | -| `projectId` | string | No | Jira project ID to retrieve issues from. If not provided, all issues will be retrieved. | +| `projectId` | string | No | Jira project ID \(optional; not required to retrieve a single issue\). | | `issueKey` | string | Yes | Jira issue key to retrieve \(e.g., PROJ-123\) | | `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts index 9e89fa1cd1..7310dd95de 100644 --- a/apps/sim/app/api/tools/jira/issues/route.ts +++ b/apps/sim/app/api/tools/jira/issues/route.ts @@ -6,17 +6,32 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JiraIssuesAPI') +// Helper functions +const createErrorResponse = async (response: Response, defaultMessage: string) => { + try { + const errorData = await response.json() + return errorData.message || errorData.errorMessages?.[0] || defaultMessage + } catch { + return defaultMessage + } +} + +const validateRequiredParams = (domain: string | null, accessToken: string | null) => { + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + if (!accessToken) { + return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) + } + return null +} + export async function POST(request: Request) { try { const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json() - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } + const validationError = validateRequiredParams(domain || null, accessToken || null) + if (validationError) return validationError if (issueKeys.length === 0) { logger.info('No issue keys provided, returning empty result') @@ -24,7 +39,7 @@ export async function POST(request: Request) { } // Use provided cloudId or fetch it if not provided - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) + const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!)) // Build the URL using cloudId for Jira API const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/bulkfetch` @@ -53,47 +68,24 @@ export async function POST(request: Request) { if (!response.ok) { logger.error(`Jira API error: ${response.status} ${response.statusText}`) - let errorMessage - - try { - const errorData = await response.json() - logger.error('Error details:', JSON.stringify(errorData, null, 2)) - errorMessage = errorData.message || `Failed to fetch Jira issues (${response.status})` - } catch (e) { - logger.error('Could not parse error response as JSON:', e) - - try { - const _text = await response.text() - errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}` - } catch (_textError) { - errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}` - } - } - + const errorMessage = await createErrorResponse( + response, + `Failed to fetch Jira issues (${response.status})` + ) return NextResponse.json({ error: errorMessage }, { status: response.status }) } const data = await response.json() + const issues = (data.issues || []).map((issue: any) => ({ + id: issue.key, + name: issue.fields.summary, + mimeType: 'jira/issue', + url: `https://${domain}/browse/${issue.key}`, + modifiedTime: issue.fields.updated, + webViewLink: `https://${domain}/browse/${issue.key}`, + })) - if (data.issues && data.issues.length > 0) { - data.issues.slice(0, 3).forEach((issue: any) => { - logger.info(`- ${issue.key}: ${issue.fields.summary}`) - }) - } - - return NextResponse.json({ - issues: data.issues - ? data.issues.map((issue: any) => ({ - id: issue.key, - name: issue.fields.summary, - mimeType: 'jira/issue', - url: `https://${domain}/browse/${issue.key}`, - modifiedTime: issue.fields.updated, - webViewLink: `https://${domain}/browse/${issue.key}`, - })) - : [], - cloudId, // Return the cloudId so it can be cached - }) + return NextResponse.json({ issues, cloudId }) } catch (error) { logger.error('Error fetching Jira issues:', error) return NextResponse.json( @@ -111,83 +103,79 @@ export async function GET(request: Request) { const providedCloudId = url.searchParams.get('cloudId') const query = url.searchParams.get('query') || '' const projectId = url.searchParams.get('projectId') || '' + const manualProjectId = url.searchParams.get('manualProjectId') || '' + const all = url.searchParams.get('all')?.toLowerCase() === 'true' + const limitParam = Number.parseInt(url.searchParams.get('limit') || '', 10) + const limit = Number.isFinite(limitParam) && limitParam > 0 ? limitParam : 0 - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - // Use provided cloudId or fetch it if not provided - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) - logger.info('Using cloud ID:', cloudId) - - // Build query parameters - const params = new URLSearchParams() - - // Only add query if it exists - if (query) { - params.append('query', query) - } + const validationError = validateRequiredParams(domain || null, accessToken || null) + if (validationError) return validationError + const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!)) let data: any if (query) { - const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}` - logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`) + const params = new URLSearchParams({ query }) + const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params}` const response = await fetch(apiUrl, { - method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }) - logger.info('Response status:', response.status, response.statusText) + if (!response.ok) { - logger.error(`Jira API error: ${response.status} ${response.statusText}`) - let errorMessage - try { - const errorData = await response.json() - logger.error('Error details:', errorData) - errorMessage = - errorData.message || `Failed to fetch issue suggestions (${response.status})` - } catch (_e) { - errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}` - } + const errorMessage = await createErrorResponse( + response, + `Failed to fetch issue suggestions (${response.status})` + ) return NextResponse.json({ error: errorMessage }, { status: response.status }) } data = await response.json() - } else if (projectId) { - // When no query, list latest issues for the selected project using Search API - const searchParams = new URLSearchParams() - searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`) - searchParams.append('maxResults', '25') - searchParams.append('fields', 'summary,key') - const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}` - logger.info(`Fetching Jira issues via search from: ${searchUrl}`) - const response = await fetch(searchUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - if (!response.ok) { - let errorMessage - try { - const errorData = await response.json() - logger.error('Jira Search API error details:', errorData) - errorMessage = - errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})` - } catch (_e) { - errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}` - } - return NextResponse.json({ error: errorMessage }, { status: response.status }) + } else if (projectId || manualProjectId) { + const SAFETY_CAP = 1000 + const PAGE_SIZE = 100 + const target = Math.min(all ? limit || SAFETY_CAP : 25, SAFETY_CAP) + const projectKey = (projectId || manualProjectId).trim() + + const buildSearchUrl = (startAt: number) => { + const params = new URLSearchParams({ + jql: `project=${projectKey} ORDER BY updated DESC`, + maxResults: String(Math.min(PAGE_SIZE, target)), + startAt: String(startAt), + fields: 'summary,key,updated', + }) + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params}` } - const searchData = await response.json() - const issues = (searchData.issues || []).map((it: any) => ({ + + let startAt = 0 + let collected: any[] = [] + let total = 0 + + do { + const response = await fetch(buildSearchUrl(startAt), { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorMessage = await createErrorResponse( + response, + `Failed to fetch issues (${response.status})` + ) + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const page = await response.json() + const issues = page.issues || [] + total = page.total || issues.length + collected = collected.concat(issues) + startAt += PAGE_SIZE + } while (all && collected.length < Math.min(total, target)) + + const issues = collected.slice(0, target).map((it: any) => ({ key: it.key, summary: it.fields?.summary || it.key, })) @@ -196,10 +184,7 @@ export async function GET(request: Request) { data = { sections: [], cloudId } } - return NextResponse.json({ - ...data, - cloudId, // Return the cloudId so it can be cached - }) + return NextResponse.json({ ...data, cloudId }) } catch (error) { logger.error('Error fetching Jira issue suggestions:', error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/jira/write/route.ts b/apps/sim/app/api/tools/jira/write/route.ts index 6cbfdfff01..fc4eab419b 100644 --- a/apps/sim/app/api/tools/jira/write/route.ts +++ b/apps/sim/app/api/tools/jira/write/route.ts @@ -42,10 +42,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Summary is required' }, { status: 400 }) } - if (!issueType) { - logger.error('Missing issue type in request') - return NextResponse.json({ error: 'Issue type is required' }, { status: 400 }) - } + const normalizedIssueType = issueType || 'Task' // Use provided cloudId or fetch it if not provided const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) @@ -62,7 +59,7 @@ export async function POST(request: Request) { id: projectId, }, issuetype: { - name: issueType, + name: normalizedIssueType, }, summary: summary, } diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index 2a0a79544e..d93a6e08d4 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -20,7 +20,6 @@ export const JiraBlock: BlockConfig = { layout: 'full', options: [ { label: 'Read Issue', id: 'read' }, - { label: 'Read Issues', id: 'read-bulk' }, { label: 'Update Issue', id: 'update' }, { label: 'Write Issue', id: 'write' }, ], @@ -99,7 +98,7 @@ export const JiraBlock: BlockConfig = { layout: 'full', canonicalParamId: 'issueKey', placeholder: 'Enter Jira issue key', - dependsOn: ['credential', 'domain', 'projectId'], + dependsOn: ['credential', 'domain', 'projectId', 'manualProjectId'], condition: { field: 'operation', value: ['read', 'update'] }, mode: 'advanced', }, @@ -127,8 +126,15 @@ export const JiraBlock: BlockConfig = { access: ['jira_retrieve', 'jira_update', 'jira_write', 'jira_bulk_read'], config: { tool: (params) => { + const effectiveProjectId = (params.projectId || params.manualProjectId || '').trim() + const effectiveIssueKey = (params.issueKey || params.manualIssueKey || '').trim() + switch (params.operation) { case 'read': + // If a project is selected but no issue is chosen, route to bulk read + if (effectiveProjectId && !effectiveIssueKey) { + return 'jira_bulk_read' + } return 'jira_retrieve' case 'update': return 'jira_update' @@ -194,25 +200,34 @@ export const JiraBlock: BlockConfig = { } } case 'read': { - if (!effectiveIssueKey) { + // Check for project ID from either source + const projectForRead = (params.projectId || params.manualProjectId || '').trim() + const issueForRead = (params.issueKey || params.manualIssueKey || '').trim() + + if (!issueForRead) { throw new Error( - 'Issue Key is required. Please select an issue or enter an issue key manually.' + 'Select a project to read issues, or provide an issue key to read a single issue.' ) } return { ...baseParams, - issueKey: effectiveIssueKey, + issueKey: issueForRead, + // Include projectId if available for context + ...(projectForRead && { projectId: projectForRead }), } } case 'read-bulk': { - if (!effectiveProjectId) { + // Check both projectId and manualProjectId directly from params + const finalProjectId = params.projectId || params.manualProjectId || '' + + if (!finalProjectId) { throw new Error( 'Project ID is required. Please select a project or enter a project ID manually.' ) } return { ...baseParams, - projectId: effectiveProjectId, + projectId: finalProjectId.trim(), } } default: diff --git a/apps/sim/tools/jira/bulk_read.ts b/apps/sim/tools/jira/bulk_read.ts index 6554ed7285..f1940e1622 100644 --- a/apps/sim/tools/jira/bulk_read.ts +++ b/apps/sim/tools/jira/bulk_read.ts @@ -43,7 +43,10 @@ export const jiraBulkRetrieveTool: ToolConfig { if (params.cloudId) { - return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/picker?currentJQL=project=${params.projectId}` + const base = `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/search` + // Don't encode JQL here - transformResponse will handle project resolution + // Initial page; transformResponse will paginate to retrieve all (with a safety cap) + return `${base}?maxResults=100&startAt=0&fields=summary,description,created,updated` } // If no cloudId, use the accessible resources endpoint return 'https://api.atlassian.com/oauth/token/accessible-resources' @@ -57,7 +60,40 @@ export const jiraBulkRetrieveTool: ToolConfig { - // If we don't have a cloudId, we need to fetch it first + const MAX_TOTAL = 1000 + const PAGE_SIZE = 100 + + // Helper to extract description text safely (ADF can be nested) + const extractDescription = (desc: any): string => { + try { + return ( + desc?.content?.[0]?.content?.[0]?.text || + desc?.content?.flatMap((c: any) => c?.content || [])?.find((c: any) => c?.text)?.text || + '' + ) + } catch (_e) { + return '' + } + } + + // Helper to resolve a project reference (id or key) to its canonical key + 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) { + // If can't resolve, fall back to original ref (JQL can still work with id or key) + return refTrimmed + } + const project = await resp.json() + return project?.key || refTrimmed + } + + // If we don't have a cloudId, look it up first if (!params?.cloudId) { const accessibleResources = await response.json() const normalizedInput = `https://${params?.domain}`.toLowerCase() @@ -65,99 +101,89 @@ export const jiraBulkRetrieveTool: ToolConfig r.url.toLowerCase() === normalizedInput ) - // First get issue keys from picker - const pickerUrl = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/issue/picker?currentJQL=project=${params?.projectId}` - const pickerResponse = await fetch(pickerUrl, { + const base = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/search` + const projectKey = await resolveProjectKey( + matchedResource.id, + params!.accessToken, + params!.projectId + ) + const jql = encodeURIComponent(`project=${projectKey} ORDER BY updated DESC`) + + let startAt = 0 + let collected: any[] = [] + let total = 0 + + while (startAt < MAX_TOTAL) { + const url = `${base}?jql=${jql}&maxResults=${PAGE_SIZE}&startAt=${startAt}&fields=summary,description,created,updated` + const pageResponse = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${params?.accessToken}`, + Accept: 'application/json', + }, + }) + + const pageData = await pageResponse.json() + const issues = pageData.issues || [] + total = pageData.total || issues.length + collected = collected.concat(issues) + + if (collected.length >= Math.min(total, MAX_TOTAL) || issues.length === 0) break + startAt += PAGE_SIZE + } + + return { + success: true, + output: collected.slice(0, MAX_TOTAL).map((issue: any) => ({ + ts: new Date().toISOString(), + summary: issue.fields?.summary, + description: extractDescription(issue.fields?.description), + created: issue.fields?.created, + updated: issue.fields?.updated, + })), + } + } + + // cloudId present: resolve project and paginate using the Search API + // Resolve to canonical project key for consistent JQL + const projectKey = await resolveProjectKey( + params!.cloudId!, + params!.accessToken, + params!.projectId + ) + + const base = `https://api.atlassian.com/ex/jira/${params?.cloudId}/rest/api/3/search` + const jql = encodeURIComponent(`project=${projectKey} ORDER BY updated DESC`) + + // Always do full pagination with resolved key + let collected: any[] = [] + let total = 0 + let startAt = 0 + while (startAt < MAX_TOTAL) { + const url = `${base}?jql=${jql}&maxResults=${PAGE_SIZE}&startAt=${startAt}&fields=summary,description,created,updated` + const pageResponse = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${params?.accessToken}`, Accept: 'application/json', }, }) - - const pickerData = await pickerResponse.json() - const issueKeys = pickerData.sections - .flatMap((section: any) => section.issues || []) - .map((issue: any) => issue.key) - - if (issueKeys.length === 0) { - return { - success: true, - output: [], - } - } - - // Now use bulkfetch to get the full issue details - const bulkfetchUrl = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/issue/bulkfetch` - const bulkfetchResponse = await fetch(bulkfetchUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${params?.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - expand: ['names'], - fields: ['summary', 'description', 'created', 'updated'], - fieldsByKeys: false, - issueIdsOrKeys: issueKeys, - properties: [], - }), - }) - - const data = await bulkfetchResponse.json() - return { - success: true, - output: data.issues.map((issue: any) => ({ - ts: new Date().toISOString(), - summary: issue.fields.summary, - description: issue.fields.description?.content?.[0]?.content?.[0]?.text || '', - created: issue.fields.created, - updated: issue.fields.updated, - })), - } + const pageData = await pageResponse.json() + const issues = pageData.issues || [] + total = pageData.total || issues.length + collected = collected.concat(issues) + if (issues.length === 0 || collected.length >= Math.min(total, MAX_TOTAL)) break + startAt += PAGE_SIZE } - // If we have a cloudId, this response is from the issue picker - const pickerData = await response.json() - const issueKeys = pickerData.sections - .flatMap((section: any) => section.issues || []) - .map((issue: any) => issue.key) - - if (issueKeys.length === 0) { - return { - success: true, - output: [], - } - } - - // Use bulkfetch to get the full issue details - const bulkfetchUrl = `https://api.atlassian.com/ex/jira/${params?.cloudId}/rest/api/3/issue/bulkfetch` - const bulkfetchResponse = await fetch(bulkfetchUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${params?.accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - expand: ['names'], - fields: ['summary', 'description', 'created', 'updated'], - fieldsByKeys: false, - issueIdsOrKeys: issueKeys, - properties: [], - }), - }) - - const data = await bulkfetchResponse.json() return { success: true, - output: data.issues.map((issue: any) => ({ + output: collected.slice(0, MAX_TOTAL).map((issue: any) => ({ ts: new Date().toISOString(), - summary: issue.fields.summary, - description: issue.fields.description?.content?.[0]?.content?.[0]?.text || '', - created: issue.fields.created, - updated: issue.fields.updated, + summary: issue.fields?.summary, + description: extractDescription(issue.fields?.description), + created: issue.fields?.created, + updated: issue.fields?.updated, })), } }, diff --git a/apps/sim/tools/jira/retrieve.ts b/apps/sim/tools/jira/retrieve.ts index a0d4cd3ca5..51b65ba7fc 100644 --- a/apps/sim/tools/jira/retrieve.ts +++ b/apps/sim/tools/jira/retrieve.ts @@ -1,4 +1,5 @@ import type { JiraRetrieveParams, JiraRetrieveResponse } from '@/tools/jira/types' +import { getJiraCloudId } from '@/tools/jira/utils' import type { ToolConfig } from '@/tools/types' export const jiraRetrieveTool: ToolConfig = { @@ -30,8 +31,7 @@ export const jiraRetrieveTool: ToolConfig { - // If we don't have a cloudId, we need to fetch it first - if (!params?.cloudId) { - const accessibleResources = await response.json() - const normalizedInput = `https://${params?.domain}`.toLowerCase() - const matchedResource = accessibleResources.find( - (r: any) => r.url.toLowerCase() === normalizedInput + if (!params?.issueKey) { + throw new Error( + 'Select a project to read issues, or provide an issue key to read a single issue.' ) + } + // If we don't have a cloudId, resolve it robustly using the Jira utils helper + if (!params?.cloudId) { + const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) // Now fetch the actual issue with the found cloudId - const issueUrl = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/issue/${params?.issueKey}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog` + const issueUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${params?.issueKey}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog` const issueResponse = await fetch(issueUrl, { method: 'GET', headers: { @@ -84,31 +85,48 @@ export const jiraRetrieveTool: ToolConfig