chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { jiraConnector } from '@/connectors/jira/jira'
|
||||
@@ -0,0 +1,357 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import type { ConnectorMeta } from '@/connectors/types'
|
||||
|
||||
export const jiraConnectorMeta: ConnectorMeta = {
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
description: 'Sync issues from a Jira project',
|
||||
version: '1.0.0',
|
||||
icon: JiraIcon,
|
||||
|
||||
auth: { mode: 'oauth', provider: 'jira', requiredScopes: ['read:jira-work', 'offline_access'] },
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Jira Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'yoursite.atlassian.net',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'projectSelector',
|
||||
title: 'Projects',
|
||||
type: 'selector',
|
||||
selectorKey: 'jira.projects',
|
||||
canonicalParamId: 'projectKey',
|
||||
mode: 'basic',
|
||||
multi: true,
|
||||
dependsOn: ['domain'],
|
||||
placeholder: 'Select one or more projects',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'projectKey',
|
||||
title: 'Project Keys',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'projectKey',
|
||||
mode: 'advanced',
|
||||
multi: true,
|
||||
placeholder: 'e.g. ENG, PROJ (comma-separated for multiple)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'jql',
|
||||
title: 'JQL Filter',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: 'e.g. status = "Done" AND type = Bug',
|
||||
},
|
||||
{
|
||||
id: 'maxIssues',
|
||||
title: 'Max Issues',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: 'e.g. 500 (default: unlimited)',
|
||||
},
|
||||
],
|
||||
|
||||
tagDefinitions: [
|
||||
{ id: 'issueType', displayName: 'Issue Type', fieldType: 'text' },
|
||||
{ id: 'status', displayName: 'Status', fieldType: 'text' },
|
||||
{ id: 'priority', displayName: 'Priority', fieldType: 'text' },
|
||||
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
|
||||
{ id: 'assignee', displayName: 'Assignee', fieldType: 'text' },
|
||||
{ id: 'updated', displayName: 'Last Updated', fieldType: 'date' },
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user