From 8a778b0ba344cb635b2015ebed6201e6fd488d34 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 19:56:00 +0000 Subject: [PATCH 1/2] feat(linear): add Linear webhook ingestion in router --- src/router/ackMessageGenerator.ts | 26 ++ src/router/acknowledgments.ts | 24 ++ src/router/adapters/linear.ts | 206 +++++++++ src/router/index.ts | 27 ++ src/router/platformClients/credentials.ts | 22 +- src/router/platformClients/index.ts | 2 + src/router/platformClients/linear.ts | 136 ++++++ src/router/queue.ts | 14 +- src/router/webhookVerification.ts | 36 +- src/webhook/signatureVerification.ts | 21 + src/webhook/webhookHandlers.ts | 1 + src/webhook/webhookParsers.ts | 24 ++ tests/unit/router/adapters/linear.test.ts | 435 ++++++++++++++++++++ tests/unit/router/webhook-signature.test.ts | 125 ++++++ 14 files changed, 1096 insertions(+), 3 deletions(-) create mode 100644 src/router/adapters/linear.ts create mode 100644 src/router/platformClients/linear.ts create mode 100644 tests/unit/router/adapters/linear.test.ts 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..855bc257 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. diff --git a/src/router/adapters/linear.ts b/src/router/adapters/linear.ts new file mode 100644 index 00000000..74be597e --- /dev/null +++ b/src/router/adapters/linear.ts @@ -0,0 +1,206 @@ +/** + * 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 } 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 { + // Linear comment self-authoring detection could be added here later. + // For now, Linear has no bot persona concept — always return false. + 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/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..58305408 --- /dev/null +++ b/tests/unit/router/adapters/linear.test.ts @@ -0,0 +1,435 @@ +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(), +})); +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 } 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('always returns false (no bot persona in Linear)', async () => { + const result = await adapter.isSelfAuthored( + { projectIdentifier: 'team-abc-123', eventType: 'create/Comment', isCommentEvent: true }, + {}, + ); + 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) // --------------------------------------------------------------------------- From 6c939326ef240c962704585c2a57bab7dd09ad04 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 20:05:59 +0000 Subject: [PATCH 2/2] fix(linear): implement isSelfAuthored to prevent infinite comment loop Add resolveLinearBotUserId() using the Linear GraphQL viewer query, mirroring the JIRA bot identity pattern. isSelfAuthored() now compares the comment payload's userId against the bot's user ID so bot-posted ack comments are filtered before trigger dispatch, preventing an infinite create/Comment webhook loop when triggers match comment creation events. Co-Authored-By: Claude Opus 4.6 --- src/router/acknowledgments.ts | 2 + src/router/adapters/linear.ts | 18 +++++-- src/router/bot-identity-resolvers.ts | 42 +++++++++++++++- tests/unit/router/adapters/linear.test.ts | 60 +++++++++++++++++++++-- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index 855bc257..42a81df3 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -121,8 +121,10 @@ export async function deleteLinearAck( 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 index 74be597e..2f4cdd08 100644 --- a/src/router/adapters/linear.ts +++ b/src/router/adapters/linear.ts @@ -14,7 +14,7 @@ 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 } from '../acknowledgments.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'; @@ -95,10 +95,18 @@ export class LinearRouterAdapter implements RouterPlatformAdapter { return PROCESSABLE_TYPES.some((t) => event.eventType.endsWith(`/${t}`)); } - async isSelfAuthored(_event: ParsedWebhookEvent, _payload: unknown): Promise { - // Linear comment self-authoring detection could be added here later. - // For now, Linear has no bot persona concept — always return false. - return false; + 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 { 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/tests/unit/router/adapters/linear.test.ts b/tests/unit/router/adapters/linear.test.ts index 58305408..e9db5e21 100644 --- a/tests/unit/router/adapters/linear.test.ts +++ b/tests/unit/router/adapters/linear.test.ts @@ -17,6 +17,7 @@ vi.mock('../../../../src/router/queue.js', () => ({ })); 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'), @@ -35,7 +36,7 @@ vi.mock('../../../../src/linear/client.js', () => ({ withLinearCredentials: vi.fn().mockImplementation((_creds: unknown, fn: () => unknown) => fn()), })); -import { postLinearAck } from '../../../../src/router/acknowledgments.js'; +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'; @@ -183,10 +184,63 @@ describe('LinearRouterAdapter', () => { }); describe('isSelfAuthored', () => { - it('always returns false (no bot persona in Linear)', async () => { + 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); });