diff --git a/src/router/reactions.ts b/src/router/reactions.ts index 23a1d8df..e47e8085 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -9,7 +9,9 @@ */ import { getProjectGitHubToken } from '../config/projects.js'; +import { getIntegrationCredential } from '../config/provider.js'; import { isCascadeBot, type PersonaIdentities } from '../github/personas.js'; +import { linearClient, withLinearCredentials } from '../linear/client.js'; import { trelloClient, withTrelloCredentials } from '../trello/client.js'; import type { ProjectConfig } from '../types/index.js'; import { logger } from '../utils/logging.js'; @@ -160,10 +162,31 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise { - // Linear does not support emoji reactions on comments via the same API pattern - // as Trello/JIRA. This is a no-op placeholder for API consistency. - logger.info('[Reactions] Linear reaction skipped (not supported via webhook API)'); +async function sendLinearReaction(projectId: string, payload: unknown): Promise { + // Only react to Comment.create events + const p = payload as Record; + if (p.type !== 'Comment' || p.action !== 'create') return; + + const data = p.data as Record | undefined; + const commentId = data?.id as string | undefined; + if (!commentId) return; + + let apiKey: string; + try { + apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + } catch { + logger.warn('[Reactions] Missing Linear credentials, skipping reaction'); + return; + } + + try { + await withLinearCredentials({ apiKey }, async () => { + await linearClient.createReaction(commentId, '👀'); + }); + logger.info('[Reactions] Linear reaction sent for comment:', commentId); + } catch (err) { + logger.warn('[Reactions] Linear reaction failed:', String(err)); + } } // --------------------------------------------------------------------------- @@ -172,7 +195,7 @@ async function sendLinearReaction(_projectId: string, _payload: unknown): Promis /** * Send an acknowledgment reaction for an incoming webhook. - * Dispatches to Trello (👀), GitHub (👀), JIRA (💭), or Linear (no-op) based on source. + * Dispatches to Trello (👀), GitHub (👀), JIRA (💭), or Linear (👀) based on source. * * For GitHub, pass `repoFullName` as the `projectId` parameter, along with * `personaIdentities` and the already-resolved `project`. The reaction is diff --git a/tests/unit/router/reactions.test.ts b/tests/unit/router/reactions.test.ts index 3fc0f55e..c23b7917 100644 --- a/tests/unit/router/reactions.test.ts +++ b/tests/unit/router/reactions.test.ts @@ -33,6 +33,14 @@ vi.mock('../../../src/trello/client.js', () => ({ }, })); +// Mock linear client +vi.mock('../../../src/linear/client.js', () => ({ + withLinearCredentials: vi.fn(async (_creds: unknown, fn: () => Promise) => fn()), + linearClient: { + createReaction: vi.fn(), + }, +})); + // Mock logger vi.mock('../../../src/utils/logging.js', () => ({ logger: { @@ -50,6 +58,7 @@ import { getIntegrationCredential, } from '../../../src/config/provider.js'; import type { PersonaIdentities } from '../../../src/github/personas.js'; +import { linearClient, withLinearCredentials } from '../../../src/linear/client.js'; import { _resetJiraCloudIdCache, sendAcknowledgeReaction } from '../../../src/router/reactions.js'; import { trelloClient, withTrelloCredentials } from '../../../src/trello/client.js'; import type { ProjectConfig } from '../../../src/types/index.js'; @@ -61,6 +70,8 @@ const mockFindProjectByRepo = vi.mocked(findProjectByRepo); const mockFindProjectById = vi.mocked(findProjectById); const mockAddActionReaction = vi.mocked(trelloClient.addActionReaction); const mockWithTrelloCredentials = vi.mocked(withTrelloCredentials); +const mockCreateReaction = vi.mocked(linearClient.createReaction); +const mockWithLinearCredentials = vi.mocked(withLinearCredentials); const mockLogger = vi.mocked(logger); // Mock global fetch @@ -146,6 +157,9 @@ describe('sendAcknowledgeReaction', () => { mockAddActionReaction.mockReset(); mockWithTrelloCredentials.mockReset(); mockWithTrelloCredentials.mockImplementation(async (_creds, fn) => fn()); + mockCreateReaction.mockReset(); + mockWithLinearCredentials.mockReset(); + mockWithLinearCredentials.mockImplementation(async (_creds, fn) => fn()); _resetJiraCloudIdCache(); mockLogger.info.mockReset(); mockLogger.warn.mockReset(); @@ -601,6 +615,88 @@ describe('sendAcknowledgeReaction', () => { }); }); + // ------------------------------------------------------------------------- + // Linear + // ------------------------------------------------------------------------- + + describe('Linear reactions', () => { + const LINEAR_COMMENT_PAYLOAD = { + type: 'Comment', + action: 'create', + data: { id: 'comment-linear-123' }, + }; + + it('sends 👀 reaction for Comment.create event', async () => { + mockCreateReaction.mockResolvedValueOnce({ + id: 'reaction-1', + emoji: '👀', + user: null, + createdAt: '2026-01-01T00:00:00Z', + }); + + await sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD); + + expect(mockCreateReaction).toHaveBeenCalledOnce(); + expect(mockCreateReaction).toHaveBeenCalledWith('comment-linear-123', '👀'); + }); + + it('skips reaction for non-comment Linear events (e.g. Issue.update)', async () => { + const payload = { type: 'Issue', action: 'update', data: { id: 'issue-abc' } }; + + await sendAcknowledgeReaction('linear', PROJECT_ID, payload); + + expect(mockCreateReaction).not.toHaveBeenCalled(); + }); + + it('skips reaction for Comment events that are not create (e.g. Comment.update)', async () => { + const payload = { type: 'Comment', action: 'update', data: { id: 'comment-xyz' } }; + + await sendAcknowledgeReaction('linear', PROJECT_ID, payload); + + expect(mockCreateReaction).not.toHaveBeenCalled(); + }); + + it('skips reaction when Linear credentials are missing and logs warning', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); + + await sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD); + + expect(mockCreateReaction).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Missing Linear credentials'), + ); + }); + + it('does not throw when Linear credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found')); + + await expect( + sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD), + ).resolves.toBeUndefined(); + }); + + it('logs warning on Linear API error but does not throw', async () => { + mockCreateReaction.mockRejectedValueOnce(new Error('Linear API error: 403')); + + await expect( + sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD), + ).resolves.toBeUndefined(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Linear reaction failed'), + expect.stringContaining('403'), + ); + }); + + it('skips reaction when comment id is missing from payload data', async () => { + const payload = { type: 'Comment', action: 'create', data: {} }; + + await sendAcknowledgeReaction('linear', PROJECT_ID, payload); + + expect(mockCreateReaction).not.toHaveBeenCalled(); + }); + }); + // ------------------------------------------------------------------------- // Error handling (top-level) // -------------------------------------------------------------------------