Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions src/router/reactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -160,10 +162,31 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise<vo
await client.postReaction('', { issueId, commentId });
}

async function sendLinearReaction(_projectId: string, _payload: unknown): Promise<void> {
// 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<void> {
// Only react to Comment.create events
const p = payload as Record<string, unknown>;
if (p.type !== 'Comment' || p.action !== 'create') return;

const data = p.data as Record<string, unknown> | 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));
}
}

// ---------------------------------------------------------------------------
Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/unit/router/reactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) => fn()),
linearClient: {
createReaction: vi.fn(),
},
}));

// Mock logger
vi.mock('../../../src/utils/logging.js', () => ({
logger: {
Expand All @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
// -------------------------------------------------------------------------
Expand Down
Loading