Files
simstudioai--sim/apps/sim/connectors/jira/jira.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

358 lines
12 KiB
TypeScript

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, unknown>): 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<string, unknown>, domain: string): ExternalDocument {
const fields = (issue.fields || {}) as Record<string, unknown>
const key = issue.key as string
const issueType = fields.issuetype as Record<string, unknown> | undefined
const status = fields.status as Record<string, unknown> | undefined
const priority = fields.priority as Record<string, unknown> | undefined
const assignee = fields.assignee as Record<string, unknown> | undefined
const reporter = fields.reporter as Record<string, unknown> | undefined
const project = fields.project as Record<string, unknown> | 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<string, unknown>, domain: string): ExternalDocument {
const stub = issueToStub(issue, domain)
const fields = (issue.fields || {}) as Record<string, unknown>
const content = buildIssueContent(fields)
return {
...stub,
content,
contentDeferred: false,
}
}
export const jiraConnector: ConnectorConfig = {
...jiraConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
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<string, unknown>[]
/**
* `/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<string, unknown>,
externalId: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> => {
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<string, unknown>
): 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<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
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
},
}