Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/router/reactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,19 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise<vo
await client.postReaction('', { issueId, commentId });
}

async function sendLinearReaction(_projectId: string, _payload: unknown): Promise<void> {
// 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
Expand All @@ -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));
Expand Down
2 changes: 2 additions & 0 deletions src/triggers/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@

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';

export function registerBuiltInTriggers(registry: TriggerRegistry): void {
registerTrelloTriggers(registry);
registerJiraTriggers(registry);
registerLinearTriggers(registry);
registerGitHubTriggers(registry);
registerSentryTriggers(registry);
}
16 changes: 1 addition & 15 deletions src/triggers/jira/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
splitting: 'splitting',
planning: 'planning',
todo: 'implementation',
backlog: 'backlog-manager',
};
export { STATUS_TO_AGENT } from '../shared/status-to-agent.js';
134 changes: 134 additions & 0 deletions src/triggers/linear/comment-mention.ts
Original file line number Diff line number Diff line change
@@ -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<TriggerResult | null> {
// 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 ?? '<missing>',
hasCommentBody: !!commentBody,
commentAuthorId: commentAuthorId ?? '<missing>',
});

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,
};
}
}
11 changes: 11 additions & 0 deletions src/triggers/linear/index.ts
Original file line number Diff line number Diff line change
@@ -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';
130 changes: 130 additions & 0 deletions src/triggers/linear/label-added.ts
Original file line number Diff line number Diff line change
@@ -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<TriggerResult | null> {
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,
};
}
}
29 changes: 29 additions & 0 deletions src/triggers/linear/register.ts
Original file line number Diff line number Diff line change
@@ -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());
}
Loading
Loading