From f17259d975a8fc7c69eef342d4c68b789efcdb61 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 18 Feb 2026 21:06:10 +0000 Subject: [PATCH] feat(router): move emoji reactions from worker to router for instant feedback --- src/router/index.ts | 17 ++ src/router/reactions.ts | 266 +++++++++++++++++ tests/unit/router/reactions.test.ts | 431 ++++++++++++++++++++++++++++ 3 files changed, 714 insertions(+) create mode 100644 src/router/reactions.ts create mode 100644 tests/unit/router/reactions.test.ts diff --git a/src/router/index.ts b/src/router/index.ts index ad63d74b..10eac156 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { logWebhookCall } from '../utils/webhookLogger.js'; import { type RouterProjectConfig, getProjectConfig, loadProjectConfig } from './config.js'; import { type CascadeJob, addJob, getQueueStats } from './queue.js'; +import { sendAcknowledgeReaction } from './reactions.js'; import { getActiveWorkerCount, getActiveWorkers, @@ -187,6 +188,11 @@ app.post('/trello/webhook', async (c) => { if (shouldProcess && project && cardId) { console.log('[Router] Queueing Trello job:', { actionType, cardId, projectId: project.id }); + // Fire-and-forget acknowledgment reaction — don't block the 200 response + void sendAcknowledgeReaction('trello', project.id, payload).catch((err) => + console.error('[Router] Trello reaction error:', err), + ); + const job: CascadeJob = { type: 'trello', source: 'trello', @@ -287,6 +293,12 @@ app.post('/github/webhook', async (c) => { if (shouldProcess) { console.log('[Router] Queueing GitHub job:', { eventType, repoFullName }); + // Fire-and-forget acknowledgment reaction — pass repoFullName so the + // reaction module can resolve the project and credentials. + void sendAcknowledgeReaction('github', repoFullName, payload).catch((err) => + console.error('[Router] GitHub reaction error:', err), + ); + const job: CascadeJob = { type: 'github', source: 'github', @@ -371,6 +383,11 @@ app.post('/jira/webhook', async (c) => { if (shouldProcess && project) { console.log('[Router] Queueing JIRA job:', { webhookEvent, issueKey, projectId: project.id }); + // Fire-and-forget acknowledgment reaction — don't block the 200 response + void sendAcknowledgeReaction('jira', project.id, payload).catch((err) => + console.error('[Router] JIRA reaction error:', err), + ); + const job: CascadeJob = { type: 'jira', source: 'jira', diff --git a/src/router/reactions.ts b/src/router/reactions.ts new file mode 100644 index 00000000..8715649c --- /dev/null +++ b/src/router/reactions.ts @@ -0,0 +1,266 @@ +/** + * Immediate acknowledgment reactions on webhook acceptance. + * + * Fires a platform-native reaction (💭 or 👀) on the source comment + * to signal "message received, processing" before the worker container + * even starts. Uses raw fetch() with no client library dependencies, + * following the notifications.ts pattern. + * + * Errors are always caught and logged — never propagated. + */ + +import { getProjectGitHubToken } from '../config/projects.js'; +import { findProjectByRepo, getProjectSecret } from '../config/provider.js'; + +// In-memory JIRA CloudId cache keyed by baseUrl +const jiraCloudIdCache = new Map(); + +/** + * Lightweight JIRA cloudId resolver with in-memory cache. + * Mirrors jiraClient.getCloudId() but uses standalone fetch() with explicit credentials. + */ +async function getJiraCloudId( + baseUrl: string, + email: string, + apiToken: string, +): Promise { + const cached = jiraCloudIdCache.get(baseUrl); + if (cached) return cached; + + const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); + let response: Response; + try { + response = await fetch(`${baseUrl}/_edge/tenant_info`, { + headers: { Authorization: `Basic ${auth}` }, + }); + } catch (err) { + console.warn('[Reactions] Failed to fetch JIRA cloudId:', String(err)); + return null; + } + + if (!response.ok) { + console.warn('[Reactions] JIRA tenant_info returned', response.status); + return null; + } + + const data = (await response.json()) as { cloudId?: string }; + if (!data.cloudId) { + console.warn('[Reactions] JIRA tenant_info missing cloudId'); + return null; + } + + jiraCloudIdCache.set(baseUrl, data.cloudId); + return data.cloudId; +} + +/** @internal Visible for testing only */ +export function _resetJiraCloudIdCache(): void { + jiraCloudIdCache.clear(); +} + +// --------------------------------------------------------------------------- +// Platform-specific reaction senders +// --------------------------------------------------------------------------- + +async function sendTrelloReaction(projectId: string, payload: unknown): Promise { + // Only react to commentCard actions + const p = payload as Record; + const action = p.action as Record | undefined; + if (!action || action.type !== 'commentCard') return; + + const actionId = action.id as string | undefined; + if (!actionId) return; + + let trelloApiKey: string; + let trelloToken: string; + try { + trelloApiKey = await getProjectSecret(projectId, 'TRELLO_API_KEY'); + trelloToken = await getProjectSecret(projectId, 'TRELLO_TOKEN'); + } catch { + console.warn('[Reactions] Missing Trello credentials, skipping reaction'); + return; + } + + const url = `https://api.trello.com/1/actions/${actionId}/reactions?key=${trelloApiKey}&token=${trelloToken}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shortName: 'thought_balloon', native: '💭', unified: '1f4ad' }), + }); + + if (!response.ok) { + console.warn('[Reactions] Trello reaction failed:', response.status, await response.text()); + } else { + console.log('[Reactions] Trello reaction sent for action:', actionId); + } +} + +/** + * Send a GitHub 👀 reaction on an issue comment or PR review comment. + * `repoFullName` is used to look up the project and resolve credentials. + */ +async function sendGitHubReaction(repoFullName: string, payload: unknown): Promise { + const p = payload as Record; + + const comment = p.comment as Record | undefined; + if (!comment) return; + const commentId = comment.id as number | undefined; + if (commentId === undefined) return; + + // Distinguish issue_comment from pull_request_review_comment by the presence + // of p.issue (issue_comment) vs p.pull_request (pull_request_review_comment). + const isIssueComment = typeof p.issue === 'object' && p.issue !== null; + const isPRReviewComment = typeof p.pull_request === 'object' && p.pull_request !== null; + + if (!isIssueComment && !isPRReviewComment) return; + + const project = await findProjectByRepo(repoFullName); + if (!project) { + console.warn('[Reactions] No project found for repo, skipping GitHub reaction', { + repoFullName, + }); + return; + } + + let githubToken: string; + try { + githubToken = await getProjectGitHubToken(project); + } catch { + console.warn('[Reactions] Missing GitHub token, skipping reaction'); + return; + } + + const [owner, repo] = repoFullName.split('/'); + let url: string; + if (isIssueComment) { + url = `https://api.github.com/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`; + } else { + url = `https://api.github.com/repos/${owner}/${repo}/pulls/comments/${commentId}/reactions`; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${githubToken}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content: 'eyes' }), + }); + + if (!response.ok) { + console.warn('[Reactions] GitHub reaction failed:', response.status, await response.text()); + } else { + console.log('[Reactions] GitHub reaction sent for comment:', commentId); + } +} + +async function sendJiraReaction(projectId: string, payload: unknown): Promise { + const p = payload as Record; + const issue = p.issue as Record | undefined; + const comment = p.comment as Record | undefined; + + const issueId = issue?.id as string | undefined; + const commentId = comment?.id as string | undefined; + const issueKey = issue?.key as string | undefined; + + if (!issueId || !commentId) return; + + let jiraEmail: string; + let jiraApiToken: string; + let jiraBaseUrl: string; + try { + jiraEmail = await getProjectSecret(projectId, 'JIRA_EMAIL'); + jiraApiToken = await getProjectSecret(projectId, 'JIRA_API_TOKEN'); + jiraBaseUrl = await getProjectSecret(projectId, 'JIRA_BASE_URL'); + } catch { + console.warn('[Reactions] Missing JIRA credentials, skipping reaction'); + return; + } + + const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); + + // Try the reactions API first + const cloudId = await getJiraCloudId(jiraBaseUrl, jiraEmail, jiraApiToken); + if (cloudId) { + const emojiId = 'atlassian-thought_balloon'; + const ari = `ari%3Acloud%3Ajira%3A${cloudId}%3Acomment%2F${issueId}%2F${commentId}`; + const reactionsUrl = `${jiraBaseUrl}/rest/reactions/1.0/reactions/${ari}/${emojiId}`; + const reactionResponse = await fetch(reactionsUrl, { + method: 'PUT', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + }); + + if (reactionResponse.ok) { + console.log('[Reactions] JIRA reaction sent for comment:', commentId); + return; + } + + console.warn( + '[Reactions] JIRA reactions API failed:', + reactionResponse.status, + '; falling back to comment', + ); + } + + // Fallback: post a comment + if (!issueKey) { + console.warn('[Reactions] JIRA fallback skipped: no issueKey in payload'); + return; + } + + const commentUrl = `${jiraBaseUrl}/rest/api/2/issue/${issueKey}/comment`; + const fallbackResponse = await fetch(commentUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body: '💭' }), + }); + + if (!fallbackResponse.ok) { + console.warn( + '[Reactions] JIRA fallback comment failed:', + fallbackResponse.status, + await fallbackResponse.text(), + ); + } else { + console.log('[Reactions] JIRA fallback comment posted for issue:', issueKey); + } +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Send an acknowledgment reaction for an incoming webhook. + * Dispatches to Trello (💭), GitHub (👀), or JIRA (💭) based on source. + * + * For GitHub, pass `repoFullName` as the `projectId` parameter — it will be + * used to resolve the project via `findProjectByRepo`. + * + * Fire-and-forget: errors are caught and logged, never propagated. + */ +export async function sendAcknowledgeReaction( + source: 'trello' | 'github' | 'jira', + projectId: string, + payload: unknown, +): Promise { + try { + if (source === 'trello') { + await sendTrelloReaction(projectId, payload); + } else if (source === 'github') { + await sendGitHubReaction(projectId, payload); + } else if (source === 'jira') { + await sendJiraReaction(projectId, payload); + } + } catch (err) { + console.error('[Reactions] Unexpected error sending reaction:', String(err)); + } +} diff --git a/tests/unit/router/reactions.test.ts b/tests/unit/router/reactions.test.ts new file mode 100644 index 00000000..64cf2f50 --- /dev/null +++ b/tests/unit/router/reactions.test.ts @@ -0,0 +1,431 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock config provider +vi.mock('../../../src/config/provider.js', () => ({ + getProjectSecret: vi.fn(), + findProjectByRepo: vi.fn(), +})); + +// Mock getProjectGitHubToken +vi.mock('../../../src/config/projects.js', () => ({ + getProjectGitHubToken: vi.fn(), +})); + +// Mock config cache (imported transitively) +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + getProjectByBoardId: vi.fn().mockReturnValue(null), + getProjectByRepo: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + setProjectByBoardId: vi.fn(), + setProjectByRepo: vi.fn(), + setSecrets: vi.fn(), + invalidate: vi.fn(), + }, +})); + +import { getProjectGitHubToken } from '../../../src/config/projects.js'; +import { findProjectByRepo, getProjectSecret } from '../../../src/config/provider.js'; +import { _resetJiraCloudIdCache, sendAcknowledgeReaction } from '../../../src/router/reactions.js'; + +const mockGetProjectSecret = vi.mocked(getProjectSecret); +const mockGetProjectGitHubToken = vi.mocked(getProjectGitHubToken); +const mockFindProjectByRepo = vi.mocked(findProjectByRepo); + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +const PROJECT_ID = 'test-project'; +const REPO_FULL_NAME = 'owner/repo'; + +const TRELLO_COMMENT_PAYLOAD = { + model: { id: 'board123', name: 'Test Board' }, + action: { + id: 'action123', + type: 'commentCard', + data: { card: { id: 'card123', name: 'Test Card', idShort: 1, shortLink: 'abc' } }, + }, +}; + +const GITHUB_ISSUE_COMMENT_PAYLOAD = { + action: 'created', + issue: { number: 42, title: 'Test Issue', html_url: 'https://github.com/owner/repo/issues/42' }, + comment: { + id: 99, + body: 'Hello', + html_url: 'https://github.com/owner/repo/issues/42#issuecomment-99', + user: { login: 'user' }, + }, + repository: { full_name: REPO_FULL_NAME, html_url: 'https://github.com/owner/repo' }, + sender: { login: 'user' }, +}; + +const GITHUB_PR_REVIEW_COMMENT_PAYLOAD = { + action: 'created', + pull_request: { + number: 7, + title: 'My PR', + html_url: 'https://github.com/owner/repo/pull/7', + head: { ref: 'feature/x', sha: 'abc' }, + base: { ref: 'main' }, + }, + comment: { + id: 55, + body: 'Review comment', + path: 'src/file.ts', + line: 10, + user: { login: 'reviewer' }, + html_url: 'https://github.com/owner/repo/pull/7#issuecomment-55', + }, + repository: { full_name: REPO_FULL_NAME, html_url: 'https://github.com/owner/repo' }, + sender: { login: 'reviewer' }, +}; + +const JIRA_COMMENT_PAYLOAD = { + webhookEvent: 'comment_created', + issue: { id: 'issue-id-123', key: 'PROJ-42' }, + comment: { id: 'comment-id-456' }, +}; + +describe('sendAcknowledgeReaction', () => { + beforeEach(() => { + mockFetch.mockReset(); + _resetJiraCloudIdCache(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Default credential mocks + mockGetProjectSecret.mockImplementation(async (_projectId, key) => { + if (key === 'TRELLO_API_KEY') return 'test-trello-key'; + if (key === 'TRELLO_TOKEN') return 'test-trello-token'; + if (key === 'JIRA_EMAIL') return 'bot@example.com'; + if (key === 'JIRA_API_TOKEN') return 'test-jira-token'; + if (key === 'JIRA_BASE_URL') return 'https://test.atlassian.net'; + throw new Error(`Secret '${key}' not found`); + }); + + mockGetProjectGitHubToken.mockResolvedValue('test-github-token'); + + mockFindProjectByRepo.mockResolvedValue({ + id: PROJECT_ID, + name: 'Test', + repo: REPO_FULL_NAME, + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { boardId: 'b1', lists: {}, labels: {} }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Trello + // ------------------------------------------------------------------------- + + describe('Trello reactions', () => { + it('sends 💭 reaction for commentCard action', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + await sendAcknowledgeReaction('trello', PROJECT_ID, TRELLO_COMMENT_PAYLOAD); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('https://api.trello.com/1/actions/action123/reactions'); + expect(url).toContain('key=test-trello-key'); + expect(url).toContain('token=test-trello-token'); + expect(options.method).toBe('POST'); + const body = JSON.parse(options.body); + expect(body.shortName).toBe('thought_balloon'); + expect(body.native).toBe('💭'); + }); + + it('skips reaction for non-commentCard Trello action', async () => { + const payload = { + model: { id: 'board123', name: 'Test Board' }, + action: { id: 'action456', type: 'updateCard', data: {} }, + }; + + await sendAcknowledgeReaction('trello', PROJECT_ID, payload); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips reaction when Trello credentials are missing', async () => { + mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + + await sendAcknowledgeReaction('trello', PROJECT_ID, TRELLO_COMMENT_PAYLOAD); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Missing Trello credentials'), + ); + }); + + it('logs warning on Trello API error but does not throw', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + await expect( + sendAcknowledgeReaction('trello', PROJECT_ID, TRELLO_COMMENT_PAYLOAD), + ).resolves.toBeUndefined(); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Trello reaction failed'), + 401, + 'Unauthorized', + ); + }); + + it('skips non-comment actions without fetching credentials', async () => { + const payload = { + model: { id: 'board123', name: 'Test Board' }, + action: { id: 'a1', type: 'addLabelToCard', data: {} }, + }; + + await sendAcknowledgeReaction('trello', PROJECT_ID, payload); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockGetProjectSecret).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // GitHub + // ------------------------------------------------------------------------- + + describe('GitHub reactions', () => { + it('sends 👀 reaction on issue_comment payload', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + await sendAcknowledgeReaction('github', REPO_FULL_NAME, GITHUB_ISSUE_COMMENT_PAYLOAD); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.github.com/repos/owner/repo/issues/comments/99/reactions'); + expect(options.method).toBe('POST'); + expect(options.headers.Authorization).toBe('Bearer test-github-token'); + expect(JSON.parse(options.body)).toEqual({ content: 'eyes' }); + }); + + it('sends 👀 reaction on pull_request_review_comment payload', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + await sendAcknowledgeReaction('github', REPO_FULL_NAME, GITHUB_PR_REVIEW_COMMENT_PAYLOAD); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.github.com/repos/owner/repo/pulls/comments/55/reactions'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ content: 'eyes' }); + }); + + it('skips reaction for non-comment GitHub events (e.g. check_suite)', async () => { + const payload = { + action: 'completed', + check_suite: { id: 1, status: 'completed', conclusion: 'success', pull_requests: [] }, + repository: { full_name: REPO_FULL_NAME }, + }; + + await sendAcknowledgeReaction('github', REPO_FULL_NAME, payload); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips reaction for pull_request event (no comment)', async () => { + const payload = { + action: 'opened', + number: 10, + pull_request: { + number: 10, + title: 'PR', + head: { ref: 'f', sha: 'x' }, + base: { ref: 'main' }, + }, + repository: { full_name: REPO_FULL_NAME }, + }; + + await sendAcknowledgeReaction('github', REPO_FULL_NAME, payload); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips reaction when project not found for repo', async () => { + mockFindProjectByRepo.mockResolvedValueOnce(undefined); + + await sendAcknowledgeReaction('github', REPO_FULL_NAME, GITHUB_ISSUE_COMMENT_PAYLOAD); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('No project found for repo'), + expect.objectContaining({ repoFullName: REPO_FULL_NAME }), + ); + }); + + it('skips reaction when GitHub token is missing', async () => { + mockGetProjectGitHubToken.mockRejectedValueOnce(new Error('Missing GITHUB_TOKEN')); + + await sendAcknowledgeReaction('github', REPO_FULL_NAME, GITHUB_ISSUE_COMMENT_PAYLOAD); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Missing GitHub token')); + }); + + it('logs warning on GitHub API error but does not throw', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); + + await expect( + sendAcknowledgeReaction('github', REPO_FULL_NAME, GITHUB_ISSUE_COMMENT_PAYLOAD), + ).resolves.toBeUndefined(); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('GitHub reaction failed'), + 403, + 'Forbidden', + ); + }); + }); + + // ------------------------------------------------------------------------- + // JIRA + // ------------------------------------------------------------------------- + + describe('JIRA reactions', () => { + it('sends 💭 reaction via reactions API when cloudId is available', async () => { + // First fetch: tenant_info → cloudId + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: 'cloud-abc' }), + }); + // Second fetch: PUT reaction + mockFetch.mockResolvedValueOnce({ ok: true }); + + await sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const [tenantUrl] = mockFetch.mock.calls[0]; + expect(tenantUrl).toBe('https://test.atlassian.net/_edge/tenant_info'); + + const [reactionUrl, reactionOptions] = mockFetch.mock.calls[1]; + expect(reactionUrl).toContain('/rest/reactions/1.0/reactions/'); + expect(reactionUrl).toContain('cloud-abc'); + expect(reactionUrl).toContain('issue-id-123'); + expect(reactionUrl).toContain('comment-id-456'); + expect(reactionUrl).toContain('atlassian-thought_balloon'); + expect(reactionOptions.method).toBe('PUT'); + expect(reactionOptions.headers.Authorization).toMatch(/^Basic /); + }); + + it('caches cloudId between calls', async () => { + // First call: tenant_info + reaction + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: 'cloud-xyz' }), + }); + mockFetch.mockResolvedValueOnce({ ok: true }); + + await sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD); + + // Second call: only reaction (cloudId cached) + mockFetch.mockResolvedValueOnce({ ok: true }); + await sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD); + + // tenant_info called only once across both reaction calls + const tenantCalls = mockFetch.mock.calls.filter(([url]) => + (url as string).includes('tenant_info'), + ); + expect(tenantCalls).toHaveLength(1); + }); + + it('falls back to comment when reactions API fails', async () => { + // tenant_info + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: 'cloud-abc' }), + }); + // reactions API fails + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + // fallback comment + mockFetch.mockResolvedValueOnce({ ok: true }); + + await sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD); + + expect(mockFetch).toHaveBeenCalledTimes(3); + const [fallbackUrl, fallbackOptions] = mockFetch.mock.calls[2]; + expect(fallbackUrl).toBe('https://test.atlassian.net/rest/api/2/issue/PROJ-42/comment'); + expect(fallbackOptions.method).toBe('POST'); + expect(JSON.parse(fallbackOptions.body)).toEqual({ body: '💭' }); + }); + + it('falls back to comment when cloudId fetch fails', async () => { + // tenant_info fetch fails (network error) + mockFetch.mockRejectedValueOnce(new Error('Network error')); + // fallback comment succeeds + mockFetch.mockResolvedValueOnce({ ok: true }); + + await sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD); + + const fallbackCall = mockFetch.mock.calls.find(([url]) => + (url as string).includes('/rest/api/2/issue/PROJ-42/comment'), + ); + expect(fallbackCall).toBeDefined(); + }); + + it('skips reaction when issue.id or comment.id are missing', async () => { + const payload = { webhookEvent: 'jira:issue_updated', issue: { id: 'x' } }; // no comment + + await sendAcknowledgeReaction('jira', PROJECT_ID, payload); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips reaction when JIRA credentials are missing', async () => { + mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + + await sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Missing JIRA credentials'), + ); + }); + + it('does not throw when credentials are missing', async () => { + mockGetProjectSecret.mockRejectedValue(new Error('Secret not found')); + + await expect( + sendAcknowledgeReaction('jira', PROJECT_ID, JIRA_COMMENT_PAYLOAD), + ).resolves.toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Error handling (top-level) + // ------------------------------------------------------------------------- + + describe('error handling', () => { + it('catches unexpected errors without throwing', async () => { + // Make getProjectSecret throw unexpectedly inside the inner try block + mockGetProjectSecret.mockImplementation(() => { + throw new Error('Unexpected sync error'); + }); + + await expect( + sendAcknowledgeReaction('trello', PROJECT_ID, TRELLO_COMMENT_PAYLOAD), + ).resolves.toBeUndefined(); + }); + }); +});