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:
Waleed Latif
2025-04-23 01:46:49 -07:00
committed by GitHub
parent 2749cebe31
commit c7e29192af
30 changed files with 4669 additions and 6361 deletions

View 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`

View File

@@ -18,6 +18,7 @@
"guesty",
"image_generator",
"jina",
"jira",
"linkup",
"mem0",
"notion",

View File

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

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

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

View File

@@ -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()}
/>
)}
</>
)
}

View File

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

View File

@@ -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()}
/>
)}
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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'
}
}

View File

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

View File

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