import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { jiraConnectorMeta } from '@/connectors/jira/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' import { extractAdfText, getJiraCloudId } from '@/tools/jira/utils' const logger = createLogger('JiraConnector') const PAGE_SIZE = 50 /** * Builds a JQL clause restricting issues to the given project keys. * Single key uses `project = "X"`; multiple keys use `project in ("X","Y")`. * Each key is escaped for inclusion in a JQL double-quoted string. */ function buildProjectClause(projectKeys: string[]): string { const escapeKey = (key: string) => key.replace(/\\/g, '\\\\').replace(/"/g, '\\"') if (projectKeys.length === 1) { return `project = "${escapeKey(projectKeys[0])}"` } const list = projectKeys.map((k) => `"${escapeKey(k)}"`).join(',') return `project in (${list})` } /** * Builds a plain-text representation of a Jira issue for knowledge base indexing. */ function buildIssueContent(fields: Record): string { const parts: string[] = [] const summary = fields.summary as string | undefined if (summary) parts.push(summary) const description = extractAdfText(fields.description) if (description) parts.push(description) const comments = fields.comment as { comments?: Array<{ body?: unknown }> } | undefined if (comments?.comments) { for (const comment of comments.comments) { const text = extractAdfText(comment.body) if (text) parts.push(text) } } return parts.join('\n\n').trim() } /** * Extracts common metadata fields from a Jira issue into an ExternalDocument * stub with deferred content. The contentHash is metadata-based so it is * identical whether produced during listing or full fetch. */ function issueToStub(issue: Record, domain: string): ExternalDocument { const fields = (issue.fields || {}) as Record const key = issue.key as string const issueType = fields.issuetype as Record | undefined const status = fields.status as Record | undefined const priority = fields.priority as Record | undefined const assignee = fields.assignee as Record | undefined const reporter = fields.reporter as Record | undefined const project = fields.project as Record | undefined const labels = Array.isArray(fields.labels) ? (fields.labels as string[]) : [] const updated = (fields.updated as string) ?? '' return { externalId: String(issue.id), title: `${key}: ${(fields.summary as string) || 'Untitled'}`, content: '', contentDeferred: true, mimeType: 'text/plain', sourceUrl: `https://${domain}/browse/${key}`, contentHash: `jira:${issue.id}:${updated}`, metadata: { key, issueType: issueType?.name, status: status?.name, priority: priority?.name, assignee: assignee?.displayName, reporter: reporter?.displayName, project: project?.key, labels, created: fields.created, updated: fields.updated, }, } } /** * Converts a fully-fetched Jira issue (with description and comments) into an * ExternalDocument with resolved content. */ function issueToFullDocument(issue: Record, domain: string): ExternalDocument { const stub = issueToStub(issue, domain) const fields = (issue.fields || {}) as Record const content = buildIssueContent(fields) return { ...stub, content, contentDeferred: false, } } export const jiraConnector: ConnectorConfig = { ...jiraConnectorMeta, listDocuments: async ( accessToken: string, sourceConfig: Record, cursor?: string, syncContext?: Record ): Promise => { const domain = sourceConfig.domain as string const projectKeys = parseMultiValue(sourceConfig.projectKey) const jqlFilter = (sourceConfig.jql as string) || '' const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0 if (projectKeys.length === 0) { throw new Error('At least one project key is required') } let cloudId = syncContext?.cloudId as string | undefined if (!cloudId) { cloudId = await getJiraCloudId(domain, accessToken) if (syncContext) syncContext.cloudId = cloudId } const projectClause = buildProjectClause(projectKeys) let jql = `${projectClause} ORDER BY updated DESC` if (jqlFilter.trim()) { jql = `${projectClause} AND (${jqlFilter.trim()}) ORDER BY updated DESC` } /** * Collected-count is encoded in the cursor as `${pageToken}|${count}` so * the maxIssues cap works correctly even when the caller doesn't pass * syncContext. Falls back to syncContext.collectedCount for backwards * compatibility with cursors emitted before this format existed. */ let pageToken: string | undefined let collectedSoFar = (syncContext?.collectedCount as number | undefined) ?? 0 if (cursor) { const sep = cursor.lastIndexOf('|') if (sep > 0) { pageToken = cursor.slice(0, sep) const parsed = Number(cursor.slice(sep + 1)) if (Number.isFinite(parsed) && parsed >= 0) collectedSoFar = parsed } else { pageToken = cursor } } const remaining = maxIssues > 0 ? Math.max(0, maxIssues - collectedSoFar) : PAGE_SIZE if (maxIssues > 0 && remaining === 0) { return { documents: [], hasMore: false } } const params = new URLSearchParams() params.append('jql', jql) params.append('maxResults', String(Math.min(PAGE_SIZE, remaining))) params.append( 'fields', 'summary,issuetype,status,priority,assignee,reporter,project,labels,created,updated' ) if (pageToken) params.append('nextPageToken', pageToken) const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` logger.info(`Listing Jira issues for ${projectKeys.length} project(s)`, { projectKeys, hasCursor: Boolean(cursor), }) const response = await fetchWithRetry(url, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, }, }) if (!response.ok) { const errorText = await response.text() logger.error('Failed to search Jira issues', { status: response.status, error: errorText, }) throw new Error(`Failed to search Jira issues: ${response.status}`) } const data = await response.json() let issues = (data.issues || []) as Record[] /** * `/rest/api/3/search/jql` signals end-of-results purely by the absence * of `nextPageToken`. `data.isLast` is unreliable on this endpoint and * has been observed returning `true` alongside a valid token * (JRACLOUD-95477), so we ignore it. */ const nextPageToken = data.nextPageToken as string | undefined const isLast = !nextPageToken if (maxIssues > 0 && issues.length > remaining) { issues = issues.slice(0, remaining) } const documents: ExternalDocument[] = issues.map((issue) => issueToStub(issue, domain)) const newCollected = collectedSoFar + issues.length if (syncContext) syncContext.collectedCount = newCollected const reachedCap = maxIssues > 0 && newCollected >= maxIssues const hasMore = !isLast && !reachedCap return { documents, nextCursor: hasMore && nextPageToken ? `${nextPageToken}|${newCollected}` : undefined, hasMore, } }, getDocument: async ( accessToken: string, sourceConfig: Record, externalId: string, syncContext?: Record ): Promise => { const domain = sourceConfig.domain as string let cloudId = syncContext?.cloudId as string | undefined if (!cloudId) { cloudId = await getJiraCloudId(domain, accessToken) if (syncContext) syncContext.cloudId = cloudId } const params = new URLSearchParams() params.append( 'fields', 'summary,description,comment,issuetype,status,priority,assignee,reporter,project,labels,created,updated' ) const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${externalId}?${params.toString()}` const response = await fetchWithRetry(url, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, }, }) if (!response.ok) { if (response.status === 404) return null throw new Error(`Failed to get Jira issue: ${response.status}`) } const issue = await response.json() return issueToFullDocument(issue, domain) }, validateConfig: async ( accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const domain = sourceConfig.domain as string const projectKeys = parseMultiValue(sourceConfig.projectKey) if (!domain || projectKeys.length === 0) { return { valid: false, error: 'Domain and at least one project key are required' } } const maxIssues = sourceConfig.maxIssues as string | undefined if (maxIssues && (Number.isNaN(Number(maxIssues)) || Number(maxIssues) <= 0)) { return { valid: false, error: 'Max issues must be a positive number' } } const jqlFilter = (sourceConfig.jql as string | undefined)?.trim() || '' try { const cloudId = await getJiraCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) const projectClause = buildProjectClause(projectKeys) const params = new URLSearchParams() params.append('jql', projectClause) params.append('maxResults', '1') const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` const response = await fetchWithRetry( url, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, }, }, VALIDATE_RETRY_OPTIONS ) if (!response.ok) { const errorText = await response.text() if (response.status === 400) { return { valid: false, error: `One or more projects not found (${projectKeys.join(', ')}) or JQL is invalid`, } } return { valid: false, error: `Failed to validate: ${response.status} - ${errorText}` } } if (jqlFilter) { const filterParams = new URLSearchParams() filterParams.append('jql', `${projectClause} AND (${jqlFilter})`) filterParams.append('maxResults', '1') const filterUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${filterParams.toString()}` const filterResponse = await fetchWithRetry( filterUrl, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, }, }, VALIDATE_RETRY_OPTIONS ) if (!filterResponse.ok) { return { valid: false, error: 'Invalid JQL filter. Check syntax and field names.' } } } return { valid: true } } catch (error) { return { valid: false, error: toError(error).message || 'Failed to validate configuration' } } }, mapTags: (metadata: Record): Record => { const result: Record = {} if (typeof metadata.issueType === 'string') result.issueType = metadata.issueType if (typeof metadata.status === 'string') result.status = metadata.status if (typeof metadata.priority === 'string') result.priority = metadata.priority const labels = joinTagArray(metadata.labels) if (labels) result.labels = labels if (typeof metadata.assignee === 'string') result.assignee = metadata.assignee const updated = parseTagDate(metadata.updated) if (updated) result.updated = updated return result }, }