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
358 lines
12 KiB
TypeScript
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
|
|
},
|
|
}
|