From af400c973a6283f5dbfead25342ea8c550a71bf8 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Feb 2026 20:13:35 +0000 Subject: [PATCH 1/2] refactor(router): extract shared credential resolution & platform API helpers --- src/router/acknowledgments.ts | 114 ++----- src/router/notifications.ts | 42 +-- src/router/platformClients.ts | 179 +++++++++++ src/router/pre-actions.ts | 20 +- src/router/reactions.ts | 45 +-- tests/unit/router/platformClients.test.ts | 357 ++++++++++++++++++++++ 6 files changed, 596 insertions(+), 161 deletions(-) create mode 100644 src/router/platformClients.ts create mode 100644 tests/unit/router/platformClients.test.ts diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index fd1f059f..3a78c9ce 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -11,14 +11,14 @@ */ import { getProjectGitHubToken } from '../config/projects.js'; -import { - findProjectById, - findProjectByRepo, - getIntegrationCredential, -} from '../config/provider.js'; -import { getJiraConfig } from '../pm/config.js'; +import { findProjectByRepo } from '../config/provider.js'; import { markdownToAdf } from '../pm/jira/adf.js'; import type { ProjectConfig } from '../types/index.js'; +import { + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from './platformClients.js'; // --------------------------------------------------------------------------- // Trello @@ -29,17 +29,13 @@ export async function postTrelloAck( cardId: string, message: string, ): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { + const creds = await resolveTrelloCredentials(projectId); + if (!creds) { console.warn('[Ack] Missing Trello credentials, skipping ack comment'); return null; } - const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${trelloApiKey}&token=${trelloToken}`; + const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -61,16 +57,10 @@ export async function deleteTrelloAck( cardId: string, commentId: string, ): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { - return; - } + const creds = await resolveTrelloCredentials(projectId); + if (!creds) return; - const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${trelloApiKey}&token=${trelloToken}`; + const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${creds.apiKey}&token=${creds.token}`; try { await fetch(url, { method: 'DELETE' }); console.log('[Ack] Trello orphan ack deleted:', commentId); @@ -92,12 +82,7 @@ export async function postGitHubAck( const url = `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; const response = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Content-Type': 'application/json', - }, + headers: resolveGitHubHeaders(token, { 'Content-Type': 'application/json' }), body: JSON.stringify({ body: message }), }); @@ -120,11 +105,7 @@ export async function deleteGitHubAck( try { await fetch(url, { method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }, + headers: resolveGitHubHeaders(token), }); console.log('[Ack] GitHub orphan ack deleted:', commentId); } catch (err) { @@ -141,27 +122,18 @@ export async function postJiraAck( issueKey: string, message: string, ): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { + const creds = await resolveJiraCredentials(projectId); + if (!creds) { console.warn('[Ack] Missing JIRA credentials, skipping ack comment'); return null; } - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); const adfBody = markdownToAdf(message); - const url = `${jiraBaseUrl}/rest/api/3/issue/${issueKey}/comment`; + const url = `${creds.baseUrl}/rest/api/3/issue/${issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${creds.auth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: adfBody }), @@ -182,26 +154,15 @@ export async function deleteJiraAck( issueKey: string, commentId: string, ): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); - const project = await findProjectById(projectId); - jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { - return; - } + const creds = await resolveJiraCredentials(projectId); + if (!creds) return; - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); - const url = `${jiraBaseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; + const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; try { await fetch(url, { method: 'DELETE', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${creds.auth}`, 'Content-Type': 'application/json', }, }); @@ -227,23 +188,12 @@ export async function resolveJiraBotAccountId(projectId: string): Promise { - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(job.projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(job.projectId, 'pm', 'token'); - } catch { + const creds = await resolveTrelloCredentials(job.projectId); + if (!creds) { console.warn('[Notifications] Missing Trello credentials in DB, skipping timeout notification'); return; } @@ -93,7 +90,7 @@ async function notifyTrelloTimeout(job: TrelloJob, info: TimeoutInfo): Promise { - let jiraEmail: string; - let jiraApiToken: string; - let jiraBaseUrl: string; - try { - jiraEmail = await getIntegrationCredential(job.projectId, 'pm', 'email'); - jiraApiToken = await getIntegrationCredential(job.projectId, 'pm', 'api_token'); - const project = await findProjectById(job.projectId); - jiraBaseUrl = project?.jira?.baseUrl ?? ''; - if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); - } catch { + const creds = await resolveJiraCredentials(job.projectId); + if (!creds) { console.warn('[Notifications] Missing JIRA credentials in DB, skipping timeout notification'); return; } @@ -182,12 +167,11 @@ async function notifyJiraTimeout(job: JiraJob, info: TimeoutInfo): Promise // Use v2 API which accepts plain text, avoiding the pm/jira/adf dependency // (the router image doesn't include pm/ modules) - const url = `${jiraBaseUrl}/rest/api/2/issue/${job.issueKey}/comment`; - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); + const url = `${creds.baseUrl}/rest/api/2/issue/${job.issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { - Authorization: `Basic ${auth}`, + Authorization: `Basic ${creds.auth}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: message }), diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts new file mode 100644 index 00000000..e8148ded --- /dev/null +++ b/src/router/platformClients.ts @@ -0,0 +1,179 @@ +/** + * Shared, credential-aware platform API helpers for router modules. + * + * Resolves credentials once per call and exposes typed methods for + * posting comments to Trello, GitHub, and JIRA. All errors are caught + * and logged — never propagated (fire-and-forget contract). + * + * Uses raw `fetch()` throughout — the router Docker image does not bundle + * `src/trello/client.ts` or `src/github/client.ts`. + */ + +import { findProjectById, getIntegrationCredential } from '../config/provider.js'; +import { getJiraConfig } from '../pm/config.js'; +import { markdownToAdf } from '../pm/jira/adf.js'; + +// --------------------------------------------------------------------------- +// Credential resolution helpers +// --------------------------------------------------------------------------- + +export interface TrelloCredentials { + apiKey: string; + token: string; +} + +export interface JiraCredentials { + email: string; + apiToken: string; + baseUrl: string; + /** Pre-computed Base64 Basic auth value: `email:apiToken` */ + auth: string; +} + +/** + * Resolve Trello credentials for a project. + * Returns `{ apiKey, token }` or `null` if credentials are missing. + */ +export async function resolveTrelloCredentials( + projectId: string, +): Promise { + try { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + const token = await getIntegrationCredential(projectId, 'pm', 'token'); + return { apiKey, token }; + } catch { + return null; + } +} + +/** + * Resolve JIRA credentials for a project. + * Returns `{ email, apiToken, baseUrl, auth }` or `null` if credentials/config are missing. + * The `auth` field is the pre-computed Base64 Basic auth string. + */ +export async function resolveJiraCredentials(projectId: string): Promise { + try { + const email = await getIntegrationCredential(projectId, 'pm', 'email'); + const apiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); + const project = await findProjectById(projectId); + const baseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; + if (!baseUrl) throw new Error('Missing JIRA base URL'); + const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); + return { email, apiToken, baseUrl, auth }; + } catch { + return null; + } +} + +/** + * Build standard GitHub API request headers for a given token. + * Used in place of the 6+ inline header objects scattered across router files. + */ +export function resolveGitHubHeaders( + token: string, + extra?: Record, +): Record { + return { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...extra, + }; +} + +// --------------------------------------------------------------------------- +// High-level platform API helpers +// --------------------------------------------------------------------------- + +/** + * Post a comment to a Trello card. + * Resolves credentials, posts, and returns the new comment ID — or `null` on any failure. + */ +export async function postTrelloComment( + projectId: string, + cardId: string, + text: string, +): Promise { + const creds = await resolveTrelloCredentials(projectId); + if (!creds) return null; + + const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!response.ok) return null; + const data = (await response.json()) as { id?: string }; + return data.id ?? null; + } catch { + return null; + } +} + +/** + * Post a comment to a GitHub issue or PR. + * Returns the new comment ID — or `null` on any failure. + */ +export async function postGitHubComment( + token: string, + repoFullName: string, + prNumber: number, + body: string, +): Promise { + const url = `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; + try { + const response = await fetch(url, { + method: 'POST', + headers: resolveGitHubHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ body }), + }); + if (!response.ok) return null; + const data = (await response.json()) as { id?: number }; + return data.id ?? null; + } catch { + return null; + } +} + +/** + * Post a comment to a JIRA issue. + * + * @param useAdf - When `true` (default), converts `body` from Markdown to ADF + * and posts to the v3 API. When `false`, posts plain text to the v2 API. + * Use `false` when the router image does not bundle the ADF converter. + * + * Returns the new comment ID — or `null` on any failure. + */ +export async function postJiraComment( + projectId: string, + issueKey: string, + body: string, + useAdf = true, +): Promise { + const creds = await resolveJiraCredentials(projectId); + if (!creds) return null; + + const apiVersion = useAdf ? '3' : '2'; + const url = `${creds.baseUrl}/rest/api/${apiVersion}/issue/${issueKey}/comment`; + const requestBody = useAdf + ? JSON.stringify({ body: markdownToAdf(body) }) + : JSON.stringify({ body }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${creds.auth}`, + 'Content-Type': 'application/json', + }, + body: requestBody, + }); + if (!response.ok) return null; + const data = (await response.json()) as { id?: string }; + return data.id ?? null; + } catch { + return null; + } +} diff --git a/src/router/pre-actions.ts b/src/router/pre-actions.ts index f37baef2..a62e8c61 100644 --- a/src/router/pre-actions.ts +++ b/src/router/pre-actions.ts @@ -1,5 +1,6 @@ import { findProjectByRepo, getIntegrationCredential } from '../config/provider.js'; import { parseRepoFullName } from '../utils/repo.js'; +import { resolveGitHubHeaders } from './platformClients.js'; import type { GitHubJob } from './queue.js'; /** @@ -25,11 +26,7 @@ async function getReviewerUsername(projectId: string, token: string): Promise { const { owner, repo } = parseRepoFullName(repoFullName); const reviewsUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`; const reviewsResponse = await fetch(reviewsUrl, { - headers: { - Authorization: `Bearer ${reviewerToken}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }, + headers: resolveGitHubHeaders(reviewerToken), }); if (!reviewsResponse.ok) { @@ -133,12 +126,7 @@ export async function addEyesReactionToPR(job: GitHubJob): Promise { const reactionUrl = `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/reactions`; const reactionResponse = await fetch(reactionUrl, { method: 'POST', - headers: { - Authorization: `Bearer ${reviewerToken}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Content-Type': 'application/json', - }, + headers: resolveGitHubHeaders(reviewerToken, { 'Content-Type': 'application/json' }), body: JSON.stringify({ content: 'eyes' }), }); diff --git a/src/router/reactions.ts b/src/router/reactions.ts index 976f1757..9defa981 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -9,12 +9,15 @@ */ import { getProjectGitHubToken } from '../config/projects.js'; -import { findProjectById, getIntegrationCredential } from '../config/provider.js'; import { type PersonaIdentities, isCascadeBot } from '../github/personas.js'; -import { getJiraConfig } from '../pm/config.js'; import { trelloClient, withTrelloCredentials } from '../trello/client.js'; import type { ProjectConfig } from '../types/index.js'; import { parseRepoFullName } from '../utils/repo.js'; +import { + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from './platformClients.js'; // In-memory JIRA CloudId cache keyed by baseUrl const jiraCloudIdCache = new Map(); @@ -75,12 +78,8 @@ async function sendTrelloReaction(projectId: string, payload: unknown): Promise< const actionId = action.id as string | undefined; if (!actionId) return; - let trelloApiKey: string; - let trelloToken: string; - try { - trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); - trelloToken = await getIntegrationCredential(projectId, 'pm', 'token'); - } catch { + const creds = await resolveTrelloCredentials(projectId); + if (!creds) { console.warn('[Reactions] Missing Trello credentials, skipping reaction'); return; } @@ -88,7 +87,7 @@ async function sendTrelloReaction(projectId: string, payload: unknown): Promise< const emoji = { shortName: 'eyes', native: '👀', unified: '1f440' }; try { - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, async () => { + await withTrelloCredentials({ apiKey: creds.apiKey, token: creds.token }, async () => { await trelloClient.addActionReaction(actionId, emoji); }); console.log('[Reactions] Trello reaction sent for action:', actionId); @@ -171,12 +170,7 @@ async function sendGitHubReaction( 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', - }, + headers: resolveGitHubHeaders(githubToken, { 'Content-Type': 'application/json' }), body: JSON.stringify({ content: 'eyes' }), }); @@ -203,33 +197,22 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise ({ + getIntegrationCredential: vi.fn(), + findProjectById: vi.fn(), +})); + +// Mock config cache (imported transitively) +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getConfig: vi.fn().mockReturnValue(null), + getProjectByBoardId: vi.fn().mockReturnValue(null), + getProjectByRepo: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + setProjectByBoardId: vi.fn(), + setProjectByRepo: vi.fn(), + invalidate: vi.fn(), + }, +})); + +import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; +import { + postGitHubComment, + postJiraComment, + postTrelloComment, + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from '../../../src/router/platformClients.js'; + +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); +const mockFindProjectById = vi.mocked(findProjectById); + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +const MOCK_CREDENTIALS: Record = { + 'pm/api_key': 'trello-key', + 'pm/token': 'trello-token', + 'pm/email': 'bot@example.com', + 'pm/api_token': 'jira-api-token', +}; + +const MOCK_PROJECT_WITH_JIRA = { + id: 'proj1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + jira: { + baseUrl: 'https://test.atlassian.net', + projectKey: 'PROJ', + statuses: {}, + labels: {}, + }, +}; + +beforeEach(() => { + mockFetch.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + mockGetIntegrationCredential.mockImplementation(async (_projectId, category, role) => { + const value = MOCK_CREDENTIALS[`${category}/${role}`]; + if (value) return value; + throw new Error(`Credential '${category}/${role}' not found`); + }); + mockFindProjectById.mockResolvedValue(MOCK_PROJECT_WITH_JIRA); +}); + +// --------------------------------------------------------------------------- +// resolveTrelloCredentials +// --------------------------------------------------------------------------- + +describe('resolveTrelloCredentials', () => { + it('returns apiKey and token on success', async () => { + const result = await resolveTrelloCredentials('proj1'); + + expect(result).not.toBeNull(); + expect(result?.apiKey).toBe('trello-key'); + expect(result?.token).toBe('trello-token'); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveTrelloCredentials('proj1'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveJiraCredentials +// --------------------------------------------------------------------------- + +describe('resolveJiraCredentials', () => { + it('returns email, apiToken, baseUrl, and pre-computed auth on success', async () => { + const result = await resolveJiraCredentials('proj1'); + + expect(result).not.toBeNull(); + expect(result?.email).toBe('bot@example.com'); + expect(result?.apiToken).toBe('jira-api-token'); + expect(result?.baseUrl).toBe('https://test.atlassian.net'); + // auth is base64 of email:apiToken + const expected = Buffer.from('bot@example.com:jira-api-token').toString('base64'); + expect(result?.auth).toBe(expected); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveJiraCredentials('proj1'); + + expect(result).toBeNull(); + }); + + it('returns null when project has no JIRA base URL', async () => { + mockFindProjectById.mockResolvedValue({ + id: 'proj1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); + + const result = await resolveJiraCredentials('proj1'); + + expect(result).toBeNull(); + }); + + it('returns null when project is not found', async () => { + mockFindProjectById.mockResolvedValue(undefined); + + const result = await resolveJiraCredentials('proj1'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveGitHubHeaders +// --------------------------------------------------------------------------- + +describe('resolveGitHubHeaders', () => { + it('returns standard GitHub API headers', () => { + const headers = resolveGitHubHeaders('ghp_token'); + + expect(headers).toEqual({ + Authorization: 'Bearer ghp_token', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }); + }); + + it('merges extra headers without overwriting standard ones', () => { + const headers = resolveGitHubHeaders('ghp_token', { 'Content-Type': 'application/json' }); + + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Authorization).toBe('Bearer ghp_token'); + }); + + it('allows overriding standard headers with extra', () => { + const headers = resolveGitHubHeaders('ghp_token', { Accept: 'text/plain' }); + + expect(headers.Accept).toBe('text/plain'); + }); +}); + +// --------------------------------------------------------------------------- +// postTrelloComment +// --------------------------------------------------------------------------- + +describe('postTrelloComment', () => { + it('posts a comment and returns the comment ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'comment-abc' }), + }); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBe('comment-abc'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('https://api.trello.com/1/cards/card1/actions/comments'); + expect(url).toContain('key=trello-key'); + expect(url).toContain('token=trello-token'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ text: 'Hello!' }); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API failure', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + }); + + it('returns null on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await postTrelloComment('proj1', 'card1', 'Hello!'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// postGitHubComment +// --------------------------------------------------------------------------- + +describe('postGitHubComment', () => { + it('posts a comment and returns the comment ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 42 }), + }); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Working on it...'); + + expect(result).toBe(42); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.github.com/repos/owner/repo/issues/5/comments'); + expect(options.method).toBe('POST'); + expect(options.headers.Authorization).toBe('Bearer ghp_token'); + expect(options.headers.Accept).toBe('application/vnd.github+json'); + expect(JSON.parse(options.body)).toEqual({ body: 'Working on it...' }); + }); + + it('returns null on API failure', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// postJiraComment +// --------------------------------------------------------------------------- + +describe('postJiraComment', () => { + it('posts an ADF comment (v3) and returns the comment ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'jira-comment-1' }), + }); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello JIRA!'); + + expect(result).toBe('jira-comment-1'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment'); + expect(options.method).toBe('POST'); + expect(options.headers.Authorization).toMatch(/^Basic /); + // body should be ADF (has type: 'doc') + const parsed = JSON.parse(options.body); + expect(parsed.body).toHaveProperty('type', 'doc'); + }); + + it('posts a plain-text comment (v2) when useAdf=false', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'jira-comment-2' }), + }); + + const result = await postJiraComment('proj1', 'PROJ-2', 'Plain text', false); + + expect(result).toBe('jira-comment-2'); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/2/issue/PROJ-2/comment'); + const parsed = JSON.parse(options.body); + expect(parsed.body).toBe('Plain text'); + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API failure', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + }); + + it('returns null on fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); + + expect(result).toBeNull(); + }); +}); From df49bcdb4dd7de7ffa56f6edb6f7901c30e58521 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Feb 2026 20:30:04 +0000 Subject: [PATCH 2/2] fix(router): remove unused high-level helpers and fix stale comment - Remove dead-code `postTrelloComment`, `postGitHubComment`, `postJiraComment` exports from platformClients.ts (zero consumers outside tests) - Remove unused `markdownToAdf` import from platformClients.ts - Remove 15 corresponding tests for the removed helpers - Fix stale comment in notifications.ts that incorrectly referenced pm/jira/adf dependency avoidance (now transitively imported) Co-Authored-By: Claude Opus 4.6 --- src/router/notifications.ts | 4 +- src/router/platformClients.ts | 107 +----------- tests/unit/router/platformClients.test.ts | 190 ---------------------- 3 files changed, 5 insertions(+), 296 deletions(-) diff --git a/src/router/notifications.ts b/src/router/notifications.ts index 83b54a4d..ec1f91ef 100644 --- a/src/router/notifications.ts +++ b/src/router/notifications.ts @@ -165,8 +165,8 @@ async function notifyJiraTimeout(job: JiraJob, info: TimeoutInfo): Promise 'Transition the issue back to the trigger status to retry.', ); - // Use v2 API which accepts plain text, avoiding the pm/jira/adf dependency - // (the router image doesn't include pm/ modules) + // Use v2 API which accepts plain text — no Markdown-to-ADF conversion needed + // for simple timeout messages const url = `${creds.baseUrl}/rest/api/2/issue/${job.issueKey}/comment`; const response = await fetch(url, { method: 'POST', diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts index e8148ded..bd0141ad 100644 --- a/src/router/platformClients.ts +++ b/src/router/platformClients.ts @@ -1,17 +1,13 @@ /** - * Shared, credential-aware platform API helpers for router modules. + * Shared credential resolution and platform API header helpers for router modules. * - * Resolves credentials once per call and exposes typed methods for - * posting comments to Trello, GitHub, and JIRA. All errors are caught - * and logged — never propagated (fire-and-forget contract). - * - * Uses raw `fetch()` throughout — the router Docker image does not bundle + * Resolves credentials once per call and returns typed objects. + * Callers use raw `fetch()` — the router Docker image does not bundle * `src/trello/client.ts` or `src/github/client.ts`. */ import { findProjectById, getIntegrationCredential } from '../config/provider.js'; import { getJiraConfig } from '../pm/config.js'; -import { markdownToAdf } from '../pm/jira/adf.js'; // --------------------------------------------------------------------------- // Credential resolution helpers @@ -80,100 +76,3 @@ export function resolveGitHubHeaders( ...extra, }; } - -// --------------------------------------------------------------------------- -// High-level platform API helpers -// --------------------------------------------------------------------------- - -/** - * Post a comment to a Trello card. - * Resolves credentials, posts, and returns the new comment ID — or `null` on any failure. - */ -export async function postTrelloComment( - projectId: string, - cardId: string, - text: string, -): Promise { - const creds = await resolveTrelloCredentials(projectId); - if (!creds) return null; - - const url = `https://api.trello.com/1/cards/${cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`; - try { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }); - if (!response.ok) return null; - const data = (await response.json()) as { id?: string }; - return data.id ?? null; - } catch { - return null; - } -} - -/** - * Post a comment to a GitHub issue or PR. - * Returns the new comment ID — or `null` on any failure. - */ -export async function postGitHubComment( - token: string, - repoFullName: string, - prNumber: number, - body: string, -): Promise { - const url = `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; - try { - const response = await fetch(url, { - method: 'POST', - headers: resolveGitHubHeaders(token, { 'Content-Type': 'application/json' }), - body: JSON.stringify({ body }), - }); - if (!response.ok) return null; - const data = (await response.json()) as { id?: number }; - return data.id ?? null; - } catch { - return null; - } -} - -/** - * Post a comment to a JIRA issue. - * - * @param useAdf - When `true` (default), converts `body` from Markdown to ADF - * and posts to the v3 API. When `false`, posts plain text to the v2 API. - * Use `false` when the router image does not bundle the ADF converter. - * - * Returns the new comment ID — or `null` on any failure. - */ -export async function postJiraComment( - projectId: string, - issueKey: string, - body: string, - useAdf = true, -): Promise { - const creds = await resolveJiraCredentials(projectId); - if (!creds) return null; - - const apiVersion = useAdf ? '3' : '2'; - const url = `${creds.baseUrl}/rest/api/${apiVersion}/issue/${issueKey}/comment`; - const requestBody = useAdf - ? JSON.stringify({ body: markdownToAdf(body) }) - : JSON.stringify({ body }); - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - body: requestBody, - }); - if (!response.ok) return null; - const data = (await response.json()) as { id?: string }; - return data.id ?? null; - } catch { - return null; - } -} diff --git a/tests/unit/router/platformClients.test.ts b/tests/unit/router/platformClients.test.ts index 6e962861..78d06cc0 100644 --- a/tests/unit/router/platformClients.test.ts +++ b/tests/unit/router/platformClients.test.ts @@ -21,9 +21,6 @@ vi.mock('../../../src/config/configCache.js', () => ({ import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; import { - postGitHubComment, - postJiraComment, - postTrelloComment, resolveGitHubHeaders, resolveJiraCredentials, resolveTrelloCredentials, @@ -168,190 +165,3 @@ describe('resolveGitHubHeaders', () => { expect(headers.Accept).toBe('text/plain'); }); }); - -// --------------------------------------------------------------------------- -// postTrelloComment -// --------------------------------------------------------------------------- - -describe('postTrelloComment', () => { - it('posts a comment and returns the comment ID', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'comment-abc' }), - }); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBe('comment-abc'); - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toContain('https://api.trello.com/1/cards/card1/actions/comments'); - expect(url).toContain('key=trello-key'); - expect(url).toContain('token=trello-token'); - expect(options.method).toBe('POST'); - expect(JSON.parse(options.body)).toEqual({ text: 'Hello!' }); - }); - - it('returns null when credentials are missing', async () => { - mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('returns null on API failure', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - }); - - it('returns null when response has no id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - }); - - it('returns null on fetch error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const result = await postTrelloComment('proj1', 'card1', 'Hello!'); - - expect(result).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// postGitHubComment -// --------------------------------------------------------------------------- - -describe('postGitHubComment', () => { - it('posts a comment and returns the comment ID', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 42 }), - }); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Working on it...'); - - expect(result).toBe(42); - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('https://api.github.com/repos/owner/repo/issues/5/comments'); - expect(options.method).toBe('POST'); - expect(options.headers.Authorization).toBe('Bearer ghp_token'); - expect(options.headers.Accept).toBe('application/vnd.github+json'); - expect(JSON.parse(options.body)).toEqual({ body: 'Working on it...' }); - }); - - it('returns null on API failure', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null when response has no id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null on fetch error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const result = await postGitHubComment('ghp_token', 'owner/repo', 5, 'Hello'); - - expect(result).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// postJiraComment -// --------------------------------------------------------------------------- - -describe('postJiraComment', () => { - it('posts an ADF comment (v3) and returns the comment ID', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'jira-comment-1' }), - }); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello JIRA!'); - - expect(result).toBe('jira-comment-1'); - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment'); - expect(options.method).toBe('POST'); - expect(options.headers.Authorization).toMatch(/^Basic /); - // body should be ADF (has type: 'doc') - const parsed = JSON.parse(options.body); - expect(parsed.body).toHaveProperty('type', 'doc'); - }); - - it('posts a plain-text comment (v2) when useAdf=false', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'jira-comment-2' }), - }); - - const result = await postJiraComment('proj1', 'PROJ-2', 'Plain text', false); - - expect(result).toBe('jira-comment-2'); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('https://test.atlassian.net/rest/api/2/issue/PROJ-2/comment'); - const parsed = JSON.parse(options.body); - expect(parsed.body).toBe('Plain text'); - }); - - it('returns null when credentials are missing', async () => { - mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('returns null on API failure', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null when response has no id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - }); - - it('returns null on fetch error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const result = await postJiraComment('proj1', 'PROJ-1', 'Hello'); - - expect(result).toBeNull(); - }); -});