diff --git a/docs/content/docs/tools/jira.mdx b/docs/content/docs/tools/jira.mdx index 987ad05e9..626ad1e54 100644 --- a/docs/content/docs/tools/jira.mdx +++ b/docs/content/docs/tools/jira.mdx @@ -114,6 +114,25 @@ Write a Jira issue | `success` | string | | `url` | string | +### `jira_bulk_read` + +Retrieve multiple Jira issues in bulk + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | OAuth access token for Jira | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `projectId` | string | Yes | Jira project ID | +| `cloudId` | string | No | Jira cloud ID | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `issues` | array | + ## Block Configuration diff --git a/sim/blocks/blocks/jira.ts b/sim/blocks/blocks/jira.ts index cac6caf87..dd91cca36 100644 --- a/sim/blocks/blocks/jira.ts +++ b/sim/blocks/blocks/jira.ts @@ -1,8 +1,8 @@ import { JiraIcon } from '@/components/icons' import { BlockConfig } from '../types' -import { JiraRetrieveResponse, JiraUpdateResponse, JiraWriteResponse } from '@/tools/jira/types' +import { JiraRetrieveResponse, JiraUpdateResponse, JiraWriteResponse, JiraRetrieveResponseBulk } from '@/tools/jira/types' -type JiraResponse = JiraRetrieveResponse | JiraUpdateResponse | JiraWriteResponse +type JiraResponse = JiraRetrieveResponse | JiraUpdateResponse | JiraWriteResponse | JiraRetrieveResponseBulk export const JiraBlock: BlockConfig = { type: 'jira', @@ -22,6 +22,7 @@ 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' }, ], @@ -60,7 +61,6 @@ export const JiraBlock: BlockConfig = { provider: 'jira', serviceId: 'jira', placeholder: 'Select Jira project', - condition: { field: 'operation', value: ['read', 'update', 'write'] }, }, { id: 'issueKey', @@ -90,7 +90,7 @@ export const JiraBlock: BlockConfig = { }, ], tools: { - access: ['jira_retrieve', 'jira_update', 'jira_write'], + access: ['jira_retrieve', 'jira_update', 'jira_write', 'jira_bulk_read'], config: { tool: (params) => { switch (params.operation) { @@ -100,6 +100,8 @@ export const JiraBlock: BlockConfig = { return 'jira_update' case 'write': return 'jira_write' + case 'read-bulk': + return 'jira_bulk_read' default: return 'jira_retrieve' } @@ -149,6 +151,13 @@ export const JiraBlock: BlockConfig = { issueKey: params.issueKey, } } + case 'read-bulk': { + // For read-bulk operations, only include read-bulk-specific fields + return { + ...baseParams, + projectId: params.projectId, + } + } default: return baseParams } diff --git a/sim/lib/auth.ts b/sim/lib/auth.ts index ee08506d2..05dcc5be6 100644 --- a/sim/lib/auth.ts +++ b/sim/lib/auth.ts @@ -490,6 +490,7 @@ export const auth = betterAuth({ responseType: 'code', pkce: true, accessType: 'offline', + authentication: 'basic', prompt: 'consent', redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/confluence`, getUserInfo: async (tokens) => { @@ -534,7 +535,6 @@ export const auth = betterAuth({ clientId: process.env.JIRA_CLIENT_ID as string, clientSecret: process.env.JIRA_CLIENT_SECRET as string, authorizationUrl: 'https://auth.atlassian.com/authorize', - prompt: 'consent', tokenUrl: 'https://auth.atlassian.com/oauth/token', userInfoUrl: 'https://api.atlassian.com/me', scopes: [ @@ -557,6 +557,11 @@ export const auth = betterAuth({ 'read:field-configuration:jira', 'read:issue-details:jira' ], + responseType: 'code', + pkce: true, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/jira`, getUserInfo: async (tokens) => { try { diff --git a/sim/lib/oauth.ts b/sim/lib/oauth.ts index 10df3d024..027f1d9a2 100644 --- a/sim/lib/oauth.ts +++ b/sim/lib/oauth.ts @@ -407,11 +407,13 @@ export async function refreshOAuthToken( tokenEndpoint = 'https://auth.atlassian.com/oauth/token' clientId = process.env.CONFLUENCE_CLIENT_ID clientSecret = process.env.CONFLUENCE_CLIENT_SECRET + useBasicAuth = true break case 'jira': tokenEndpoint = 'https://auth.atlassian.com/oauth/token' clientId = process.env.JIRA_CLIENT_ID clientSecret = process.env.JIRA_CLIENT_SECRET + useBasicAuth = true break case 'airtable': tokenEndpoint = 'https://airtable.com/oauth2/v1/token' @@ -466,8 +468,8 @@ export async function refreshOAuthToken( } else { throw new Error('Both client ID and client secret are required for Airtable OAuth') } - } else if (provider === 'x') { - // Handle X differently + } else if (provider === 'x' || provider === 'confluence' || provider === 'jira') { + // Handle X and Atlassian services (Confluence, Jira) the same way // Confidential client - use Basic Auth const authString = `${clientId}:${clientSecret}` const basicAuth = Buffer.from(authString).toString('base64') @@ -475,6 +477,7 @@ export async function refreshOAuthToken( // When using Basic Auth, don't include client_id in body delete bodyParams.client_id + delete bodyParams.client_secret } else { // For other providers, use the general approach if (useBasicAuth) { diff --git a/sim/tools/jira/bulk_read.ts b/sim/tools/jira/bulk_read.ts new file mode 100644 index 000000000..b2c05146c --- /dev/null +++ b/sim/tools/jira/bulk_read.ts @@ -0,0 +1,204 @@ +import { ToolConfig } from '../types' + import { JiraRetrieveBulkParams, JiraRetrieveResponseBulk } from './types' + + export const jiraBulkRetrieveTool: ToolConfig = { + id: 'jira_bulk_read', + name: 'Jira Bulk Read', + description: 'Retrieve multiple Jira issues in bulk', + version: '1.0.0', + oauth: { + required: true, + provider: 'jira', + additionalScopes: [ + 'read:jira-work', + 'read:jira-user', + 'read:me', + 'offline_access', + ], + }, + params: { + accessToken: { + type: 'string', + required: true, + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + projectId: { + type: 'string', + required: true, + description: 'Jira project ID', + }, + cloudId: { + type: 'string', + required: false, + description: 'Jira cloud ID', + }, + }, + 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}` + } + // If no cloudId, use the accessible resources endpoint + return 'https://api.atlassian.com/oauth/token/accessible-resources' + }, + method: 'GET', + headers: (params: JiraRetrieveBulkParams) => ({ + 'Authorization': `Bearer ${params.accessToken}`, + 'Accept': 'application/json' + }), + body: (params: JiraRetrieveBulkParams) => ({}) + }, + transformResponse: async (response: Response, params?: JiraRetrieveBulkParams) => { + if (!params) { + throw new Error('Parameters are required for Jira bulk issue retrieval') + } + + try { + // If we don't have a cloudId, we need to fetch it first + if (!params.cloudId) { + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.message || `Failed to fetch accessible resources: ${response.status} ${response.statusText}`) + } + + const accessibleResources = await response.json() + if (!Array.isArray(accessibleResources) || accessibleResources.length === 0) { + throw new Error('No accessible Jira resources found for this account') + } + + const normalizedInput = `https://${params.domain}`.toLowerCase() + const matchedResource = accessibleResources.find(r => r.url.toLowerCase() === normalizedInput) + + if (!matchedResource) { + throw new Error(`Could not find matching Jira site for domain: ${params.domain}`) + } + + // 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, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${params.accessToken}`, + 'Accept': 'application/json' + } + }) + + if (!pickerResponse.ok) { + const errorData = await pickerResponse.json().catch(() => null) + throw new Error(errorData?.message || `Failed to retrieve issue keys: ${pickerResponse.status} ${pickerResponse.statusText}`) + } + + 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: [] + }) + }) + + if (!bulkfetchResponse.ok) { + const errorData = await bulkfetchResponse.json().catch(() => null) + throw new Error(errorData?.message || `Failed to retrieve Jira issues: ${bulkfetchResponse.status} ${bulkfetchResponse.statusText}`) + } + + 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 + })) + } + } + + // If we have a cloudId, this response is from the issue picker + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.message || `Failed to retrieve issue keys: ${response.status} ${response.statusText}`) + } + + 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: [] + }) + }) + + if (!bulkfetchResponse.ok) { + const errorData = await bulkfetchResponse.json().catch(() => null) + throw new Error(errorData?.message || `Failed to retrieve Jira issues: ${bulkfetchResponse.status} ${bulkfetchResponse.statusText}`) + } + + 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 + })) + } + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)) + } + }, + transformError: (error: any) => { + return error.message || 'Failed to retrieve Jira issues' + } + } \ No newline at end of file diff --git a/sim/tools/jira/index.ts b/sim/tools/jira/index.ts index 30d73405b..6c18fdccd 100644 --- a/sim/tools/jira/index.ts +++ b/sim/tools/jira/index.ts @@ -1,7 +1,9 @@ import { jiraRetrieveTool } from './retrieve' import { jiraUpdateTool } from './update' import { jiraWriteTool } from './write' +import { jiraBulkRetrieveTool } from './bulk_read' export { jiraRetrieveTool } export { jiraUpdateTool } export { jiraWriteTool } +export { jiraBulkRetrieveTool } diff --git a/sim/tools/jira/retrieve.ts b/sim/tools/jira/retrieve.ts index c59149e0d..df2b139bd 100644 --- a/sim/tools/jira/retrieve.ts +++ b/sim/tools/jira/retrieve.ts @@ -1,6 +1,5 @@ import { ToolConfig } from '../types' -import { JiraRetrieveResponse } from './types' -import { JiraRetrieveParams } from './types' +import { JiraRetrieveResponse, JiraRetrieveParams } from './types' export const jiraRetrieveTool: ToolConfig = { id: 'jira_retrieve', diff --git a/sim/tools/jira/types.ts b/sim/tools/jira/types.ts index f026661fd..cce2d0d59 100644 --- a/sim/tools/jira/types.ts +++ b/sim/tools/jira/types.ts @@ -18,6 +18,24 @@ export interface JiraRetrieveResponse extends ToolResponse { } } +export interface JiraRetrieveBulkParams { + accessToken: string + domain: string + projectId: string + cloudId: string +} + +export interface JiraRetrieveResponseBulk extends ToolResponse { + output: { + ts: string + summary: string + description: string + created: string + updated: string + }[] +} + + export interface JiraUpdateParams { accessToken: string domain: string diff --git a/sim/tools/registry.ts b/sim/tools/registry.ts index 1e4f90ce6..6b37ef1ba 100644 --- a/sim/tools/registry.ts +++ b/sim/tools/registry.ts @@ -40,7 +40,7 @@ import { youtubeSearchTool } from './youtube' import { elevenLabsTtsTool } from './elevenlabs' import { ToolConfig } from './types' import { s3GetObjectTool } from './s3' -import { jiraRetrieveTool, jiraUpdateTool, jiraWriteTool } from './jira' +import { jiraRetrieveTool, jiraUpdateTool, jiraWriteTool, jiraBulkRetrieveTool } from './jira' // Registry of all available tools export const tools: Record = { @@ -60,6 +60,7 @@ export const tools: Record = { jira_retrieve: jiraRetrieveTool, jira_update: jiraUpdateTool, jira_write: jiraWriteTool, + jira_bulk_read: jiraBulkRetrieveTool, slack_message: slackMessageTool, github_repo_info: githubRepoInfoTool, github_latest_commit: githubLatestCommitTool,