mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
feat(tools): added jira tools/block, oauth (#298)
* added jira oauth * issue-selector working * test * working on 404 error for jira * jira read tool working * jira read good to go * working on update and write, am close * jira update working * write tool is almost done * jira tool working * edited error handling * Remove conflicted files to resolve merge conflicts * added logger * updated PR comments * added package-lock.json * cleaned up, fixed failing tests, fixed name of s3 file, added docs * removed extraneous log --------- Co-authored-by: Adam Gough <adam.gough2020@gmail.com>
This commit is contained in:
147
docs/content/docs/tools/jira.mdx
Normal file
147
docs/content/docs/tools/jira.mdx
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Jira
|
||||
description: Interact with Jira
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="jira"
|
||||
color="#E0E0E0"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon"
|
||||
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 30 30"
|
||||
|
||||
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="#1868DB"
|
||||
d="M11.034 21.99h-2.22c-3.346 0-5.747-2.05-5.747-5.052h11.932c.619 0 1.019.44 1.019 1.062v12.007c-2.983 0-4.984-2.416-4.984-5.784zm5.893-5.967h-2.219c-3.347 0-5.748-2.013-5.748-5.015h11.933c.618 0 1.055.402 1.055 1.025V24.04c-2.983 0-5.02-2.416-5.02-5.784zm5.93-5.93h-2.219c-3.347 0-5.748-2.05-5.748-5.052h11.933c.618 0 1.018.439 1.018 1.025v12.007c-2.983 0-4.984-2.416-4.984-5.784z"
|
||||
/>
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to Jira workspaces to read, write, and update issues. Access content, metadata, and integrate Jira documentation into your workflows.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `jira_retrieve`
|
||||
|
||||
Retrieve detailed information about a specific Jira issue
|
||||
|
||||
#### 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 | No | Jira project ID to retrieve issues from. If not provided, all issues will be retrieved. |
|
||||
| `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. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type |
|
||||
| --------- | ---- |
|
||||
| `ts` | string |
|
||||
| `issueKey` | string |
|
||||
| `summary` | string |
|
||||
| `description` | string |
|
||||
| `created` | string |
|
||||
| `updated` | string |
|
||||
|
||||
### `jira_update`
|
||||
|
||||
Update a Jira issue
|
||||
|
||||
#### 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 | No | Jira project ID to update issues in. If not provided, all issues will be retrieved. |
|
||||
| `issueKey` | string | Yes | Jira issue key to update |
|
||||
| `summary` | string | No | New summary for the issue |
|
||||
| `description` | string | No | New description for the issue |
|
||||
| `status` | string | No | New status for the issue |
|
||||
| `priority` | string | No | New priority for the issue |
|
||||
| `assignee` | string | No | New assignee for the issue |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type |
|
||||
| --------- | ---- |
|
||||
| `ts` | string |
|
||||
| `issueKey` | string |
|
||||
| `summary` | string |
|
||||
| `success` | string |
|
||||
|
||||
### `jira_write`
|
||||
|
||||
Write a Jira issue
|
||||
|
||||
#### 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 | Project ID for the issue |
|
||||
| `summary` | string | Yes | Summary for the issue |
|
||||
| `description` | string | No | Description for the issue |
|
||||
| `priority` | string | No | Priority for the issue |
|
||||
| `assignee` | string | No | Assignee for the issue |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
| `issueType` | string | Yes | Type of issue to create \(e.g., Task, Story, Bug, Sub-task\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type |
|
||||
| --------- | ---- |
|
||||
| `ts` | string |
|
||||
| `issueKey` | string |
|
||||
| `summary` | string |
|
||||
| `success` | string |
|
||||
| `url` | string |
|
||||
|
||||
|
||||
|
||||
## Block Configuration
|
||||
|
||||
### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `operation` | string | Yes | Operation |
|
||||
|
||||
|
||||
|
||||
### Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `response` | object | Output from response |
|
||||
| ↳ `ts` | string | ts of the response |
|
||||
| ↳ `issueKey` | string | issueKey of the response |
|
||||
| ↳ `summary` | string | summary of the response |
|
||||
| ↳ `description` | string | description of the response |
|
||||
| ↳ `created` | string | created of the response |
|
||||
| ↳ `updated` | string | updated of the response |
|
||||
| ↳ `success` | boolean | success of the response |
|
||||
| ↳ `url` | string | url of the response |
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `jira`
|
||||
@@ -18,6 +18,7 @@
|
||||
"guesty",
|
||||
"image_generator",
|
||||
"jina",
|
||||
"jira",
|
||||
"linkup",
|
||||
"mem0",
|
||||
"notion",
|
||||
|
||||
@@ -20,8 +20,26 @@ Retrieve and view files from Amazon S3 buckets using presigned URLs.
|
||||
|
||||
## Tools
|
||||
|
||||
### `get_object`
|
||||
### `s3_get_object`
|
||||
|
||||
Retrieve an object from an AWS S3 bucket
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `accessKeyId` | string | Yes | Your AWS Access Key ID |
|
||||
| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
|
||||
| `s3Uri` | string | Yes | S3 Object URL \(e.g., https://bucket-name.s3.region.amazonaws.com/path/to/file\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type |
|
||||
| --------- | ---- |
|
||||
| `metadata` | string |
|
||||
| `size` | string |
|
||||
| `name` | string |
|
||||
| `lastModified` | string |
|
||||
|
||||
|
||||
|
||||
|
||||
100
sim/app/api/auth/oauth/jira/issue/route.ts
Normal file
100
sim/app/api/auth/oauth/jira/issue/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = new Logger('jira_issue')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { domain, accessToken, issueId, cloudId: providedCloudId } = await request.json()
|
||||
// Add detailed request logging
|
||||
if (!domain) {
|
||||
logger.error('Missing domain in request')
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Missing access token in request')
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!issueId) {
|
||||
logger.error('Missing issue ID in request')
|
||||
return NextResponse.json({ error: 'Issue ID 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 the URL using cloudId for Jira API
|
||||
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueId}`
|
||||
|
||||
logger.info('Fetching Jira issue from:', url)
|
||||
|
||||
// Make the request to Jira API
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Jira API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
})
|
||||
|
||||
let errorMessage
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
errorMessage = errorData.message || `Failed to fetch issue (${response.status})`
|
||||
} catch (e) {
|
||||
errorMessage = `Failed to fetch issue: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Transform the Jira issue data into our expected format
|
||||
const issueInfo: any = {
|
||||
id: data.key,
|
||||
name: data.fields.summary,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${data.key}`,
|
||||
modifiedTime: data.fields.updated,
|
||||
webViewLink: `https://${domain}/browse/${data.key}`,
|
||||
// Add additional fields that might be needed for the workflow
|
||||
status: data.fields.status?.name,
|
||||
description: data.fields.description,
|
||||
priority: data.fields.priority?.name,
|
||||
assignee: data.fields.assignee?.displayName,
|
||||
reporter: data.fields.reporter?.displayName,
|
||||
project: {
|
||||
key: data.fields.project?.key,
|
||||
name: data.fields.project?.name
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
issue: issueInfo,
|
||||
cloudId // Return the cloudId so it can be cached
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
// Add more context to the error response
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to retrieve Jira issue',
|
||||
details: (error as Error).message,
|
||||
stack: (error as Error).stack
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
173
sim/app/api/auth/oauth/jira/issues/route.ts
Normal file
173
sim/app/api/auth/oauth/jira/issues/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = new Logger('jira_issues')
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
if (issueKeys.length === 0) {
|
||||
logger.info('No issue keys provided, returning empty result')
|
||||
return NextResponse.json({ issues: [] })
|
||||
}
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
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`
|
||||
|
||||
// Prepare the request body for bulk fetch
|
||||
const requestBody = {
|
||||
expand: ["names"],
|
||||
fields: ["summary", "status", "assignee", "updated", "project"],
|
||||
fieldsByKeys: false,
|
||||
issueIdsOrKeys: issueKeys,
|
||||
properties: []
|
||||
}
|
||||
|
||||
// Make the request to Jira API with OAuth Bearer token
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
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}`)
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
|
||||
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
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira issues:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const domain = url.searchParams.get('domain')?.trim()
|
||||
const accessToken = url.searchParams.get('accessToken')
|
||||
const providedCloudId = url.searchParams.get('cloudId')
|
||||
let query = url.searchParams.get('query') || ''
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Use the correct Jira Cloud OAuth endpoint structure
|
||||
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 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}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
cloudId // Return the cloudId so it can be cached
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira issue suggestions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
161
sim/app/api/auth/oauth/jira/projects/route.ts
Normal file
161
sim/app/api/auth/oauth/jira/projects/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = new Logger('jira_projects')
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const domain = url.searchParams.get('domain')?.trim()
|
||||
const accessToken = url.searchParams.get('accessToken')
|
||||
const providedCloudId = url.searchParams.get('cloudId')
|
||||
let query = url.searchParams.get('query') || ''
|
||||
|
||||
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 the URL for the Jira API projects endpoint
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/search`
|
||||
|
||||
// Add query parameters if searching
|
||||
const queryParams = new URLSearchParams()
|
||||
if (query) {
|
||||
queryParams.append('query', query)
|
||||
}
|
||||
// Add other useful parameters
|
||||
queryParams.append('orderBy', 'name')
|
||||
queryParams.append('expand', 'description,lead,url,projectKeys')
|
||||
|
||||
const finalUrl = `${apiUrl}?${queryParams.toString()}`
|
||||
logger.info(`Fetching Jira projects from: ${finalUrl}`)
|
||||
|
||||
const response = await fetch(finalUrl, {
|
||||
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 projects (${response.status})`
|
||||
} catch (e) {
|
||||
errorMessage = `Failed to fetch projects: ${response.status} ${response.statusText}`
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Add detailed logging
|
||||
logger.info(`Jira API Response Status: ${response.status}`)
|
||||
logger.info(`Found projects: ${data.values?.length || 0}`)
|
||||
|
||||
// Transform the response to match our expected format
|
||||
const projects = data.values?.map((project: any) => ({
|
||||
id: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
url: project.self,
|
||||
avatarUrl: project.avatarUrls?.['48x48'], // Use the medium size avatar
|
||||
description: project.description,
|
||||
projectTypeKey: project.projectTypeKey,
|
||||
simplified: project.simplified,
|
||||
style: project.style,
|
||||
isPrivate: project.isPrivate,
|
||||
})) || []
|
||||
|
||||
return NextResponse.json({
|
||||
projects,
|
||||
cloudId // Return the cloudId so it can be cached
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira projects:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// For individual project retrieval if needed
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { domain, accessToken, projectId, 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 })
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Project ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || await getJiraCloudId(domain, accessToken)
|
||||
|
||||
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${projectId}`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Error details:', errorData)
|
||||
return NextResponse.json(
|
||||
{ error: errorData.message || `Failed to fetch project (${response.status})` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const project = await response.json()
|
||||
|
||||
return NextResponse.json({
|
||||
project: {
|
||||
id: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
url: project.self,
|
||||
avatarUrl: project.avatarUrls?.['48x48'],
|
||||
description: project.description,
|
||||
projectTypeKey: project.projectTypeKey,
|
||||
simplified: project.simplified,
|
||||
style: project.style,
|
||||
isPrivate: project.isPrivate,
|
||||
},
|
||||
cloudId
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Jira project:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,22 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'workspace.read': 'Read your Notion workspace',
|
||||
'workspace.write': 'Write to your Notion workspace',
|
||||
'user.email:read': 'Read your email address',
|
||||
'read:jira-user': 'Read your Jira user',
|
||||
'read:jira-work': 'Read your Jira work',
|
||||
'write:jira-work': 'Write to your Jira work',
|
||||
'write:issue:jira': 'Write to your Jira issues',
|
||||
'read:project:jira': 'Read your Jira projects',
|
||||
'read:issue-type:jira': 'Read your Jira issue types',
|
||||
'read:issue-meta:jira': 'Read your Jira issue meta',
|
||||
'read:issue-security-level:jira': 'Read your Jira issue security level',
|
||||
'read:issue.vote:jira': 'Read your Jira issue votes',
|
||||
'read:issue.changelog:jira': 'Read your Jira issue changelog',
|
||||
'read:avatar:jira': 'Read your Jira avatar',
|
||||
'read:issue:jira': 'Read your Jira issues',
|
||||
'read:status:jira': 'Read your Jira status',
|
||||
'read:user:jira': 'Read your Jira user',
|
||||
'read:field-configuration:jira': 'Read your Jira field configuration',
|
||||
'read:issue-details:jira': 'Read your Jira issue details',
|
||||
}
|
||||
|
||||
// Convert OAuth scope to user-friendly description
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { saveToStorage } from '@/stores/workflows/persistence'
|
||||
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = new Logger('jira_issue_selector')
|
||||
|
||||
export interface JiraIssueInfo {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webViewLink?: string
|
||||
modifiedTime?: string
|
||||
spaceId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface JiraIssueSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, issueInfo?: JiraIssueInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onIssueInfoChange?: (issueInfo: JiraIssueInfo | null) => void
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
export function JiraIssueSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Jira issue',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onIssueInfoChange,
|
||||
projectId,
|
||||
}: JiraIssueSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [issues, setIssues] = useState<JiraIssueInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedIssueId, setSelectedIssueId] = useState(value)
|
||||
const [selectedIssue, setSelectedIssue] = useState<JiraIssueInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cloudId, setCloudId] = useState<string | null>(null)
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length >= 1) { // Changed from > 2 to >= 1 to be more responsive
|
||||
fetchIssues(value)
|
||||
} else {
|
||||
setIssues([]) // Clear issues if search is empty
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch issue info when we have a selected issue ID
|
||||
const fetchIssueInfo = useCallback(
|
||||
async (issueId: string) => {
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
throw new Error(errorData.error || 'Failed to get access token')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token received')
|
||||
}
|
||||
|
||||
// Use the access token to fetch the issue info
|
||||
const response = await fetch('/api/auth/oauth/jira/issue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
accessToken,
|
||||
issueId,
|
||||
cloudId
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to fetch issue info')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.cloudId) {
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
if (data.issue) {
|
||||
setSelectedIssue(data.issue)
|
||||
onIssueInfoChange?.(data.issue)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching issue info:', error)
|
||||
setError((error as Error).message)
|
||||
// Clear selection on error to prevent infinite retry loops
|
||||
setSelectedIssue(null)
|
||||
onIssueInfoChange?.(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onIssueInfoChange, cloudId]
|
||||
)
|
||||
|
||||
// Fetch issues from Jira
|
||||
const fetchIssues = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setIssues([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
|
||||
// If there's a token error, we might need to reconnect the account
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the issues endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
...(projectId && { projectId }),
|
||||
...(searchQuery && { query: searchQuery }),
|
||||
...(cloudId && { cloudId }),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/auth/oauth/jira/issues?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch issues')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.cloudId) {
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
// Process the issue picker results
|
||||
let foundIssues: JiraIssueInfo[] = []
|
||||
|
||||
// Handle the sections returned by the issue picker API
|
||||
if (data.sections) {
|
||||
// Combine issues from all sections
|
||||
data.sections.forEach((section: any) => {
|
||||
if (section.issues && section.issues.length > 0) {
|
||||
const sectionIssues = section.issues.map((issue: any) => ({
|
||||
id: issue.key,
|
||||
name: issue.summary || issue.summaryText || issue.key,
|
||||
mimeType: 'jira/issue',
|
||||
url: `https://${domain}/browse/${issue.key}`,
|
||||
webViewLink: `https://${domain}/browse/${issue.key}`,
|
||||
}))
|
||||
foundIssues = [...foundIssues, ...sectionIssues]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Received ${foundIssues.length} issues from API`)
|
||||
setIssues(foundIssues)
|
||||
|
||||
// If we have a selected issue ID, find the issue info
|
||||
if (selectedIssueId) {
|
||||
const issueInfo = foundIssues.find((issue: JiraIssueInfo) => issue.id === selectedIssueId)
|
||||
if (issueInfo) {
|
||||
setSelectedIssue(issueInfo)
|
||||
onIssueInfoChange?.(issueInfo)
|
||||
} else if (!searchQuery && selectedIssueId) {
|
||||
// If we can't find the issue in the list, try to fetch it directly
|
||||
fetchIssueInfo(selectedIssueId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching issues:', error)
|
||||
setError((error as Error).message)
|
||||
setIssues([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, selectedIssueId, onIssueInfoChange, fetchIssueInfo, cloudId, projectId]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch recent/default issues when opening the dropdown
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchIssues('') // Pass empty string to get recent or default issues
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected issue when value changes externally
|
||||
useEffect(() => {
|
||||
if (value !== selectedIssueId) {
|
||||
setSelectedIssueId(value)
|
||||
|
||||
// Only fetch issue info if we have a valid value
|
||||
if (value && value.trim() !== '') {
|
||||
// Find issue info if we have issues loaded
|
||||
if (issues.length > 0) {
|
||||
const issueInfo = issues.find((issue) => issue.id === value) || null
|
||||
setSelectedIssue(issueInfo)
|
||||
onIssueInfoChange?.(issueInfo)
|
||||
} else if (!selectedIssue && selectedCredentialId && domain && domain.includes('.')) {
|
||||
// If we don't have issues loaded yet but have a value, try to fetch the issue info
|
||||
fetchIssueInfo(value)
|
||||
}
|
||||
} else {
|
||||
// If value is empty or undefined, clear the selection without triggering API calls
|
||||
setSelectedIssue(null)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
}
|
||||
}, [value, issues, selectedIssue, selectedCredentialId, domain, onIssueInfoChange, fetchIssueInfo])
|
||||
|
||||
// Handle issue selection
|
||||
const handleSelectIssue = (issue: JiraIssueInfo) => {
|
||||
setSelectedIssueId(issue.id)
|
||||
setSelectedIssue(issue)
|
||||
onChange(issue.id, issue)
|
||||
onIssueInfoChange?.(issue)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
const providerId = getProviderId()
|
||||
|
||||
// Store information about the required connection
|
||||
saveToStorage<string>('pending_service_id', effectiveServiceId)
|
||||
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
|
||||
saveToStorage<string>('pending_oauth_return_url', window.location.href)
|
||||
saveToStorage<string>('pending_oauth_provider_id', providerId)
|
||||
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedIssueId('')
|
||||
setSelectedIssue(null)
|
||||
setError(null) // Clear any existing errors
|
||||
onChange('', undefined)
|
||||
onIssueInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
disabled={disabled || !domain}
|
||||
>
|
||||
{selectedIssue ? (
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="font-normal truncate">{selectedIssue.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[300px]" align="start">
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder="Search issues..." onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span className="ml-2">Loading issues...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm font-medium">No accounts connected.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm font-medium">No issues found.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="font-normal">{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className="ml-auto h-4 w-4" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Issues
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={`issue-${issue.id}-${issue.name}`}
|
||||
onSelect={() => handleSelectIssue(issue)}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="font-normal truncate">{issue.name}</span>
|
||||
</div>
|
||||
{issue.id === selectedIssueId && <Check className="ml-auto h-4 w-4" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Issue preview */}
|
||||
{showPreview && selectedIssue && (
|
||||
<div className="mt-2 rounded-md border border-muted bg-muted/10 p-2 relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 hover:bg-muted"
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pr-4">
|
||||
<div className="flex-shrink-0 flex items-center justify-center h-6 w-6 bg-muted/20 rounded">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-xs font-medium truncate">{selectedIssue.name}</h4>
|
||||
{selectedIssue.modifiedTime && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(selectedIssue.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedIssue.webViewLink ? (
|
||||
<a
|
||||
href={selectedIssue.webViewLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Jira</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName="Jira"
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { SubBlockConfig } from '@/blocks/types'
|
||||
import { ConfluenceFileInfo, ConfluenceFileSelector } from './components/confluence-file-selector'
|
||||
import { JiraIssueInfo, JiraIssueSelector } from './components/jira-issue-selector'
|
||||
import { FileInfo, GoogleDrivePicker } from './components/google-drive-picker'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
@@ -16,14 +17,17 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
|
||||
const { getValue, setValue } = useSubBlockStore()
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>('')
|
||||
const [fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
|
||||
const [issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'google-drive'
|
||||
const isConfluence = provider === 'confluence'
|
||||
const isJira = provider === 'jira'
|
||||
|
||||
// For Confluence, we need the domain and credentials
|
||||
const domain = isConfluence ? (getValue(blockId, 'domain') as string) || '' : ''
|
||||
const credentials = isConfluence ? (getValue(blockId, 'credential') as string) || '' : ''
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : ''
|
||||
const credentials = isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : ''
|
||||
|
||||
// Get the current value from the store
|
||||
useEffect(() => {
|
||||
@@ -40,6 +44,19 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
|
||||
setValue(blockId, subBlock.id, fileId)
|
||||
}
|
||||
|
||||
// Handle issue selection
|
||||
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
|
||||
setSelectedIssueId(issueKey)
|
||||
setIssueInfo(info || null)
|
||||
setValue(blockId, subBlock.id, issueKey)
|
||||
|
||||
// Clear the fields when a new issue is selected
|
||||
if (isJira) {
|
||||
setValue(blockId, 'summary', '')
|
||||
setValue(blockId, 'description', '')
|
||||
}
|
||||
}
|
||||
|
||||
// For Google Drive
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
|
||||
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || ''
|
||||
@@ -62,6 +79,23 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
|
||||
)
|
||||
}
|
||||
|
||||
if (isJira) {
|
||||
return (
|
||||
<JiraIssueSelector
|
||||
value={selectedIssueId}
|
||||
onChange={handleIssueChange}
|
||||
domain={domain}
|
||||
provider="jira"
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira issue'}
|
||||
disabled={false}
|
||||
showPreview={true}
|
||||
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Default to Google Drive picker
|
||||
return (
|
||||
<GoogleDrivePicker
|
||||
@@ -79,4 +113,4 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Credential,
|
||||
getProviderIdFromServiceId,
|
||||
getServiceIdFromScopes,
|
||||
OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { saveToStorage } from '@/stores/workflows/persistence'
|
||||
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
|
||||
import { Logger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = new Logger('jira_project_selector')
|
||||
|
||||
export interface JiraProjectInfo {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
url?: string
|
||||
avatarUrl?: string
|
||||
description?: string
|
||||
projectTypeKey?: string
|
||||
simplified?: boolean
|
||||
style?: string
|
||||
isPrivate?: boolean
|
||||
}
|
||||
|
||||
interface JiraProjectSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string, projectInfo?: JiraProjectInfo) => void
|
||||
provider: OAuthProvider
|
||||
requiredScopes?: string[]
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
serviceId?: string
|
||||
domain: string
|
||||
showPreview?: boolean
|
||||
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
|
||||
}
|
||||
|
||||
export function JiraProjectSelector({
|
||||
value,
|
||||
onChange,
|
||||
provider,
|
||||
requiredScopes = [],
|
||||
label = 'Select Jira project',
|
||||
disabled = false,
|
||||
serviceId,
|
||||
domain,
|
||||
showPreview = true,
|
||||
onProjectInfoChange,
|
||||
}: JiraProjectSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(value)
|
||||
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cloudId, setCloudId] = useState<string | null>(null)
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
// Clear any existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (value.length >= 1) {
|
||||
fetchProjects(value)
|
||||
} else {
|
||||
fetchProjects() // Fetch all projects if no search term
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
}
|
||||
|
||||
// Clean up the timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
return getServiceIdFromScopes(provider, requiredScopes)
|
||||
}
|
||||
|
||||
// Determine the appropriate provider ID based on service and scopes
|
||||
const getProviderId = (): string => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
return getProviderIdFromServiceId(effectiveServiceId)
|
||||
}
|
||||
|
||||
// Fetch available credentials for this provider
|
||||
const fetchCredentials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const providerId = getProviderId()
|
||||
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
|
||||
// Fetch detailed project information
|
||||
const fetchProjectInfo = useCallback(
|
||||
async (projectId: string) => {
|
||||
if (!selectedCredentialId || !domain || !projectId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the project endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
projectId,
|
||||
...(cloudId && { cloudId })
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/auth/oauth/jira/project?${queryParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch project details')
|
||||
}
|
||||
|
||||
const projectInfo = await response.json()
|
||||
|
||||
if (projectInfo.cloudId) {
|
||||
setCloudId(projectInfo.cloudId)
|
||||
}
|
||||
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching project details:', error)
|
||||
setError((error as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onProjectInfoChange, cloudId]
|
||||
)
|
||||
|
||||
// Fetch projects from Jira
|
||||
const fetchProjects = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
if (!trimmedDomain.includes('.')) {
|
||||
setError(
|
||||
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
|
||||
)
|
||||
setProjects([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get the access token from the selected credential
|
||||
const tokenResponse = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
logger.error('Access token error:', errorData)
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Jira account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Build query parameters for the projects endpoint
|
||||
const queryParams = new URLSearchParams({
|
||||
domain,
|
||||
accessToken,
|
||||
...(searchQuery && { query: searchQuery }),
|
||||
...(cloudId && { cloudId })
|
||||
})
|
||||
|
||||
// Use the GET endpoint for project search
|
||||
const response = await fetch(`/api/auth/oauth/jira/projects?${queryParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Jira API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch projects')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.cloudId) {
|
||||
setCloudId(data.cloudId)
|
||||
}
|
||||
|
||||
// Process the projects results
|
||||
const foundProjects = data.projects || []
|
||||
logger.info(`Received ${foundProjects.length} projects from API`)
|
||||
setProjects(foundProjects)
|
||||
|
||||
// If we have a selected project ID, find the project info
|
||||
if (selectedProjectId) {
|
||||
const projectInfo = foundProjects.find(
|
||||
(project: JiraProjectInfo) => project.id === selectedProjectId
|
||||
)
|
||||
if (projectInfo) {
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else if (!searchQuery && selectedProjectId) {
|
||||
// If we can't find the project in the list, try to fetch it directly
|
||||
fetchProjectInfo(selectedProjectId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching projects:', error)
|
||||
setError((error as Error).message)
|
||||
setProjects([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, selectedProjectId, onProjectInfoChange, fetchProjectInfo, cloudId]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Update selected project when value changes externally
|
||||
useEffect(() => {
|
||||
if (value !== selectedProjectId) {
|
||||
setSelectedProjectId(value)
|
||||
|
||||
// Only fetch project info if we have a valid value
|
||||
if (value && value.trim() !== '') {
|
||||
// Find project info if we have projects loaded
|
||||
if (projects.length > 0) {
|
||||
const projectInfo = projects.find((project) => project.id === value) || null
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else if (!selectedProject && selectedCredentialId && domain && domain.includes('.')) {
|
||||
// If we don't have projects loaded yet but have a value, try to fetch the project info
|
||||
fetchProjectInfo(value)
|
||||
}
|
||||
} else {
|
||||
// If value is empty or undefined, clear the selection without triggering API calls
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
}
|
||||
}, [value, projects, selectedProject, selectedCredentialId, domain, onProjectInfoChange, fetchProjectInfo])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchProjects('') // Pass empty string to get all projects
|
||||
}
|
||||
}
|
||||
|
||||
// Handle project selection
|
||||
const handleSelectProject = (project: JiraProjectInfo) => {
|
||||
setSelectedProjectId(project.id)
|
||||
setSelectedProject(project)
|
||||
onChange(project.id, project)
|
||||
onProjectInfoChange?.(project)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Handle adding a new credential
|
||||
const handleAddCredential = () => {
|
||||
const effectiveServiceId = getServiceId()
|
||||
const providerId = getProviderId()
|
||||
|
||||
// Store information about the required connection
|
||||
saveToStorage<string>('pending_service_id', effectiveServiceId)
|
||||
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
|
||||
saveToStorage<string>('pending_oauth_return_url', window.location.href)
|
||||
saveToStorage<string>('pending_oauth_provider_id', providerId)
|
||||
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedProjectId('')
|
||||
setSelectedProject(null)
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
disabled={disabled || !domain}
|
||||
>
|
||||
{selectedProject ? (
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="font-normal truncate">{selectedProject.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[300px]" align="start">
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search projects..."
|
||||
onValueChange={handleSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span className="ml-2">Loading projects...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm font-medium">No accounts connected.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-sm font-medium">No projects found.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span className="font-normal">{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className="ml-auto h-4 w-4" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Projects list */}
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{project.avatarUrl ? (
|
||||
<img
|
||||
src={project.avatarUrl}
|
||||
alt={project.name}
|
||||
className="h-4 w-4 rounded"
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-normal truncate">{project.name}</span>
|
||||
</div>
|
||||
{project.id === selectedProjectId && <Check className="ml-auto h-4 w-4" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Project preview */}
|
||||
{showPreview && selectedProject && (
|
||||
<div className="mt-2 rounded-md border border-muted bg-muted/10 p-2 relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 hover:bg-muted"
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pr-4">
|
||||
<div className="flex-shrink-0 flex items-center justify-center h-6 w-6 bg-muted/20 rounded">
|
||||
{selectedProject.avatarUrl ? (
|
||||
<img
|
||||
src={selectedProject.avatarUrl}
|
||||
alt={selectedProject.name}
|
||||
className="h-4 w-4 rounded"
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-xs font-medium truncate">{selectedProject.name}</h4>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{selectedProject.key}
|
||||
</span>
|
||||
</div>
|
||||
{selectedProject.url && (
|
||||
<a
|
||||
href={selectedProject.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Jira</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName="Jira"
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { SubBlockConfig } from '@/blocks/types'
|
||||
import { JiraProjectInfo, JiraProjectSelector } from './components/jira-project-selector'
|
||||
|
||||
interface ProjectSelectorInputProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled?: boolean
|
||||
onProjectSelect?: (projectId: string) => void
|
||||
}
|
||||
|
||||
export function ProjectSelectorInput({
|
||||
blockId,
|
||||
subBlock,
|
||||
disabled = false,
|
||||
onProjectSelect
|
||||
}: ProjectSelectorInputProps) {
|
||||
const { getValue, setValue } = useSubBlockStore()
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||
const [projectInfo, setProjectInfo] = useState<JiraProjectInfo | null>(null)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'jira'
|
||||
|
||||
// For Jira, we need the domain
|
||||
const domain = getValue(blockId, 'domain') as string || ''
|
||||
const credentials = getValue(blockId, 'credential') as string || ''
|
||||
|
||||
// Get the current value from the store
|
||||
useEffect(() => {
|
||||
const value = getValue(blockId, subBlock.id)
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedProjectId(value)
|
||||
}
|
||||
}, [blockId, subBlock.id, getValue])
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectChange = (projectId: string, info?: JiraProjectInfo) => {
|
||||
setSelectedProjectId(projectId)
|
||||
setProjectInfo(info || null)
|
||||
setValue(blockId, subBlock.id, projectId)
|
||||
|
||||
// Clear the issue-related fields when a new project is selected
|
||||
if (provider === 'jira') {
|
||||
setValue(blockId, 'summary', '')
|
||||
setValue(blockId, 'description', '')
|
||||
setValue(blockId, 'issueKey', '')
|
||||
}
|
||||
|
||||
onProjectSelect?.(projectId)
|
||||
}
|
||||
|
||||
return (
|
||||
<JiraProjectSelector
|
||||
value={selectedProjectId}
|
||||
onChange={handleProjectChange}
|
||||
domain={domain}
|
||||
provider="jira"
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira project'}
|
||||
disabled={disabled}
|
||||
showPreview={true}
|
||||
onProjectInfoChange={setProjectInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -476,12 +476,27 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
|
||||
}
|
||||
|
||||
const handleOperationChange = (toolIndex: number, operation: string) => {
|
||||
const tool = selectedTools[toolIndex]
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
// Clear fields when operation changes for Jira
|
||||
if (tool.type === 'jira') {
|
||||
// Clear all fields that might be shared between operations
|
||||
subBlockStore.setValue(blockId, 'summary', '')
|
||||
subBlockStore.setValue(blockId, 'description', '')
|
||||
subBlockStore.setValue(blockId, 'issueKey', '')
|
||||
subBlockStore.setValue(blockId, 'projectId', '')
|
||||
subBlockStore.setValue(blockId, 'parentIssue', '')
|
||||
}
|
||||
|
||||
setValue(
|
||||
selectedTools.map((tool, index) =>
|
||||
index === toolIndex
|
||||
? {
|
||||
...tool,
|
||||
operation,
|
||||
// Reset params when operation changes
|
||||
params: {},
|
||||
}
|
||||
: tool
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DateInput } from './components/date-input'
|
||||
import { Dropdown } from './components/dropdown'
|
||||
import { EvalInput } from './components/eval-input'
|
||||
import { FileSelectorInput } from './components/file-selector/file-selector-input'
|
||||
import { ProjectSelectorInput } from './components/project-selector/project-selector-input'
|
||||
import { FileUpload } from './components/file-upload'
|
||||
import { FolderSelectorInput } from './components/folder-selector/components/folder-selector-input'
|
||||
import { LongInput } from './components/long-input'
|
||||
@@ -173,6 +174,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
|
||||
)
|
||||
case 'file-selector':
|
||||
return <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
|
||||
case 'project-selector':
|
||||
return <ProjectSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
|
||||
case 'folder-selector':
|
||||
return <FolderSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
|
||||
case 'input-format':
|
||||
|
||||
184
sim/blocks/blocks/jira.ts
Normal file
184
sim/blocks/blocks/jira.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { BlockConfig } from '../types'
|
||||
import { JiraRetrieveResponse, JiraUpdateResponse, JiraWriteResponse } from '@/tools/jira/types'
|
||||
|
||||
type JiraResponse = JiraRetrieveResponse | JiraUpdateResponse | JiraWriteResponse
|
||||
|
||||
export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
type: 'jira',
|
||||
name: 'Jira',
|
||||
description: 'Interact with Jira',
|
||||
longDescription:
|
||||
'Connect to Jira workspaces to read, write, and update issues. Access content, metadata, and integrate Jira documentation into your workflows.',
|
||||
category: 'tools',
|
||||
bgColor: '#E0E0E0',
|
||||
icon: JiraIcon,
|
||||
subBlocks: [
|
||||
// Operation selector
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
layout: 'full',
|
||||
options: [
|
||||
{ label: 'Read Issue', id: 'read' },
|
||||
{ label: 'Update Issue', id: 'update' },
|
||||
{ label: 'Write Issue', id: 'write' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter Jira domain (e.g., simstudio.atlassian.net)',
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Jira Account',
|
||||
type: 'oauth-input',
|
||||
layout: 'full',
|
||||
provider: 'jira',
|
||||
serviceId: 'jira',
|
||||
requiredScopes: [
|
||||
'read:jira-work',
|
||||
'read:jira-user',
|
||||
'write:jira-work',
|
||||
'read:issue-event:jira',
|
||||
'write:issue:jira',
|
||||
'read:me',
|
||||
'offline_access',
|
||||
],
|
||||
placeholder: 'Select Jira account',
|
||||
},
|
||||
// Use file-selector component for issue selection
|
||||
{
|
||||
id: 'projectId',
|
||||
title: 'Select Project',
|
||||
type: 'project-selector',
|
||||
layout: 'full',
|
||||
provider: 'jira',
|
||||
serviceId: 'jira',
|
||||
placeholder: 'Select Jira project',
|
||||
condition: { field: 'operation', value: ['read', 'update', 'write'] },
|
||||
},
|
||||
{
|
||||
id: 'issueKey',
|
||||
title: 'Select Issue',
|
||||
type: 'file-selector',
|
||||
layout: 'full',
|
||||
provider: 'jira',
|
||||
serviceId: 'jira',
|
||||
placeholder: 'Select Jira issue',
|
||||
condition: { field: 'operation', value: ['read', 'update'] },
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'New Summary',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter new summary for the issue',
|
||||
condition: { field: 'operation', value: ['update', 'write'] },
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
title: 'New Description',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter new description for the issue',
|
||||
condition: { field: 'operation', value: ['update', 'write'] },
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['jira_retrieve', 'jira_update', 'jira_write'],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'read':
|
||||
return 'jira_retrieve'
|
||||
case 'update':
|
||||
return 'jira_update'
|
||||
case 'write':
|
||||
return 'jira_write'
|
||||
default:
|
||||
return 'jira_retrieve'
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
// Base params that are always needed
|
||||
const baseParams = {
|
||||
accessToken: params.credential,
|
||||
domain: params.domain,
|
||||
}
|
||||
|
||||
// Define allowed parameters for each operation
|
||||
switch (params.operation) {
|
||||
case 'write': {
|
||||
// For write operations, only include write-specific fields
|
||||
const writeParams = {
|
||||
projectId: params.projectId,
|
||||
summary: params.summary || '',
|
||||
description: params.description || '',
|
||||
issueType: params.issueType || 'Task',
|
||||
parent: params.parentIssue ? { key: params.parentIssue } : undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
...baseParams,
|
||||
...writeParams,
|
||||
}
|
||||
}
|
||||
case 'update': {
|
||||
// For update operations, only include update-specific fields
|
||||
const updateParams = {
|
||||
projectId: params.projectId,
|
||||
issueKey: params.issueKey,
|
||||
summary: params.summary || '',
|
||||
description: params.description || '',
|
||||
}
|
||||
|
||||
return {
|
||||
...baseParams,
|
||||
...updateParams,
|
||||
}
|
||||
}
|
||||
case 'read': {
|
||||
// For read operations, only include read-specific fields
|
||||
return {
|
||||
...baseParams,
|
||||
issueKey: params.issueKey,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return baseParams
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', required: true },
|
||||
domain: { type: 'string', required: true },
|
||||
credential: { type: 'string', required: true },
|
||||
issueKey: { type: 'string', required: true },
|
||||
projectId: { type: 'string', required: false },
|
||||
// Update operation inputs
|
||||
summary: { type: 'string', required: true },
|
||||
description: { type: 'string', required: false },
|
||||
// Write operation inputs
|
||||
issueType: { type: 'string', required: false },
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: {
|
||||
ts: 'string',
|
||||
issueKey: 'string',
|
||||
summary: 'string',
|
||||
description: 'string',
|
||||
created: 'string',
|
||||
updated: 'string',
|
||||
success: 'boolean',
|
||||
url: 'string'
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -37,9 +37,9 @@ export const S3Block: BlockConfig<S3Response> = {
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['get_object'],
|
||||
access: ['s3_get_object'],
|
||||
config: {
|
||||
tool: () => 'get_object',
|
||||
tool: () => 's3_get_object',
|
||||
params: (params) => {
|
||||
// Validate required fields
|
||||
if (!params.accessKeyId) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ImageGeneratorBlock } from './blocks/image_generator'
|
||||
import { JinaBlock } from './blocks/jina'
|
||||
import { LinkupBlock } from './blocks/linkup'
|
||||
import { MistralParseBlock } from './blocks/mistral_parse'
|
||||
import { JiraBlock } from './blocks/jira'
|
||||
import { NotionBlock } from './blocks/notion'
|
||||
import { OpenAIBlock } from './blocks/openai'
|
||||
import { PerplexityBlock } from './blocks/perplexity'
|
||||
@@ -66,6 +67,7 @@ export {
|
||||
GoogleSearchBlock,
|
||||
JinaBlock,
|
||||
LinkupBlock,
|
||||
JiraBlock,
|
||||
TranslateBlock,
|
||||
SlackBlock,
|
||||
GitHubBlock,
|
||||
@@ -124,6 +126,7 @@ const blocks: Record<string, BlockConfig> = {
|
||||
image_generator: ImageGeneratorBlock,
|
||||
jina: JinaBlock,
|
||||
linkup: LinkupBlock,
|
||||
jira: JiraBlock,
|
||||
mem0: Mem0Block,
|
||||
mistral_parse: MistralParseBlock,
|
||||
notion: NotionBlock,
|
||||
|
||||
@@ -29,6 +29,7 @@ export type SubBlockType =
|
||||
| 'webhook-config' // Webhook configuration
|
||||
| 'schedule-config' // Schedule status and information
|
||||
| 'file-selector' // File selector for Google Drive, etc.
|
||||
| 'project-selector' // Project selector for Jira
|
||||
| 'folder-selector' // Folder selector for Gmail, etc.
|
||||
| 'input-format' // Input structure format
|
||||
| 'file-upload' // File uploader
|
||||
|
||||
@@ -2174,4 +2174,23 @@ export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function JiraIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 30 30"
|
||||
width="24"
|
||||
height="24"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="#1868DB"
|
||||
d="M11.034 21.99h-2.22c-3.346 0-5.747-2.05-5.747-5.052h11.932c.619 0 1.019.44 1.019 1.062v12.007c-2.983 0-4.984-2.416-4.984-5.784zm5.893-5.967h-2.219c-3.347 0-5.748-2.013-5.748-5.015h11.933c.618 0 1.055.402 1.055 1.025V24.04c-2.983 0-5.02-2.416-5.02-5.784zm5.93-5.93h-2.219c-3.347 0-5.748-2.05-5.748-5.052h11.933c.618 0 1.018.439 1.018 1.025v12.007c-2.983 0-4.984-2.416-4.984-5.784z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -477,6 +477,72 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
// Jira provider
|
||||
{
|
||||
providerId: 'jira',
|
||||
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: [
|
||||
'read:jira-user',
|
||||
'read:jira-work',
|
||||
'write:jira-work',
|
||||
'write:issue:jira',
|
||||
'read:project:jira',
|
||||
'read:issue-type:jira',
|
||||
'read:me',
|
||||
'offline_access',
|
||||
'read:issue-meta:jira',
|
||||
'read:issue-security-level:jira',
|
||||
'read:issue.vote:jira',
|
||||
'read:issue.changelog:jira',
|
||||
'read:avatar:jira',
|
||||
'read:issue:jira',
|
||||
'read:status:jira',
|
||||
'read:user:jira',
|
||||
'read:field-configuration:jira',
|
||||
'read:issue-details:jira'
|
||||
],
|
||||
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/jira`,
|
||||
getUserInfo: async (tokens) => {
|
||||
try {
|
||||
const response = await fetch('https://api.atlassian.com/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Error fetching Jira user info:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = await response.json()
|
||||
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: profile.account_id,
|
||||
name: profile.name || profile.display_name || 'Jira User',
|
||||
email: profile.email || `${profile.account_id}@atlassian.com`,
|
||||
image: profile.picture || null,
|
||||
emailVerified: true, // Assume verified since it's an Atlassian account
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in Jira getUserInfo:', { error })
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Airtable provider
|
||||
{
|
||||
providerId: 'airtable',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NotionIcon,
|
||||
SupabaseIcon,
|
||||
xIcon,
|
||||
JiraIcon,
|
||||
} from '@/components/icons'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
@@ -26,6 +27,7 @@ export type OAuthProvider =
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
| 'notion'
|
||||
| 'jira'
|
||||
| string
|
||||
export type OAuthService =
|
||||
| 'google'
|
||||
@@ -39,7 +41,8 @@ export type OAuthService =
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
| 'notion'
|
||||
|
||||
| 'jira'
|
||||
|
||||
// Define the interface for OAuth provider configuration
|
||||
export interface OAuthProviderConfig {
|
||||
id: OAuthProvider
|
||||
@@ -196,6 +199,30 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'confluence',
|
||||
},
|
||||
jira: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
icon: (props) => JiraIcon(props),
|
||||
services: {
|
||||
jira: {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
description: 'Access Jira projects and issues.',
|
||||
providerId: 'jira',
|
||||
icon: (props) => JiraIcon(props),
|
||||
baseProviderIcon: (props) => JiraIcon(props),
|
||||
scopes: [ 'read:jira-user',
|
||||
'read:jira-work',
|
||||
'write:jira-work',
|
||||
'read:project:jira',
|
||||
'read:issue-type:jira',
|
||||
'read:me',
|
||||
'offline_access',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultService: 'jira',
|
||||
},
|
||||
airtable: {
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
|
||||
7981
sim/package-lock.json
generated
7981
sim/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -288,7 +288,7 @@ async function handleInternalRequest(
|
||||
const endpointUrl =
|
||||
typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
|
||||
|
||||
const fullUrl = new URL(endpointUrl, baseUrl).toString()
|
||||
const fullUrl = new URL(await endpointUrl, baseUrl).toString()
|
||||
|
||||
// For custom tools, validate parameters on the client side before sending
|
||||
if (toolId.startsWith('custom_') && tool.request.body) {
|
||||
|
||||
7
sim/tools/jira/index.ts
Normal file
7
sim/tools/jira/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { jiraRetrieveTool } from './retrieve'
|
||||
import { jiraUpdateTool } from './update'
|
||||
import { jiraWriteTool } from './write'
|
||||
|
||||
export { jiraRetrieveTool }
|
||||
export { jiraUpdateTool }
|
||||
export { jiraWriteTool }
|
||||
155
sim/tools/jira/retrieve.ts
Normal file
155
sim/tools/jira/retrieve.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { JiraRetrieveResponse } from './types'
|
||||
import { JiraRetrieveParams } from './types'
|
||||
|
||||
export const jiraRetrieveTool: ToolConfig<JiraRetrieveParams, JiraRetrieveResponse> = {
|
||||
id: 'jira_retrieve',
|
||||
name: 'Jira Retrieve',
|
||||
description: 'Retrieve detailed information about a specific Jira issue',
|
||||
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: false,
|
||||
description: 'Jira project ID to retrieve issues from. If not provided, all issues will be retrieved.',
|
||||
},
|
||||
issueKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Jira issue key to retrieve (e.g., PROJ-123)',
|
||||
},
|
||||
cloudId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: JiraRetrieveParams) => {
|
||||
if (params.cloudId) {
|
||||
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/issue/${params.issueKey}?expand=renderedFields,names,schema,transitions,operations,editmeta,changelog`
|
||||
}
|
||||
// If no cloudId, use the accessible resources endpoint
|
||||
return 'https://api.atlassian.com/oauth/token/accessible-resources'
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: JiraRetrieveParams) => {
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: JiraRetrieveParams) => {
|
||||
if (!params) {
|
||||
throw new Error('Parameters are required for Jira 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}`)
|
||||
}
|
||||
|
||||
// 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 issueResponse = await fetch(issueUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${params.accessToken}`,
|
||||
}
|
||||
})
|
||||
|
||||
if (!issueResponse.ok) {
|
||||
const errorData = await issueResponse.json().catch(() => null)
|
||||
throw new Error(errorData?.message || `Failed to retrieve Jira issue: ${issueResponse.status} ${issueResponse.statusText}`)
|
||||
}
|
||||
|
||||
const data = await issueResponse.json()
|
||||
if (!data || !data.fields) {
|
||||
throw new Error('Invalid response format from Jira API')
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a cloudId, this response is the issue data
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.message || `Failed to retrieve Jira issue: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!data || !data.fields) {
|
||||
throw new Error('Invalid response format from Jira API')
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error: any) => {
|
||||
return error.message || 'Failed to retrieve Jira issue'
|
||||
},
|
||||
}
|
||||
89
sim/tools/jira/types.ts
Normal file
89
sim/tools/jira/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ToolResponse } from '../types'
|
||||
|
||||
export interface JiraRetrieveParams {
|
||||
accessToken: string
|
||||
issueKey: string
|
||||
domain: string
|
||||
cloudId: string
|
||||
}
|
||||
|
||||
export interface JiraRetrieveResponse extends ToolResponse {
|
||||
output: {
|
||||
ts: string
|
||||
issueKey: string
|
||||
summary: string
|
||||
description: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface JiraUpdateParams {
|
||||
accessToken: string
|
||||
domain: string
|
||||
projectId?: string
|
||||
issueKey: string
|
||||
summary?: string
|
||||
title?: string
|
||||
description?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
assignee?: string
|
||||
cloudId?: string
|
||||
}
|
||||
|
||||
export interface JiraUpdateResponse extends ToolResponse {
|
||||
output: {
|
||||
ts: string
|
||||
issueKey: string
|
||||
summary: string
|
||||
success: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface JiraWriteParams {
|
||||
accessToken: string
|
||||
domain: string
|
||||
projectId: string
|
||||
summary: string
|
||||
description?: string
|
||||
priority?: string
|
||||
assignee?: string
|
||||
cloudId?: string
|
||||
issueType: string
|
||||
parent?: { key: string }
|
||||
}
|
||||
|
||||
export interface JiraWriteResponse extends ToolResponse {
|
||||
output: {
|
||||
ts: string
|
||||
issueKey: string
|
||||
summary: string
|
||||
success: boolean
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface JiraIssue {
|
||||
key: string
|
||||
summary: string
|
||||
status: string
|
||||
priority?: string
|
||||
assignee?: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface JiraProject {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface JiraCloudResource {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
scopes: string[]
|
||||
avatarUrl: string
|
||||
}
|
||||
227
sim/tools/jira/update.ts
Normal file
227
sim/tools/jira/update.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { JiraUpdateResponse, JiraUpdateParams } from './types'
|
||||
import { getJiraCloudId } from './utils'
|
||||
|
||||
export const jiraUpdateTool: ToolConfig<JiraUpdateParams, JiraUpdateResponse> = {
|
||||
id: 'jira_update',
|
||||
name: 'Jira Update',
|
||||
description: 'Update a Jira issue',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'jira',
|
||||
additionalScopes: [
|
||||
'read:jira-user',
|
||||
'write:jira-work',
|
||||
'write:issue:jira',
|
||||
'read:jira-work',
|
||||
],
|
||||
},
|
||||
|
||||
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: false,
|
||||
description: 'Jira project ID to update issues in. If not provided, all issues will be retrieved.',
|
||||
},
|
||||
issueKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Jira issue key to update',
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'New summary for the issue',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'New description for the issue',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'New status for the issue',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'New priority for the issue',
|
||||
},
|
||||
assignee: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'New assignee for the issue',
|
||||
},
|
||||
cloudId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
|
||||
},
|
||||
},
|
||||
|
||||
directExecution: async (params) => {
|
||||
// Pre-fetch the cloudId if not provided
|
||||
if (!params.cloudId) {
|
||||
try {
|
||||
params.cloudId = await getJiraCloudId(params.domain, params.accessToken)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return undefined // Let the regular request handling take over
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const { domain, issueKey, cloudId } = params
|
||||
if (!domain || !issueKey || !cloudId) {
|
||||
throw new Error('Domain, issueKey, and cloudId are required')
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueKey}`
|
||||
return url
|
||||
},
|
||||
method: 'PUT',
|
||||
headers: (params) => ({
|
||||
'Authorization': `Bearer ${params.accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: (params) => {
|
||||
|
||||
// Map the summary from either summary or title field
|
||||
const summaryValue = params.summary || params.title
|
||||
const descriptionValue = params.description
|
||||
|
||||
|
||||
|
||||
const fields: Record<string, any> = {}
|
||||
|
||||
if (summaryValue) {
|
||||
fields.summary = summaryValue
|
||||
}
|
||||
|
||||
if (descriptionValue) {
|
||||
fields.description = {
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: descriptionValue
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (params.status) {
|
||||
fields.status = {
|
||||
name: params.status
|
||||
}
|
||||
}
|
||||
|
||||
if (params.priority) {
|
||||
fields.priority = {
|
||||
name: params.priority
|
||||
}
|
||||
}
|
||||
|
||||
if (params.assignee) {
|
||||
fields.assignee = {
|
||||
id: params.assignee
|
||||
}
|
||||
}
|
||||
|
||||
return { fields }
|
||||
}
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: JiraUpdateParams) => {
|
||||
// Log the response details for debugging
|
||||
const responseText = await response.text()
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
if (responseText) {
|
||||
const data = JSON.parse(responseText)
|
||||
throw new Error(
|
||||
data.errorMessages?.[0] ||
|
||||
data.errors?.[Object.keys(data.errors)[0]] ||
|
||||
data.message ||
|
||||
'Failed to update Jira issue'
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Request failed with status ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
// If we can't parse the response as JSON, return the raw text
|
||||
throw new Error(`Jira API error (${response.status}): ${responseText}`)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// For successful responses
|
||||
try {
|
||||
if (!responseText) {
|
||||
// Some successful PUT requests might return no content
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: params?.issueKey || 'unknown',
|
||||
summary: 'Issue updated successfully',
|
||||
success: true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: data.key || params?.issueKey || 'unknown',
|
||||
summary: data.fields?.summary || 'Issue updated',
|
||||
success: true
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't parse the response but it was successful, still return success
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: params?.issueKey || 'unknown',
|
||||
summary: 'Issue updated (response parsing failed)',
|
||||
success: true
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error: any) => {
|
||||
return error.message || 'Failed to update Jira issue'
|
||||
}
|
||||
}
|
||||
35
sim/tools/jira/utils.ts
Normal file
35
sim/tools/jira/utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { JiraCloudResource } from './types'
|
||||
|
||||
export async function getJiraCloudId(domain: string, accessToken: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch('https://api.atlassian.com/oauth/token/accessible-resources', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
const resources = await response.json()
|
||||
|
||||
// If we have resources, find the matching one
|
||||
if (Array.isArray(resources) && resources.length > 0) {
|
||||
const normalizedInput = `https://${domain}`.toLowerCase()
|
||||
const matchedResource = resources.find(r => r.url.toLowerCase() === normalizedInput)
|
||||
|
||||
if (matchedResource) {
|
||||
return matchedResource.id
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find a match, return the first resource's ID
|
||||
// This is a fallback in case the URL matching fails
|
||||
if (Array.isArray(resources) && resources.length > 0) {
|
||||
return resources[0].id
|
||||
}
|
||||
|
||||
throw new Error('No Jira resources found')
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
227
sim/tools/jira/write.ts
Normal file
227
sim/tools/jira/write.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { JiraWriteResponse, JiraWriteParams } from './types'
|
||||
import { getJiraCloudId } from './utils'
|
||||
|
||||
export const jiraWriteTool: ToolConfig<JiraWriteParams, JiraWriteResponse> = {
|
||||
id: 'jira_write',
|
||||
name: 'Jira Write',
|
||||
description: 'Write a Jira issue',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'jira',
|
||||
additionalScopes: [
|
||||
'read:jira-user',
|
||||
'write:jira-work',
|
||||
'read:project:jira',
|
||||
'read:issue:jira',
|
||||
'write:issue:jira',
|
||||
'write:comment:jira',
|
||||
'write:comment.property:jira',
|
||||
'write:attachment:jira',
|
||||
'read:attachment:jira',
|
||||
],
|
||||
},
|
||||
|
||||
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: 'Project ID for the issue',
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Summary for the issue',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Description for the issue',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Priority for the issue',
|
||||
},
|
||||
assignee: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Assignee for the issue',
|
||||
},
|
||||
cloudId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
|
||||
},
|
||||
issueType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Type of issue to create (e.g., Task, Story, Bug, Sub-task)',
|
||||
},
|
||||
},
|
||||
|
||||
directExecution: async (params) => {
|
||||
// Pre-fetch the cloudId if not provided
|
||||
if (!params.cloudId) {
|
||||
try {
|
||||
params.cloudId = await getJiraCloudId(params.domain, params.accessToken)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return undefined // Let the regular request handling take over
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const { domain, cloudId } = params
|
||||
if (!domain || !cloudId) {
|
||||
throw new Error('Domain and cloudId are required')
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue`
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Authorization': `Bearer ${params.accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: (params) => {
|
||||
|
||||
// Validate required fields
|
||||
if (!params.projectId) {
|
||||
throw new Error('Project ID is required')
|
||||
}
|
||||
if (!params.summary) {
|
||||
throw new Error('Summary is required')
|
||||
}
|
||||
if (!params.issueType) {
|
||||
throw new Error('Issue type is required')
|
||||
}
|
||||
|
||||
// Construct fields object with only the necessary fields
|
||||
const fields: Record<string, any> = {
|
||||
project: {
|
||||
id: params.projectId
|
||||
},
|
||||
issuetype: {
|
||||
name: params.issueType
|
||||
},
|
||||
summary: params.summary // Use the summary field directly
|
||||
}
|
||||
|
||||
// Only add description if it exists
|
||||
if (params.description) {
|
||||
fields.description = {
|
||||
type: 'doc',
|
||||
version: 1,
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: params.description
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Only add parent if it exists
|
||||
if (params.parent) {
|
||||
fields.parent = params.parent
|
||||
}
|
||||
|
||||
const body = { fields }
|
||||
return body
|
||||
}
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: JiraWriteParams) => {
|
||||
// Log the response details for debugging
|
||||
const responseText = await response.text()
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
if (responseText) {
|
||||
const data = JSON.parse(responseText)
|
||||
throw new Error(
|
||||
data.errorMessages?.[0] ||
|
||||
data.errors?.[Object.keys(data.errors)[0]] ||
|
||||
data.message ||
|
||||
'Failed to create Jira issue'
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Request failed with status ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
// If we can't parse the response as JSON, return the raw text
|
||||
throw new Error(`Jira API error (${response.status}): ${responseText}`)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// For successful responses
|
||||
try {
|
||||
if (!responseText) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: 'unknown',
|
||||
summary: 'Issue created successfully',
|
||||
success: true,
|
||||
url: ''
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: data.key || 'unknown',
|
||||
summary: data.fields?.summary || 'Issue created',
|
||||
success: true,
|
||||
url: `https://${params?.domain}/browse/${data.key}`
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
issueKey: 'unknown',
|
||||
summary: 'Issue created (response parsing failed)',
|
||||
success: true,
|
||||
url: ''
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error: any) => {
|
||||
return error.message || 'Failed to create Jira issue'
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { youtubeSearchTool } from './youtube'
|
||||
import { elevenLabsTtsTool } from './elevenlabs'
|
||||
import { ToolConfig } from './types'
|
||||
import { s3GetObjectTool } from './s3'
|
||||
import { jiraRetrieveTool, jiraUpdateTool, jiraWriteTool } from './jira'
|
||||
|
||||
// Registry of all available tools
|
||||
export const tools: Record<string, ToolConfig> = {
|
||||
@@ -56,6 +57,9 @@ export const tools: Record<string, ToolConfig> = {
|
||||
google_search: googleSearchTool,
|
||||
jina_read_url: readUrlTool,
|
||||
linkup_search: linkupSearchTool,
|
||||
jira_retrieve: jiraRetrieveTool,
|
||||
jira_update: jiraUpdateTool,
|
||||
jira_write: jiraWriteTool,
|
||||
slack_message: slackMessageTool,
|
||||
github_repo_info: githubRepoInfoTool,
|
||||
github_latest_commit: githubLatestCommitTool,
|
||||
@@ -119,5 +123,5 @@ export const tools: Record<string, ToolConfig> = {
|
||||
mem0_search_memories: mem0SearchMemoriesTool,
|
||||
mem0_get_memories: mem0GetMemoriesTool,
|
||||
elevenlabs_tts: elevenLabsTtsTool,
|
||||
get_object: s3GetObjectTool,
|
||||
s3_get_object: s3GetObjectTool,
|
||||
}
|
||||
@@ -80,7 +80,7 @@ function generatePresignedUrl(params: any, expiresIn: number = 3600): string {
|
||||
|
||||
// Get Object Tool
|
||||
export const s3GetObjectTool: ToolConfig = {
|
||||
id: 'get_object',
|
||||
id: 's3_get_object',
|
||||
name: 'S3 Get Object',
|
||||
description: 'Retrieve an object from an AWS S3 bucket',
|
||||
version: '2.0.0',
|
||||
|
||||
Reference in New Issue
Block a user