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; } @@ -180,14 +165,13 @@ 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) - const url = `${jiraBaseUrl}/rest/api/2/issue/${job.issueKey}/comment`; - const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64'); + // 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', 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..bd0141ad --- /dev/null +++ b/src/router/platformClients.ts @@ -0,0 +1,78 @@ +/** + * Shared credential resolution and platform API header helpers for router modules. + * + * 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'; + +// --------------------------------------------------------------------------- +// 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, + }; +} 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 { + 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'); + }); +});