diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index 0c4e8998..6fcff612 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -10,17 +10,10 @@ * Errors are always caught and logged — never propagated. */ -import { getProjectGitHubToken } from '../config/projects.js'; -import { findProjectByRepo, getIntegrationCredential } from '../config/provider.js'; -import { markdownToAdf } from '../pm/jira/adf.js'; -import type { ProjectConfig } from '../types/index.js'; -import { logger } from '../utils/logging.js'; -import { BotIdentityCache } from './bot-identity.js'; import { GitHubPlatformClient, + JiraPlatformClient, TrelloPlatformClient, - resolveJiraCredentials, - resolveTrelloCredentials, } from './platformClients/index.js'; // --------------------------------------------------------------------------- @@ -76,7 +69,7 @@ export async function deleteGitHubAck( } // --------------------------------------------------------------------------- -// JIRA +// JIRA — delegates to JiraPlatformClient (ADF via api/3) // --------------------------------------------------------------------------- export async function postJiraAck( @@ -84,31 +77,8 @@ export async function postJiraAck( issueKey: string, message: string, ): Promise { - const creds = await resolveJiraCredentials(projectId); - if (!creds) { - logger.warn('[Ack] Missing JIRA credentials, skipping ack comment'); - return null; - } - - const adfBody = markdownToAdf(message); - const url = `${creds.baseUrl}/rest/api/3/issue/${issueKey}/comment`; - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body: adfBody }), - }); - - if (!response.ok) { - logger.warn('[Ack] JIRA comment failed:', response.status, await response.text()); - return null; - } - - const data = (await response.json()) as { id?: string }; - logger.info('[Ack] JIRA ack comment posted for issue:', issueKey); - return data.id ?? null; + const client = new JiraPlatformClient(projectId); + return client.postComment(issueKey, message); } export async function deleteJiraAck( @@ -116,126 +86,28 @@ export async function deleteJiraAck( issueKey: string, commentId: string, ): Promise { - const creds = await resolveJiraCredentials(projectId); - if (!creds) return; - - const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment/${commentId}`; - try { - await fetch(url, { - method: 'DELETE', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - }); - logger.info('[Ack] JIRA orphan ack deleted:', commentId); - } catch (err) { - logger.warn('[Ack] Failed to delete JIRA orphan ack:', String(err)); - } + const client = new JiraPlatformClient(projectId); + await client.deleteComment(issueKey, commentId); } // --------------------------------------------------------------------------- -// Bot identity resolution (cached, for self-authored comment detection) +// Bot identity resolution — re-exported from bot-identity-resolvers.ts +// for backward compatibility with pm/ integrations and router/trello.ts. // --------------------------------------------------------------------------- -const jiraBotIdentityCache = new BotIdentityCache('accountId'); -const trelloBotIdentityCache = new BotIdentityCache('memberId'); - -/** - * Resolve the JIRA account ID for the bot credentials linked to a project. - * Cached per-project with 60s TTL. Returns null on any failure. - */ -export async function resolveJiraBotAccountId(projectId: string): Promise { - return jiraBotIdentityCache.resolve(projectId, async () => { - const creds = await resolveJiraCredentials(projectId); - if (!creds) return null; - - const response = await fetch(`${creds.baseUrl}/rest/api/2/myself`, { - headers: { Authorization: `Basic ${creds.auth}`, Accept: 'application/json' }, - }); - if (!response.ok) return null; - - const data = (await response.json()) as { accountId?: string }; - return data.accountId ?? null; - }); -} - -/** @internal Visible for testing only */ -export function _resetJiraBotCache(): void { - jiraBotIdentityCache._reset(); -} - -/** - * Resolve the Trello member ID for the bot credentials linked to a project. - * Cached per-project with 60s TTL. Returns null on any failure. - */ -export async function resolveTrelloBotMemberId(projectId: string): Promise { - return trelloBotIdentityCache.resolve(projectId, async () => { - const creds = await resolveTrelloCredentials(projectId); - if (!creds) return null; - - const response = await fetch( - `https://api.trello.com/1/members/me?key=${creds.apiKey}&token=${creds.token}`, - { headers: { Accept: 'application/json' } }, - ); - if (!response.ok) return null; - - const data = (await response.json()) as { id?: string }; - return data.id ?? null; - }); -} - -/** @internal Visible for testing only */ -export function _resetTrelloBotCache(): void { - trelloBotIdentityCache._reset(); -} +export { + _resetJiraBotCache, + _resetTrelloBotCache, + resolveJiraBotAccountId, + resolveTrelloBotMemberId, +} from './bot-identity-resolvers.js'; // --------------------------------------------------------------------------- -// Resolve GitHub token for router-side ack posting +// GitHub token resolution for router-side ack posting // --------------------------------------------------------------------------- -/** - * Resolve a GitHub token for posting ack comments from the router. - * Uses the implementer token since ack comments are "from" the bot. - */ -export async function resolveGitHubTokenForAck( - repoFullName: string, -): Promise<{ token: string; project: ProjectConfig } | null> { - const project = await findProjectByRepo(repoFullName); - if (!project) return null; - - try { - const token = await getProjectGitHubToken(project); - return { token, project }; - } catch { - logger.warn('[Ack] Missing GitHub token for repo:', repoFullName); - return null; - } -} - -/** - * Resolve a persona-appropriate GitHub token for ack comments. - * Returns the reviewer token for `review` agents so the ack comment - * is posted by the same persona that will run the agent (and can - * later update it via ProgressMonitor). All other agents use the - * implementer token. - */ -export async function resolveGitHubTokenForAckByAgent( - repoFullName: string, - agentType: string, -): Promise<{ token: string; project: ProjectConfig } | null> { - const project = await findProjectByRepo(repoFullName); - if (!project) return null; - - try { - if (agentType === 'review') { - const token = await getIntegrationCredential(project.id, 'scm', 'reviewer_token'); - return { token, project }; - } - const token = await getProjectGitHubToken(project); - return { token, project }; - } catch { - logger.warn('[Ack] Missing GitHub token for repo:', repoFullName); - return null; - } -} +export type { ResolvedGitHubToken } from './github-token-resolver.js'; +export { + resolveGitHubTokenForAck, + resolveGitHubTokenForAckByAgent, +} from './github-token-resolver.js'; diff --git a/src/router/bot-identity-resolvers.ts b/src/router/bot-identity-resolvers.ts new file mode 100644 index 00000000..4c72eb88 --- /dev/null +++ b/src/router/bot-identity-resolvers.ts @@ -0,0 +1,72 @@ +/** + * Bot identity resolution for self-authored comment detection. + * + * Resolves the bot account IDs / member IDs for JIRA and Trello projects, + * using a per-project TTL cache to avoid repeated API calls on every webhook. + * + * Extracted from `acknowledgments.ts` to keep that module focused on ack CRUD. + */ + +import { BotIdentityCache } from './bot-identity.js'; +import { resolveJiraCredentials, resolveTrelloCredentials } from './platformClients/index.js'; + +// --------------------------------------------------------------------------- +// JIRA bot identity +// --------------------------------------------------------------------------- + +const jiraBotIdentityCache = new BotIdentityCache('accountId'); + +/** + * Resolve the JIRA account ID for the bot credentials linked to a project. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveJiraBotAccountId(projectId: string): Promise { + return jiraBotIdentityCache.resolve(projectId, async () => { + const creds = await resolveJiraCredentials(projectId); + if (!creds) return null; + + const response = await fetch(`${creds.baseUrl}/rest/api/2/myself`, { + headers: { Authorization: `Basic ${creds.auth}`, Accept: 'application/json' }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { accountId?: string }; + return data.accountId ?? null; + }); +} + +/** @internal Visible for testing only */ +export function _resetJiraBotCache(): void { + jiraBotIdentityCache._reset(); +} + +// --------------------------------------------------------------------------- +// Trello bot identity +// --------------------------------------------------------------------------- + +const trelloBotIdentityCache = new BotIdentityCache('memberId'); + +/** + * Resolve the Trello member ID for the bot credentials linked to a project. + * Cached per-project with 60s TTL. Returns null on any failure. + */ +export async function resolveTrelloBotMemberId(projectId: string): Promise { + return trelloBotIdentityCache.resolve(projectId, async () => { + const creds = await resolveTrelloCredentials(projectId); + if (!creds) return null; + + const response = await fetch( + `https://api.trello.com/1/members/me?key=${creds.apiKey}&token=${creds.token}`, + { headers: { Accept: 'application/json' } }, + ); + if (!response.ok) return null; + + const data = (await response.json()) as { id?: string }; + return data.id ?? null; + }); +} + +/** @internal Visible for testing only */ +export function _resetTrelloBotCache(): void { + trelloBotIdentityCache._reset(); +} diff --git a/src/router/config.ts b/src/router/config.ts index a601f67a..2aa23db0 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -35,12 +35,34 @@ export interface RouterConfig { emailScheduleIntervalMs: number; } +// --------------------------------------------------------------------------- +// Cached project config — 5s TTL to eliminate ~10 redundant DB queries per +// webhook event across parseWebhook / isSelfAuthored / resolveProject / +// dispatchWithCredentials calls in the adapter chain. +// --------------------------------------------------------------------------- + +const PROJECT_CONFIG_TTL_MS = 5_000; + +let _projectConfigCache: { projects: RouterProjectConfig[]; fullProjects: ProjectConfig[] } | null = + null; +let _projectConfigExpiresAt = 0; + +/** @internal Visible for testing only */ +export function _resetProjectConfigCache(): void { + _projectConfigCache = null; + _projectConfigExpiresAt = 0; +} + export async function loadProjectConfig(): Promise<{ projects: RouterProjectConfig[]; fullProjects: ProjectConfig[]; }> { + if (_projectConfigCache && Date.now() < _projectConfigExpiresAt) { + return _projectConfigCache; + } + const config: CascadeConfig = await loadConfig(); - return { + const result = { projects: config.projects.map((p) => { const trelloConfig = getTrelloConfig(p); const jiraConfig = getJiraConfig(p); @@ -65,6 +87,11 @@ export async function loadProjectConfig(): Promise<{ }), fullProjects: config.projects, }; + + _projectConfigCache = result; + _projectConfigExpiresAt = Date.now() + PROJECT_CONFIG_TTL_MS; + + return result; } // Router runtime config from environment diff --git a/src/router/github-token-resolver.ts b/src/router/github-token-resolver.ts new file mode 100644 index 00000000..d82c6bed --- /dev/null +++ b/src/router/github-token-resolver.ts @@ -0,0 +1,65 @@ +/** + * GitHub token resolution for router-side acknowledgment comment posting. + * + * Extracted from `acknowledgments.ts` to keep that module focused on ack CRUD. + * The GitHub adapter (`adapters/github.ts`) is the primary consumer, but this + * is also re-exported through `acknowledgments.ts` for backward compatibility + * with any external callers. + */ + +import { getProjectGitHubToken } from '../config/projects.js'; +import { findProjectByRepo, getIntegrationCredential } from '../config/provider.js'; +import type { ProjectConfig } from '../types/index.js'; +import { logger } from '../utils/logging.js'; + +/** Return type for resolved GitHub credentials */ +export interface ResolvedGitHubToken { + token: string; + project: ProjectConfig; +} + +/** + * Resolve a GitHub token for posting ack comments from the router. + * Uses the implementer token since ack comments are "from" the bot. + */ +export async function resolveGitHubTokenForAck( + repoFullName: string, +): Promise { + const project = await findProjectByRepo(repoFullName); + if (!project) return null; + + try { + const token = await getProjectGitHubToken(project); + return { token, project }; + } catch { + logger.warn('[Ack] Missing GitHub token for repo:', repoFullName); + return null; + } +} + +/** + * Resolve a persona-appropriate GitHub token for ack comments. + * Returns the reviewer token for `review` agents so the ack comment + * is posted by the same persona that will run the agent (and can + * later update it via ProgressMonitor). All other agents use the + * implementer token. + */ +export async function resolveGitHubTokenForAckByAgent( + repoFullName: string, + agentType: string, +): Promise { + const project = await findProjectByRepo(repoFullName); + if (!project) return null; + + try { + if (agentType === 'review') { + const token = await getIntegrationCredential(project.id, 'scm', 'reviewer_token'); + return { token, project }; + } + const token = await getProjectGitHubToken(project); + return { token, project }; + } catch { + logger.warn('[Ack] Missing GitHub token for repo:', repoFullName); + return null; + } +} diff --git a/src/router/platformClients/jira.ts b/src/router/platformClients/jira.ts index 2445bb49..115e1d62 100644 --- a/src/router/platformClients/jira.ts +++ b/src/router/platformClients/jira.ts @@ -1,7 +1,11 @@ /** * JIRA platform client for posting/deleting comments and reactions via the JIRA REST API. + * + * Comments are posted using the JIRA REST API v3 with Atlassian Document Format (ADF) bodies + * so that rich text (bold, code, lists) renders correctly in JIRA Cloud. */ +import { markdownToAdf } from '../../pm/jira/adf.js'; import { logger } from '../../utils/logging.js'; import { resolveJiraCredentials } from './credentials.js'; import type { PlatformCommentClient } from './types.js'; @@ -25,14 +29,15 @@ export class JiraPlatformClient implements PlatformCommentClient { } try { - const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment`; + const adfBody = markdownToAdf(message); + const url = `${creds.baseUrl}/rest/api/3/issue/${issueKey}/comment`; const response = await fetch(url, { method: 'POST', headers: { Authorization: `Basic ${creds.auth}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ body: message }), + body: JSON.stringify({ body: adfBody }), }); if (!response.ok) { diff --git a/tests/unit/router/acknowledgments.test.ts b/tests/unit/router/acknowledgments.test.ts index 90e51fc4..3e7e1ea2 100644 --- a/tests/unit/router/acknowledgments.test.ts +++ b/tests/unit/router/acknowledgments.test.ts @@ -398,7 +398,7 @@ describe('deleteJiraAck', () => { await deleteJiraAck('test', 'PROJ-42', 'jira-comment-456'); expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to delete JIRA orphan ack'), + expect.stringContaining('Failed to delete JIRA comment'), expect.any(String), ); }); diff --git a/tests/unit/router/bot-identity-resolvers.test.ts b/tests/unit/router/bot-identity-resolvers.test.ts new file mode 100644 index 00000000..cb7a2bb2 --- /dev/null +++ b/tests/unit/router/bot-identity-resolvers.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock config provider for DB secret resolution +vi.mock('../../../src/config/provider.js', () => ({ + 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(), + }, +})); + +// Mock logger +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { findProjectById, getIntegrationCredential } from '../../../src/config/provider.js'; +import { + _resetJiraBotCache, + _resetTrelloBotCache, + resolveJiraBotAccountId, + resolveTrelloBotMemberId, +} from '../../../src/router/bot-identity-resolvers.js'; + +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); +const mockFindProjectById = vi.mocked(findProjectById); + +const MOCK_CREDENTIALS: Record = { + 'pm/api_key': 'test-trello-key', + 'pm/token': 'test-trello-token', + 'pm/email': 'bot@example.com', + 'pm/api_token': 'test-jira-token', +}; + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); + + 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({ + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + jira: { + baseUrl: 'https://test.atlassian.net', + projectKey: 'PROJ', + statuses: {}, + labels: {}, + }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + _resetJiraBotCache(); + _resetTrelloBotCache(); +}); + +describe('resolveJiraBotAccountId', () => { + it('returns account ID from /rest/api/2/myself', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ accountId: 'jira-bot-123' }), + }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBe('jira-bot-123'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://test.atlassian.net/rest/api/2/myself'); + expect(options.headers.Authorization).toMatch(/^Basic /); + }); + + it('caches the result for subsequent calls', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ accountId: 'jira-bot-123' }), + }); + + const result1 = await resolveJiraBotAccountId('test'); + const result2 = await resolveJiraBotAccountId('test'); + + expect(result1).toBe('jira-bot-123'); + expect(result2).toBe('jira-bot-123'); + expect(mockFetch).toHaveBeenCalledOnce(); // Only one API call + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null when JIRA base URL is missing', async () => { + mockFindProjectById.mockResolvedValue({ + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no accountId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await resolveJiraBotAccountId('test'); + + expect(result).toBeNull(); + }); +}); + +describe('resolveTrelloBotMemberId', () => { + it('returns member ID from /1/members/me', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'trello-bot-456' }), + }); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBe('trello-bot-456'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('https://api.trello.com/1/members/me'); + expect(url).toContain('key=test-trello-key'); + expect(url).toContain('token=test-trello-token'); + }); + + it('caches the result for subsequent calls', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'trello-bot-456' }), + }); + + const result1 = await resolveTrelloBotMemberId('test'); + const result2 = await resolveTrelloBotMemberId('test'); + + expect(result1).toBe('trello-bot-456'); + expect(result2).toBe('trello-bot-456'); + expect(mockFetch).toHaveBeenCalledOnce(); // Only one API call + }); + + it('returns null when credentials are missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns null on API error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }); + + const result = await resolveTrelloBotMemberId('test'); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts index 52760745..55ebbf6f 100644 --- a/tests/unit/router/config.test.ts +++ b/tests/unit/router/config.test.ts @@ -17,7 +17,11 @@ vi.mock('../../../src/config/configCache.js', () => ({ })); import { loadConfig } from '../../../src/config/provider.js'; -import { loadProjectConfig, routerConfig } from '../../../src/router/config.js'; +import { + _resetProjectConfigCache, + loadProjectConfig, + routerConfig, +} from '../../../src/router/config.js'; const mockLoadConfig = vi.mocked(loadConfig); @@ -152,7 +156,7 @@ describe('loadProjectConfig', () => { expect(result.projects[0].pmType).toBe('trello'); }); - it('always fetches fresh config from DB', async () => { + it('caches config for subsequent calls within the TTL window', async () => { const innerMock = vi.fn().mockResolvedValue({ projects: [ { @@ -177,10 +181,19 @@ describe('loadProjectConfig', () => { }, })); - const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const { loadProjectConfig: freshLoad, _resetProjectConfigCache: resetCache } = await import( + '../../../src/router/config.js' + ); + + // First call fetches from DB await freshLoad(); + // Second call within TTL should use cache await freshLoad(); + expect(innerMock).toHaveBeenCalledTimes(1); + // After cache reset, next call fetches from DB again + resetCache(); + await freshLoad(); expect(innerMock).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/unit/router/github-token-resolver.test.ts b/tests/unit/router/github-token-resolver.test.ts new file mode 100644 index 00000000..df433069 --- /dev/null +++ b/tests/unit/router/github-token-resolver.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock config provider for DB secret resolution +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: 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: { + 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(), + }, +})); + +// Mock logger +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { getProjectGitHubToken } from '../../../src/config/projects.js'; +import { findProjectByRepo, getIntegrationCredential } from '../../../src/config/provider.js'; +import { + resolveGitHubTokenForAck, + resolveGitHubTokenForAckByAgent, +} from '../../../src/router/github-token-resolver.js'; + +const mockGetIntegrationCredential = vi.mocked(getIntegrationCredential); +const mockGetProjectGitHubToken = vi.mocked(getProjectGitHubToken); +const mockFindProjectByRepo = vi.mocked(findProjectByRepo); + +const MOCK_CREDENTIALS: Record = { + 'pm/api_key': 'test-trello-key', + 'pm/token': 'test-trello-token', + 'pm/email': 'bot@example.com', + 'pm/api_token': 'test-jira-token', +}; + +beforeEach(() => { + mockGetIntegrationCredential.mockImplementation(async (_projectId, category, role) => { + const value = MOCK_CREDENTIALS[`${category}/${role}`]; + if (value) return value; + throw new Error(`Credential '${category}/${role}' not found`); + }); + mockGetProjectGitHubToken.mockResolvedValue('test-github-token'); + mockFindProjectByRepo.mockResolvedValue({ + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { boardId: 'b1', lists: {}, labels: {} }, + }); +}); + +describe('resolveGitHubTokenForAck', () => { + it('returns token and project when both are found', async () => { + const result = await resolveGitHubTokenForAck('owner/repo'); + + expect(result).not.toBeNull(); + expect(result?.token).toBe('test-github-token'); + expect(result?.project.id).toBe('test'); + }); + + it('returns null when project is not found', async () => { + mockFindProjectByRepo.mockResolvedValue(undefined); + + const result = await resolveGitHubTokenForAck('unknown/repo'); + + expect(result).toBeNull(); + }); + + it('returns null when GitHub token is missing', async () => { + mockGetProjectGitHubToken.mockRejectedValue(new Error('Missing token')); + + const result = await resolveGitHubTokenForAck('owner/repo'); + + expect(result).toBeNull(); + }); +}); + +describe('resolveGitHubTokenForAckByAgent', () => { + it('returns reviewer token for review agent type', async () => { + mockGetIntegrationCredential.mockImplementation(async (_projectId, category, role) => { + if (category === 'scm' && role === 'reviewer_token') return 'test-reviewer-token'; + const value = MOCK_CREDENTIALS[`${category}/${role}`]; + if (value) return value; + throw new Error(`Credential '${category}/${role}' not found`); + }); + + const result = await resolveGitHubTokenForAckByAgent('owner/repo', 'review'); + + expect(result).not.toBeNull(); + expect(result?.token).toBe('test-reviewer-token'); + expect(result?.project.id).toBe('test'); + expect(mockGetProjectGitHubToken).not.toHaveBeenCalled(); + }); + + it('returns implementer token for non-review agent types', async () => { + const result = await resolveGitHubTokenForAckByAgent('owner/repo', 'implementation'); + + expect(result).not.toBeNull(); + expect(result?.token).toBe('test-github-token'); + expect(result?.project.id).toBe('test'); + expect(mockGetProjectGitHubToken).toHaveBeenCalled(); + }); + + it('returns null when project is not found', async () => { + mockFindProjectByRepo.mockResolvedValue(undefined); + + const result = await resolveGitHubTokenForAckByAgent('unknown/repo', 'review'); + + expect(result).toBeNull(); + }); + + it('returns null when reviewer token is missing', async () => { + mockGetIntegrationCredential.mockRejectedValue(new Error('not found')); + + const result = await resolveGitHubTokenForAckByAgent('owner/repo', 'review'); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/router/notifications.test.ts b/tests/unit/router/notifications.test.ts index ef09d368..fc90de43 100644 --- a/tests/unit/router/notifications.test.ts +++ b/tests/unit/router/notifications.test.ts @@ -351,7 +351,7 @@ describe('notifyTimeout', () => { expect(mockFetch).toHaveBeenCalledOnce(); const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('https://test.atlassian.net/rest/api/2/issue/DAM-1/comment'); + expect(url).toBe('https://test.atlassian.net/rest/api/3/issue/DAM-1/comment'); expect(options.method).toBe('POST'); expect(options.headers.Authorization).toMatch(/^Basic /); expect(JSON.parse(options.body)).toHaveProperty('body'); // ADF document