From 0c62fc1c3d9dce1be34f4a0f73963891cce96df5 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 19:11:21 +0000 Subject: [PATCH 1/2] feat(pm): add Linear PM provider adapter and integration --- src/agents/prompts/index.ts | 2 +- src/api/routers/webhooks/types.ts | 2 +- src/config/integrationRoles.ts | 15 +- src/config/schema.ts | 23 +- src/pm/config.ts | 26 ++ src/pm/index.ts | 1 + src/pm/linear/adapter.ts | 338 ++++++++++++++ src/pm/linear/integration.ts | 226 ++++++++++ src/pm/types.ts | 2 +- src/router/config.ts | 2 +- tests/unit/pm/linear/adapter.test.ts | 536 +++++++++++++++++++++++ tests/unit/pm/linear/integration.test.ts | 419 ++++++++++++++++++ 12 files changed, 1586 insertions(+), 6 deletions(-) create mode 100644 src/pm/linear/adapter.ts create mode 100644 src/pm/linear/integration.ts create mode 100644 tests/unit/pm/linear/adapter.test.ts create mode 100644 tests/unit/pm/linear/integration.test.ts diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 4f7b84b0..90ff853d 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -34,7 +34,7 @@ export interface PromptContext { projectId?: string; // PM vocabulary (computed from pmType) - pmType?: 'trello' | 'jira'; + pmType?: 'trello' | 'jira' | 'linear'; workItemNoun?: string; // "card" or "issue" workItemNounPlural?: string; // "cards" or "issues" workItemNounCap?: string; // "Card" or "Issue" diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts index 323b3ccb..b97f83cd 100644 --- a/src/api/routers/webhooks/types.ts +++ b/src/api/routers/webhooks/types.ts @@ -36,7 +36,7 @@ export interface ProjectContext { projectId: string; orgId: string; repo?: string; - pmType: 'trello' | 'jira'; + pmType: 'trello' | 'jira' | 'linear'; boardId?: string; jiraBaseUrl?: string; jiraProjectKey?: string; diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts index 124f53df..a79cd37d 100644 --- a/src/config/integrationRoles.ts +++ b/src/config/integrationRoles.ts @@ -1,5 +1,5 @@ export type IntegrationCategory = 'pm' | 'scm' | 'alerting'; -export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'sentry'; +export type IntegrationProvider = 'trello' | 'jira' | 'linear' | 'github' | 'sentry'; export interface CredentialRoleDef { role: string; @@ -35,6 +35,18 @@ const _rolesRegistry = new Map([ }, ], ], + [ + 'linear', + [ + { role: 'api_key', label: 'API Key', envVarKey: 'LINEAR_API_KEY' }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'LINEAR_WEBHOOK_SECRET', + optional: true, + }, + ], + ], [ 'github', [ @@ -69,6 +81,7 @@ const _rolesRegistry = new Map([ const _categoryRegistry = new Map([ ['trello', 'pm'], ['jira', 'pm'], + ['linear', 'pm'], ['github', 'scm'], ['sentry', 'alerting'], ]); diff --git a/src/config/schema.ts b/src/config/schema.ts index 60cbe368..8dcd3246 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -36,6 +36,25 @@ const JiraConfigSchema = z.object({ .optional(), }); +const LinearConfigSchema = z.object({ + teamId: z.string().min(1), + statuses: z.record(z.string()), // CASCADE status names → Linear state IDs + labels: z + .object({ + processing: z.string().optional(), + processed: z.string().optional(), + error: z.string().optional(), + readyToProcess: z.string().optional(), + auto: z.string().optional(), + }) + .optional(), + customFields: z + .object({ + cost: z.string().optional(), + }) + .optional(), +}); + export const ProjectConfigSchema = z.object({ id: z.string().min(1), orgId: z.string().min(1), @@ -49,7 +68,7 @@ export const ProjectConfigSchema = z.object({ pm: z .object({ - type: z.enum(['trello', 'jira']).default('trello'), + type: z.enum(['trello', 'jira', 'linear']).default('trello'), }) .default({ type: 'trello' }), @@ -68,6 +87,8 @@ export const ProjectConfigSchema = z.object({ jira: JiraConfigSchema.optional(), + linear: LinearConfigSchema.optional(), + model: z.string().default(PROJECT_DEFAULTS.model), agentModels: z.record(z.string()).optional(), maxIterations: z.number().int().positive().default(PROJECT_DEFAULTS.maxIterations), diff --git a/src/pm/config.ts b/src/pm/config.ts index 00f1c20e..048b15a1 100644 --- a/src/pm/config.ts +++ b/src/pm/config.ts @@ -53,6 +53,29 @@ export function getJiraConfig(project: ProjectConfig): JiraConfig | undefined { return project.jira as JiraConfig | undefined; } +/** Linear-specific configuration (from project_integrations JSONB) */ +export interface LinearConfig { + teamId: string; + statuses: Record; + labels?: { + processing?: string; + processed?: string; + error?: string; + readyToProcess?: string; + auto?: string; + }; + customFields?: { cost?: string }; +} + +/** + * Get the Linear config for a project. + * Returns the config or undefined if this is not a Linear project. + */ +export function getLinearConfig(project: ProjectConfig): LinearConfig | undefined { + if (project.pm?.type !== 'linear') return undefined; + return project.linear as LinearConfig | undefined; +} + /** * Get the cost custom field ID for a project, regardless of PM type. */ @@ -60,5 +83,8 @@ export function getCostFieldId(project: ProjectConfig): string | undefined { if (project.pm?.type === 'jira') { return getJiraConfig(project)?.customFields?.cost; } + if (project.pm?.type === 'linear') { + return getLinearConfig(project)?.customFields?.cost; + } return getTrelloConfig(project)?.customFields?.cost; } diff --git a/src/pm/index.ts b/src/pm/index.ts index b8364a76..e4a531c8 100644 --- a/src/pm/index.ts +++ b/src/pm/index.ts @@ -4,6 +4,7 @@ export type { PMIntegration, PMWebhookEvent } from './integration.js'; export { JiraPMProvider } from './jira/adapter.js'; export type { ProjectPMConfig } from './lifecycle.js'; export { hasAutoLabel, PMLifecycleManager, resolveProjectPMConfig } from './lifecycle.js'; +export { LinearPMProvider } from './linear/adapter.js'; export { extractMarkdownImages, filterImageMedia, diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts new file mode 100644 index 00000000..39fb70c4 --- /dev/null +++ b/src/pm/linear/adapter.ts @@ -0,0 +1,338 @@ +/** + * LinearPMProvider — wraps linearClient to implement the PMProvider interface. + * + * Assumes linearClient credentials are already in scope via withLinearCredentials(). + * + * Linear does not have native checklists. We model them using child issues + * (sub-issues), following the same pattern used by JiraPMProvider for subtasks. + */ + +import { linearClient } from '../../linear/client.js'; +import { logger } from '../../utils/logging.js'; +import type { + Attachment, + Checklist, + ChecklistItem, + CreateWorkItemConfig, + ListWorkItemsFilter, + PMProvider, + WorkItem, + WorkItemComment, + WorkItemLabel, +} from '../types.js'; + +interface LinearConfig { + teamId: string; + statuses: Record; + labels?: Record; + customFields?: { cost?: string }; +} + +export class LinearPMProvider implements PMProvider { + readonly type = 'linear' as const; + + constructor(private config: LinearConfig) {} + + async getWorkItem(id: string): Promise { + const issue = await linearClient.getIssue(id); + return { + id: issue.identifier || issue.id, + title: issue.title, + description: issue.description ?? '', + url: issue.url, + status: issue.state?.name, + labels: issue.labels.map( + (l): WorkItemLabel => ({ + id: l.id, + name: l.name, + color: l.color, + }), + ), + }; + } + + async getWorkItemComments(id: string): Promise { + const comments = await linearClient.getIssueComments(id); + return comments.map((c) => ({ + id: c.id, + date: c.createdAt, + text: c.body, + author: { + id: c.user?.id ?? '', + name: c.user?.displayName ?? c.user?.name ?? '', + username: c.user?.email ?? '', + }, + })); + } + + async updateWorkItem( + id: string, + updates: { title?: string; description?: string }, + ): Promise { + await linearClient.updateIssue(id, { + title: updates.title, + description: updates.description, + }); + } + + async addComment(id: string, text: string): Promise { + const comment = await linearClient.createComment(id, text); + return comment.id; + } + + async updateComment(_id: string, commentId: string, text: string): Promise { + await linearClient.updateComment(commentId, text); + } + + async createWorkItem(config: CreateWorkItemConfig): Promise { + const teamId = config.containerId || this.config.teamId; + const issue = await linearClient.createIssue({ + teamId, + title: config.title, + description: config.description, + ...(config.labels?.length + ? { + labelIds: config.labels + .map((name) => this.config.labels?.[name]) + .filter((id): id is string => !!id), + } + : {}), + }); + + // Transition to backlog status if configured + const backlogStatus = this.config.statuses?.backlog; + if (backlogStatus) { + try { + await this.moveWorkItem(issue.id, backlogStatus); + } catch (err) { + logger.warn('[Linear] Failed to transition new issue to backlog status', { + issueId: issue.id, + targetStatus: backlogStatus, + error: String(err), + }); + } + } + + return { + id: issue.identifier || issue.id, + title: issue.title, + description: issue.description ?? '', + url: issue.url, + labels: [], + }; + } + + async listWorkItems(containerId: string, filter?: ListWorkItemsFilter): Promise { + // containerId is the Linear team ID + const teamId = containerId || this.config.teamId; + const issues = await linearClient.listIssues({ + teamId, + ...(filter?.status + ? { + stateId: this.config.statuses?.[filter.status] ?? filter.status, + } + : {}), + }); + return issues.map((issue) => ({ + id: issue.identifier || issue.id, + title: issue.title, + description: issue.description ?? '', + url: issue.url, + status: issue.state?.name, + labels: issue.labels.map( + (l): WorkItemLabel => ({ + id: l.id, + name: l.name, + color: l.color, + }), + ), + })); + } + + async moveWorkItem(id: string, destination: string): Promise { + // destination is a Linear state name or ID from config.statuses + const stateId = this.config.statuses?.[destination] ?? destination; + await linearClient.updateIssueState(id, stateId); + } + + async addLabel(id: string, labelIdOrName: string): Promise { + // Resolve name → ID via config if possible + const labelId = this.config.labels?.[labelIdOrName] ?? labelIdOrName; + await linearClient.addLabel(id, labelId); + } + + async removeLabel(id: string, labelIdOrName: string): Promise { + const labelId = this.config.labels?.[labelIdOrName] ?? labelIdOrName; + await linearClient.removeLabel(id, labelId); + } + + async getChecklists(workItemId: string): Promise { + // Linear doesn't have native checklists — map child issues (sub-issues) + // We fetch the issue's children by listing issues with parentId filter. + // The linearClient doesn't expose a direct children query, so we use + // a workaround: list issues filtered by parent identifier. + // Since linearClient.listIssues() doesn't support parentId filter + // directly, we fall back to getting the issue and checking its + // children via the GraphQL API through getIssue() which doesn't + // return children. We'll use a workaround using the attachment/comment + // based "pseudo-checklist" pattern with a dedicated sub-issue list call. + // + // For now, use listIssues with a parent identifier approach: + // Linear's filter supports parent.id, but our client doesn't expose that. + // Return an empty list and rely on the item-level operations for now. + // This is consistent with how the JIRA implementation works for empty subtask lists. + logger.debug('[Linear] getChecklists — returning empty list (sub-issues not yet cached)', { + workItemId, + }); + return [ + { + id: `subtasks-${workItemId}`, + name: 'Sub-issues', + workItemId, + items: [] as ChecklistItem[], + }, + ]; + } + + async createChecklist(workItemId: string, name: string): Promise { + // In Linear, "create checklist" = create a parent context. + // Items will be sub-issues created via addChecklistItem. + return { + id: `checklist-${workItemId}-${Date.now()}`, + name, + workItemId, + items: [], + }; + } + + async addChecklistItem( + checklistId: string, + name: string, + _checked = false, + description?: string, + ): Promise { + // Extract parent issue ID from checklistId format: + // "checklist--" or "subtasks-" + const match = checklistId.match(/(?:checklist|subtasks)-(.+?)(?:-\d{10,})?$/); + const parentId = match?.[1]; + if (!parentId) { + throw new Error(`Cannot extract parent issue ID from checklist ID: ${checklistId}`); + } + + await linearClient.createIssue({ + teamId: this.config.teamId, + title: name, + description, + }); + // Note: Linear sub-issue (parent) assignment is done via parentId in IssueCreateInput. + // The linearClient.createIssue accepts the full IssueCreateInput which supports parentId. + // We create a separate issue and rely on the parent ID matching. + logger.debug('[Linear] addChecklistItem — created sub-issue', { parentId, title: name }); + } + + async updateChecklistItem( + _workItemId: string, + checkItemId: string, + complete: boolean, + ): Promise { + // checkItemId is a Linear issue ID (sub-issue) + const targetStatus = complete + ? (this.config.statuses?.done ?? 'Done') + : (this.config.statuses?.backlog ?? 'Todo'); + await this.moveWorkItem(checkItemId, targetStatus); + } + + async deleteChecklistItem(_workItemId: string, checkItemId: string): Promise { + // Linear doesn't support issue deletion via API — transition to cancelled state + // We try to find a cancelled/done state and transition to it. + const cancelledStateId = this.config.statuses?.cancelled ?? this.config.statuses?.done ?? null; + + if (cancelledStateId) { + try { + await linearClient.updateIssueState(checkItemId, cancelledStateId); + logger.info('[Linear] deleteChecklistItem — transitioned sub-issue to terminal state', { + checkItemId, + stateId: cancelledStateId, + }); + return; + } catch (err) { + logger.warn('[Linear] Failed to transition sub-issue to terminal state', { + checkItemId, + error: String(err), + }); + } + } + + logger.warn('[Linear] deleteChecklistItem — no terminal state configured, skipping', { + checkItemId, + }); + } + + async getAttachments(workItemId: string): Promise { + const attachments = await linearClient.getAttachments(workItemId); + return attachments.map((a) => ({ + id: a.id, + name: a.title, + url: a.url, + mimeType: (a.metadata?.mimeType as string) ?? 'application/octet-stream', + bytes: (a.metadata?.size as number) ?? 0, + date: a.createdAt, + })); + } + + async addAttachment(workItemId: string, url: string, name: string): Promise { + await linearClient.createAttachment(workItemId, { title: name, url }); + } + + async addAttachmentFile( + workItemId: string, + _buffer: Buffer, + name: string, + _mimeType: string, + ): Promise { + // Linear doesn't support binary file uploads — add as a comment with a placeholder. + // This mirrors the JIRA addAttachment fallback for URL-only attachments. + await this.addComment(workItemId, `Attachment: ${name} (binary upload not supported)`); + } + + async getCustomFieldNumber(_workItemId: string, _fieldId: string): Promise { + // Linear doesn't have generic custom number fields. + // Return 0 as a safe fallback. + return 0; + } + + async updateCustomFieldNumber( + _workItemId: string, + _fieldId: string, + _value: number, + ): Promise { + // Linear doesn't have generic custom number fields — no-op. + logger.warn('[Linear] updateCustomFieldNumber — not supported, skipping', { _fieldId }); + } + + async linkPR(workItemId: string, prUrl: string, prTitle: string): Promise { + await linearClient.createAttachment(workItemId, { + title: prTitle, + url: prUrl, + subtitle: 'Pull Request', + metadata: { type: 'github_pr' }, + }); + } + + getWorkItemUrl(id: string): string { + // Linear URLs follow pattern: https://linear.app/team/issue/TEAM-123 + // The id here may be the identifier (TEAM-123) or internal UUID. + // The issue.url from the API is already correct; for URL construction + // from an identifier alone we fall back to a generic format. + return `https://linear.app/issue/${id}`; + } + + async getAuthenticatedUser(): Promise<{ id: string; name: string; username: string }> { + const user = await linearClient.getMe(); + return { + id: user.id, + name: user.displayName || user.name, + username: user.email, + }; + } +} diff --git a/src/pm/linear/integration.ts b/src/pm/linear/integration.ts new file mode 100644 index 00000000..2d529757 --- /dev/null +++ b/src/pm/linear/integration.ts @@ -0,0 +1,226 @@ +/** + * LinearIntegration — implements PMIntegration for Linear. + * + * Encapsulates all Linear-specific concerns: credential resolution, + * webhook parsing, ack comments, reactions, project lookup, and work item ID + * extraction. + * + * Credential roles are self-registered at module load time via + * registerCredentialRoles(), so no changes to integrationRoles.ts are needed. + */ + +import { + PROVIDER_CREDENTIAL_ROLES, + registerCredentialRoles, +} from '../../config/integrationRoles.js'; +import { getIntegrationCredential, getIntegrationCredentialOrNull } from '../../config/provider.js'; +import { getIntegrationProvider } from '../../db/repositories/credentialsRepository.js'; +import { withLinearCredentials } from '../../linear/client.js'; +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { getLinearConfig } from '../config.js'; +import type { PMIntegration, PMWebhookEvent } from '../integration.js'; +import type { ProjectPMConfig } from '../lifecycle.js'; +import type { PMProvider } from '../types.js'; +import { LinearPMProvider } from './adapter.js'; + +// Self-register credential roles at module load time. +// This is idempotent — safe to call multiple times. +registerCredentialRoles('linear', 'pm', [ + { role: 'api_key', label: 'API Key', envVarKey: 'LINEAR_API_KEY' }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'LINEAR_WEBHOOK_SECRET', + optional: true, + }, +]); + +// Linear issue identifier pattern: TEAM-123 +const LINEAR_ISSUE_KEY_REGEX = /\b([A-Z][A-Z0-9]+-\d+)\b/; + +export class LinearIntegration implements PMIntegration { + readonly type = 'linear'; + readonly category = 'pm' as const; + + async hasIntegration(projectId: string): Promise { + const provider = await getIntegrationProvider(projectId, 'pm'); + if (provider !== 'linear') return false; + + const roles = PROVIDER_CREDENTIAL_ROLES.linear; + const requiredRoles = roles.filter((r) => !r.optional); + const values = await Promise.all( + requiredRoles.map((roleDef) => getIntegrationCredentialOrNull(projectId, 'pm', roleDef.role)), + ); + return values.every((v) => v !== null); + } + + createProvider(project: ProjectConfig): PMProvider { + const linearConfig = getLinearConfig(project); + if (!linearConfig?.teamId) { + throw new Error('Linear integration requires teamId in config'); + } + return new LinearPMProvider(linearConfig); + } + + async withCredentials(projectId: string, fn: () => Promise): Promise { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + return withLinearCredentials({ apiKey }, fn); + } + + resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { + const linearConfig = getLinearConfig(project); + const labels = linearConfig?.labels; + return { + labels: { + processing: labels?.processing ?? 'cascade-processing', + processed: labels?.processed ?? 'cascade-processed', + error: labels?.error ?? 'cascade-error', + readyToProcess: labels?.readyToProcess ?? 'cascade-ready', + auto: labels?.auto ?? 'cascade-auto', + }, + statuses: { + backlog: linearConfig?.statuses?.backlog, + inProgress: linearConfig?.statuses?.inProgress, + inReview: linearConfig?.statuses?.inReview, + done: linearConfig?.statuses?.done, + merged: linearConfig?.statuses?.merged, + }, + }; + } + + parseWebhookPayload(raw: unknown): PMWebhookEvent | null { + if (!raw || typeof raw !== 'object') return null; + const p = raw as Record; + + // Linear webhook shape: { action, type, data, organizationId, ... } + const action = p.action as string | undefined; + const type = p.type as string | undefined; + if (typeof action !== 'string' || typeof type !== 'string') return null; + + const data = p.data as Record | undefined; + if (!data) return null; + + // The event type is "." e.g. "Issue.create", "Comment.create" + const eventType = `${type}.${action}`; + + // For Issue events, data.teamId is the project identifier + // For Comment events, the issue identifier is in data.issue.identifier + let projectIdentifier: string | undefined; + let workItemId: string | undefined; + + if (type === 'Issue') { + projectIdentifier = data.teamId as string | undefined; + workItemId = (data.identifier as string | undefined) ?? (data.id as string | undefined); + } else if (type === 'Comment') { + const issue = data.issue as Record | undefined; + projectIdentifier = issue?.teamId as string | undefined; + workItemId = (issue?.identifier as string | undefined) ?? (issue?.id as string | undefined); + } else { + // For other event types, try to find a teamId in data + projectIdentifier = data.teamId as string | undefined; + } + + if (!projectIdentifier) return null; + + return { + eventType, + projectIdentifier, + workItemId, + raw, + }; + } + + async isSelfAuthored(event: PMWebhookEvent, _projectId: string): Promise { + // For comment events, check if the comment was authored by the bot user. + // Linear comments have a userId in the data. + if (!event.eventType.startsWith('Comment.')) return false; + + const p = event.raw as Record; + const data = p.data as Record | undefined; + const commentUserId = data?.userId as string | undefined; + if (!commentUserId) return false; + + try { + // Get the authenticated user to compare + const { withLinearCredentials: _withCreds, linearClient } = await import( + '../../linear/client.js' + ); + const me = await linearClient.getMe(); + return me.id === commentUserId; + } catch { + return false; + } + } + + async postAckComment( + projectId: string, + workItemId: string, + message: string, + ): Promise { + try { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + return await withLinearCredentials({ apiKey }, async () => { + const { linearClient } = await import('../../linear/client.js'); + const comment = await linearClient.createComment(workItemId, message); + return comment.id; + }); + } catch (err) { + const { logger } = await import('../../utils/logging.js'); + logger.warn('[Linear] Failed to post ack comment', { + projectId, + workItemId, + error: String(err), + }); + return null; + } + } + + async deleteAckComment(projectId: string, _workItemId: string, commentId: string): Promise { + try { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + await withLinearCredentials({ apiKey }, async () => { + const { linearClient } = await import('../../linear/client.js'); + await linearClient.deleteComment(commentId); + }); + } catch (err) { + const { logger } = await import('../../utils/logging.js'); + logger.warn('[Linear] Failed to delete ack comment', { + projectId, + commentId, + error: String(err), + }); + } + } + + async sendReaction(_projectId: string, event: PMWebhookEvent): Promise { + // Linear supports reactions on comments. For now, we skip since Linear + // reactions require a comment ID (not available at the issue level). + // This is a no-op — reactions are optional in the PMIntegration interface. + const p = event.raw as Record; + const data = p.data as Record | undefined; + const commentId = data?.id as string | undefined; + if (!commentId || !event.eventType.startsWith('Comment.')) return; + + // We'd need project credentials to call the API here, but this is a + // best-effort operation so we skip if no comment ID is available. + } + + async lookupProject( + _identifier: string, + ): Promise<{ project: ProjectConfig; config: CascadeConfig } | null> { + // Linear project lookup by teamId is not yet implemented in the config + // repository (separate story). Return null to fall through to other lookup + // mechanisms. + return null; + } + + extractWorkItemId(text: string): string | null { + // Linear issue identifiers follow the same TEAM-123 pattern as JIRA. + // Also check Linear URLs: https://linear.app/org/issue/TEAM-123 + const urlMatch = text.match(/https:\/\/linear\.app\/[^/]+\/issue\/([A-Z][A-Z0-9]+-\d+)/); + if (urlMatch) return urlMatch[1]; + + const match = text.match(LINEAR_ISSUE_KEY_REGEX); + return match ? match[1] : null; + } +} diff --git a/src/pm/types.ts b/src/pm/types.ts index e4d3aa33..bc4e5308 100644 --- a/src/pm/types.ts +++ b/src/pm/types.ts @@ -3,7 +3,7 @@ * future project-management integrations must implement. */ -export type PMType = 'trello' | 'jira'; +export type PMType = 'trello' | 'jira' | 'linear'; /** * A reference to an inline media item (image, etc.) embedded in a work item diff --git a/src/router/config.ts b/src/router/config.ts index 83029e50..ca71e837 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -6,7 +6,7 @@ import type { CascadeConfig, ProjectConfig } from '../types/index.js'; export interface RouterProjectConfig { id: string; repo?: string; // owner/repo format (optional for projects without SCM integration) - pmType: 'trello' | 'jira'; + pmType: 'trello' | 'jira' | 'linear'; trello?: { boardId: string; lists: Record; diff --git a/tests/unit/pm/linear/adapter.test.ts b/tests/unit/pm/linear/adapter.test.ts new file mode 100644 index 00000000..552d2af2 --- /dev/null +++ b/tests/unit/pm/linear/adapter.test.ts @@ -0,0 +1,536 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockGetIssue = vi.fn(); +const mockGetIssueComments = vi.fn(); +const mockCreateComment = vi.fn(); +const mockUpdateComment = vi.fn(); +const mockCreateIssue = vi.fn(); +const mockUpdateIssue = vi.fn(); +const mockUpdateIssueState = vi.fn(); +const mockListIssues = vi.fn(); +const mockAddLabel = vi.fn(); +const mockRemoveLabel = vi.fn(); +const mockGetAttachments = vi.fn(); +const mockCreateAttachment = vi.fn(); +const mockGetMe = vi.fn(); + +vi.mock('../../../../src/linear/client.js', () => ({ + linearClient: { + getIssue: (...args: unknown[]) => mockGetIssue(...args), + getIssueComments: (...args: unknown[]) => mockGetIssueComments(...args), + createComment: (...args: unknown[]) => mockCreateComment(...args), + updateComment: (...args: unknown[]) => mockUpdateComment(...args), + createIssue: (...args: unknown[]) => mockCreateIssue(...args), + updateIssue: (...args: unknown[]) => mockUpdateIssue(...args), + updateIssueState: (...args: unknown[]) => mockUpdateIssueState(...args), + listIssues: (...args: unknown[]) => mockListIssues(...args), + addLabel: (...args: unknown[]) => mockAddLabel(...args), + removeLabel: (...args: unknown[]) => mockRemoveLabel(...args), + getAttachments: (...args: unknown[]) => mockGetAttachments(...args), + createAttachment: (...args: unknown[]) => mockCreateAttachment(...args), + getMe: (...args: unknown[]) => mockGetMe(...args), + }, +})); + +import { LinearPMProvider } from '../../../../src/pm/linear/adapter.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const defaultConfig = { + teamId: 'team-abc', + statuses: { + backlog: 'state-backlog', + inProgress: 'state-in-progress', + inReview: 'state-in-review', + done: 'state-done', + merged: 'state-merged', + cancelled: 'state-cancelled', + }, + labels: { + processing: 'label-processing-id', + }, +}; + +function makeIssue(overrides: Record = {}) { + return { + id: 'issue-uuid', + identifier: 'TEAM-1', + title: 'Test Issue', + description: 'A description', + priority: 0, + priorityLabel: 'No priority', + state: { id: 'state-backlog', name: 'Backlog', type: 'backlog', color: '#ccc' }, + team: { id: 'team-abc', name: 'Team ABC', key: 'TEAM', description: null }, + assignee: null, + labels: [], + url: 'https://linear.app/org/issue/TEAM-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LinearPMProvider', () => { + let provider: LinearPMProvider; + + beforeEach(() => { + provider = new LinearPMProvider(defaultConfig); + vi.clearAllMocks(); + }); + + it('has type "linear"', () => { + expect(provider.type).toBe('linear'); + }); + + // ========================================================================= + // getWorkItem + // ========================================================================= + describe('getWorkItem', () => { + it('maps a Linear issue to a WorkItem', async () => { + mockGetIssue.mockResolvedValue( + makeIssue({ + labels: [{ id: 'label-1', name: 'Bug', color: '#f00', description: null }], + }), + ); + + const result = await provider.getWorkItem('issue-uuid'); + + expect(mockGetIssue).toHaveBeenCalledWith('issue-uuid'); + expect(result.id).toBe('TEAM-1'); // uses identifier + expect(result.title).toBe('Test Issue'); + expect(result.description).toBe('A description'); + expect(result.url).toBe('https://linear.app/org/issue/TEAM-1'); + expect(result.status).toBe('Backlog'); + expect(result.labels).toHaveLength(1); + expect(result.labels[0]).toEqual({ id: 'label-1', name: 'Bug', color: '#f00' }); + }); + + it('uses id when identifier is empty', async () => { + mockGetIssue.mockResolvedValue(makeIssue({ identifier: '' })); + const result = await provider.getWorkItem('issue-uuid'); + expect(result.id).toBe('issue-uuid'); + }); + + it('returns empty string for null description', async () => { + mockGetIssue.mockResolvedValue(makeIssue({ description: null })); + const result = await provider.getWorkItem('issue-uuid'); + expect(result.description).toBe(''); + }); + }); + + // ========================================================================= + // getWorkItemComments + // ========================================================================= + describe('getWorkItemComments', () => { + it('maps Linear comments to WorkItemComment[]', async () => { + mockGetIssueComments.mockResolvedValue([ + { + id: 'c1', + body: 'Hello world', + createdAt: '2024-01-02T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + issueId: 'issue-uuid', + user: { + id: 'u1', + name: 'Alice', + email: 'alice@example.com', + displayName: 'Alice Smith', + avatarUrl: null, + active: true, + }, + }, + ]); + + const result = await provider.getWorkItemComments('issue-uuid'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('c1'); + expect(result[0].text).toBe('Hello world'); + expect(result[0].author.id).toBe('u1'); + expect(result[0].author.name).toBe('Alice Smith'); + expect(result[0].author.username).toBe('alice@example.com'); + }); + + it('handles comments with no user', async () => { + mockGetIssueComments.mockResolvedValue([ + { + id: 'c2', + body: 'Bot comment', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + issueId: 'issue-uuid', + user: null, + }, + ]); + + const result = await provider.getWorkItemComments('issue-uuid'); + + expect(result[0].author.id).toBe(''); + expect(result[0].author.name).toBe(''); + expect(result[0].author.username).toBe(''); + }); + }); + + // ========================================================================= + // updateWorkItem + // ========================================================================= + describe('updateWorkItem', () => { + it('calls updateIssue with title and description', async () => { + mockUpdateIssue.mockResolvedValue(makeIssue()); + await provider.updateWorkItem('issue-uuid', { title: 'New title', description: 'New desc' }); + expect(mockUpdateIssue).toHaveBeenCalledWith('issue-uuid', { + title: 'New title', + description: 'New desc', + }); + }); + }); + + // ========================================================================= + // addComment + // ========================================================================= + describe('addComment', () => { + it('creates a comment and returns its id', async () => { + mockCreateComment.mockResolvedValue({ id: 'comment-new', body: 'hi' }); + const result = await provider.addComment('issue-uuid', 'hi there'); + expect(mockCreateComment).toHaveBeenCalledWith('issue-uuid', 'hi there'); + expect(result).toBe('comment-new'); + }); + }); + + // ========================================================================= + // updateComment + // ========================================================================= + describe('updateComment', () => { + it('updates comment by commentId (not issueId)', async () => { + mockUpdateComment.mockResolvedValue({ id: 'c1', body: 'updated' }); + await provider.updateComment('issue-uuid', 'c1', 'updated body'); + expect(mockUpdateComment).toHaveBeenCalledWith('c1', 'updated body'); + }); + }); + + // ========================================================================= + // createWorkItem + // ========================================================================= + describe('createWorkItem', () => { + it('creates an issue in the given team', async () => { + mockCreateIssue.mockResolvedValue(makeIssue({ identifier: 'TEAM-2', title: 'New Story' })); + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + const result = await provider.createWorkItem({ + containerId: 'team-abc', + title: 'New Story', + description: 'A story', + }); + + expect(mockCreateIssue).toHaveBeenCalledWith( + expect.objectContaining({ teamId: 'team-abc', title: 'New Story' }), + ); + expect(result.id).toBe('TEAM-2'); + expect(result.title).toBe('New Story'); + }); + + it('falls back to config teamId when containerId is empty', async () => { + mockCreateIssue.mockResolvedValue(makeIssue()); + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await provider.createWorkItem({ containerId: '', title: 'Test' }); + + expect(mockCreateIssue).toHaveBeenCalledWith(expect.objectContaining({ teamId: 'team-abc' })); + }); + + it('transitions to backlog status after creation', async () => { + mockCreateIssue.mockResolvedValue(makeIssue()); + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await provider.createWorkItem({ containerId: 'team-abc', title: 'Test' }); + + expect(mockUpdateIssueState).toHaveBeenCalledWith('issue-uuid', 'state-backlog'); + }); + }); + + // ========================================================================= + // listWorkItems + // ========================================================================= + describe('listWorkItems', () => { + it('lists issues for a team', async () => { + mockListIssues.mockResolvedValue([makeIssue(), makeIssue({ identifier: 'TEAM-2' })]); + + const result = await provider.listWorkItems('team-abc'); + + expect(mockListIssues).toHaveBeenCalledWith(expect.objectContaining({ teamId: 'team-abc' })); + expect(result).toHaveLength(2); + }); + }); + + // ========================================================================= + // moveWorkItem + // ========================================================================= + describe('moveWorkItem', () => { + it('resolves status name to state ID from config', async () => { + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await provider.moveWorkItem('issue-uuid', 'done'); + + expect(mockUpdateIssueState).toHaveBeenCalledWith('issue-uuid', 'state-done'); + }); + + it('passes destination directly when not in config', async () => { + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await provider.moveWorkItem('issue-uuid', 'unknown-state-id'); + + expect(mockUpdateIssueState).toHaveBeenCalledWith('issue-uuid', 'unknown-state-id'); + }); + }); + + // ========================================================================= + // addLabel / removeLabel + // ========================================================================= + describe('addLabel', () => { + it('resolves label name to ID from config', async () => { + mockAddLabel.mockResolvedValue(makeIssue()); + + await provider.addLabel('issue-uuid', 'processing'); + + expect(mockAddLabel).toHaveBeenCalledWith('issue-uuid', 'label-processing-id'); + }); + + it('passes label ID directly when not in config', async () => { + mockAddLabel.mockResolvedValue(makeIssue()); + + await provider.addLabel('issue-uuid', 'raw-label-id'); + + expect(mockAddLabel).toHaveBeenCalledWith('issue-uuid', 'raw-label-id'); + }); + }); + + describe('removeLabel', () => { + it('resolves label name to ID from config', async () => { + mockRemoveLabel.mockResolvedValue(makeIssue()); + + await provider.removeLabel('issue-uuid', 'processing'); + + expect(mockRemoveLabel).toHaveBeenCalledWith('issue-uuid', 'label-processing-id'); + }); + }); + + // ========================================================================= + // getChecklists + // ========================================================================= + describe('getChecklists', () => { + it('returns a placeholder checklist', async () => { + const result = await provider.getChecklists('issue-uuid'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('subtasks-issue-uuid'); + expect(result[0].name).toBe('Sub-issues'); + expect(result[0].workItemId).toBe('issue-uuid'); + expect(result[0].items).toEqual([]); + }); + }); + + // ========================================================================= + // createChecklist + // ========================================================================= + describe('createChecklist', () => { + it('returns a synthetic checklist object', async () => { + const result = await provider.createChecklist('issue-uuid', 'Acceptance Criteria'); + expect(result.workItemId).toBe('issue-uuid'); + expect(result.name).toBe('Acceptance Criteria'); + expect(result.id).toMatch(/^checklist-issue-uuid-\d+$/); + expect(result.items).toEqual([]); + }); + }); + + // ========================================================================= + // addChecklistItem + // ========================================================================= + describe('addChecklistItem', () => { + it('creates a sub-issue when parent ID is extractable', async () => { + mockCreateIssue.mockResolvedValue(makeIssue()); + + await provider.addChecklistItem('subtasks-issue-uuid', 'Sub-task 1'); + + expect(mockCreateIssue).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Sub-task 1', teamId: 'team-abc' }), + ); + }); + + it('throws when checklistId has no extractable parent', async () => { + await expect(provider.addChecklistItem('invalid-id', 'Sub-task')).rejects.toThrow( + 'Cannot extract parent issue ID from checklist ID: invalid-id', + ); + }); + }); + + // ========================================================================= + // updateChecklistItem + // ========================================================================= + describe('updateChecklistItem', () => { + it('transitions sub-issue to done state when complete=true', async () => { + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await provider.updateChecklistItem('parent-uuid', 'sub-uuid', true); + + expect(mockUpdateIssueState).toHaveBeenCalledWith('sub-uuid', 'state-done'); + }); + + it('transitions sub-issue to backlog state when complete=false', async () => { + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await provider.updateChecklistItem('parent-uuid', 'sub-uuid', false); + + expect(mockUpdateIssueState).toHaveBeenCalledWith('sub-uuid', 'state-backlog'); + }); + }); + + // ========================================================================= + // deleteChecklistItem + // ========================================================================= + describe('deleteChecklistItem', () => { + it('transitions to cancelled state when configured', async () => { + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await provider.deleteChecklistItem('parent-uuid', 'sub-uuid'); + + expect(mockUpdateIssueState).toHaveBeenCalledWith('sub-uuid', 'state-cancelled'); + }); + + it('falls back to done state when no cancelled state configured', async () => { + const providerNoCancelled = new LinearPMProvider({ + teamId: 'team-abc', + statuses: { done: 'state-done' }, + }); + mockUpdateIssueState.mockResolvedValue(makeIssue()); + + await providerNoCancelled.deleteChecklistItem('parent-uuid', 'sub-uuid'); + + expect(mockUpdateIssueState).toHaveBeenCalledWith('sub-uuid', 'state-done'); + }); + }); + + // ========================================================================= + // getAttachments + // ========================================================================= + describe('getAttachments', () => { + it('maps Linear attachments to Attachment[]', async () => { + mockGetAttachments.mockResolvedValue([ + { + id: 'att-1', + title: 'Screenshot', + url: 'https://storage.linear.app/att-1', + subtitle: null, + metadata: { mimeType: 'image/png', size: 12345 }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ]); + + const result = await provider.getAttachments('issue-uuid'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('att-1'); + expect(result[0].name).toBe('Screenshot'); + expect(result[0].url).toBe('https://storage.linear.app/att-1'); + expect(result[0].mimeType).toBe('image/png'); + expect(result[0].bytes).toBe(12345); + }); + }); + + // ========================================================================= + // addAttachment + // ========================================================================= + describe('addAttachment', () => { + it('creates an attachment link', async () => { + mockCreateAttachment.mockResolvedValue({ id: 'att-new' }); + + await provider.addAttachment('issue-uuid', 'https://example.com/file.pdf', 'Report'); + + expect(mockCreateAttachment).toHaveBeenCalledWith('issue-uuid', { + title: 'Report', + url: 'https://example.com/file.pdf', + }); + }); + }); + + // ========================================================================= + // linkPR + // ========================================================================= + describe('linkPR', () => { + it('creates an attachment for the PR', async () => { + mockCreateAttachment.mockResolvedValue({ id: 'att-pr' }); + + await provider.linkPR( + 'issue-uuid', + 'https://github.com/org/repo/pull/42', + 'feat: add linear', + ); + + expect(mockCreateAttachment).toHaveBeenCalledWith('issue-uuid', { + title: 'feat: add linear', + url: 'https://github.com/org/repo/pull/42', + subtitle: 'Pull Request', + metadata: { type: 'github_pr' }, + }); + }); + }); + + // ========================================================================= + // getWorkItemUrl + // ========================================================================= + describe('getWorkItemUrl', () => { + it('constructs a Linear issue URL', () => { + expect(provider.getWorkItemUrl('TEAM-123')).toBe('https://linear.app/issue/TEAM-123'); + }); + }); + + // ========================================================================= + // getAuthenticatedUser + // ========================================================================= + describe('getAuthenticatedUser', () => { + it('returns the authenticated user', async () => { + mockGetMe.mockResolvedValue({ + id: 'user-bot', + name: 'Bot User', + email: 'bot@example.com', + displayName: 'Cascade Bot', + avatarUrl: null, + active: true, + }); + + const user = await provider.getAuthenticatedUser(); + + expect(user.id).toBe('user-bot'); + expect(user.name).toBe('Cascade Bot'); // prefers displayName + expect(user.username).toBe('bot@example.com'); + }); + }); + + // ========================================================================= + // getCustomFieldNumber / updateCustomFieldNumber + // ========================================================================= + describe('getCustomFieldNumber', () => { + it('returns 0 (not supported)', async () => { + const result = await provider.getCustomFieldNumber('issue-uuid', 'custom-field'); + expect(result).toBe(0); + }); + }); + + describe('updateCustomFieldNumber', () => { + it('is a no-op (not supported)', async () => { + // Should not throw + await expect( + provider.updateCustomFieldNumber('issue-uuid', 'custom-field', 42), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/pm/linear/integration.test.ts b/tests/unit/pm/linear/integration.test.ts new file mode 100644 index 00000000..9b5df82d --- /dev/null +++ b/tests/unit/pm/linear/integration.test.ts @@ -0,0 +1,419 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockGetIntegrationCredential = vi.fn(); +const mockGetIntegrationCredentialOrNull = vi.fn(); + +vi.mock('../../../../src/config/provider.js', () => ({ + getIntegrationCredential: (...args: unknown[]) => mockGetIntegrationCredential(...args), + getIntegrationCredentialOrNull: (...args: unknown[]) => + mockGetIntegrationCredentialOrNull(...args), +})); + +const mockGetIntegrationProvider = vi.fn(); +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + getIntegrationProvider: (...args: unknown[]) => mockGetIntegrationProvider(...args), +})); + +const mockWithLinearCredentials = vi.fn().mockImplementation((_creds, fn) => fn()); +vi.mock('../../../../src/linear/client.js', () => ({ + withLinearCredentials: (...args: unknown[]) => mockWithLinearCredentials(...args), + linearClient: { + getMe: vi.fn().mockResolvedValue({ id: 'user-bot', name: 'Bot', email: 'bot@example.com' }), + createComment: vi.fn().mockResolvedValue({ id: 'comment-id', body: 'msg' }), + deleteComment: vi.fn().mockResolvedValue(undefined), + }, +})); + +const mockGetLinearConfig = vi.fn(); +vi.mock('../../../../src/pm/config.js', () => ({ + getLinearConfig: (...args: unknown[]) => mockGetLinearConfig(...args), +})); + +// Must mock registerCredentialRoles to avoid side effects in tests +vi.mock('../../../../src/config/integrationRoles.js', () => ({ + PROVIDER_CREDENTIAL_ROLES: new Proxy( + {}, + { + get(_target, prop: string) { + if (prop === 'linear') { + return [ + { role: 'api_key', label: 'API Key', envVarKey: 'LINEAR_API_KEY' }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'LINEAR_WEBHOOK_SECRET', + optional: true, + }, + ]; + } + return []; + }, + }, + ), + registerCredentialRoles: vi.fn(), +})); + +import { LinearIntegration } from '../../../../src/pm/linear/integration.js'; +import type { ProjectConfig } from '../../../../src/types/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeProject(overrides: Partial = {}): ProjectConfig { + return { + id: 'proj-1', + orgId: 'org-1', + name: 'Test Linear Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'linear' }, + ...overrides, + } as ProjectConfig; +} + +function makeLinearConfig(overrides: Record = {}) { + return { + teamId: 'team-abc', + statuses: { + backlog: 'state-backlog', + inProgress: 'state-in-progress', + inReview: 'state-in-review', + done: 'state-done', + merged: 'state-merged', + }, + labels: { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', + auto: 'cascade-auto', + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LinearIntegration', () => { + let integration: LinearIntegration; + + beforeEach(() => { + integration = new LinearIntegration(); + mockGetLinearConfig.mockReturnValue(makeLinearConfig()); + vi.clearAllMocks(); + mockGetLinearConfig.mockReturnValue(makeLinearConfig()); + mockWithLinearCredentials.mockImplementation((_creds, fn) => fn()); + }); + + it('has type "linear"', () => { + expect(integration.type).toBe('linear'); + }); + + it('has category "pm"', () => { + expect(integration.category).toBe('pm'); + }); + + // ========================================================================= + // hasIntegration + // ========================================================================= + describe('hasIntegration', () => { + it('returns false when PM provider is not linear', async () => { + mockGetIntegrationProvider.mockResolvedValue(null); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + expect(mockGetIntegrationCredentialOrNull).not.toHaveBeenCalled(); + }); + + it('returns false when PM provider is trello (not linear)', async () => { + mockGetIntegrationProvider.mockResolvedValue('trello'); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('returns true when provider is linear and required credentials are present', async () => { + mockGetIntegrationProvider.mockResolvedValue('linear'); + // LINEAR_API_KEY is required; LINEAR_WEBHOOK_SECRET is optional + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('lin_api_key_xxx'); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(true); + }); + + it('returns false when api_key is missing', async () => { + mockGetIntegrationProvider.mockResolvedValue('linear'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce(null); + + const result = await integration.hasIntegration('proj-1'); + + expect(result).toBe(false); + }); + }); + + // ========================================================================= + // createProvider + // ========================================================================= + describe('createProvider', () => { + it('returns a LinearPMProvider instance when teamId is present', () => { + const project = makeProject(); + const provider = integration.createProvider(project); + expect(provider).toBeDefined(); + expect(provider.type).toBe('linear'); + }); + + it('throws when linear config has no teamId', () => { + mockGetLinearConfig.mockReturnValue({ statuses: {} }); // no teamId + const project = makeProject(); + expect(() => integration.createProvider(project)).toThrow( + 'Linear integration requires teamId in config', + ); + }); + + it('throws when linear config is undefined', () => { + mockGetLinearConfig.mockReturnValue(undefined); + const project = makeProject(); + expect(() => integration.createProvider(project)).toThrow( + 'Linear integration requires teamId in config', + ); + }); + }); + + // ========================================================================= + // withCredentials + // ========================================================================= + describe('withCredentials', () => { + it('fetches api_key and calls withLinearCredentials', async () => { + mockGetIntegrationCredential.mockResolvedValueOnce('lin_api_key_xxx'); + + const fn = vi.fn().mockResolvedValue('done'); + const result = await integration.withCredentials('proj-1', fn); + + expect(mockGetIntegrationCredential).toHaveBeenCalledWith('proj-1', 'pm', 'api_key'); + expect(mockWithLinearCredentials).toHaveBeenCalledWith({ apiKey: 'lin_api_key_xxx' }, fn); + expect(result).toBe('done'); + }); + }); + + // ========================================================================= + // resolveLifecycleConfig + // ========================================================================= + describe('resolveLifecycleConfig', () => { + it('maps linear labels and statuses to lifecycle config', () => { + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + expect(config.labels.processing).toBe('cascade-processing'); + expect(config.labels.processed).toBe('cascade-processed'); + expect(config.labels.error).toBe('cascade-error'); + expect(config.labels.readyToProcess).toBe('cascade-ready'); + expect(config.labels.auto).toBe('cascade-auto'); + expect(config.statuses.backlog).toBe('state-backlog'); + expect(config.statuses.inProgress).toBe('state-in-progress'); + expect(config.statuses.done).toBe('state-done'); + }); + + it('uses defaults when labels config is missing', () => { + mockGetLinearConfig.mockReturnValue({ teamId: 'team-abc', statuses: {} }); + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + expect(config.labels.processing).toBe('cascade-processing'); + expect(config.labels.processed).toBe('cascade-processed'); + expect(config.labels.readyToProcess).toBe('cascade-ready'); + }); + + it('has undefined statuses when linear config has no statuses', () => { + mockGetLinearConfig.mockReturnValue({ teamId: 'team-abc', statuses: {} }); + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + expect(config.statuses.backlog).toBeUndefined(); + }); + }); + + // ========================================================================= + // parseWebhookPayload + // ========================================================================= + describe('parseWebhookPayload', () => { + it('returns null when payload is null', () => { + expect(integration.parseWebhookPayload(null)).toBeNull(); + }); + + it('returns null when payload is not an object', () => { + expect(integration.parseWebhookPayload('string')).toBeNull(); + }); + + it('returns null when action or type is missing', () => { + expect(integration.parseWebhookPayload({ action: 'create' })).toBeNull(); + expect(integration.parseWebhookPayload({ type: 'Issue' })).toBeNull(); + }); + + it('returns null when data is missing', () => { + const raw = { action: 'create', type: 'Issue' }; + expect(integration.parseWebhookPayload(raw)).toBeNull(); + }); + + it('returns null when projectIdentifier is missing', () => { + const raw = { + action: 'create', + type: 'Issue', + data: { identifier: 'TEAM-1' }, // no teamId + }; + expect(integration.parseWebhookPayload(raw)).toBeNull(); + }); + + it('parses an Issue.create payload', () => { + const raw = { + action: 'create', + type: 'Issue', + organizationId: 'org-123', + data: { + id: 'issue-uuid', + identifier: 'TEAM-123', + teamId: 'team-abc', + }, + }; + + const result = integration.parseWebhookPayload(raw); + + expect(result).not.toBeNull(); + expect(result?.eventType).toBe('Issue.create'); + expect(result?.projectIdentifier).toBe('team-abc'); + expect(result?.workItemId).toBe('TEAM-123'); + expect(result?.raw).toBe(raw); + }); + + it('parses an Issue.update payload', () => { + const raw = { + action: 'update', + type: 'Issue', + data: { + id: 'issue-uuid', + identifier: 'ENG-456', + teamId: 'team-xyz', + }, + }; + + const result = integration.parseWebhookPayload(raw); + + expect(result?.eventType).toBe('Issue.update'); + expect(result?.projectIdentifier).toBe('team-xyz'); + expect(result?.workItemId).toBe('ENG-456'); + }); + + it('parses a Comment.create payload', () => { + const raw = { + action: 'create', + type: 'Comment', + data: { + id: 'comment-uuid', + body: 'Hello', + userId: 'user-123', + issue: { + id: 'issue-uuid', + identifier: 'TEAM-7', + teamId: 'team-abc', + }, + }, + }; + + const result = integration.parseWebhookPayload(raw); + + expect(result?.eventType).toBe('Comment.create'); + expect(result?.projectIdentifier).toBe('team-abc'); + expect(result?.workItemId).toBe('TEAM-7'); + }); + }); + + // ========================================================================= + // isSelfAuthored + // ========================================================================= + describe('isSelfAuthored', () => { + it('returns false for non-comment events', async () => { + const event = { + eventType: 'Issue.update', + projectIdentifier: 'team-abc', + raw: {}, + }; + const result = await integration.isSelfAuthored(event, 'proj-1'); + expect(result).toBe(false); + }); + + it('returns false when comment has no userId', async () => { + const event = { + eventType: 'Comment.create', + projectIdentifier: 'team-abc', + raw: { data: {} }, + }; + const result = await integration.isSelfAuthored(event, 'proj-1'); + expect(result).toBe(false); + }); + }); + + // ========================================================================= + // lookupProject + // ========================================================================= + describe('lookupProject', () => { + it('returns null (not yet implemented)', async () => { + const result = await integration.lookupProject('team-abc'); + expect(result).toBeNull(); + }); + }); + + // ========================================================================= + // extractWorkItemId + // ========================================================================= + describe('extractWorkItemId', () => { + it('extracts Linear issue identifier from text', () => { + expect(integration.extractWorkItemId('Working on TEAM-123 today')).toBe('TEAM-123'); + }); + + it('extracts issue identifier from Linear URL', () => { + expect( + integration.extractWorkItemId('See https://linear.app/myorg/issue/ENG-42 for details'), + ).toBe('ENG-42'); + }); + + it('extracts from PR body with Linear URL', () => { + expect( + integration.extractWorkItemId( + 'Fixes https://linear.app/acme/issue/ACME-999\n\nImplementation details...', + ), + ).toBe('ACME-999'); + }); + + it('extracts using text pattern when no URL', () => { + expect(integration.extractWorkItemId('Refs ABC-42')).toBe('ABC-42'); + }); + + it('returns null when no identifier found', () => { + expect(integration.extractWorkItemId('No issue reference here')).toBeNull(); + }); + + it('returns null for lowercase issue references', () => { + expect(integration.extractWorkItemId('team-123 is lowercase')).toBeNull(); + }); + + it('matches multi-letter team keys', () => { + expect(integration.extractWorkItemId('MYTEAM-999')).toBe('MYTEAM-999'); + }); + + it('prefers URL match over text match', () => { + expect( + integration.extractWorkItemId('URL: https://linear.app/org/issue/FRONT-10 text: BACK-20'), + ).toBe('FRONT-10'); + }); + }); +}); From 0cf494592fee8a8e0d6afff9ab2f952e76410d7b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 19:23:59 +0000 Subject: [PATCH 2/2] fix(linear): address review feedback on Linear PM provider adapter - Register LinearIntegration in bootstrap.ts following Trello/JIRA pattern - Fix addChecklistItem to pass parentId to createIssue() for proper sub-issue relationship - Add parentId to LinearCreateIssueInput type in linear/types.ts - Fix isSelfAuthored to use withLinearCredentials() with project credentials - Remove dead code from sendReaction (no-op with dead extraction logic) - Import canonical LinearConfig from pm/config.ts instead of redeclaring local type - Fix getPromptTerminology() to return issue/Linear for Linear projects - Fix getListIds() to read Linear statuses config for prompt context - Add Linear config to RouterProjectConfig and loadProjectConfig() Co-Authored-By: Claude Opus 4.6 --- src/agents/shared/promptContext.ts | 41 ++++++++++++++++++++---------- src/integrations/bootstrap.ts | 9 ++++++- src/linear/types.ts | 1 + src/pm/linear/adapter.ts | 20 +++++---------- src/pm/linear/integration.ts | 25 ++++++------------ src/router/config.ts | 11 +++++++- 6 files changed, 62 insertions(+), 45 deletions(-) diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index 0d7a7a64..1ef32ebf 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -1,4 +1,4 @@ -import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; +import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../../pm/config.js'; import { getPMProviderOrNull } from '../../pm/index.js'; import type { ProjectConfig } from '../../types/index.js'; import type { PromptContext } from '../prompts/index.js'; @@ -6,29 +6,44 @@ import type { PromptContext } from '../prompts/index.js'; function getListIds(project: ProjectConfig) { const trelloConfig = getTrelloConfig(project); const jiraConfig = getJiraConfig(project); + const linearConfig = getLinearConfig(project); return { - backlogListId: trelloConfig?.lists?.backlog ?? jiraConfig?.statuses?.backlog, - todoListId: trelloConfig?.lists?.todo ?? jiraConfig?.statuses?.todo, - inProgressListId: trelloConfig?.lists?.inProgress ?? jiraConfig?.statuses?.inProgress, - inReviewListId: trelloConfig?.lists?.inReview ?? jiraConfig?.statuses?.inReview, - doneListId: trelloConfig?.lists?.done ?? jiraConfig?.statuses?.done, - mergedListId: trelloConfig?.lists?.merged ?? jiraConfig?.statuses?.merged, + backlogListId: + trelloConfig?.lists?.backlog ?? + jiraConfig?.statuses?.backlog ?? + linearConfig?.statuses?.backlog, + todoListId: + trelloConfig?.lists?.todo ?? jiraConfig?.statuses?.todo ?? linearConfig?.statuses?.todo, + inProgressListId: + trelloConfig?.lists?.inProgress ?? + jiraConfig?.statuses?.inProgress ?? + linearConfig?.statuses?.inProgress, + inReviewListId: + trelloConfig?.lists?.inReview ?? + jiraConfig?.statuses?.inReview ?? + linearConfig?.statuses?.inReview, + doneListId: + trelloConfig?.lists?.done ?? jiraConfig?.statuses?.done ?? linearConfig?.statuses?.done, + mergedListId: + trelloConfig?.lists?.merged ?? jiraConfig?.statuses?.merged ?? linearConfig?.statuses?.merged, debugListId: trelloConfig?.lists?.debug, processedLabelId: trelloConfig?.labels?.processed, - autoLabelId: trelloConfig?.labels?.auto ?? jiraConfig?.labels?.auto, + autoLabelId: + trelloConfig?.labels?.auto ?? jiraConfig?.labels?.auto ?? linearConfig?.labels?.auto, }; } function getPromptTerminology(pmType: string | undefined) { const isJira = pmType === 'jira'; + const isLinear = pmType === 'linear'; return { - workItemNoun: isJira ? 'issue' : 'card', - workItemNounPlural: isJira ? 'issues' : 'cards', - workItemNounCap: isJira ? 'Issue' : 'Card', - workItemNounPluralCap: isJira ? 'Issues' : 'Cards', - pmName: isJira ? 'JIRA' : 'Trello', + workItemNoun: isJira || isLinear ? 'issue' : 'card', + workItemNounPlural: isJira || isLinear ? 'issues' : 'cards', + workItemNounCap: isJira || isLinear ? 'Issue' : 'Card', + workItemNounPluralCap: isJira || isLinear ? 'Issues' : 'Cards', + pmName: isJira ? 'JIRA' : isLinear ? 'Linear' : 'Trello', }; } diff --git a/src/integrations/bootstrap.ts b/src/integrations/bootstrap.ts index dd93db4e..ce359692 100644 --- a/src/integrations/bootstrap.ts +++ b/src/integrations/bootstrap.ts @@ -1,9 +1,10 @@ /** * Unified integration bootstrap — canonical registration point for all integrations. * - * Registers all 4 built-in integrations into the `integrationRegistry`: + * Registers all 5 built-in integrations into the `integrationRegistry`: * - TrelloIntegration (PM) * - JiraIntegration (PM) + * - LinearIntegration (PM) * - GitHubSCMIntegration (SCM) * - SentryAlertingIntegration (Alerting) * @@ -26,6 +27,7 @@ import { GitHubSCMIntegration } from '../github/scm-integration.js'; import { integrationRegistry } from '../integrations/registry.js'; import { JiraIntegration } from '../pm/jira/integration.js'; +import { LinearIntegration } from '../pm/linear/integration.js'; import { pmRegistry } from '../pm/registry.js'; import { TrelloIntegration } from '../pm/trello/integration.js'; import { SentryAlertingIntegration } from '../sentry/alerting-integration.js'; @@ -40,6 +42,11 @@ if (!pmRegistry.getOrNull('jira')) { pmRegistry.register(jira); if (!integrationRegistry.getOrNull('jira')) integrationRegistry.register(jira); } +if (!pmRegistry.getOrNull('linear')) { + const linear = new LinearIntegration(); + pmRegistry.register(linear); + if (!integrationRegistry.getOrNull('linear')) integrationRegistry.register(linear); +} if (!integrationRegistry.getOrNull('github')) { integrationRegistry.register(new GitHubSCMIntegration()); } diff --git a/src/linear/types.ts b/src/linear/types.ts index e582eec4..806e852e 100644 --- a/src/linear/types.ts +++ b/src/linear/types.ts @@ -80,6 +80,7 @@ export interface LinearCreateIssueInput { title: string; description?: string; teamId: string; + parentId?: string; assigneeId?: string; stateId?: string; priority?: number; diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index 39fb70c4..fb7c1e61 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -9,6 +9,7 @@ import { linearClient } from '../../linear/client.js'; import { logger } from '../../utils/logging.js'; +import type { LinearConfig } from '../config.js'; import type { Attachment, Checklist, @@ -21,13 +22,6 @@ import type { WorkItemLabel, } from '../types.js'; -interface LinearConfig { - teamId: string; - statuses: Record; - labels?: Record; - customFields?: { cost?: string }; -} - export class LinearPMProvider implements PMProvider { readonly type = 'linear' as const; @@ -93,7 +87,7 @@ export class LinearPMProvider implements PMProvider { ...(config.labels?.length ? { labelIds: config.labels - .map((name) => this.config.labels?.[name]) + .map((name) => (this.config.labels as Record | undefined)?.[name]) .filter((id): id is string => !!id), } : {}), @@ -157,12 +151,14 @@ export class LinearPMProvider implements PMProvider { async addLabel(id: string, labelIdOrName: string): Promise { // Resolve name → ID via config if possible - const labelId = this.config.labels?.[labelIdOrName] ?? labelIdOrName; + const labelId = + (this.config.labels as Record | undefined)?.[labelIdOrName] ?? labelIdOrName; await linearClient.addLabel(id, labelId); } async removeLabel(id: string, labelIdOrName: string): Promise { - const labelId = this.config.labels?.[labelIdOrName] ?? labelIdOrName; + const labelId = + (this.config.labels as Record | undefined)?.[labelIdOrName] ?? labelIdOrName; await linearClient.removeLabel(id, labelId); } @@ -223,10 +219,8 @@ export class LinearPMProvider implements PMProvider { teamId: this.config.teamId, title: name, description, + parentId, }); - // Note: Linear sub-issue (parent) assignment is done via parentId in IssueCreateInput. - // The linearClient.createIssue accepts the full IssueCreateInput which supports parentId. - // We create a separate issue and rely on the parent ID matching. logger.debug('[Linear] addChecklistItem — created sub-issue', { parentId, title: name }); } diff --git a/src/pm/linear/integration.ts b/src/pm/linear/integration.ts index 2d529757..c6985eae 100644 --- a/src/pm/linear/integration.ts +++ b/src/pm/linear/integration.ts @@ -130,7 +130,7 @@ export class LinearIntegration implements PMIntegration { }; } - async isSelfAuthored(event: PMWebhookEvent, _projectId: string): Promise { + async isSelfAuthored(event: PMWebhookEvent, projectId: string): Promise { // For comment events, check if the comment was authored by the bot user. // Linear comments have a userId in the data. if (!event.eventType.startsWith('Comment.')) return false; @@ -141,11 +141,10 @@ export class LinearIntegration implements PMIntegration { if (!commentUserId) return false; try { - // Get the authenticated user to compare - const { withLinearCredentials: _withCreds, linearClient } = await import( - '../../linear/client.js' - ); - const me = await linearClient.getMe(); + // Get the authenticated user to compare — credentials must be in scope. + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + const { linearClient } = await import('../../linear/client.js'); + const me = await withLinearCredentials({ apiKey }, () => linearClient.getMe()); return me.id === commentUserId; } catch { return false; @@ -192,17 +191,9 @@ export class LinearIntegration implements PMIntegration { } } - async sendReaction(_projectId: string, event: PMWebhookEvent): Promise { - // Linear supports reactions on comments. For now, we skip since Linear - // reactions require a comment ID (not available at the issue level). - // This is a no-op — reactions are optional in the PMIntegration interface. - const p = event.raw as Record; - const data = p.data as Record | undefined; - const commentId = data?.id as string | undefined; - if (!commentId || !event.eventType.startsWith('Comment.')) return; - - // We'd need project credentials to call the API here, but this is a - // best-effort operation so we skip if no comment ID is available. + async sendReaction(_projectId: string, _event: PMWebhookEvent): Promise { + // Linear reactions require a dedicated API call with credentials. + // Reactions are optional in the PMIntegration interface — no-op for now. } async lookupProject( diff --git a/src/router/config.ts b/src/router/config.ts index ca71e837..c20e5a23 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -1,5 +1,5 @@ import { loadConfig } from '../config/provider.js'; -import { getJiraConfig, getTrelloConfig } from '../pm/config.js'; +import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../pm/config.js'; import type { CascadeConfig, ProjectConfig } from '../types/index.js'; // Minimal config types - what router needs for quick filtering @@ -16,6 +16,9 @@ export interface RouterProjectConfig { projectKey: string; baseUrl: string; }; + linear?: { + teamId: string; + }; } export interface RouterConfig { @@ -84,6 +87,7 @@ export async function loadProjectConfig(): Promise<{ projects: config.projects.map((p) => { const trelloConfig = getTrelloConfig(p); const jiraConfig = getJiraConfig(p); + const linearConfig = getLinearConfig(p); return { id: p.id, repo: p.repo, @@ -101,6 +105,11 @@ export async function loadProjectConfig(): Promise<{ baseUrl: jiraConfig.baseUrl, }, }), + ...(linearConfig && { + linear: { + teamId: linearConfig.teamId, + }, + }), }; }), fullProjects: config.projects,