Files
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

344 lines
9.5 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { asanaConnectorMeta } from '@/connectors/asana/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { joinTagArray, parseTagDate } from '@/connectors/utils'
const logger = createLogger('AsanaConnector')
const ASANA_API = 'https://app.asana.com/api/1.0'
const TASK_OPT_FIELDS =
'name,notes,completed,completed_at,modified_at,assignee.name,tags.name,permalink_url'
/**
* Asana API response shape for paginated endpoints.
*/
interface AsanaPageResponse {
data: AsanaTask[]
next_page: { offset: string; uri: string } | null
}
/**
* Minimal Asana task shape used by this connector.
*/
interface AsanaTask {
gid: string
name: string
notes?: string
completed: boolean
completed_at?: string
modified_at?: string
assignee?: { name: string }
tags?: { name: string }[]
permalink_url?: string
}
/**
* Asana workspace shape.
*/
interface AsanaWorkspace {
gid: string
name: string
}
/**
* Asana project shape.
*/
interface AsanaProject {
gid: string
name: string
}
/**
* Makes a GET request to the Asana REST API.
*/
async function asanaGet<T>(
accessToken: string,
path: string,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<T> {
const response = await fetchWithRetry(
`${ASANA_API}${path}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
retryOptions
)
if (!response.ok) {
const errorText = await response.text()
logger.error('Asana API request failed', { status: response.status, path, error: errorText })
throw new Error(`Asana API error: ${response.status}`)
}
return (await response.json()) as T
}
/**
* Builds a formatted text document from an Asana task.
*/
function buildTaskContent(task: AsanaTask): string {
const parts: string[] = []
parts.push(task.name || 'Untitled')
if (task.assignee?.name) parts.push(`Assignee: ${task.assignee.name}`)
parts.push(`Completed: ${task.completed ? 'Yes' : 'No'}`)
const tagNames = task.tags?.map((t) => t.name).filter(Boolean)
if (tagNames && tagNames.length > 0) {
parts.push(`Labels: ${tagNames.join(', ')}`)
}
if (task.notes) {
parts.push('')
parts.push(task.notes)
}
return parts.join('\n')
}
/**
* Fetches all project GIDs in a workspace, used when no specific project is configured.
*/
async function listWorkspaceProjects(
accessToken: string,
workspaceGid: string
): Promise<AsanaProject[]> {
const projects: AsanaProject[] = []
let offset: string | undefined
// eslint-disable-next-line no-constant-condition
while (true) {
const offsetParam = offset ? `&offset=${offset}` : ''
const result = await asanaGet<{ data: AsanaProject[]; next_page: { offset: string } | null }>(
accessToken,
`/projects?workspace=${workspaceGid}&limit=100${offsetParam}`
)
projects.push(...result.data)
if (!result.next_page) break
offset = result.next_page.offset
}
return projects
}
export const asanaConnector: ConnectorConfig = {
...asanaConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const workspaceGid = sourceConfig.workspace as string
const projectGid = (sourceConfig.project as string) || ''
const maxTasks = sourceConfig.maxTasks ? Number(sourceConfig.maxTasks) : 0
const pageSize = maxTasks > 0 ? Math.min(maxTasks, 100) : 100
/**
* Cursor format:
* - For a single project: the offset string directly, or undefined
* - For all projects: JSON-encoded { projectIndex, offset }
*/
let projectGids: string[]
let projectIndex = 0
let offset: string | undefined
if (projectGid) {
projectGids = [projectGid]
} else {
if (!syncContext?.projectGids) {
logger.info('Fetching all projects in workspace', { workspaceGid })
const projects = await listWorkspaceProjects(accessToken, workspaceGid)
if (syncContext) syncContext.projectGids = projects.map((p) => p.gid)
projectGids = projects.map((p) => p.gid)
} else {
projectGids = syncContext.projectGids as string[]
}
}
if (cursor) {
try {
const parsed = JSON.parse(cursor) as { projectIndex: number; offset?: string }
projectIndex = parsed.projectIndex
offset = parsed.offset
} catch {
offset = cursor
}
}
logger.info('Listing Asana tasks', {
workspaceGid,
projectCount: projectGids.length,
projectIndex,
offset,
pageSize,
})
const documents: ExternalDocument[] = []
let nextCursor: string | undefined
let hasMore = false
while (projectIndex < projectGids.length) {
const currentProjectGid = projectGids[projectIndex]
const offsetParam = offset ? `&offset=${offset}` : ''
const result = await asanaGet<AsanaPageResponse>(
accessToken,
`/tasks?project=${currentProjectGid}&opt_fields=${TASK_OPT_FIELDS}&limit=${pageSize}${offsetParam}`
)
for (const task of result.data) {
const content = buildTaskContent(task)
const tagNames = task.tags?.map((t) => t.name).filter(Boolean) || []
documents.push({
externalId: task.gid,
title: task.name || 'Untitled',
content,
mimeType: 'text/plain',
sourceUrl: task.permalink_url || undefined,
contentHash: `asana:${task.gid}:${task.modified_at ?? ''}`,
metadata: {
project: currentProjectGid,
assignee: task.assignee?.name,
completed: task.completed,
lastModified: task.modified_at,
labels: tagNames,
},
})
}
if (result.next_page) {
nextCursor = JSON.stringify({ projectIndex, offset: result.next_page.offset })
hasMore = true
break
}
projectIndex++
offset = undefined
if (projectIndex < projectGids.length) {
nextCursor = JSON.stringify({ projectIndex, offset: undefined })
hasMore = true
break
}
}
const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0
if (maxTasks > 0) {
const remaining = maxTasks - previouslyFetched
if (documents.length > remaining) {
documents.splice(remaining)
}
}
const totalFetched = previouslyFetched + documents.length
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = maxTasks > 0 && totalFetched >= maxTasks
if (hitLimit) {
hasMore = false
nextCursor = undefined
}
return {
documents,
nextCursor: hasMore ? nextCursor : undefined,
hasMore,
}
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
try {
const result = await asanaGet<{ data: AsanaTask }>(
accessToken,
`/tasks/${externalId}?opt_fields=${TASK_OPT_FIELDS}`
)
const task = result.data
if (!task) return null
const content = buildTaskContent(task)
const tagNames = task.tags?.map((t) => t.name).filter(Boolean) || []
return {
externalId: task.gid,
title: task.name || 'Untitled',
content,
mimeType: 'text/plain',
sourceUrl: task.permalink_url || undefined,
contentHash: `asana:${task.gid}:${task.modified_at ?? ''}`,
metadata: {
assignee: task.assignee?.name,
completed: task.completed,
lastModified: task.modified_at,
labels: tagNames,
},
}
} catch (error) {
logger.error('Failed to get Asana task', {
externalId,
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const workspaceGid = sourceConfig.workspace as string | undefined
if (!workspaceGid) {
return { valid: false, error: 'Workspace GID is required' }
}
const maxTasks = sourceConfig.maxTasks as string | undefined
if (maxTasks && (Number.isNaN(Number(maxTasks)) || Number(maxTasks) <= 0)) {
return { valid: false, error: 'Max tasks must be a positive number' }
}
try {
await asanaGet<{ data: AsanaWorkspace }>(
accessToken,
`/workspaces/${workspaceGid}`,
VALIDATE_RETRY_OPTIONS
)
return { valid: true }
} catch (error) {
const message = getErrorMessage(error, 'Failed to validate configuration')
return { valid: false, error: message }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.project === 'string') result.project = metadata.project
if (typeof metadata.assignee === 'string') result.assignee = metadata.assignee
if (typeof metadata.completed === 'boolean') result.completed = metadata.completed
const lastModified = parseTagDate(metadata.lastModified)
if (lastModified) result.lastModified = lastModified
const labels = joinTagArray(metadata.labels)
if (labels) result.labels = labels
return result
},
}