diff --git a/src/router/ackMessageGenerator.ts b/src/router/ackMessageGenerator.ts index 0f757d26..46aff6fd 100644 --- a/src/router/ackMessageGenerator.ts +++ b/src/router/ackMessageGenerator.ts @@ -160,6 +160,32 @@ export function extractJiraContext(payload: unknown): string { return truncate(parts.join('\n')); } +/** + * Extract context from a Linear webhook payload. + * Pulls issue title and optional comment body. + */ +export function extractLinearContext(payload: unknown): string { + if (!payload || typeof payload !== 'object') return ''; + + const p = payload as Record; + const parts: string[] = []; + + const data = p.data as Record | undefined; + if (!data) return ''; + + // Issue title (present for Issue and Comment events) + if (data.title) { + parts.push(`Issue: ${data.title as string}`); + } + + // Comment body (present for Comment events) + if (data.body) { + parts.push(`Comment: ${data.body as string}`); + } + + return truncate(parts.join('\n')); +} + // --------------------------------------------------------------------------- // Core generator // --------------------------------------------------------------------------- diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index 0585a8b9..42a81df3 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -13,6 +13,7 @@ import { GitHubPlatformClient, JiraPlatformClient, + LinearPlatformClient, TrelloPlatformClient, } from './platformClients/index.js'; @@ -90,6 +91,29 @@ export async function deleteJiraAck( await client.deleteComment(issueKey, commentId); } +// --------------------------------------------------------------------------- +// Linear — delegates to LinearPlatformClient +// --------------------------------------------------------------------------- + +export async function postLinearAck( + projectId: string, + issueId: string, + message: string, +): Promise { + const client = new LinearPlatformClient(projectId); + const result = await client.postComment(issueId, message); + return typeof result === 'string' ? result : null; +} + +export async function deleteLinearAck( + projectId: string, + issueId: string, + commentId: string, +): Promise { + const client = new LinearPlatformClient(projectId); + await client.deleteComment(issueId, commentId); +} + // --------------------------------------------------------------------------- // Bot identity resolution — re-exported from bot-identity-resolvers.ts // for backward compatibility with pm/ integrations and router/trello.ts. @@ -97,8 +121,10 @@ export async function deleteJiraAck( export { _resetJiraBotCache, + _resetLinearBotCache, _resetTrelloBotCache, resolveJiraBotAccountId, + resolveLinearBotUserId, resolveTrelloBotMemberId, } from './bot-identity-resolvers.js'; diff --git a/src/router/adapters/linear.ts b/src/router/adapters/linear.ts new file mode 100644 index 00000000..2f4cdd08 --- /dev/null +++ b/src/router/adapters/linear.ts @@ -0,0 +1,214 @@ +/** + * LinearRouterAdapter — platform-specific logic for the router-side + * Linear webhook processing pipeline. + * + * Follows the same pattern as JiraRouterAdapter and SentryRouterAdapter, + * implementing RouterPlatformAdapter so it can be driven by the generic + * processRouterWebhook() function. + */ + +import { withLinearCredentials } from '../../linear/client.js'; +import type { LinearWebhookPayload } from '../../linear/types.js'; +import type { TriggerRegistry } from '../../triggers/registry.js'; +import type { TriggerContext, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { buildWorkItemRunsLink, getDashboardUrl } from '../../utils/runLink.js'; +import { extractLinearContext, generateAckMessage } from '../ackMessageGenerator.js'; +import { postLinearAck, resolveLinearBotUserId } from '../acknowledgments.js'; +import { loadProjectConfig, type RouterProjectConfig } from '../config.js'; +import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; +import { resolveLinearCredentials } from '../platformClients/index.js'; +import type { CascadeJob, LinearJob } from '../queue.js'; + +// ============================================================================ +// Processable event combinations (action/type) +// ============================================================================ + +const PROCESSABLE_TYPES = ['Issue', 'Comment', 'IssueLabel'] as const; + +type ProcessableType = (typeof PROCESSABLE_TYPES)[number]; + +// ============================================================================ +// Extended parsed event for Linear +// ============================================================================ + +interface LinearParsedEvent extends ParsedWebhookEvent { + projectId: string; + action: string; + resourceType: string; +} + +// ============================================================================ +// Adapter +// ============================================================================ + +export class LinearRouterAdapter implements RouterPlatformAdapter { + readonly type = 'linear' as const; + + async parseWebhook(payload: unknown): Promise { + const p = payload as LinearWebhookPayload; + + if (!p.action || !p.type || !p.data) { + logger.warn('LinearRouterAdapter: missing required fields', { payload }); + return null; + } + + if (!PROCESSABLE_TYPES.includes(p.type as ProcessableType)) { + logger.debug('LinearRouterAdapter: ignoring non-processable type', { type: p.type }); + return null; + } + + // Extract teamId from payload data for project lookup + const data = p.data as Record; + const teamId = data.teamId as string | undefined; + + if (!teamId) { + logger.debug('LinearRouterAdapter: no teamId in payload data, skipping'); + return null; + } + + const config = await loadProjectConfig(); + const project = config.projects.find((proj) => proj.linear?.teamId === teamId); + if (!project) { + logger.debug('LinearRouterAdapter: no project found for teamId', { teamId }); + return null; + } + + const isCommentEvent = p.type === 'Comment'; + const workItemId = isCommentEvent + ? (data.issueId as string | undefined) + : (data.id as string | undefined); + + return { + projectIdentifier: teamId, + eventType: `${p.action}/${p.type}`, + workItemId, + isCommentEvent, + projectId: project.id, + action: p.action, + resourceType: p.type, + }; + } + + isProcessableEvent(event: ParsedWebhookEvent): boolean { + // All parsed events are processable (we filter in parseWebhook) + return PROCESSABLE_TYPES.some((t) => event.eventType.endsWith(`/${t}`)); + } + + async isSelfAuthored(event: ParsedWebhookEvent, payload: unknown): Promise { + if (!event.isCommentEvent) return false; + const data = (payload as Record)?.data as Record | undefined; + const commentAuthorId = data?.userId as string | undefined; + if (!commentAuthorId) return false; + try { + const projectId = (event as LinearParsedEvent).projectId; + const botId = await resolveLinearBotUserId(projectId); + return !!botId && commentAuthorId === botId; + } catch { + return false; + } + } + + sendReaction(_event: ParsedWebhookEvent, _payload: unknown): void { + // Linear does not support emoji reactions on comments via the same API pattern. + // No-op for now. + } + + async resolveProject(event: ParsedWebhookEvent): Promise { + const config = await loadProjectConfig(); + return config.projects.find((p) => p.linear?.teamId === event.projectIdentifier) ?? null; + } + + async dispatchWithCredentials( + _event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + triggerRegistry: TriggerRegistry, + ): Promise { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + if (!fullProject) { + logger.info('LinearRouterAdapter: no full project config found', { + projectId: project.id, + }); + return null; + } + + const linearCreds = await resolveLinearCredentials(project.id); + if (!linearCreds) { + logger.warn('LinearRouterAdapter: missing Linear credentials, cannot dispatch triggers', { + projectId: project.id, + }); + return null; + } + + const ctx: TriggerContext = { project: fullProject, source: 'linear', payload }; + return withLinearCredentials({ apiKey: linearCreds.apiKey }, () => + triggerRegistry.dispatch(ctx), + ); + } + + async postAck( + event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + agentType: string, + _triggerResult?: TriggerResult, + ): Promise { + const linearEvent = event as LinearParsedEvent; + const issueId = linearEvent.workItemId; + if (!issueId) return undefined; + + try { + const context = extractLinearContext(payload); + let message = await generateAckMessage(agentType, context, project.id); + + // Append run link footer when enabled for this project + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + if (fullProject?.runLinksEnabled && event.workItemId) { + const dashboardUrl = getDashboardUrl(); + if (dashboardUrl) { + const link = buildWorkItemRunsLink({ + dashboardUrl, + projectId: project.id, + workItemId: event.workItemId, + }); + if (link) message += link; + } + } + + const commentId = await postLinearAck(project.id, issueId, message); + if (commentId) return { commentId, message }; + return undefined; + } catch (err) { + logger.warn('LinearRouterAdapter: ack comment failed (non-fatal)', { + error: String(err), + issueId, + }); + return undefined; + } + } + + buildJob( + event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + result: TriggerResult, + ackResult?: AckResult, + ): CascadeJob { + const linearEvent = event as LinearParsedEvent; + const job: LinearJob = { + type: 'linear', + source: 'linear', + payload, + projectId: project.id, + workItemId: linearEvent.workItemId, + eventType: linearEvent.eventType, + receivedAt: new Date().toISOString(), + triggerResult: result, + ackCommentId: ackResult?.commentId as string | undefined, + }; + return job; + } +} diff --git a/src/router/bot-identity-resolvers.ts b/src/router/bot-identity-resolvers.ts index 4c72eb88..68f04cd2 100644 --- a/src/router/bot-identity-resolvers.ts +++ b/src/router/bot-identity-resolvers.ts @@ -8,7 +8,11 @@ */ import { BotIdentityCache } from './bot-identity.js'; -import { resolveJiraCredentials, resolveTrelloCredentials } from './platformClients/index.js'; +import { + resolveJiraCredentials, + resolveLinearCredentials, + resolveTrelloCredentials, +} from './platformClients/index.js'; // --------------------------------------------------------------------------- // JIRA bot identity @@ -70,3 +74,39 @@ export async function resolveTrelloBotMemberId(projectId: string): Promise('userId'); + +/** + * Resolve the Linear user ID for the bot credentials linked to a project. + * Uses the `viewer` query to fetch the authenticated user's ID. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveLinearBotUserId(projectId: string): Promise { + return linearBotIdentityCache.resolve(projectId, async () => { + const creds = await resolveLinearCredentials(projectId); + if (!creds) return null; + + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${creds.apiKey}`, + }, + body: JSON.stringify({ query: '{ viewer { id } }' }), + }); + if (!response.ok) return null; + + const data = (await response.json()) as { data?: { viewer?: { id?: string } } }; + return data.data?.viewer?.id ?? null; + }); +} + +/** @internal Visible for testing only */ +export function _resetLinearBotCache(): void { + linearBotIdentityCache._reset(); +} diff --git a/src/router/index.ts b/src/router/index.ts index 7ad590d3..2ab721a1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -15,11 +15,13 @@ import { createWebhookHandler, parseGitHubPayload, parseJiraPayload, + parseLinearPayload, parseSentryPayload, parseTrelloPayload, } from '../webhook/webhookHandlers.js'; import { GitHubRouterAdapter, injectEventType } from './adapters/github.js'; import { JiraRouterAdapter } from './adapters/jira.js'; +import { LinearRouterAdapter } from './adapters/linear.js'; import { SentryRouterAdapter } from './adapters/sentry.js'; import { TrelloRouterAdapter } from './adapters/trello.js'; import { startCancelListener, stopCancelListener } from './cancel-listener.js'; @@ -28,6 +30,7 @@ import { processRouterWebhook } from './webhook-processor.js'; import { verifyGitHubWebhookSignature, verifyJiraWebhookSignature, + verifyLinearWebhookSignature, verifySentryWebhookSignature, verifyTrelloWebhookSignature, } from './webhookVerification.js'; @@ -167,6 +170,30 @@ app.post( }), ); +// Linear webhook verification +app.get('/linear/webhook', (c) => { + return c.text('OK', 200); +}); + +// Linear webhook handler +app.post( + '/linear/webhook', + createWebhookHandler({ + source: 'linear', + parsePayload: parseLinearPayload, + verifySignature: verifyLinearWebhookSignature, + processWebhook: async (payload) => { + const adapter = new LinearRouterAdapter(); + const result = await processRouterWebhook(adapter, payload, triggerRegistry); + return { + processed: result.shouldProcess, + projectId: result.projectId, + decisionReason: result.decisionReason, + }; + }, + }), +); + // Graceful shutdown async function shutdown(signal: string): Promise { logger.info('Received shutdown signal', { signal }); diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index eef48516..7efafcb5 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -11,6 +11,7 @@ import { getIntegrationCredential, getIntegrationCredentialOrNull, } from '../../config/provider.js'; +import type { LinearCredentials } from '../../linear/types.js'; import { getJiraConfig } from '../../pm/config.js'; import type { JiraCredentialsWithAuth, TrelloCredentials } from './types.js'; @@ -51,6 +52,21 @@ export async function resolveJiraCredentials( } } +/** + * Resolve Linear credentials for a project. + * Returns `{ apiKey }` or `null` if credentials are missing. + */ +export async function resolveLinearCredentials( + projectId: string, +): Promise { + try { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + return { apiKey }; + } catch { + return null; + } +} + /** * Resolve the webhook secret for a given provider and project. * @@ -59,12 +75,13 @@ export async function resolveJiraCredentials( * Trello computes webhook HMAC signatures using the API Secret (shown below the * API Key at https://trello.com/app-key), not the public API Key. * - `'jira'`: resolves the `webhook_secret` credential from the PM integration. + * - `'linear'`: resolves the `webhook_secret` credential from the PM integration. * * Returns `null` if the credential is not configured. */ export async function resolveWebhookSecret( projectId: string, - provider: 'github' | 'trello' | 'jira' | 'sentry', + provider: 'github' | 'trello' | 'jira' | 'sentry' | 'linear', ): Promise { if (provider === 'github') { return getIntegrationCredentialOrNull(projectId, 'scm', 'webhook_secret'); @@ -75,6 +92,9 @@ export async function resolveWebhookSecret( if (provider === 'sentry') { return getIntegrationCredentialOrNull(projectId, 'alerting', 'webhook_secret'); } + if (provider === 'linear') { + return getIntegrationCredentialOrNull(projectId, 'pm', 'webhook_secret'); + } // Trello signs webhook payloads with the API Secret, not the public API Key. return getIntegrationCredentialOrNull(projectId, 'pm', 'api_secret'); } diff --git a/src/router/platformClients/index.ts b/src/router/platformClients/index.ts index e9b4f9b9..78125531 100644 --- a/src/router/platformClients/index.ts +++ b/src/router/platformClients/index.ts @@ -11,9 +11,11 @@ export { resolveGitHubHeaders, resolveJiraCredentials, + resolveLinearCredentials, resolveTrelloCredentials, } from './credentials.js'; export { GitHubPlatformClient } from './github.js'; export { _resetJiraCloudIdCache, JiraPlatformClient } from './jira.js'; +export { LinearPlatformClient } from './linear.js'; export { TrelloPlatformClient } from './trello.js'; export type { JiraCredentialsWithAuth, PlatformCommentClient, TrelloCredentials } from './types.js'; diff --git a/src/router/platformClients/linear.ts b/src/router/platformClients/linear.ts new file mode 100644 index 00000000..3a32e500 --- /dev/null +++ b/src/router/platformClients/linear.ts @@ -0,0 +1,136 @@ +/** + * Linear platform client for posting/deleting comments on Linear issues + * via the Linear GraphQL API. + * + * Comments are posted using the Linear GraphQL API with markdown body text. + */ + +import { logger } from '../../utils/logging.js'; +import { resolveLinearCredentials } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +const LINEAR_API_URL = 'https://api.linear.app/graphql'; + +async function linearGraphQL( + apiKey: string, + query: string, + variables?: Record, +): Promise> { + const response = await fetch(LINEAR_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`Linear API HTTP error ${response.status}`); + } + + const json = (await response.json()) as { + data?: Record; + errors?: Array<{ message: string }>; + }; + + if (json.errors && json.errors.length > 0) { + const messages = json.errors.map((e) => e.message).join('; '); + throw new Error(`Linear API error: ${messages}`); + } + + return json.data ?? {}; +} + +export class LinearPlatformClient implements PlatformCommentClient { + constructor(private readonly projectId: string) {} + + async postComment(issueId: string, message: string): Promise { + const creds = await resolveLinearCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing Linear credentials, skipping comment'); + return null; + } + + try { + const mutation = ` + mutation CreateComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + comment { + id + } + } + } + `; + + const data = await linearGraphQL(creds.apiKey, mutation, { + issueId, + body: message, + }); + + const commentCreate = data.commentCreate as + | { success: boolean; comment?: { id: string } } + | undefined; + + if (!commentCreate?.success) { + logger.warn('[PlatformClient] Linear commentCreate returned success=false'); + return null; + } + + const commentId = commentCreate.comment?.id ?? null; + logger.info('[PlatformClient] Linear comment posted for issue:', issueId); + return commentId; + } catch (err) { + logger.warn('[PlatformClient] Failed to post Linear comment:', String(err)); + return null; + } + } + + async deleteComment(_issueId: string, commentId: string | number): Promise { + const creds = await resolveLinearCredentials(this.projectId); + if (!creds) return; + + try { + const mutation = ` + mutation DeleteComment($commentId: String!) { + commentDelete(id: $commentId) { + success + } + } + `; + + await linearGraphQL(creds.apiKey, mutation, { + commentId: String(commentId), + }); + + logger.info('[PlatformClient] Linear comment deleted:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete Linear comment:', String(err)); + } + } + + async updateComment(commentId: string, message: string): Promise { + const creds = await resolveLinearCredentials(this.projectId); + if (!creds) return; + + try { + const mutation = ` + mutation UpdateComment($commentId: String!, $body: String!) { + commentUpdate(id: $commentId, input: { body: $body }) { + success + } + } + `; + + await linearGraphQL(creds.apiKey, mutation, { + commentId, + body: message, + }); + + logger.info('[PlatformClient] Linear comment updated:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to update Linear comment:', String(err)); + } + } +} diff --git a/src/router/queue.ts b/src/router/queue.ts index f35035cb..b69067e3 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -58,7 +58,19 @@ export interface SentryJob { triggerResult?: TriggerResult; } -export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob; +export interface LinearJob { + type: 'linear'; + source: 'linear'; + payload: unknown; + projectId: string; + workItemId?: string; + eventType: string; + receivedAt: string; + ackCommentId?: string; + triggerResult?: TriggerResult; +} + +export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob | LinearJob; // Create the job queue export const jobQueue = new Queue('cascade-jobs', { diff --git a/src/router/webhookVerification.ts b/src/router/webhookVerification.ts index da279c79..5ad268c7 100644 --- a/src/router/webhookVerification.ts +++ b/src/router/webhookVerification.ts @@ -10,6 +10,7 @@ import { logger } from '../utils/logging.js'; import { verifyGitHubSignature, verifyJiraSignature, + verifyLinearSignature, verifySentrySignature, verifyTrelloSignature, } from '../webhook/signatureVerification.js'; @@ -17,7 +18,7 @@ import { loadProjectConfig, routerConfig } from './config.js'; import { resolveWebhookSecret } from './platformClients/credentials.js'; /** The set of platforms that have a webhook secret in {@link resolveWebhookSecret}. */ -type WebhookPlatform = 'github' | 'trello' | 'jira' | 'sentry'; +type WebhookPlatform = 'github' | 'trello' | 'jira' | 'sentry' | 'linear'; // --------------------------------------------------------------------------- // Helpers @@ -268,3 +269,36 @@ export const verifyJiraWebhookSignature = createWebhookVerifier({ ) as { id: string } | undefined, verify: (rawBody, sig, secret) => verifyJiraSignature(rawBody, sig, secret), }); + +/** + * Extract the Linear team ID from a raw webhook payload. + * Linear sends the team ID nested in `data.teamId` for Issue events. + */ +export function extractLinearTeamId(rawBody: string): string | undefined { + try { + const parsed = JSON.parse(rawBody) as Record; + const data = parsed?.data as Record | undefined; + return data?.teamId as string | undefined; + } catch { + return undefined; + } +} + +/** + * verifySignature callback for the Linear webhook handler. + * Returns null to skip verification when no secret is configured (backwards compat). + * + * Linear sends the signature as a raw HMAC-SHA256 hex digest in the + * `Linear-Signature` header (no prefix). + */ +export const verifyLinearWebhookSignature = createWebhookVerifier({ + headerName: 'Linear-Signature', + platform: 'linear', + platformLabel: 'Linear', + extractIdentifier: (_c, rawBody) => extractLinearTeamId(rawBody), + findProject: (teamId, projects) => + projects.find((p) => (p.linear as Record | undefined)?.teamId === teamId) as + | { id: string } + | undefined, + verify: (rawBody, sig, secret) => verifyLinearSignature(rawBody, sig, secret), +}); diff --git a/src/webhook/signatureVerification.ts b/src/webhook/signatureVerification.ts index f20189c9..f3c14963 100644 --- a/src/webhook/signatureVerification.ts +++ b/src/webhook/signatureVerification.ts @@ -165,3 +165,24 @@ export function verifyJiraSignature(rawBody: string, signature: string, secret: prefix: 'sha256=', }); } + +/** + * Verify a Linear webhook signature. + * + * Linear signs payloads with HMAC-SHA256 and sends the result as a raw hex + * digest in the `Linear-Signature` header (no prefix). + * + * @param rawBody - The raw request body string. + * @param signature - The value of the `Linear-Signature` header. + * @param secret - The LINEAR_WEBHOOK_SECRET configured for the webhook. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export function verifyLinearSignature(rawBody: string, signature: string, secret: string): boolean { + return verifyHmac({ + algorithm: 'sha256', + data: rawBody, + secret, + signature, + encoding: 'hex', + }); +} diff --git a/src/webhook/webhookHandlers.ts b/src/webhook/webhookHandlers.ts index b5e9f00a..9a8580d0 100644 --- a/src/webhook/webhookHandlers.ts +++ b/src/webhook/webhookHandlers.ts @@ -23,6 +23,7 @@ import { handleProcessingError, logSuccessfulWebhook } from './webhookLogging.js export { parseGitHubPayload, parseJiraPayload, + parseLinearPayload, parseSentryPayload, parseTrelloPayload, } from './webhookParsers.js'; diff --git a/src/webhook/webhookParsers.ts b/src/webhook/webhookParsers.ts index e4b52a24..a38da013 100644 --- a/src/webhook/webhookParsers.ts +++ b/src/webhook/webhookParsers.ts @@ -112,3 +112,27 @@ export async function parseJiraPayload(c: Context): Promise { return { ok: false, error: String(err) }; } } + +/** + * Parse a Linear webhook request (plain JSON). + * Extracts `{action}/{type}` as the event type (e.g. `create/Issue`, `update/Issue`). + * Linear sends the action in `action` and the resource type in `type`. + */ +export async function parseLinearPayload(c: Context): Promise { + try { + const rawBody = await c.req.text(); + const payload = JSON.parse(rawBody); + const p = payload as Record; + const action = p?.action as string | undefined; + const type = p?.type as string | undefined; + const eventType = action && type ? `${action}/${type}` : (action ?? type ?? 'unknown'); + logger.info('Received Linear webhook', { + action, + type, + eventType, + }); + return { ok: true, payload, eventType, rawBody }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} diff --git a/tests/unit/router/adapters/linear.test.ts b/tests/unit/router/adapters/linear.test.ts new file mode 100644 index 00000000..e9db5e21 --- /dev/null +++ b/tests/unit/router/adapters/linear.test.ts @@ -0,0 +1,489 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn(), +})); +vi.mock('../../../../src/router/queue.js', () => ({ + addJob: vi.fn(), +})); +vi.mock('../../../../src/router/acknowledgments.js', () => ({ + postLinearAck: vi.fn(), + resolveLinearBotUserId: vi.fn().mockResolvedValue(null), +})); +vi.mock('../../../../src/router/ackMessageGenerator.js', () => ({ + extractLinearContext: vi.fn().mockReturnValue('Issue: Fix the bug'), + generateAckMessage: vi.fn().mockResolvedValue('Working on it...'), +})); +vi.mock('../../../../src/router/platformClients/index.js', () => ({ + resolveLinearCredentials: vi.fn().mockResolvedValue({ + apiKey: 'lin_api_test', + }), +})); +vi.mock('../../../../src/utils/runLink.js', () => ({ + buildWorkItemRunsLink: vi.fn().mockReturnValue(null), + getDashboardUrl: vi.fn().mockReturnValue(null), +})); +vi.mock('../../../../src/linear/client.js', () => ({ + withLinearCredentials: vi.fn().mockImplementation((_creds: unknown, fn: () => unknown) => fn()), +})); + +import { postLinearAck, resolveLinearBotUserId } from '../../../../src/router/acknowledgments.js'; +import { LinearRouterAdapter } from '../../../../src/router/adapters/linear.js'; +import type { RouterProjectConfig } from '../../../../src/router/config.js'; +import { loadProjectConfig } from '../../../../src/router/config.js'; +import { resolveLinearCredentials } from '../../../../src/router/platformClients/index.js'; +import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; +import { buildWorkItemRunsLink, getDashboardUrl } from '../../../../src/utils/runLink.js'; + +const mockProject: RouterProjectConfig = { + id: 'p1', + repo: 'owner/repo', + pmType: 'linear', + linear: { + teamId: 'team-abc-123', + }, +}; + +const mockTriggerRegistry = { + dispatch: vi.fn().mockResolvedValue(null), +} as unknown as TriggerRegistry; + +beforeEach(() => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1' } as never], + }); +}); + +const baseLinearPayload = { + action: 'create', + type: 'Issue', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'issue-abc', + title: 'Fix the bug', + teamId: 'team-abc-123', + }, + url: 'https://linear.app/team/issue/TEAM-1', +}; + +describe('LinearRouterAdapter', () => { + let adapter: LinearRouterAdapter; + + beforeEach(() => { + adapter = new LinearRouterAdapter(); + }); + + describe('parseWebhook', () => { + it('returns null for empty payload', async () => { + const result = await adapter.parseWebhook({}); + expect(result).toBeNull(); + }); + + it('returns null for unsupported type', async () => { + const result = await adapter.parseWebhook({ + action: 'create', + type: 'CycleIssue', + data: { teamId: 'team-abc-123' }, + }); + expect(result).toBeNull(); + }); + + it('returns null when no project matches teamId', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [], fullProjects: [] }); + const result = await adapter.parseWebhook(baseLinearPayload); + expect(result).toBeNull(); + }); + + it('returns null when no teamId in data', async () => { + const result = await adapter.parseWebhook({ + action: 'create', + type: 'Issue', + data: { id: 'issue-abc', title: 'Test' }, + }); + expect(result).toBeNull(); + }); + + it('returns parsed event for create/Issue', async () => { + const result = await adapter.parseWebhook(baseLinearPayload); + expect(result).not.toBeNull(); + expect(result?.eventType).toBe('create/Issue'); + expect(result?.workItemId).toBe('issue-abc'); + expect(result?.isCommentEvent).toBe(false); + expect(result?.projectIdentifier).toBe('team-abc-123'); + }); + + it('returns parsed event for Comment (isCommentEvent=true)', async () => { + const commentPayload = { + action: 'create', + type: 'Comment', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'comment-xyz', + body: 'Great fix!', + issueId: 'issue-abc', + teamId: 'team-abc-123', + }, + url: 'https://linear.app/issue', + }; + const result = await adapter.parseWebhook(commentPayload); + expect(result).not.toBeNull(); + expect(result?.isCommentEvent).toBe(true); + expect(result?.eventType).toBe('create/Comment'); + // For comments, workItemId is the issueId + expect(result?.workItemId).toBe('issue-abc'); + }); + + it('returns parsed event for update/Issue', async () => { + const result = await adapter.parseWebhook({ ...baseLinearPayload, action: 'update' }); + expect(result?.eventType).toBe('update/Issue'); + }); + }); + + describe('isProcessableEvent', () => { + it('returns true for Issue events', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + isCommentEvent: false, + }), + ).toBe(true); + }); + + it('returns true for Comment events', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'team-abc-123', + eventType: 'create/Comment', + isCommentEvent: true, + }), + ).toBe(true); + }); + + it('returns false for unknown event types', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'team-abc-123', + eventType: 'create/Cycle', + isCommentEvent: false, + }), + ).toBe(false); + }); + }); + + describe('isSelfAuthored', () => { + it('returns false for non-comment events', async () => { + const result = await adapter.isSelfAuthored( + { projectIdentifier: 'team-abc-123', eventType: 'create/Issue', isCommentEvent: false }, + { data: { id: 'issue-abc', teamId: 'team-abc-123' } }, + ); + expect(result).toBe(false); + }); + + it('returns false when comment has no userId', async () => { + const result = await adapter.isSelfAuthored( + { projectIdentifier: 'team-abc-123', eventType: 'create/Comment', isCommentEvent: true }, + { data: { id: 'comment-xyz', body: 'Hello' } }, + ); + expect(result).toBe(false); + }); + + it('returns false when bot userId cannot be resolved', async () => { + vi.mocked(resolveLinearBotUserId).mockResolvedValueOnce(null); + const result = await adapter.isSelfAuthored( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Comment', + isCommentEvent: true, + // @ts-expect-error extended field + projectId: 'p1', + }, + { data: { id: 'comment-xyz', body: 'Hello', userId: 'user-bot-id' } }, + ); + expect(result).toBe(false); + }); + + it('returns true when comment userId matches bot userId', async () => { + vi.mocked(resolveLinearBotUserId).mockResolvedValueOnce('user-bot-id'); + const result = await adapter.isSelfAuthored( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Comment', + isCommentEvent: true, + // @ts-expect-error extended field + projectId: 'p1', + }, + { data: { id: 'comment-xyz', body: 'Hello', userId: 'user-bot-id' } }, + ); + expect(result).toBe(true); + }); + + it('returns false when comment userId does not match bot userId', async () => { + vi.mocked(resolveLinearBotUserId).mockResolvedValueOnce('user-bot-id'); + const result = await adapter.isSelfAuthored( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Comment', + isCommentEvent: true, + // @ts-expect-error extended field + projectId: 'p1', + }, + { data: { id: 'comment-xyz', body: 'Hello', userId: 'user-other-id' } }, + ); + expect(result).toBe(false); + }); + }); + + describe('sendReaction', () => { + it('does nothing (no-op)', () => { + // Should not throw + adapter.sendReaction( + { projectIdentifier: 'team-abc-123', eventType: 'create/Issue', isCommentEvent: false }, + {}, + ); + }); + }); + + describe('resolveProject', () => { + it('returns project matching Linear teamId', async () => { + const project = await adapter.resolveProject({ + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + isCommentEvent: false, + }); + expect(project?.id).toBe('p1'); + }); + + it('returns null for unknown teamId', async () => { + const project = await adapter.resolveProject({ + projectIdentifier: 'unknown-team', + eventType: 'create/Issue', + isCommentEvent: false, + }); + expect(project).toBeNull(); + }); + }); + + describe('dispatchWithCredentials', () => { + it('dispatches with Linear credentials', async () => { + vi.mocked(mockTriggerRegistry.dispatch).mockResolvedValue({ + agentType: 'implementation', + agentInput: {}, + } as never); + + const result = await adapter.dispatchWithCredentials( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + mockTriggerRegistry, + ); + expect(result).not.toBeNull(); + expect(mockTriggerRegistry.dispatch).toHaveBeenCalled(); + }); + + it('returns null when Linear credentials are missing', async () => { + vi.mocked(resolveLinearCredentials).mockResolvedValueOnce(null); + + const result = await adapter.dispatchWithCredentials( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + mockTriggerRegistry, + ); + expect(result).toBeNull(); + }); + + it('returns null when no full project config found', async () => { + vi.mocked(loadProjectConfig).mockResolvedValueOnce({ + projects: [mockProject], + fullProjects: [], + }); + + const result = await adapter.dispatchWithCredentials( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + isCommentEvent: false, + }, + baseLinearPayload, + mockProject, + mockTriggerRegistry, + ); + expect(result).toBeNull(); + }); + }); + + describe('postAck', () => { + it('posts ack and returns AckResult', async () => { + vi.mocked(postLinearAck).mockResolvedValue('comment-123'); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: 'issue-abc', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + 'implementation', + ); + expect(ackResult?.commentId).toBe('comment-123'); + expect(ackResult?.message).toBe('Working on it...'); + }); + + it('returns undefined when no workItemId', async () => { + const ackResult = await adapter.postAck( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: undefined, + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + 'implementation', + ); + expect(ackResult).toBeUndefined(); + }); + + it('returns undefined when postLinearAck returns null', async () => { + vi.mocked(postLinearAck).mockResolvedValue(null); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: 'issue-abc', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + 'implementation', + ); + expect(ackResult).toBeUndefined(); + }); + + it('appends run link footer when runLinksEnabled and dashboardUrl available', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', runLinksEnabled: true } as never], + }); + vi.mocked(getDashboardUrl).mockReturnValue('https://dashboard.example.com'); + vi.mocked(buildWorkItemRunsLink).mockReturnValue( + '\n[View runs](https://dashboard.example.com/runs)', + ); + vi.mocked(postLinearAck).mockResolvedValue('comment-123'); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: 'issue-abc', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + 'implementation', + ); + expect(buildWorkItemRunsLink).toHaveBeenCalled(); + expect(ackResult?.message).toContain('[View runs]'); + }); + + it('handles postLinearAck error gracefully', async () => { + vi.mocked(postLinearAck).mockRejectedValue(new Error('API error')); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: 'issue-abc', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + 'implementation', + ); + expect(ackResult).toBeUndefined(); + }); + }); + + describe('buildJob', () => { + it('builds a linear job with correct fields', () => { + const result = { agentType: 'implementation', agentInput: { issueId: 'issue-abc' } }; + const job = adapter.buildJob( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: 'issue-abc', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + action: 'create', + resourceType: 'Issue', + }, + baseLinearPayload, + mockProject, + result as never, + ); + expect(job.type).toBe('linear'); + expect((job as { workItemId?: string }).workItemId).toBe('issue-abc'); + expect((job as { ackCommentId?: string }).ackCommentId).toBeUndefined(); + }); + + it('includes ackCommentId when ackResult is provided', () => { + const result = { agentType: 'implementation', agentInput: {} }; + const job = adapter.buildJob( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: 'issue-abc', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + action: 'create', + resourceType: 'Issue', + }, + baseLinearPayload, + mockProject, + result as never, + { commentId: 'comment-789', message: 'Working...' }, + ); + expect((job as { ackCommentId?: string }).ackCommentId).toBe('comment-789'); + }); + }); +}); diff --git a/tests/unit/router/webhook-signature.test.ts b/tests/unit/router/webhook-signature.test.ts index e66b0852..9fec705e 100644 --- a/tests/unit/router/webhook-signature.test.ts +++ b/tests/unit/router/webhook-signature.test.ts @@ -125,9 +125,11 @@ import { buildTrelloCallbackUrl, createWebhookVerifier, extractJiraProjectKey, + extractLinearTeamId, extractTrelloBoardId, verifyGitHubWebhookSignature, verifyJiraWebhookSignature, + verifyLinearWebhookSignature, verifyTrelloWebhookSignature, } from '../../../src/router/webhookVerification.js'; import { logger } from '../../../src/utils/logging.js'; @@ -180,9 +182,19 @@ const JIRA_PROJECT = { }, }; +const LINEAR_PROJECT = { + id: 'proj-linear', + repo: 'owner/repo', + pmType: 'linear' as const, + linear: { + teamId: 'team-abc-123', + }, +}; + const GITHUB_SECRET = 'my-github-webhook-secret'; const TRELLO_SECRET = 'my-trello-api-secret'; const JIRA_SECRET = 'my-jira-webhook-secret'; +const LINEAR_SECRET = 'my-linear-webhook-secret'; const TRELLO_CALLBACK_URL = 'https://example.com/trello/webhook'; // --------------------------------------------------------------------------- @@ -432,6 +444,119 @@ describe('verifyTrelloWebhookSignature — direct function tests', () => { }); }); +// --------------------------------------------------------------------------- +// Unit tests: extractLinearTeamId +// --------------------------------------------------------------------------- + +describe('extractLinearTeamId', () => { + it('extracts teamId from data.teamId', () => { + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + data: { id: 'issue-abc', teamId: 'team-abc-123' }, + }); + expect(extractLinearTeamId(body)).toBe('team-abc-123'); + }); + + it('returns undefined when data.teamId is missing', () => { + const body = JSON.stringify({ action: 'create', type: 'Issue', data: {} }); + expect(extractLinearTeamId(body)).toBeUndefined(); + }); + + it('returns undefined for invalid JSON', () => { + expect(extractLinearTeamId('not json')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests: verifyLinearWebhookSignature (function directly) +// --------------------------------------------------------------------------- + +describe('verifyLinearWebhookSignature — direct function tests', () => { + beforeEach(() => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [LINEAR_PROJECT] }); + vi.mocked(resolveWebhookSecret).mockResolvedValue(LINEAR_SECRET); + }); + + function makeContext(headers: Record = {}) { + return { + req: { + header: (name: string) => headers[name.toLowerCase()] ?? headers[name], + }, + } as unknown as import('hono').Context; + } + + function linearSignature(body: string, secret: string): string { + return createHmac('sha256', secret).update(body, 'utf8').digest('hex'); + } + + it('returns { valid: true } when signature is correct', async () => { + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + data: { id: 'issue-abc', teamId: 'team-abc-123' }, + }); + const sig = linearSignature(body, LINEAR_SECRET); + const result = await verifyLinearWebhookSignature( + makeContext({ 'Linear-Signature': sig }), + body, + ); + expect(result).toEqual({ valid: true, reason: 'Signature valid' }); + }); + + it('returns { valid: false } when signature is wrong', async () => { + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + data: { id: 'issue-abc', teamId: 'team-abc-123' }, + }); + const badSig = linearSignature(body, 'wrong-secret'); + const result = await verifyLinearWebhookSignature( + makeContext({ 'Linear-Signature': badSig }), + body, + ); + expect(result).toEqual({ valid: false, reason: 'Linear signature mismatch' }); + }); + + it('returns { valid: false, reason: "Missing signature header" } when header absent but secret configured', async () => { + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + data: { id: 'issue-abc', teamId: 'team-abc-123' }, + }); + const result = await verifyLinearWebhookSignature(makeContext({}), body); + expect(result).toEqual({ valid: false, reason: 'Missing signature header' }); + }); + + it('returns null (skip) when no secret configured', async () => { + vi.mocked(resolveWebhookSecret).mockResolvedValue(null); + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + data: { id: 'issue-abc', teamId: 'team-abc-123' }, + }); + const result = await verifyLinearWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); + + it('returns null (skip) when project not found for teamId', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [] }); + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + data: { id: 'issue-abc', teamId: 'unknown-team' }, + }); + const result = await verifyLinearWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); + + it('returns null (skip) when teamId is missing from payload', async () => { + const body = JSON.stringify({ action: 'create', type: 'Issue', data: {} }); + const result = await verifyLinearWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // Integration tests: end-to-end via Hono app (mirrors src/router/index.ts wiring) // ---------------------------------------------------------------------------