Skip to content

Commit 09ee1a7

Browse files
aaightCascade Bot
andauthored
feat(reactions): wire up Linear reaction for Comment.create webhook events (#1106)
Co-authored-by: Cascade Bot <bot@cascade.dev>
1 parent acfa097 commit 09ee1a7

2 files changed

Lines changed: 124 additions & 5 deletions

File tree

src/router/reactions.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
*/
1010

1111
import { getProjectGitHubToken } from '../config/projects.js';
12+
import { getIntegrationCredential } from '../config/provider.js';
1213
import { isCascadeBot, type PersonaIdentities } from '../github/personas.js';
14+
import { linearClient, withLinearCredentials } from '../linear/client.js';
1315
import { trelloClient, withTrelloCredentials } from '../trello/client.js';
1416
import type { ProjectConfig } from '../types/index.js';
1517
import { logger } from '../utils/logging.js';
@@ -160,10 +162,31 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise<vo
160162
await client.postReaction('', { issueId, commentId });
161163
}
162164

163-
async function sendLinearReaction(_projectId: string, _payload: unknown): Promise<void> {
164-
// Linear does not support emoji reactions on comments via the same API pattern
165-
// as Trello/JIRA. This is a no-op placeholder for API consistency.
166-
logger.info('[Reactions] Linear reaction skipped (not supported via webhook API)');
165+
async function sendLinearReaction(projectId: string, payload: unknown): Promise<void> {
166+
// Only react to Comment.create events
167+
const p = payload as Record<string, unknown>;
168+
if (p.type !== 'Comment' || p.action !== 'create') return;
169+
170+
const data = p.data as Record<string, unknown> | undefined;
171+
const commentId = data?.id as string | undefined;
172+
if (!commentId) return;
173+
174+
let apiKey: string;
175+
try {
176+
apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key');
177+
} catch {
178+
logger.warn('[Reactions] Missing Linear credentials, skipping reaction');
179+
return;
180+
}
181+
182+
try {
183+
await withLinearCredentials({ apiKey }, async () => {
184+
await linearClient.createReaction(commentId, '👀');
185+
});
186+
logger.info('[Reactions] Linear reaction sent for comment:', commentId);
187+
} catch (err) {
188+
logger.warn('[Reactions] Linear reaction failed:', String(err));
189+
}
167190
}
168191

169192
// ---------------------------------------------------------------------------
@@ -172,7 +195,7 @@ async function sendLinearReaction(_projectId: string, _payload: unknown): Promis
172195

173196
/**
174197
* Send an acknowledgment reaction for an incoming webhook.
175-
* Dispatches to Trello (👀), GitHub (👀), JIRA (💭), or Linear (no-op) based on source.
198+
* Dispatches to Trello (👀), GitHub (👀), JIRA (💭), or Linear (👀) based on source.
176199
*
177200
* For GitHub, pass `repoFullName` as the `projectId` parameter, along with
178201
* `personaIdentities` and the already-resolved `project`. The reaction is

tests/unit/router/reactions.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ vi.mock('../../../src/trello/client.js', () => ({
3333
},
3434
}));
3535

36+
// Mock linear client
37+
vi.mock('../../../src/linear/client.js', () => ({
38+
withLinearCredentials: vi.fn(async (_creds: unknown, fn: () => Promise<unknown>) => fn()),
39+
linearClient: {
40+
createReaction: vi.fn(),
41+
},
42+
}));
43+
3644
// Mock logger
3745
vi.mock('../../../src/utils/logging.js', () => ({
3846
logger: {
@@ -50,6 +58,7 @@ import {
5058
getIntegrationCredential,
5159
} from '../../../src/config/provider.js';
5260
import type { PersonaIdentities } from '../../../src/github/personas.js';
61+
import { linearClient, withLinearCredentials } from '../../../src/linear/client.js';
5362
import { _resetJiraCloudIdCache, sendAcknowledgeReaction } from '../../../src/router/reactions.js';
5463
import { trelloClient, withTrelloCredentials } from '../../../src/trello/client.js';
5564
import type { ProjectConfig } from '../../../src/types/index.js';
@@ -61,6 +70,8 @@ const mockFindProjectByRepo = vi.mocked(findProjectByRepo);
6170
const mockFindProjectById = vi.mocked(findProjectById);
6271
const mockAddActionReaction = vi.mocked(trelloClient.addActionReaction);
6372
const mockWithTrelloCredentials = vi.mocked(withTrelloCredentials);
73+
const mockCreateReaction = vi.mocked(linearClient.createReaction);
74+
const mockWithLinearCredentials = vi.mocked(withLinearCredentials);
6475
const mockLogger = vi.mocked(logger);
6576

6677
// Mock global fetch
@@ -146,6 +157,9 @@ describe('sendAcknowledgeReaction', () => {
146157
mockAddActionReaction.mockReset();
147158
mockWithTrelloCredentials.mockReset();
148159
mockWithTrelloCredentials.mockImplementation(async (_creds, fn) => fn());
160+
mockCreateReaction.mockReset();
161+
mockWithLinearCredentials.mockReset();
162+
mockWithLinearCredentials.mockImplementation(async (_creds, fn) => fn());
149163
_resetJiraCloudIdCache();
150164
mockLogger.info.mockReset();
151165
mockLogger.warn.mockReset();
@@ -601,6 +615,88 @@ describe('sendAcknowledgeReaction', () => {
601615
});
602616
});
603617

618+
// -------------------------------------------------------------------------
619+
// Linear
620+
// -------------------------------------------------------------------------
621+
622+
describe('Linear reactions', () => {
623+
const LINEAR_COMMENT_PAYLOAD = {
624+
type: 'Comment',
625+
action: 'create',
626+
data: { id: 'comment-linear-123' },
627+
};
628+
629+
it('sends 👀 reaction for Comment.create event', async () => {
630+
mockCreateReaction.mockResolvedValueOnce({
631+
id: 'reaction-1',
632+
emoji: '👀',
633+
user: null,
634+
createdAt: '2026-01-01T00:00:00Z',
635+
});
636+
637+
await sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD);
638+
639+
expect(mockCreateReaction).toHaveBeenCalledOnce();
640+
expect(mockCreateReaction).toHaveBeenCalledWith('comment-linear-123', '👀');
641+
});
642+
643+
it('skips reaction for non-comment Linear events (e.g. Issue.update)', async () => {
644+
const payload = { type: 'Issue', action: 'update', data: { id: 'issue-abc' } };
645+
646+
await sendAcknowledgeReaction('linear', PROJECT_ID, payload);
647+
648+
expect(mockCreateReaction).not.toHaveBeenCalled();
649+
});
650+
651+
it('skips reaction for Comment events that are not create (e.g. Comment.update)', async () => {
652+
const payload = { type: 'Comment', action: 'update', data: { id: 'comment-xyz' } };
653+
654+
await sendAcknowledgeReaction('linear', PROJECT_ID, payload);
655+
656+
expect(mockCreateReaction).not.toHaveBeenCalled();
657+
});
658+
659+
it('skips reaction when Linear credentials are missing and logs warning', async () => {
660+
mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found'));
661+
662+
await sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD);
663+
664+
expect(mockCreateReaction).not.toHaveBeenCalled();
665+
expect(mockLogger.warn).toHaveBeenCalledWith(
666+
expect.stringContaining('Missing Linear credentials'),
667+
);
668+
});
669+
670+
it('does not throw when Linear credentials are missing', async () => {
671+
mockGetIntegrationCredential.mockRejectedValue(new Error('Credential not found'));
672+
673+
await expect(
674+
sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD),
675+
).resolves.toBeUndefined();
676+
});
677+
678+
it('logs warning on Linear API error but does not throw', async () => {
679+
mockCreateReaction.mockRejectedValueOnce(new Error('Linear API error: 403'));
680+
681+
await expect(
682+
sendAcknowledgeReaction('linear', PROJECT_ID, LINEAR_COMMENT_PAYLOAD),
683+
).resolves.toBeUndefined();
684+
685+
expect(mockLogger.warn).toHaveBeenCalledWith(
686+
expect.stringContaining('Linear reaction failed'),
687+
expect.stringContaining('403'),
688+
);
689+
});
690+
691+
it('skips reaction when comment id is missing from payload data', async () => {
692+
const payload = { type: 'Comment', action: 'create', data: {} };
693+
694+
await sendAcknowledgeReaction('linear', PROJECT_ID, payload);
695+
696+
expect(mockCreateReaction).not.toHaveBeenCalled();
697+
});
698+
});
699+
604700
// -------------------------------------------------------------------------
605701
// Error handling (top-level)
606702
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)