Fix(jira): reading multiple issues and write

fixed the read and write tools in jira
This commit is contained in:
Adam Gough
2025-09-06 20:48:49 -07:00
committed by GitHub
parent ced64129da
commit 07ba17422b
7 changed files with 268 additions and 227 deletions

View File

@@ -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. |

View File

@@ -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(

View File

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

View File

@@ -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:

View File

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

View File

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

View File

@@ -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 {