diff --git a/src/linear/client.ts b/src/linear/client.ts index 3d798b14..8f087338 100644 --- a/src/linear/client.ts +++ b/src/linear/client.ts @@ -303,6 +303,21 @@ export const linearClient = { return mapIssue(data.issue as RawIssue); }, + async getIssueProjectId(issueId: string): Promise { + logger.debug('Fetching Linear issue project', { issueId }); + const data = await linearGraphQL<{ issue: { project?: { id?: string } | null } | null }>( + `query GetIssueProject($id: String!) { + issue(id: $id) { + project { + id + } + } + }`, + { id: issueId }, + ); + return data.issue?.project?.id ?? null; + }, + async listIssues(filter?: { teamId?: string; projectId?: string; diff --git a/src/router/adapters/linear.ts b/src/router/adapters/linear.ts index 63410353..dc0bb3ac 100644 --- a/src/router/adapters/linear.ts +++ b/src/router/adapters/linear.ts @@ -7,7 +7,7 @@ * processRouterWebhook() function. */ -import { withLinearCredentials } from '../../linear/client.js'; +import { linearClient, 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'; @@ -28,6 +28,12 @@ const PROCESSABLE_TYPES = ['Issue', 'Comment', 'IssueLabel'] as const; type ProcessableType = (typeof PROCESSABLE_TYPES)[number]; +function nestedId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const id = (value as Record).id; + return typeof id === 'string' ? id : undefined; +} + // ============================================================================ // Extended parsed event for Linear // ============================================================================ @@ -38,6 +44,16 @@ interface LinearParsedEvent extends ParsedWebhookEvent { resourceType: string; } +interface LinearProjectScopeInput { + project: RouterProjectConfig; + isCommentEvent: boolean; + workItemId: string | undefined; + data: Record; + issue: Record | undefined; + eventType: string; + teamId: string; +} + // ============================================================================ // Adapter // ============================================================================ @@ -58,9 +74,12 @@ export class LinearRouterAdapter implements RouterPlatformAdapter { return null; } - // Extract teamId from payload data for project lookup + // Extract teamId from payload data for project lookup. Linear Comment and + // IssueLabel webhooks can nest issue context under data.issue instead of + // repeating teamId at data.teamId. const data = p.data as Record; - const teamId = data.teamId as string | undefined; + const issue = data.issue as Record | undefined; + const teamId = (data.teamId as string | undefined) ?? (issue?.teamId as string | undefined); if (!teamId) { logger.debug('LinearRouterAdapter: no teamId in payload data, skipping'); @@ -84,23 +103,17 @@ export class LinearRouterAdapter implements RouterPlatformAdapter { // to a specific Linear Project, drop webhook events whose issue is not in // that project. Linear cannot scope webhooks to a project, so the filter // runs here, after team-match. - const configuredProjectId = project.linear?.projectId; - if (configuredProjectId) { - const issueProjectId = isCommentEvent - ? ((data.issue as Record | undefined)?.projectId as string | undefined) - : (data.projectId as string | undefined); - if (issueProjectId !== configuredProjectId) { - logger.info('LinearRouterAdapter: dropping event outside project scope', { - reason: issueProjectId ? 'project scope mismatch' : 'issue has no project', - configuredProjectId, - issueProjectId, - issueId: workItemId, - teamId, - projectId: project.id, - eventType, - }); - return null; - } + const matchesProjectScope = await this.matchesConfiguredProjectScope({ + project, + isCommentEvent, + workItemId, + data, + issue, + eventType, + teamId, + }); + if (!matchesProjectScope) { + return null; } return { @@ -114,6 +127,62 @@ export class LinearRouterAdapter implements RouterPlatformAdapter { }; } + private async matchesConfiguredProjectScope(input: LinearProjectScopeInput): Promise { + const configuredProjectId = input.project.linear?.projectId; + if (!configuredProjectId) return true; + + const payloadProjectId = input.isCommentEvent + ? ((input.issue?.projectId as string | undefined) ?? nestedId(input.issue?.project)) + : ((input.data.projectId as string | undefined) ?? nestedId(input.data.project)); + const issueProjectId = + payloadProjectId ?? + (input.isCommentEvent && input.workItemId + ? await this.fetchIssueProjectId(input.project.id, input.workItemId) + : undefined); + + if (issueProjectId === configuredProjectId) return true; + + logger.info('LinearRouterAdapter: dropping event outside project scope', { + reason: issueProjectId ? 'project scope mismatch' : 'issue has no project', + configuredProjectId, + issueProjectId, + issueId: input.workItemId, + teamId: input.teamId, + projectId: input.project.id, + eventType: input.eventType, + }); + return false; + } + + private async fetchIssueProjectId( + projectId: string, + issueId: string, + ): Promise { + const linearCreds = await resolveLinearCredentials(projectId); + if (!linearCreds) { + logger.warn('LinearRouterAdapter: missing Linear credentials, cannot fetch issue project', { + projectId, + issueId, + }); + return undefined; + } + + try { + return ( + (await withLinearCredentials({ apiKey: linearCreds.apiKey }, () => + linearClient.getIssueProjectId(issueId), + )) ?? undefined + ); + } catch (err) { + logger.warn('LinearRouterAdapter: failed to fetch issue project', { + error: String(err), + projectId, + issueId, + }); + return undefined; + } + } + isProcessableEvent(event: ParsedWebhookEvent): boolean { // All parsed events are processable (we filter in parseWebhook) return PROCESSABLE_TYPES.some((t) => event.eventType.endsWith(`/${t}`)); diff --git a/src/router/bot-identity-resolvers.ts b/src/router/bot-identity-resolvers.ts index 6d380d8e..b1461c34 100644 --- a/src/router/bot-identity-resolvers.ts +++ b/src/router/bot-identity-resolvers.ts @@ -80,31 +80,71 @@ export function _resetTrelloBotCache(): void { // Linear bot identity // --------------------------------------------------------------------------- +export interface LinearBotIdentity { + id: string; + name: string; + email: string; + displayName: string; +} + +const linearBotIdentityDetailsCache = new BotIdentityCache('user'); const linearBotIdentityCache = new BotIdentityCache('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. + * Resolve the Linear user identity for the bot credentials linked to a project. + * Uses the `viewer` query to fetch the authenticated user. * Cached per-project with 60s TTL. Returns null on any failure. */ -export async function resolveLinearBotUserId(projectId: string): Promise { - return linearBotIdentityCache.resolve(projectId, async () => { +export async function resolveLinearBotIdentity( + projectId: string, +): Promise { + return linearBotIdentityDetailsCache.resolve(projectId, async () => { const creds = await resolveLinearCredentials(projectId); if (!creds) return null; const response = await fetch('https://api.linear.app/graphql', { method: 'POST', headers: linearAuthHeader(creds.apiKey), - body: JSON.stringify({ query: '{ viewer { id } }' }), + body: JSON.stringify({ + query: '{ viewer { id name email displayName } }', + }), }); if (!response.ok) return null; - const data = (await response.json()) as { data?: { viewer?: { id?: string } } }; - return data.data?.viewer?.id ?? null; + const data = (await response.json()) as { + data?: { + viewer?: { + id?: string; + name?: string; + email?: string; + displayName?: string; + }; + }; + }; + const viewer = data.data?.viewer; + if (!viewer?.id) return null; + return { + id: viewer.id, + name: viewer.name ?? '', + email: viewer.email ?? '', + displayName: viewer.displayName ?? viewer.name ?? '', + }; + }); +} + +/** + * Resolve the Linear user ID for the bot credentials linked to a project. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveLinearBotUserId(projectId: string): Promise { + return linearBotIdentityCache.resolve(projectId, async () => { + const identity = await resolveLinearBotIdentity(projectId); + return identity?.id ?? null; }); } /** @internal Visible for testing only */ export function _resetLinearBotCache(): void { + linearBotIdentityDetailsCache._reset(); linearBotIdentityCache._reset(); } diff --git a/src/triggers/linear/comment-mention.ts b/src/triggers/linear/comment-mention.ts index 56d1bce8..91779d82 100644 --- a/src/triggers/linear/comment-mention.ts +++ b/src/triggers/linear/comment-mention.ts @@ -12,7 +12,7 @@ * data.issue.identifier: the issue identifier (e.g. TEAM-123) */ -import { resolveLinearBotUserId } from '../../router/bot-identity-resolvers.js'; +import { resolveLinearBotIdentity } from '../../router/bot-identity-resolvers.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; @@ -21,10 +21,32 @@ import type { LinearWebhookCommentTriggerData, LinearWebhookTriggerPayload } fro /** * Check if a Linear comment body contains an @mention for the given user ID. * Linear uses @[Display Name](userId) markdown mention syntax, where userId is - * a UUID. Checking for userId as a substring is sufficient and safe in practice. + * a UUID. Some Linear webhook payloads normalize mentions to plain @handles, so + * also compare against stable aliases derived from the authenticated bot user. */ -function hasMention(body: string, userId: string): boolean { - return body.includes(userId); +function hasMention( + body: string, + identity: { id: string; name: string; displayName: string; email: string }, +): boolean { + if (body.includes(identity.id)) return true; + + const mentionedHandles = new Set( + Array.from(body.matchAll(/@([A-Za-z0-9._-]+)/g), (match) => match[1]?.toLowerCase()).filter( + Boolean, + ), + ); + if (mentionedHandles.size === 0) return false; + + const aliases = new Set( + [identity.name, identity.displayName, identity.email.split('@')[0]] + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + ); + + for (const alias of aliases) { + if (mentionedHandles.has(alias)) return true; + } + return false; } export class LinearCommentMentionTrigger implements TriggerHandler { @@ -75,10 +97,11 @@ export class LinearCommentMentionTrigger implements TriggerHandler { return null; } - // Resolve the bot's Linear user ID via the shared cached resolver - const botUserId = await resolveLinearBotUserId(ctx.project.id); + // Resolve the bot's Linear identity via the shared cached resolver + const botIdentity = await resolveLinearBotIdentity(ctx.project.id); + const botUserId = botIdentity?.id; - if (!botUserId) { + if (!botIdentity) { logger.warn('Linear comment trigger: could not resolve bot user ID, skipping', { projectId: ctx.project.id, }); @@ -97,7 +120,7 @@ export class LinearCommentMentionTrigger implements TriggerHandler { } // Check for bot @mention in comment body - const mentionFound = hasMention(commentBody, botUserId); + const mentionFound = hasMention(commentBody, botIdentity); if (!mentionFound) { logger.info('Linear comment trigger: no @mention of bot found in comment body', { issueIdentifier, diff --git a/tests/unit/linear/client.test.ts b/tests/unit/linear/client.test.ts index 28ab89e1..925fa81d 100644 --- a/tests/unit/linear/client.test.ts +++ b/tests/unit/linear/client.test.ts @@ -114,6 +114,37 @@ describe('linearClient.createIssue — projectId passthrough', () => { }); }); +describe('linearClient.getIssueProjectId', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('returns issue project id', async () => { + const { calls } = stubFetch({ issue: { project: { id: 'P1' } } }); + + const projectId = await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.getIssueProjectId('issue-1'), + ); + + expect(projectId).toBe('P1'); + expect(calls[0].body.query).toContain('query GetIssueProject'); + expect(calls[0].body.variables).toEqual({ id: 'issue-1' }); + }); + + it('returns null when issue has no project', async () => { + stubFetch({ issue: { project: null } }); + + const projectId = await withLinearCredentials({ apiKey: 'k' }, () => + linearClient.getIssueProjectId('issue-1'), + ); + + expect(projectId).toBeNull(); + }); +}); + describe('linearClient.getTeamProjects', () => { const originalFetch = globalThis.fetch; diff --git a/tests/unit/router/adapters/linear.test.ts b/tests/unit/router/adapters/linear.test.ts index 2d371a51..1af3d623 100644 --- a/tests/unit/router/adapters/linear.test.ts +++ b/tests/unit/router/adapters/linear.test.ts @@ -33,9 +33,13 @@ vi.mock('../../../../src/utils/runLink.js', () => ({ getDashboardUrl: vi.fn().mockReturnValue(null), })); vi.mock('../../../../src/linear/client.js', () => ({ + linearClient: { + getIssueProjectId: vi.fn().mockResolvedValue(null), + }, withLinearCredentials: vi.fn().mockImplementation((_creds: unknown, fn: () => unknown) => fn()), })); +import { linearClient, withLinearCredentials } from '../../../../src/linear/client.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'; @@ -46,6 +50,8 @@ import { logger } from '../../../../src/utils/logging.js'; import { buildWorkItemRunsLink, getDashboardUrl } from '../../../../src/utils/runLink.js'; const mockLoggerInfo = vi.mocked(logger.info); +const mockGetIssueProjectId = vi.mocked(linearClient.getIssueProjectId); +const mockWithLinearCredentials = vi.mocked(withLinearCredentials); const mockProject: RouterProjectConfig = { id: 'p1', @@ -61,6 +67,11 @@ const mockTriggerRegistry = { } as unknown as TriggerRegistry; beforeEach(() => { + vi.clearAllMocks(); + mockGetIssueProjectId.mockResolvedValue(null); + mockWithLinearCredentials.mockImplementation( + (_creds: unknown, fn: () => unknown) => fn() as never, + ); vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [mockProject], fullProjects: [{ id: 'p1' } as never], @@ -148,6 +159,34 @@ describe('LinearRouterAdapter', () => { expect(result?.workItemId).toBe('issue-abc'); }); + it('returns parsed event for Comment when teamId is nested on data.issue', async () => { + const commentPayload = { + action: 'create', + type: 'Comment', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'comment-xyz', + body: '@[Cascade](user-bot-id) please update this plan', + issueId: 'issue-abc', + issue: { + id: 'issue-abc', + identifier: 'TEAM-1', + 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'); + expect(result?.projectIdentifier).toBe('team-abc-123'); + 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'); @@ -240,7 +279,6 @@ describe('LinearRouterAdapter', () => { id: 'comment-xyz', body: 'ok', issueId: 'issue-abc', - teamId: 'team-abc-123', issue: { id: 'issue-abc', teamId: 'team-abc-123', projectId: 'P1' }, }, url: 'https://linear.app/issue', @@ -250,6 +288,37 @@ describe('LinearRouterAdapter', () => { expect(result?.isCommentEvent).toBe(true); }); + it('Comment event — fetches issue project when Linear payload omits data.issue.projectId', async () => { + mockGetIssueProjectId.mockResolvedValueOnce('P1'); + const payload = { + action: 'create', + type: 'Comment', + organizationId: 'org-123', + webhookTimestamp: Date.now(), + data: { + id: 'comment-xyz', + body: '@cascade please update this', + issueId: 'issue-abc', + issue: { + id: 'issue-abc', + identifier: 'TEAM-1', + teamId: 'team-abc-123', + }, + }, + url: 'https://linear.app/issue', + }; + + const result = await adapter.parseWebhook(payload); + + expect(result).not.toBeNull(); + expect(result?.isCommentEvent).toBe(true); + expect(mockGetIssueProjectId).toHaveBeenCalledWith('issue-abc'); + expect(mockWithLinearCredentials).toHaveBeenCalledWith( + { apiKey: 'lin_api_test' }, + expect.any(Function), + ); + }); + it('Comment event — dropped when data.issue.projectId differs from configured projectId', async () => { const payload = { action: 'create', diff --git a/tests/unit/router/bot-identity-resolvers.test.ts b/tests/unit/router/bot-identity-resolvers.test.ts index 7efd4d59..98a4f078 100644 --- a/tests/unit/router/bot-identity-resolvers.test.ts +++ b/tests/unit/router/bot-identity-resolvers.test.ts @@ -32,8 +32,11 @@ vi.mock('../../../src/utils/logging.js', () => ({ import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; import { _resetJiraBotCache, + _resetLinearBotCache, _resetTrelloBotCache, resolveJiraBotAccountId, + resolveLinearBotIdentity, + resolveLinearBotUserId, resolveTrelloBotMemberId, } from '../../../src/router/bot-identity-resolvers.js'; @@ -42,6 +45,7 @@ const mockFindProjectById = vi.mocked(findProjectById); const MOCK_CREDENTIALS: Record = { 'pm/api_key': 'test-trello-key', + 'pm/linear/api_key': 'test-linear-key', 'pm/token': 'test-trello-token', 'pm/email': 'bot@example.com', 'pm/api_token': 'test-jira-token', @@ -55,7 +59,9 @@ beforeEach(() => { mockFetch.mockReset(); mockGetIntegrationCredential.mockImplementation(async (_projectId, category, _provider, role) => { - const value = MOCK_CREDENTIALS[`${category}/${role}`]; + const value = + MOCK_CREDENTIALS[`${category}/${_provider}/${role}`] ?? + MOCK_CREDENTIALS[`${category}/${role}`]; if (value) return value; throw new Error(`Credential '${category}/${role}' not found`); }); @@ -77,6 +83,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); _resetJiraBotCache(); + _resetLinearBotCache(); _resetTrelloBotCache(); }); @@ -213,3 +220,107 @@ describe('resolveTrelloBotMemberId', () => { expect(result).toBeNull(); }); }); + +describe('resolveLinearBotIdentity', () => { + it('returns user identity from viewer query', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + viewer: { + id: 'linear-bot-789', + name: 'cascade', + email: 'cascade@example.test', + displayName: 'cascade', + }, + }, + }), + }); + + const result = await resolveLinearBotIdentity('test'); + + expect(result).toEqual({ + id: 'linear-bot-789', + name: 'cascade', + email: 'cascade@example.test', + displayName: 'cascade', + }); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.linear.app/graphql'); + expect(options.headers.Authorization).toBe('test-linear-key'); + expect(options.body).toContain('viewer { id name email displayName }'); + }); + + it('caches the identity for subsequent calls', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + viewer: { + id: 'linear-bot-789', + name: 'cascade', + email: 'cascade@example.test', + displayName: 'cascade', + }, + }, + }), + }); + + const result1 = await resolveLinearBotIdentity('test'); + const result2 = await resolveLinearBotIdentity('test'); + + expect(result1?.id).toBe('linear-bot-789'); + expect(result2?.id).toBe('linear-bot-789'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('resolves user ID through the shared identity resolver', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + viewer: { + id: 'linear-bot-789', + name: 'cascade', + email: 'cascade@example.test', + displayName: 'cascade', + }, + }, + }), + }); + + const result = await resolveLinearBotUserId('test'); + + expect(result).toBe('linear-bot-789'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveLinearBotIdentity('test'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await resolveLinearBotIdentity('test'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no viewer id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { viewer: { name: 'cascade' } } }), + }); + + const result = await resolveLinearBotIdentity('test'); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/triggers/linear-comment-mention.test.ts b/tests/unit/triggers/linear-comment-mention.test.ts index c8a80c71..a822982f 100644 --- a/tests/unit/triggers/linear-comment-mention.test.ts +++ b/tests/unit/triggers/linear-comment-mention.test.ts @@ -4,10 +4,10 @@ import { mockLogger, mockTriggerCheckModule } from '../../helpers/sharedMocks.js vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); -// Mock resolveLinearBotUserId to avoid real API calls -const mockResolveLinearBotUserId = vi.fn(); +// Mock resolveLinearBotIdentity to avoid real API calls +const mockResolveLinearBotIdentity = vi.fn(); vi.mock('../../../src/router/bot-identity-resolvers.js', () => ({ - resolveLinearBotUserId: (...args: unknown[]) => mockResolveLinearBotUserId(...args), + resolveLinearBotIdentity: (...args: unknown[]) => mockResolveLinearBotIdentity(...args), })); import { LinearCommentMentionTrigger } from '../../../src/triggers/linear/comment-mention.js'; @@ -19,6 +19,12 @@ import type { TriggerContext } from '../../../src/types/index.js'; // --------------------------------------------------------------------------- const BOT_USER_ID = 'bot-user-uuid-001'; +const BOT_IDENTITY = { + id: BOT_USER_ID, + name: 'cascade', + email: 'cascade@example.test', + displayName: 'cascade', +}; const OTHER_USER_ID = 'user-other-uuid-456'; const ISSUE_IDENTIFIER = 'TEAM-99'; const ISSUE_ID = 'issue-uuid-99'; @@ -90,7 +96,7 @@ describe('LinearCommentMentionTrigger', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(checkTriggerEnabled).mockResolvedValue(true); - mockResolveLinearBotUserId.mockResolvedValue(BOT_USER_ID); + mockResolveLinearBotIdentity.mockResolvedValue(BOT_IDENTITY); trigger = new LinearCommentMentionTrigger(); }); @@ -141,6 +147,29 @@ describe('LinearCommentMentionTrigger', () => { expect(result?.agentInput.triggerCommentText).toBe(body); }); + it('returns respond-to-planning-comment result when Linear sends a plain @handle mention', async () => { + const body = '@cascade please apply the correction before splitting'; + + const result = await trigger.handle(buildCtx({ commentBody: body })); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('respond-to-planning-comment'); + expect(result?.agentInput.triggerCommentText).toBe(body); + }); + + it('matches plain @handle mentions using the bot email local-part', async () => { + mockResolveLinearBotIdentity.mockResolvedValueOnce({ + id: BOT_USER_ID, + name: 'Cascade Bot', + email: 'cascade@example.test', + displayName: 'Cascade Bot', + }); + + const result = await trigger.handle(buildCtx({ commentBody: '@cascade please respond' })); + + expect(result).not.toBeNull(); + }); + it('includes commentAuthorId in agentInput', async () => { const result = await trigger.handle(buildCtx({ commentAuthorId: OTHER_USER_ID })); @@ -175,7 +204,7 @@ describe('LinearCommentMentionTrigger', () => { }); it('returns null when bot userId cannot be resolved', async () => { - mockResolveLinearBotUserId.mockResolvedValue(null); + mockResolveLinearBotIdentity.mockResolvedValue(null); const result = await trigger.handle(buildCtx()); @@ -217,7 +246,7 @@ describe('LinearCommentMentionTrigger', () => { it('resolves botUserId using the project ID', async () => { await trigger.handle(buildCtx()); - expect(mockResolveLinearBotUserId).toHaveBeenCalledWith('proj-linear'); + expect(mockResolveLinearBotIdentity).toHaveBeenCalledWith('proj-linear'); }); it('workItemTitle is undefined (not available in comment webhook)', async () => {