diff --git a/src/router/reactions.ts b/src/router/reactions.ts index 010f844d..23a1d8df 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -160,13 +160,19 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise { + // Linear does not support emoji reactions on comments via the same API pattern + // as Trello/JIRA. This is a no-op placeholder for API consistency. + logger.info('[Reactions] Linear reaction skipped (not supported via webhook API)'); +} + // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- /** * Send an acknowledgment reaction for an incoming webhook. - * Dispatches to Trello (πŸ‘€), GitHub (πŸ‘€), or JIRA (πŸ’­) based on source. + * Dispatches to Trello (πŸ‘€), GitHub (πŸ‘€), JIRA (πŸ’­), or Linear (no-op) based on source. * * For GitHub, pass `repoFullName` as the `projectId` parameter, along with * `personaIdentities` and the already-resolved `project`. The reaction is @@ -189,6 +195,8 @@ export async function sendAcknowledgeReaction( await sendGitHubReaction(projectId, payload, personaIdentities, project); } else if (source === 'jira') { await sendJiraReaction(projectId, payload); + } else if (source === 'linear') { + await sendLinearReaction(projectId, payload); } } catch (err) { logger.error('[Reactions] Unexpected error sending reaction:', String(err)); diff --git a/src/triggers/builtins.ts b/src/triggers/builtins.ts index 80aefcb6..353060b9 100644 --- a/src/triggers/builtins.ts +++ b/src/triggers/builtins.ts @@ -20,6 +20,7 @@ import { registerGitHubTriggers } from './github/register.js'; import { registerJiraTriggers } from './jira/register.js'; +import { registerLinearTriggers } from './linear/register.js'; import type { TriggerRegistry } from './registry.js'; import { registerSentryTriggers } from './sentry/register.js'; import { registerTrelloTriggers } from './trello/register.js'; @@ -27,6 +28,7 @@ import { registerTrelloTriggers } from './trello/register.js'; export function registerBuiltInTriggers(registry: TriggerRegistry): void { registerTrelloTriggers(registry); registerJiraTriggers(registry); + registerLinearTriggers(registry); registerGitHubTriggers(registry); registerSentryTriggers(registry); } diff --git a/src/triggers/jira/types.ts b/src/triggers/jira/types.ts index 640ff4ea..37941c66 100644 --- a/src/triggers/jira/types.ts +++ b/src/triggers/jira/types.ts @@ -35,18 +35,4 @@ export interface JiraWebhookPayload { // Constants // --------------------------------------------------------------------------- -/** - * Maps CASCADE status keys to agent types. - * - * Project config maps CASCADE status names to JIRA status names, e.g.: - * { splitting: "Splitting", planning: "Planning", todo: "To Do" } - * - * We invert that mapping at runtime: if the issue transitioned to "Splitting", - * we look up `splitting` β†’ `splitting` agent. - */ -export const STATUS_TO_AGENT: Record = { - splitting: 'splitting', - planning: 'planning', - todo: 'implementation', - backlog: 'backlog-manager', -}; +export { STATUS_TO_AGENT } from '../shared/status-to-agent.js'; diff --git a/src/triggers/linear/comment-mention.ts b/src/triggers/linear/comment-mention.ts new file mode 100644 index 00000000..56d1bce8 --- /dev/null +++ b/src/triggers/linear/comment-mention.ts @@ -0,0 +1,134 @@ +/** + * Linear comment @mention trigger. + * + * Fires when someone @mentions the CASCADE bot user in a Linear issue comment. + * Runs the respond-to-planning-comment agent. + * + * Linear webhook structure for comment creation: + * action: 'create', type: 'Comment' + * data.body: the comment text (plain markdown) + * data.userId: the author's user ID + * data.issueId: the issue ID + * data.issue.identifier: the issue identifier (e.g. TEAM-123) + */ + +import { resolveLinearBotUserId } from '../../router/bot-identity-resolvers.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import type { LinearWebhookCommentTriggerData, LinearWebhookTriggerPayload } from './types.js'; + +/** + * Check if a Linear comment body contains an @mention for the given user ID. + * Linear uses @[Display Name](userId) markdown mention syntax, where userId is + * a UUID. Checking for userId as a substring is sufficient and safe in practice. + */ +function hasMention(body: string, userId: string): boolean { + return body.includes(userId); +} + +export class LinearCommentMentionTrigger implements TriggerHandler { + name = 'linear-comment-mention'; + description = + 'Triggers respond-to-planning-comment agent when someone @mentions the bot in a Linear comment'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'linear') return false; + + const payload = ctx.payload as LinearWebhookTriggerPayload; + return payload.action === 'create' && payload.type === 'Comment'; + } + + async handle(ctx: TriggerContext): Promise { + // Check trigger config via DB-driven system + if ( + !(await checkTriggerEnabled( + ctx.project.id, + 'respond-to-planning-comment', + 'pm:comment-mention', + this.name, + )) + ) { + return null; + } + + const payload = ctx.payload as LinearWebhookTriggerPayload; + const data = payload.data as LinearWebhookCommentTriggerData; + + const commentBody = data.body; + const commentAuthorId = data.userId; + const issue = data.issue; + const issueIdentifier = issue?.identifier ?? issue?.id; + const issueId = issue?.id ?? data.issueId; + + logger.info('Linear comment trigger processing', { + issueIdentifier: issueIdentifier ?? '', + hasCommentBody: !!commentBody, + commentAuthorId: commentAuthorId ?? '', + }); + + if (!issueIdentifier || !commentBody) { + logger.info('Linear comment trigger: missing issueIdentifier or commentBody, skipping', { + hasIssueIdentifier: !!issueIdentifier, + hasCommentBody: !!commentBody, + }); + return null; + } + + // Resolve the bot's Linear user ID via the shared cached resolver + const botUserId = await resolveLinearBotUserId(ctx.project.id); + + if (!botUserId) { + logger.warn('Linear comment trigger: could not resolve bot user ID, skipping', { + projectId: ctx.project.id, + }); + return null; + } + + logger.info('Linear bot identity resolved', { botUserId }); + + // Skip self-authored comments to prevent infinite loops + if (commentAuthorId === botUserId) { + logger.info('Skipping self-authored Linear comment to prevent infinite loop', { + issueIdentifier, + botUserId, + }); + return null; + } + + // Check for bot @mention in comment body + const mentionFound = hasMention(commentBody, botUserId); + if (!mentionFound) { + logger.info('Linear comment trigger: no @mention of bot found in comment body', { + issueIdentifier, + botUserId, + bodyPreview: commentBody.length > 200 ? `${commentBody.slice(0, 200)}...` : commentBody, + }); + return null; + } + + const issueUrl = issue?.url; + + logger.info('Linear comment @mention detected, triggering agent', { + issueIdentifier, + commentAuthorId, + botUserId, + }); + + return { + agentType: 'respond-to-planning-comment', + agentInput: { + workItemId: issueIdentifier, + triggerCommentText: commentBody, + triggerCommentAuthor: commentAuthorId, + workItemUrl: issueUrl, + workItemTitle: undefined, + triggerEvent: 'pm:comment-mention', + linearIssueId: issueId, + }, + workItemId: issueIdentifier, + workItemUrl: issueUrl, + workItemTitle: undefined, + }; + } +} diff --git a/src/triggers/linear/index.ts b/src/triggers/linear/index.ts new file mode 100644 index 00000000..14ad3d30 --- /dev/null +++ b/src/triggers/linear/index.ts @@ -0,0 +1,11 @@ +/** + * Linear trigger barrel. + * + * For trigger registration use `registerLinearTriggers` from `./register.js`. + */ + +export { LinearCommentMentionTrigger } from './comment-mention.js'; +export { LinearReadyToProcessLabelTrigger } from './label-added.js'; +export { registerLinearTriggers } from './register.js'; +export { LinearStatusChangedTrigger } from './status-changed.js'; +export { processLinearWebhook } from './webhook-handler.js'; diff --git a/src/triggers/linear/label-added.ts b/src/triggers/linear/label-added.ts new file mode 100644 index 00000000..adb18e3f --- /dev/null +++ b/src/triggers/linear/label-added.ts @@ -0,0 +1,130 @@ +/** + * Linear "Ready to Process" label trigger. + * + * Fires when an IssueLabel is created (action=create, type=IssueLabel) + * matching the configured readyToProcess label. Determines which agent to run + * based on the issue's current state, using the same stateβ†’agent mapping as + * the status-changed trigger. + * + * Linear webhook structure for label additions: + * action: 'create', type: 'IssueLabel' + * data.labelId: the added label ID + * data.label.name: the label name + * data.issue.stateId: current state ID of the issue + */ + +import { getLinearConfig } from '../../pm/config.js'; +import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { + type LinearWebhookIssueLabelData, + type LinearWebhookTriggerPayload, + STATUS_TO_AGENT, +} from './types.js'; + +export class LinearReadyToProcessLabelTrigger implements TriggerHandler { + name = 'linear-ready-to-process-label-added'; + description = 'Triggers agent based on current state when "Ready to Process" label is added'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'linear') return false; + + const payload = ctx.payload as LinearWebhookTriggerPayload; + if (payload.action !== 'create' || payload.type !== 'IssueLabel') return false; + + // Check that the configured readyToProcess label was actually added + const pmConfig = resolveProjectPMConfig(ctx.project); + const readyLabel = pmConfig.labels.readyToProcess; + if (!readyLabel) return false; + + const data = payload.data as LinearWebhookIssueLabelData; + const labelName = data.label?.name; + if (!labelName) return false; + + return labelName === readyLabel || data.labelId === readyLabel; + } + + async handle(ctx: TriggerContext): Promise { + const payload = ctx.payload as LinearWebhookTriggerPayload; + const data = payload.data as LinearWebhookIssueLabelData; + + const issue = data.issue; + const issueIdentifier = issue?.identifier ?? issue?.id; + const issueId = issue?.id; + const issueUrl = issue?.url; + const issueStateId = issue?.stateId; + + if (!issueIdentifier) { + logger.debug('Linear label trigger: missing issue identifier, skipping'); + return null; + } + + if (!issueStateId) { + logger.debug('No state ID on Linear issue, cannot determine agent type', { + issueIdentifier, + }); + return null; + } + + const linearConfig = getLinearConfig(ctx.project); + if (!linearConfig?.statuses) { + logger.debug('No Linear status configuration, skipping label trigger', { + projectId: ctx.project.id, + }); + return null; + } + + // Find which CASCADE status key maps to this Linear state ID + let agentType: string | undefined; + let matchedCascadeStatus: string | undefined; + for (const [cascadeStatus, linearStateId] of Object.entries(linearConfig.statuses)) { + if (linearStateId === issueStateId) { + agentType = STATUS_TO_AGENT[cascadeStatus]; + matchedCascadeStatus = cascadeStatus; + break; + } + } + + if (!agentType) { + logger.debug('Linear issue state does not map to any agent', { + issueIdentifier, + issueStateId, + configuredStatuses: linearConfig.statuses, + }); + return null; + } + + // Check per-agent ready-to-process toggle via DB-driven system + if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:label-added', this.name))) { + return null; + } + + logger.info('Linear "Ready to Process" label added, triggering agent', { + issueIdentifier, + issueStateId, + cascadeStatus: matchedCascadeStatus, + agentType, + }); + + const workItemId = issueIdentifier; + const workItemUrl = issueUrl; + // Issue title is not included in IssueLabel webhook data + const workItemTitle: string | undefined = undefined; + + return { + agentType, + agentInput: { + workItemId, + workItemUrl, + workItemTitle, + triggerEvent: 'pm:label-added', + linearIssueId: issueId, + }, + workItemId, + workItemUrl, + workItemTitle, + }; + } +} diff --git a/src/triggers/linear/register.ts b/src/triggers/linear/register.ts new file mode 100644 index 00000000..5f347fab --- /dev/null +++ b/src/triggers/linear/register.ts @@ -0,0 +1,29 @@ +/** + * Linear trigger registration. + * + * This module only imports trigger handler classes (no webhook handlers, + * no agent execution pipeline) so it is safe to import from the router. + * + * `registerLinearTriggers` is the single call-site for wiring all built-in + * Linear triggers into a registry. Adding a new Linear trigger only + * requires updating this file, not `builtins.ts`. + */ + +import type { TriggerRegistry } from '../registry.js'; +import { LinearCommentMentionTrigger } from './comment-mention.js'; +import { LinearReadyToProcessLabelTrigger } from './label-added.js'; +import { LinearStatusChangedTrigger } from './status-changed.js'; + +/** + * Register all built-in Linear triggers into the given registry. + * + * Order matters: LinearCommentMentionTrigger must be registered before + * the status-changed trigger so it gets first crack at comment events. + */ +export function registerLinearTriggers(registry: TriggerRegistry): void { + // Must be registered before status-changed trigger + registry.register(new LinearCommentMentionTrigger()); + + registry.register(new LinearStatusChangedTrigger()); + registry.register(new LinearReadyToProcessLabelTrigger()); +} diff --git a/src/triggers/linear/status-changed.ts b/src/triggers/linear/status-changed.ts new file mode 100644 index 00000000..c9b8aa5f --- /dev/null +++ b/src/triggers/linear/status-changed.ts @@ -0,0 +1,108 @@ +/** + * Linear status-changed trigger. + * + * Fires when a Linear issue transitions to a configured state (by state ID) + * that maps to a CASCADE agent type (splitting, planning, implementation). + * + * Linear webhook structure for status changes: + * action: 'update', type: 'Issue' + * data.stateId: new state ID + * updatedFrom.stateId: previous state ID (only present when stateId changed) + */ + +import { getLinearConfig } from '../../pm/config.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { type LinearWebhookTriggerPayload, STATUS_TO_AGENT } from './types.js'; + +export class LinearStatusChangedTrigger implements TriggerHandler { + name = 'linear-status-changed'; + description = 'Triggers agent when a Linear issue transitions to a configured state'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'linear') return false; + + const payload = ctx.payload as LinearWebhookTriggerPayload; + if (payload.action !== 'update' || payload.type !== 'Issue') return false; + + // Must have a state change indicated by updatedFrom.stateId + return typeof payload.updatedFrom?.stateId === 'string'; + } + + async handle(ctx: TriggerContext): Promise { + const payload = ctx.payload as LinearWebhookTriggerPayload; + const data = payload.data as Record; + + const newStateId = data.stateId as string | undefined; + const issueIdentifier = + (data.identifier as string | undefined) ?? (data.id as string | undefined); + const issueId = data.id as string | undefined; + const issueTitle = data.title as string | undefined; + const issueUrl = data.url as string | undefined; + + if (!newStateId || !issueIdentifier) { + return null; + } + + const linearConfig = getLinearConfig(ctx.project); + if (!linearConfig?.statuses) { + logger.debug('No Linear status configuration, skipping status-changed trigger', { + projectId: ctx.project.id, + }); + return null; + } + + // Find which CASCADE status key maps to this Linear state ID + let agentType: string | undefined; + let matchedCascadeStatus: string | undefined; + for (const [cascadeStatus, linearStateId] of Object.entries(linearConfig.statuses)) { + if (linearStateId === newStateId) { + agentType = STATUS_TO_AGENT[cascadeStatus]; + matchedCascadeStatus = cascadeStatus; + break; + } + } + + if (!agentType) { + logger.debug('Linear state transition does not map to any agent', { + issueIdentifier, + newStateId, + configuredStatuses: linearConfig.statuses, + }); + return null; + } + + // Check per-agent toggle for statusChanged via DB-driven system + if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:status-changed', this.name))) { + return null; + } + + logger.info('Linear issue transitioned to agent-triggering state', { + issueIdentifier, + previousStateId: payload.updatedFrom?.stateId, + newStateId, + cascadeStatus: matchedCascadeStatus, + agentType, + }); + + // Use issueIdentifier (e.g. TEAM-123) as the workItemId, falling back to id + const workItemId = issueIdentifier; + const workItemUrl = issueUrl; + const workItemTitle = issueTitle; + + return { + agentType, + agentInput: { + workItemId, + workItemUrl, + workItemTitle, + triggerEvent: 'pm:status-changed', + linearIssueId: issueId, + }, + workItemId, + workItemUrl, + workItemTitle, + }; + } +} diff --git a/src/triggers/linear/types.ts b/src/triggers/linear/types.ts new file mode 100644 index 00000000..718ed812 --- /dev/null +++ b/src/triggers/linear/types.ts @@ -0,0 +1,80 @@ +/** + * Shared Linear webhook types and constants used across Linear trigger handlers. + */ + +// --------------------------------------------------------------------------- +// Webhook Payload +// --------------------------------------------------------------------------- + +export interface LinearWebhookIssueTriggerData { + id: string; + identifier: string; + title: string; + description?: string | null; + priority: number; + priorityLabel: string; + url: string; + teamId: string; + stateId: string; + assigneeId?: string | null; + labelIds: string[]; + createdAt: string; + updatedAt: string; +} + +export interface LinearWebhookCommentTriggerData { + id: string; + body: string; + issueId: string; + userId: string; + createdAt: string; + updatedAt: string; + issue?: { + id: string; + identifier: string; + title: string; + teamId: string; + url: string; + stateId: string; + }; +} + +export interface LinearWebhookIssueLabelData { + id: string; + issueId: string; + labelId: string; + label?: { + id: string; + name: string; + }; + issue?: { + id: string; + identifier: string; + title: string; + teamId: string; + url: string; + stateId: string; + }; + teamId?: string; +} + +export interface LinearWebhookTriggerPayload { + action: 'create' | 'update' | 'remove'; + type: 'Issue' | 'Comment' | 'IssueLabel' | 'Reaction'; + organizationId: string; + webhookTimestamp: number; + data: + | LinearWebhookIssueTriggerData + | LinearWebhookCommentTriggerData + | LinearWebhookIssueLabelData + | Record; + url: string; + /** Present on update events β€” contains the previous values of changed fields */ + updatedFrom?: Record; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export { STATUS_TO_AGENT } from '../shared/status-to-agent.js'; diff --git a/src/triggers/linear/webhook-handler.ts b/src/triggers/linear/webhook-handler.ts new file mode 100644 index 00000000..a6dadf33 --- /dev/null +++ b/src/triggers/linear/webhook-handler.ts @@ -0,0 +1,21 @@ +/** + * Linear webhook handler. + * + * Thin wrapper around the generic PM webhook processor. + * Resolves the Linear integration from the registry and delegates. + */ + +import { pmRegistry } from '../../pm/index.js'; +import { processPMWebhook } from '../../pm/webhook-handler.js'; +import type { TriggerResult } from '../../types/index.js'; +import type { TriggerRegistry } from '../registry.js'; + +export async function processLinearWebhook( + payload: unknown, + registry: TriggerRegistry, + ackCommentId?: string, + triggerResult?: TriggerResult, +): Promise { + const integration = pmRegistry.get('linear'); + await processPMWebhook(integration, payload, registry, ackCommentId, triggerResult); +} diff --git a/src/triggers/shared/status-to-agent.ts b/src/triggers/shared/status-to-agent.ts new file mode 100644 index 00000000..71f66932 --- /dev/null +++ b/src/triggers/shared/status-to-agent.ts @@ -0,0 +1,17 @@ +/** + * Shared status-to-agent mapping used across PM trigger handlers (JIRA, Linear, etc.). + * + * Maps CASCADE status keys to agent types. + * + * Project config maps CASCADE status names to platform-specific status/state + * names, e.g.: { splitting: "Splitting", planning: "Planning", todo: "To Do" } + * + * We invert that mapping at runtime: if the issue transitioned to "Splitting", + * we look up `splitting` β†’ `splitting` agent. + */ +export const STATUS_TO_AGENT: Record = { + splitting: 'splitting', + planning: 'planning', + todo: 'implementation', + backlog: 'backlog-manager', +}; diff --git a/src/worker-entry.ts b/src/worker-entry.ts index 2072559e..ac4d067e 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -27,6 +27,7 @@ import { registerBuiltInTriggers, type TriggerRegistry, } from './triggers/index.js'; +import { processLinearWebhook } from './triggers/linear/webhook-handler.js'; import { processSentryWebhook } from './triggers/sentry/webhook-handler.js'; import { processTrelloWebhook } from './triggers/trello/webhook-handler.js'; import type { TriggerResult } from './types/index.js'; @@ -80,6 +81,19 @@ export interface SentryJobData { triggerResult?: TriggerResult; } +export interface LinearJobData { + type: 'linear'; + source: 'linear'; + payload: unknown; + projectId: string; + workItemId?: string; + /** Linear event type: e.g. 'create/Issue', 'update/Issue', 'create/Comment' */ + eventType: string; + receivedAt: string; + ackCommentId?: string; + triggerResult?: TriggerResult; +} + export interface ManualRunJobData { type: 'manual-run'; projectId: string; @@ -113,6 +127,7 @@ export type JobData = | GitHubJobData | JiraJobData | SentryJobData + | LinearJobData | DashboardJobData; export async function processDashboardJob(jobId: string, jobData: DashboardJobData): Promise { @@ -226,6 +241,22 @@ export async function dispatchJob( jobData.triggerResult, ); break; + case 'linear': + logger.info('[Worker] Processing Linear job', { + jobId, + projectId: jobData.projectId, + workItemId: jobData.workItemId, + eventType: jobData.eventType, + ackCommentId: jobData.ackCommentId, + hasTriggerResult: !!jobData.triggerResult, + }); + await processLinearWebhook( + jobData.payload, + triggerRegistry, + jobData.ackCommentId, + jobData.triggerResult, + ); + break; case 'manual-run': case 'retry-run': case 'debug-analysis': diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts index 3425a031..c915c3ce 100644 --- a/tests/unit/triggers/builtins.test.ts +++ b/tests/unit/triggers/builtins.test.ts @@ -62,6 +62,20 @@ vi.mock('../../../src/triggers/sentry/alerting-metric.js', () => ({ SentryMetricAlertTrigger: vi.fn().mockImplementation(() => ({ name: 'sentry-metric-alert' })), })); +vi.mock('../../../src/triggers/linear/comment-mention.js', () => ({ + LinearCommentMentionTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'linear-comment-mention' })), +})); +vi.mock('../../../src/triggers/linear/status-changed.js', () => ({ + LinearStatusChangedTrigger: vi.fn().mockImplementation(() => ({ name: 'linear-status-changed' })), +})); +vi.mock('../../../src/triggers/linear/label-added.js', () => ({ + LinearReadyToProcessLabelTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'linear-ready-to-process-label-added' })), +})); + vi.mock('../../../src/utils/logging.js', () => ({ logger: { debug: vi.fn(), @@ -88,8 +102,8 @@ describe('registerBuiltInTriggers', () => { registerBuiltInTriggers(registry as unknown as TriggerRegistry); - // Should have registered all 21 built-in triggers (19 + 2 Sentry alerting triggers) - expect(registry.register).toHaveBeenCalledTimes(21); + // Should have registered all 24 built-in triggers (19 + 2 Sentry alerting + 3 Linear triggers) + expect(registry.register).toHaveBeenCalledTimes(24); }); it('registers TrelloCommentMentionTrigger first', () => { @@ -142,6 +156,17 @@ describe('registerBuiltInTriggers', () => { expect(registeredNames).toContain('jira-label-added'); }); + it('registers Linear triggers', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const registeredNames = registry.handlers.map((h: object) => (h as { name: string }).name); + expect(registeredNames).toContain('linear-comment-mention'); + expect(registeredNames).toContain('linear-status-changed'); + expect(registeredNames).toContain('linear-ready-to-process-label-added'); + }); + it('registers Sentry alerting triggers', () => { const registry = createMockRegistry();