diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts index ea11563381..99f58d67ff 100644 --- a/apps/sim/app/api/tools/jira/issues/route.ts +++ b/apps/sim/app/api/tools/jira/issues/route.ts @@ -45,27 +45,22 @@ export async function POST(request: Request) { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/bulkfetch` + // Use search/jql endpoint (GET) with URL parameters + const jql = `issueKey in (${issueKeys.map((k: string) => k.trim()).join(',')})` + const params = new URLSearchParams({ + jql, + fields: 'summary,status,assignee,updated,project', + maxResults: String(Math.min(issueKeys.length, 100)), + }) + const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` - const requestBody = { - expand: ['names'], - fields: ['summary', 'status', 'assignee', 'updated', 'project'], - fieldsByKeys: false, - issueIdsOrKeys: issueKeys, - properties: [], - } - - const requestConfig = { - method: 'POST', + const response = await fetch(searchUrl, { + method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', - 'Content-Type': 'application/json', }, - body: JSON.stringify(requestBody), - } - - const response = await fetch(url, requestConfig) + }) if (!response.ok) { logger.error(`Jira API error: ${response.status} ${response.statusText}`) @@ -73,17 +68,27 @@ export async function POST(request: Request) { response, `Failed to fetch Jira issues (${response.status})` ) + if (response.status === 401 || response.status === 403) { + return NextResponse.json( + { + error: errorMessage, + authRequired: true, + requiredScopes: ['read:jira-work', 'read:project:jira'], + }, + { status: 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, + const issues = (data.issues || []).map((it: any) => ({ + id: it.key, + name: it.fields?.summary || it.key, mimeType: 'jira/issue', - url: `https://${domain}/browse/${issue.key}`, - modifiedTime: issue.fields.updated, - webViewLink: `https://${domain}/browse/${issue.key}`, + url: `https://${domain}/browse/${it.key}`, + modifiedTime: it.fields?.updated, + webViewLink: `https://${domain}/browse/${it.key}`, })) return NextResponse.json({ issues, cloudId }) @@ -138,38 +143,34 @@ export async function GET(request: Request) { let data: any - if (query) { - 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, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - 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 || manualProjectId) { + if (query || 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 projectKey = (projectId || manualProjectId || '').trim() - const buildSearchUrl = (startAt: number) => { + const escapeJql = (s: string) => s.replace(/"/g, '\\"') + + const buildJql = (startAt: number) => { + const jqlParts: string[] = [] + if (projectKey) jqlParts.push(`project = ${projectKey}`) + if (query) { + const q = escapeJql(query) + // Match by key prefix or summary text + jqlParts.push(`(key ~ "${q}" OR summary ~ "${q}")`) + } + const jql = `${jqlParts.length ? `${jqlParts.join(' AND ')} ` : ''}ORDER BY updated DESC` const params = new URLSearchParams({ - jql: `project=${projectKey} ORDER BY updated DESC`, - maxResults: String(Math.min(PAGE_SIZE, target)), - startAt: String(startAt), + jql, fields: 'summary,key,updated', + maxResults: String(Math.min(PAGE_SIZE, target)), }) - return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params}` + if (startAt > 0) { + params.set('startAt', String(startAt)) + } + return { + url: `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}`, + } } let startAt = 0 @@ -177,7 +178,9 @@ export async function GET(request: Request) { let total = 0 do { - const response = await fetch(buildSearchUrl(startAt), { + const { url: apiUrl } = buildJql(startAt) + const response = await fetch(apiUrl, { + method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', @@ -189,6 +192,16 @@ export async function GET(request: Request) { response, `Failed to fetch issues (${response.status})` ) + if (response.status === 401 || response.status === 403) { + return NextResponse.json( + { + error: errorMessage, + authRequired: true, + requiredScopes: ['read:jira-work', 'read:project:jira'], + }, + { status: response.status } + ) + } return NextResponse.json({ error: errorMessage }, { status: response.status }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx index ae74918456..fc83083828 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' import { JiraIcon } from '@/components/icons' import { Button } from '@/components/ui/button' @@ -75,7 +75,6 @@ export function JiraIssueSelector({ const [selectedIssue, setSelectedIssue] = useState(null) const [isLoading, setIsLoading] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false) - const initialFetchRef = useRef(false) const [error, setError] = useState(null) const [cloudId, setCloudId] = useState(null) @@ -123,17 +122,17 @@ export function JiraIssueSelector({ return getServiceIdFromScopes(provider, requiredScopes) } - // Determine the appropriate provider ID based on service and scopes - const getProviderId = (): string => { + // Determine the appropriate provider ID based on service and scopes (stabilized) + const providerId = useMemo(() => { const effectiveServiceId = getServiceId() return getProviderIdFromServiceId(effectiveServiceId) - } + }, [serviceId, provider, requiredScopes]) // Fetch available credentials for this provider const fetchCredentials = useCallback(async () => { + if (!providerId) return setIsLoading(true) try { - const providerId = getProviderId() const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) if (response.ok) { @@ -145,7 +144,7 @@ export function JiraIssueSelector({ } finally { setIsLoading(false) } - }, [provider, getProviderId, selectedCredentialId]) + }, [providerId]) // Fetch issue info when we have a selected issue ID const fetchIssueInfo = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx index 6830190538..00abbb1388 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' import { JiraIcon } from '@/components/icons' import { Button } from '@/components/ui/button' @@ -114,17 +114,17 @@ export function JiraProjectSelector({ return getServiceIdFromScopes(provider, requiredScopes) } - // Determine the appropriate provider ID based on service and scopes - const getProviderId = (): string => { + // Determine the appropriate provider ID based on service and scopes (stabilized) + const providerId = useMemo(() => { const effectiveServiceId = getServiceId() return getProviderIdFromServiceId(effectiveServiceId) - } + }, [serviceId, provider, requiredScopes]) // Fetch available credentials for this provider const fetchCredentials = useCallback(async () => { + if (!providerId) return setIsLoading(true) try { - const providerId = getProviderId() const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) if (response.ok) { @@ -137,7 +137,7 @@ export function JiraProjectSelector({ } finally { setIsLoading(false) } - }, [provider, getProviderId, selectedCredentialId]) + }, [providerId]) // Fetch detailed project information const fetchProjectInfo = useCallback( diff --git a/apps/sim/tools/jira/bulk_read.ts b/apps/sim/tools/jira/bulk_read.ts index f1940e1622..73f4c31bc4 100644 --- a/apps/sim/tools/jira/bulk_read.ts +++ b/apps/sim/tools/jira/bulk_read.ts @@ -42,13 +42,7 @@ export const jiraBulkRetrieveTool: ToolConfig { - if (params.cloudId) { - 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 + // Always return accessible resources endpoint; transformResponse will build search URLs return 'https://api.atlassian.com/oauth/token/accessible-resources' }, method: 'GET', @@ -56,7 +50,15 @@ export const jiraBulkRetrieveTool: ToolConfig ({}), + body: (params: JiraRetrieveBulkParams) => + params.cloudId + ? { + jql: '', // Will be set in transformResponse when we know the resolved project key + startAt: 0, + maxResults: 100, + fields: ['summary', 'description', 'created', 'updated'], + } + : {}, }, transformResponse: async (response: Response, params?: JiraRetrieveBulkParams) => { @@ -101,20 +103,27 @@ export const jiraBulkRetrieveTool: ToolConfig r.url.toLowerCase() === normalizedInput ) - 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`) + const jql = `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 queryParams = new URLSearchParams({ + jql, + fields: 'summary,description,created,updated', + maxResults: String(PAGE_SIZE), + }) + if (startAt > 0) { + queryParams.set('startAt', String(startAt)) + } + const url = `https://api.atlassian.com/ex/jira/${matchedResource.id}/rest/api/3/search/jql?${queryParams.toString()}` const pageResponse = await fetch(url, { method: 'GET', headers: { @@ -152,15 +161,22 @@ export const jiraBulkRetrieveTool: ToolConfig 0) { + queryParams.set('startAt', String(startAt)) + } + const url = `https://api.atlassian.com/ex/jira/${params?.cloudId}/rest/api/3/search/jql?${queryParams.toString()}` const pageResponse = await fetch(url, { method: 'GET', headers: {