diff --git a/README.md b/README.md index b7035f7..762aa11 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,8 @@ CodePlans sits between your issue tracker and your architecture diagram: | Asset dependency mapping & plan impact analysis | ✅ Available | | Analytics wired to real data (velocity, effort accuracy, debt by product) | ✅ Available | | Activity feed | ✅ Available | -| GitHub & GitLab Issues integrations (pull-only mirror into work items) | ✅ Available | +| GitHub, GitLab, Jira, Asana & Linear integrations (pull-only mirror into work items) | ✅ Available | | MCP server — 28 tools incl. product/asset/dependency management & email-based task assignment | ✅ Available | -| Jira / Asana / Linear connectors | 🔜 Planned | | Milestone-linked plans with mirrored tasks (mixed mode) | ✅ Available | | PR auto-linking (plan-asset PR status refreshed on sync) | ✅ Available | | AI-assisted effort estimation | 🔜 Planned | diff --git a/app/(dashboard)/actions.ts b/app/(dashboard)/actions.ts index fb95fe5..156dd5b 100644 --- a/app/(dashboard)/actions.ts +++ b/app/(dashboard)/actions.ts @@ -7,6 +7,7 @@ import { db } from '@/lib/db' import { users, organizationMembers, organizations, emailVerificationTokens, workItems, syncLog } from '@/lib/db/schema' import { eq, and, gt } from 'drizzle-orm' import { + updateIntegration, createProduct, updateProduct, deleteProduct, @@ -793,6 +794,30 @@ export async function createIntegrationAction(formData: FormData) { return {} } +export async function updateIntegrationAction(id: string, formData: FormData) { + const authUser = await requireUser() + const profile = await getUserProfile(authUser.id) + if (!profile?.organizationId) return { error: 'No workspace found.' } + + const name = formData.get('name') as string + const repo = (formData.get('repo') as string) || undefined + const baseUrl = (formData.get('baseUrl') as string) || undefined + const authRef = (formData.get('authRef') as string) || undefined + const token = (formData.get('token') as string)?.trim() || undefined + const productId = (formData.get('productId') as string) || undefined + if (!productId) return { error: 'Select a target product for mirrored items.' } + + await updateIntegration(id, { + name, + // token undefined keeps the stored credential; authRef only set when provided + token, + ...(authRef !== undefined ? { authRef } : {}), + config: { repo, baseUrl, productId }, + }) + revalidatePath('/integrations') + return { ok: true as const } +} + export async function deleteIntegrationAction(id: string) { await requireUser() await deleteIntegration(id) diff --git a/app/(dashboard)/integrations/integrations-client.tsx b/app/(dashboard)/integrations/integrations-client.tsx index 54480f7..959af0c 100644 --- a/app/(dashboard)/integrations/integrations-client.tsx +++ b/app/(dashboard)/integrations/integrations-client.tsx @@ -27,10 +27,10 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' -import { Github, Gitlab, Plus, RefreshCw, Trash2, AlertCircle, Plug } from 'lucide-react' +import { Github, Gitlab, Plus, RefreshCw, Trash2, AlertCircle, Plug, Ticket, ListTodo, Zap, Pencil } from 'lucide-react' import type { IntegrationSummary } from '@/lib/db/queries' import { cn, formatDateShort } from '@/lib/utils' -import { createIntegrationAction, deleteIntegrationAction, syncIntegrationAction } from '../actions' +import { createIntegrationAction, updateIntegrationAction, deleteIntegrationAction, syncIntegrationAction } from '../actions' type ProductOption = { id: string; name: string } @@ -40,7 +40,19 @@ const statusStyles: Record = { error: 'bg-destructive/20 text-destructive', } -const PROVIDERS = [ +type ProviderMeta = { + value: string + label: string + icon: typeof Github + repoLabel: string + repoPlaceholder: string + tokenHint: string + hasBaseUrl: boolean + baseUrlLabel?: string + baseUrlRequired?: boolean +} + +const PROVIDERS: readonly ProviderMeta[] = [ { value: 'github', label: 'GitHub Issues', @@ -49,7 +61,7 @@ const PROVIDERS = [ repoPlaceholder: 'owner/repo', tokenHint: 'GitHub token with repo read access', hasBaseUrl: false, - }, + } as ProviderMeta, { value: 'gitlab', label: 'GitLab Issues', @@ -58,8 +70,39 @@ const PROVIDERS = [ repoPlaceholder: 'group/project', tokenHint: 'GitLab token with read_api scope', hasBaseUrl: true, + baseUrlLabel: 'Instance URL', + baseUrlRequired: false, + }, + { + value: 'jira', + label: 'Jira', + icon: Ticket, + repoLabel: 'Project key', + repoPlaceholder: 'ENG', + tokenHint: 'Jira credential as email:api_token', + hasBaseUrl: true, + baseUrlLabel: 'Site URL', + baseUrlRequired: true, + }, + { + value: 'asana', + label: 'Asana', + icon: ListTodo, + repoLabel: 'Project GID', + repoPlaceholder: 'e.g. 1203456789012345', + tokenHint: 'Asana Personal Access Token', + hasBaseUrl: false, + }, + { + value: 'linear', + label: 'Linear', + icon: Zap, + repoLabel: 'Team key', + repoPlaceholder: 'ENG', + tokenHint: 'Linear API key', + hasBaseUrl: false, }, -] as const +] function providerMeta(provider: string) { return PROVIDERS.find((p) => p.value === provider) ?? PROVIDERS[0] @@ -172,6 +215,7 @@ export function IntegrationsClient({ {conn.status} + + + + + Edit connection + {meta.label} — leave the token blank to keep the current credential. + +
+
+ + +
+
+ + +
+ {meta.hasBaseUrl && ( +
+ + +
+ )} +
+ + +
+
+ + +
+ {conn.authRef && ( +
+ + +
+ )} + {error &&

{error}

} + + + + +
+
+ + ) +} + function NewConnectionDialog({ products, trigger, @@ -295,10 +418,18 @@ function NewConnectionDialog({ {meta.hasBaseUrl && (
- +
)}
diff --git a/app/(dashboard)/plans/page.tsx b/app/(dashboard)/plans/page.tsx index 0e56a05..fc1655e 100644 --- a/app/(dashboard)/plans/page.tsx +++ b/app/(dashboard)/plans/page.tsx @@ -37,7 +37,7 @@ export default async function PlansPage({ searchParams }: Props) {
- + ) } diff --git a/app/(dashboard)/plans/plans-client.tsx b/app/(dashboard)/plans/plans-client.tsx index 38811cf..b1c5c74 100644 --- a/app/(dashboard)/plans/plans-client.tsx +++ b/app/(dashboard)/plans/plans-client.tsx @@ -14,6 +14,7 @@ import { cn, formatDate } from '@/lib/utils' import { PlanCreatePanel } from './plan-create-panel' type Plan = { + ownerId?: string id: string title: string description: string @@ -52,11 +53,13 @@ const typeStyles: Record = { bugfix: 'bg-chart-5/20 text-chart-5', } -export function PlansClient({ plans, products }: { plans: Plan[]; products: Product[] }) { +export function PlansClient({ plans, products, currentUserId }: { plans: Plan[]; products: Product[]; currentUserId?: string }) { + const [mineOnly, setMineOnly] = useState(false) const [statusFilter, setStatusFilter] = useState('open') const [productFilter, setProductFilter] = useState('all') const filteredPlans = plans.filter((plan) => { + if (mineOnly && plan.ownerId !== currentUserId && !plan.assigneeIds.includes(currentUserId ?? '')) return false if (statusFilter === 'open') { if (plan.status === 'completed' || plan.status === 'cancelled') return false } else if (statusFilter !== 'all' && plan.status !== statusFilter) return false @@ -128,6 +131,13 @@ export function PlansClient({ plans, products }: { plans: Plan[]; products: Prod Completed +
{ setPlanFilter(v); setPage(0) }}> diff --git a/docs/app-spec.md b/docs/app-spec.md index 403a788..59fbe24 100644 --- a/docs/app-spec.md +++ b/docs/app-spec.md @@ -321,7 +321,7 @@ Client component (`WorkItemsClient`) with: Client component (`IntegrationsClient`) with: - Connection cards: provider icon, name, repo, mirrored count, last sync, status badge, surfaced `lastError` - "Sync now" → `syncIntegrationAction` (runs the pull-only sync engine; shows created/updated/unchanged) -- "New Connection" dialog: provider select (GitHub Issues / GitLab Issues), name, repo/project path, instance URL (GitLab self-hosted, optional), target product, and the credential — paste a token (stored encrypted) or name a server env var → `createIntegrationAction`. Cards show credential status (token stored / env ✓ / env missing ⚠) +- "New Connection" dialog: provider select (GitHub / GitLab / Jira / Asana / Linear), name, provider scope (repo, project path, Jira project key + site URL, Asana project GID, Linear team key), target product, and the credential — paste a token (stored encrypted) or name a server env var → `createIntegrationAction`. Cards show credential status (token stored / env ✓ / env missing ⚠) and an edit dialog (rename, re-scope, re-target, replace token — blank keeps current) - Delete with confirm (mirrored items are kept, stop syncing) #### `/api/mcp/[transport]` — MCP server (no UI) diff --git a/lib/db/mutations.ts b/lib/db/mutations.ts index 0cfec7e..988b012 100644 --- a/lib/db/mutations.ts +++ b/lib/db/mutations.ts @@ -537,6 +537,25 @@ export async function createIntegration(data: CreateIntegrationData) { return row } +type UpdateIntegrationData = { + name?: string + authRef?: string | null + /** New token to encrypt and store; undefined = keep existing credential. */ + token?: string + config?: Record +} + +export async function updateIntegration(id: string, data: UpdateIntegrationData) { + const { token, ...columns } = data + const patch: Record = { ...columns } + if (token) { + const { encryptToken } = await import('@/lib/integrations/secrets') + patch.tokenEncrypted = encryptToken(token) + } + const [row] = await db.update(integrations).set(patch).where(eq(integrations.id, id)).returning() + return row ?? null +} + export async function deleteIntegration(id: string) { const [deleted] = await db .delete(integrations) diff --git a/lib/integrations/asana.ts b/lib/integrations/asana.ts new file mode 100644 index 0000000..28c9c99 --- /dev/null +++ b/lib/integrations/asana.ts @@ -0,0 +1,102 @@ +import type { Connector, ConnectorAuth, ExternalItem, ExternalScope, IntegrationConfig } from './types' + +/** + * Asana connector. config.repo = project gid. Auth token = a Personal Access + * Token (bearer). Asana has no native item types — tag names map via the + * connection's typeLabelMap (tags surface as labels). + */ + +const API = 'https://app.asana.com/api/1.0' + +function headers(auth: ConnectorAuth) { + return { Authorization: `Bearer ${auth.token}`, Accept: 'application/json', 'Content-Type': 'application/json' } +} + +type AsanaTask = { + gid: string + name: string + notes?: string + completed: boolean + modified_at: string + permalink_url?: string + tags?: { name: string }[] + assignee?: { name?: string; email?: string } | null + memberships?: { section?: { name?: string } }[] +} + +export function mapAsanaTask(t: AsanaTask): ExternalItem { + // Section name gives in-progress signal on boards ("In Progress", "Doing"). + const section = t.memberships?.[0]?.section?.name?.toLowerCase() ?? '' + const state = t.completed ? 'completed' : /progress|doing|review/.test(section) ? 'in_progress' : 'open' + return { + externalId: t.gid, + externalUrl: t.permalink_url ?? `https://app.asana.com/0/0/${t.gid}`, + title: t.name, + description: t.notes ?? '', + state, + labels: (t.tags ?? []).map((x) => x.name), + assigneeEmail: t.assignee?.email, + assigneeName: t.assignee?.name ?? undefined, + updatedAt: t.modified_at, + } +} + +const FIELDS = 'name,notes,completed,modified_at,permalink_url,tags.name,assignee.name,assignee.email,memberships.section.name' + +async function listProjectTasks(auth: ConnectorAuth, projectGid: string, since?: Date): Promise { + const items: ExternalItem[] = [] + let offset: string | undefined + do { + const url = new URL(`${API}/projects/${projectGid}/tasks`) + url.searchParams.set('opt_fields', FIELDS) + url.searchParams.set('limit', '100') + if (since) url.searchParams.set('modified_since', since.toISOString()) + if (offset) url.searchParams.set('offset', offset) + const res = await fetch(url, { headers: headers(auth) }) + if (!res.ok) throw new Error(`Asana API ${res.status}: ${(await res.text()).slice(0, 200)}`) + const data = (await res.json()) as { data: AsanaTask[]; next_page?: { offset?: string } | null } + for (const t of data.data) items.push(mapAsanaTask(t)) + offset = data.next_page?.offset + } while (offset) + return items +} + +export const asanaConnector: Connector = { + provider: 'asana', + defaultStatusMap: { + open: 'open', + in_progress: 'in_progress', + completed: 'resolved', + }, + + async listItems(auth, config, since) { + return listProjectTasks(auth, config.repo!, since) + }, + + // Epic-like scope: sections in the project. + async listScopes(auth, config): Promise { + const res = await fetch(`${API}/projects/${config.repo}/sections?limit=100`, { headers: headers(auth) }) + if (!res.ok) throw new Error(`Asana API ${res.status}`) + const data = (await res.json()) as { data: { gid: string; name: string }[] } + return data.data.map((s) => ({ id: s.gid, title: s.name, state: 'open' })) + }, + + async listScopeItems(auth, config, scopeId) { + const url = new URL(`${API}/sections/${scopeId}/tasks`) + url.searchParams.set('opt_fields', FIELDS) + url.searchParams.set('limit', '100') + const res = await fetch(url, { headers: headers(auth) }) + if (!res.ok) throw new Error(`Asana API ${res.status}`) + const data = (await res.json()) as { data: AsanaTask[] } + return data.data.map(mapAsanaTask) + }, + + async postComment(auth, _config, externalId, body) { + const res = await fetch(`${API}/tasks/${externalId}/stories`, { + method: 'POST', + headers: headers(auth), + body: JSON.stringify({ data: { text: body } }), + }) + if (!res.ok) throw new Error(`Asana comment failed: ${res.status}`) + }, +} diff --git a/lib/integrations/jira.ts b/lib/integrations/jira.ts new file mode 100644 index 0000000..027251a --- /dev/null +++ b/lib/integrations/jira.ts @@ -0,0 +1,120 @@ +import type { Connector, ConnectorAuth, ExternalItem, ExternalScope, IntegrationConfig } from './types' + +/** + * Jira Cloud connector (REST v3). config.baseUrl = https://your-site.atlassian.net, + * config.repo = project key (e.g. "ENG"). Auth token format: "email:api_token" + * (basic auth pair in one secret). + */ + +function headers(auth: ConnectorAuth) { + return { + Authorization: `Basic ${Buffer.from(auth.token).toString('base64')}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + } +} + +function site(config: IntegrationConfig): string { + return (config.baseUrl ?? '').replace(/\/$/, '') +} + +type JiraIssue = { + id: string + key: string + fields: { + summary: string + description?: { content?: unknown[] } | string | null + updated: string + labels?: string[] + status?: { name?: string; statusCategory?: { key?: string } } + issuetype?: { name?: string } + assignee?: { emailAddress?: string; displayName?: string } | null + } +} + +/** Flatten Atlassian Document Format to plain text (best effort). */ +function adfToText(node: unknown): string { + if (!node) return '' + if (typeof node === 'string') return node + const n = node as { type?: string; text?: string; content?: unknown[] } + if (n.text) return n.text + const inner = (n.content ?? []).map(adfToText).join(n.type === 'paragraph' ? '' : '\n') + return n.type === 'paragraph' ? inner + '\n' : inner +} + +export function mapJiraIssue(issue: JiraIssue, siteUrl: string): ExternalItem { + const f = issue.fields + // Prefer the language-neutral status category; fall back to the raw name. + const category = f.status?.statusCategory?.key // new | indeterminate | done + const state = category === 'new' ? 'open' : category === 'indeterminate' ? 'in_progress' : category === 'done' ? 'done' : (f.status?.name ?? 'open') + const labels = [...(f.labels ?? [])] + if (f.issuetype?.name) labels.push(f.issuetype.name.toLowerCase()) + return { + externalId: issue.id, + externalKey: issue.key, + externalUrl: `${siteUrl}/browse/${issue.key}`, + title: f.summary, + description: typeof f.description === 'string' ? f.description : adfToText(f.description).trim(), + state, + labels, + assigneeEmail: f.assignee?.emailAddress, + assigneeName: f.assignee?.displayName, + updatedAt: f.updated, + } +} + +async function searchIssues(auth: ConnectorAuth, config: IntegrationConfig, jql: string): Promise { + const base = site(config) + const items: ExternalItem[] = [] + let nextPageToken: string | undefined + do { + const url = new URL(`${base}/rest/api/3/search/jql`) + url.searchParams.set('jql', jql) + url.searchParams.set('maxResults', '100') + url.searchParams.set('fields', 'summary,description,updated,labels,status,issuetype,assignee') + if (nextPageToken) url.searchParams.set('nextPageToken', nextPageToken) + const res = await fetch(url, { headers: headers(auth) }) + if (!res.ok) throw new Error(`Jira API ${res.status}: ${(await res.text()).slice(0, 200)}`) + const data = (await res.json()) as { issues?: JiraIssue[]; nextPageToken?: string; isLast?: boolean } + for (const issue of data.issues ?? []) items.push(mapJiraIssue(issue, base)) + nextPageToken = data.isLast ? undefined : data.nextPageToken + } while (nextPageToken) + return items +} + +export const jiraConnector: Connector = { + provider: 'jira', + defaultStatusMap: { + open: 'open', + in_progress: 'in_progress', + done: 'resolved', + }, + + async listItems(auth, config, since) { + let jql = `project = "${config.repo}" ORDER BY updated DESC` + if (since) jql = `project = "${config.repo}" AND updated >= "${since.toISOString().slice(0, 16).replace('T', ' ')}" ORDER BY updated DESC` + return searchIssues(auth, config, jql) + }, + + // Epic-like scope: Jira epics in the project. + async listScopes(auth, config): Promise { + const base = site(config) + const items = await searchIssues(auth, config, `project = "${config.repo}" AND issuetype = Epic ORDER BY updated DESC`) + return items.map((e) => ({ id: e.externalKey ?? e.externalId, title: e.title, state: e.state, url: `${base}/browse/${e.externalKey}` })) + }, + + async listScopeItems(auth, config, scopeId) { + return searchIssues(auth, config, `parent = "${scopeId}" ORDER BY updated DESC`) + }, + + async postComment(auth, config, externalId, body) { + const res = await fetch(`${site(config)}/rest/api/3/issue/${externalId}/comment`, { + method: 'POST', + headers: headers(auth), + body: JSON.stringify({ + body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }] }, + }), + }) + if (!res.ok) throw new Error(`Jira comment failed: ${res.status}`) + }, +} diff --git a/lib/integrations/linear.ts b/lib/integrations/linear.ts new file mode 100644 index 0000000..5376713 --- /dev/null +++ b/lib/integrations/linear.ts @@ -0,0 +1,111 @@ +import type { Connector, ConnectorAuth, ExternalItem, ExternalScope, IntegrationConfig } from './types' + +/** + * Linear connector (GraphQL). config.repo = team key (e.g. "ENG"). + * Auth token = a Linear API key (sent as-is in the Authorization header). + */ + +const API = 'https://api.linear.app/graphql' + +async function gql(auth: ConnectorAuth, query: string, variables: Record): Promise { + const res = await fetch(API, { + method: 'POST', + headers: { Authorization: auth.token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }) + if (!res.ok) throw new Error(`Linear API ${res.status}: ${(await res.text()).slice(0, 200)}`) + const data = (await res.json()) as { data?: T; errors?: { message: string }[] } + if (data.errors?.length) throw new Error(`Linear API: ${data.errors[0].message}`) + return data.data as T +} + +type LinearIssue = { + id: string + identifier: string + title: string + description?: string | null + url: string + updatedAt: string + state: { type: string } // triage|backlog|unstarted|started|completed|canceled + labels: { nodes: { name: string }[] } + assignee?: { name?: string; email?: string } | null +} + +export function mapLinearIssue(i: LinearIssue): ExternalItem { + return { + externalId: i.id, + externalKey: i.identifier, + externalUrl: i.url, + title: i.title, + description: i.description ?? '', + state: i.state.type, + labels: i.labels.nodes.map((l) => l.name), + assigneeEmail: i.assignee?.email, + assigneeName: i.assignee?.name, + updatedAt: i.updatedAt, + } +} + +const ISSUE_FIELDS = ` + id identifier title description url updatedAt + state { type } + labels { nodes { name } } + assignee { name email }` + +async function listIssues(auth: ConnectorAuth, filter: Record): Promise { + const items: ExternalItem[] = [] + let after: string | null = null + do { + const data: { + issues: { nodes: LinearIssue[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } + } = await gql(auth, `query($filter: IssueFilter, $after: String) { + issues(filter: $filter, first: 100, after: $after, includeArchived: false) { + nodes { ${ISSUE_FIELDS} } + pageInfo { hasNextPage endCursor } + } + }`, { filter, after }) + for (const i of data.issues.nodes) items.push(mapLinearIssue(i)) + after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null + } while (after) + return items +} + +export const linearConnector: Connector = { + provider: 'linear', + defaultStatusMap: { + triage: 'open', + backlog: 'open', + unstarted: 'planned', + started: 'in_progress', + completed: 'resolved', + canceled: 'wont_do', + }, + + async listItems(auth, config, since) { + const filter: Record = { team: { key: { eq: config.repo } } } + if (since) filter.updatedAt = { gt: since.toISOString() } + return listIssues(auth, filter) + }, + + // Epic-like scope: Linear projects the team participates in. + async listScopes(auth, config): Promise { + const data: { + projects: { nodes: { id: string; name: string; state: string; url: string }[] } + } = await gql(auth, `query($teamKey: String!) { + projects(filter: { accessibleTeams: { some: { key: { eq: $teamKey } } } }, first: 100) { + nodes { id name state url } + } + }`, { teamKey: config.repo }) + return data.projects.nodes.map((p) => ({ id: p.id, title: p.name, state: p.state, url: p.url })) + }, + + async listScopeItems(auth, _config, scopeId) { + return listIssues(auth, { project: { id: { eq: scopeId } } }) + }, + + async postComment(auth, _config, externalId, body) { + await gql(auth, `mutation($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { success } + }`, { issueId: externalId, body }) + }, +} diff --git a/lib/integrations/registry.ts b/lib/integrations/registry.ts index ef261fe..48aaf59 100644 --- a/lib/integrations/registry.ts +++ b/lib/integrations/registry.ts @@ -1,10 +1,16 @@ import type { Connector } from './types' import { githubConnector } from './github' import { gitlabConnector } from './gitlab' +import { jiraConnector } from './jira' +import { asanaConnector } from './asana' +import { linearConnector } from './linear' const connectors: Record = { github: githubConnector, gitlab: gitlabConnector, + jira: jiraConnector, + asana: asanaConnector, + linear: linearConnector, } export function getConnector(provider: string): Connector | null { diff --git a/package.json b/package.json index 9253112..4287a81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeplans", - "version": "0.3.16", + "version": "0.3.17", "description": "Manage and track coordinated changes across your software architecture.", "author": "Sai Prakash ", "homepage": "https://codeplans.ai", diff --git a/tests/lib/integrations/connectors.test.ts b/tests/lib/integrations/connectors.test.ts new file mode 100644 index 0000000..1b174b5 --- /dev/null +++ b/tests/lib/integrations/connectors.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { mapJiraIssue, jiraConnector } from '@/lib/integrations/jira' +import { mapAsanaTask, asanaConnector } from '@/lib/integrations/asana' +import { mapLinearIssue, linearConnector } from '@/lib/integrations/linear' +import { getConnector } from '@/lib/integrations/registry' + +describe('connector registry', () => { + it('resolves all five providers', () => { + for (const p of ['github', 'gitlab', 'jira', 'asana', 'linear']) { + expect(getConnector(p)?.provider).toBe(p) + } + }) +}) + +describe('jira mapping', () => { + const issue = { + id: '10001', + key: 'ENG-42', + fields: { + summary: 'Fix login', + description: { content: [{ type: 'paragraph', content: [{ type: 'text', text: 'It breaks.' }] }] }, + updated: '2026-07-01T00:00:00.000Z', + labels: ['auth'], + status: { name: 'In Review', statusCategory: { key: 'indeterminate' } }, + issuetype: { name: 'Bug' }, + assignee: { emailAddress: 'a@b.co', displayName: 'Al' }, + }, + } + it('maps status category, ADF description, and issue type as label', () => { + const item = mapJiraIssue(issue as never, 'https://x.atlassian.net') + expect(item.state).toBe('in_progress') + expect(item.description).toBe('It breaks.') + expect(item.labels).toEqual(['auth', 'bug']) + expect(item.externalUrl).toBe('https://x.atlassian.net/browse/ENG-42') + expect(jiraConnector.defaultStatusMap[item.state]).toBe('in_progress') + }) +}) + +describe('asana mapping', () => { + it('derives state from completed flag and section name', () => { + const base = { gid: '1', name: 'T', modified_at: '2026-07-01T00:00:00.000Z' } + expect(mapAsanaTask({ ...base, completed: true } as never).state).toBe('completed') + expect(mapAsanaTask({ ...base, completed: false, memberships: [{ section: { name: 'In Progress' } }] } as never).state).toBe('in_progress') + expect(mapAsanaTask({ ...base, completed: false } as never).state).toBe('open') + expect(asanaConnector.defaultStatusMap.completed).toBe('resolved') + }) +}) + +describe('linear mapping', () => { + it('maps workflow state types through the default status map', () => { + const issue = { + id: 'abc', identifier: 'ENG-7', title: 'T', url: 'https://linear.app/x/issue/ENG-7', + updatedAt: '2026-07-01T00:00:00.000Z', state: { type: 'started' }, labels: { nodes: [{ name: 'tech-debt' }] }, + } + const item = mapLinearIssue(issue as never) + expect(item.externalKey).toBe('ENG-7') + expect(linearConnector.defaultStatusMap[item.state]).toBe('in_progress') + expect(linearConnector.defaultStatusMap.canceled).toBe('wont_do') + }) +})