mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Fix(jira): reading multiple issues and write
fixed the read and write tools in jira
This commit is contained in:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
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<JiraResponse> = {
|
||||
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<JiraResponse> = {
|
||||
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<JiraResponse> = {
|
||||
}
|
||||
}
|
||||
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:
|
||||
|
||||
@@ -43,7 +43,10 @@ export const jiraBulkRetrieveTool: ToolConfig<JiraRetrieveBulkParams, JiraRetrie
|
||||
request: {
|
||||
url: (params: JiraRetrieveBulkParams) => {
|
||||
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<JiraRetrieveBulkParams, JiraRetrie
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: JiraRetrieveBulkParams) => {
|
||||
// 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<JiraRetrieveBulkParams, JiraRetrie
|
||||
(r: any) => 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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<JiraRetrieveParams, JiraRetrieveResponse> = {
|
||||
@@ -30,8 +31,7 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description:
|
||||
'Jira project ID to retrieve issues from. If not provided, all issues will be retrieved.',
|
||||
description: 'Jira project ID (optional; not required to retrieve a single issue).',
|
||||
},
|
||||
issueKey: {
|
||||
type: 'string',
|
||||
@@ -66,16 +66,17 @@ export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveRespon
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: JiraRetrieveParams) => {
|
||||
// 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<JiraRetrieveParams, JiraRetrieveRespon
|
||||
},
|
||||
})
|
||||
|
||||
if (!issueResponse.ok) {
|
||||
let message = `Failed to fetch Jira issue (${issueResponse.status})`
|
||||
try {
|
||||
const err = await issueResponse.json()
|
||||
message = err?.message || err?.errorMessages?.[0] || message
|
||||
} catch (_e) {}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const data = await issueResponse.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: data.key,
|
||||
summary: data.fields.summary,
|
||||
description: data.fields.description,
|
||||
created: data.fields.created,
|
||||
updated: data.fields.updated,
|
||||
issueKey: data?.key,
|
||||
summary: data?.fields?.summary,
|
||||
description: data?.fields?.description,
|
||||
created: data?.fields?.created,
|
||||
updated: data?.fields?.updated,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a cloudId, this response is the issue data
|
||||
if (!response.ok) {
|
||||
let message = `Failed to fetch Jira issue (${response.status})`
|
||||
try {
|
||||
const err = await response.json()
|
||||
message = err?.message || err?.errorMessages?.[0] || message
|
||||
} catch (_e) {}
|
||||
throw new Error(message)
|
||||
}
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: data.key,
|
||||
summary: data.fields.summary,
|
||||
description: data.fields.description,
|
||||
created: data.fields.created,
|
||||
updated: data.fields.updated,
|
||||
issueKey: data?.key,
|
||||
summary: data?.fields?.summary,
|
||||
description: data?.fields?.description,
|
||||
created: data?.fields?.created,
|
||||
updated: data?.fields?.updated,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface JiraRetrieveParams {
|
||||
accessToken: string
|
||||
issueKey: string
|
||||
domain: string
|
||||
cloudId: string
|
||||
cloudId?: string
|
||||
}
|
||||
|
||||
export interface JiraRetrieveResponse extends ToolResponse {
|
||||
@@ -22,7 +22,7 @@ export interface JiraRetrieveBulkParams {
|
||||
accessToken: string
|
||||
domain: string
|
||||
projectId: string
|
||||
cloudId: string
|
||||
cloudId?: string
|
||||
}
|
||||
|
||||
export interface JiraRetrieveResponseBulk extends ToolResponse {
|
||||
|
||||
Reference in New Issue
Block a user