diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index c0fefaeb..0c4e8998 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -21,7 +21,7 @@ import { TrelloPlatformClient, resolveJiraCredentials, resolveTrelloCredentials, -} from './platformClients.js'; +} from './platformClients/index.js'; // --------------------------------------------------------------------------- // Trello diff --git a/src/router/adapters/jira.ts b/src/router/adapters/jira.ts index fae60465..cff4f0e1 100644 --- a/src/router/adapters/jira.ts +++ b/src/router/adapters/jira.ts @@ -15,7 +15,7 @@ import { extractJiraContext, generateAckMessage } from '../ackMessageGenerator.j import { postJiraAck, resolveJiraBotAccountId } from '../acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from '../config.js'; import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; -import { resolveJiraCredentials } from '../platformClients.js'; +import { resolveJiraCredentials } from '../platformClients/index.js'; import type { CascadeJob, JiraJob } from '../queue.js'; import { sendAcknowledgeReaction } from '../reactions.js'; diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index a215b6c1..cb20ea66 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -15,7 +15,7 @@ import { extractTrelloContext, generateAckMessage } from '../ackMessageGenerator import { postTrelloAck } from '../acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from '../config.js'; import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; -import { resolveTrelloCredentials } from '../platformClients.js'; +import { resolveTrelloCredentials } from '../platformClients/index.js'; import type { CascadeJob, TrelloJob } from '../queue.js'; import { sendAcknowledgeReaction } from '../reactions.js'; import { diff --git a/src/router/notifications.ts b/src/router/notifications.ts index d2284ba2..80d7d7c1 100644 --- a/src/router/notifications.ts +++ b/src/router/notifications.ts @@ -5,7 +5,7 @@ import { GitHubPlatformClient, JiraPlatformClient, TrelloPlatformClient, -} from './platformClients.js'; +} from './platformClients/index.js'; import type { CascadeJob, GitHubJob, JiraJob, TrelloJob } from './queue.js'; /** diff --git a/src/router/platformClients.ts b/src/router/platformClients.ts deleted file mode 100644 index be57c7ef..00000000 --- a/src/router/platformClients.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * 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`. - * - * Also exports `PlatformCommentClient` — a unified abstraction that eliminates - * the repeated "resolve creds → build URL → fetch → log" pattern across - * acknowledgments.ts, notifications.ts, and reactions.ts. - */ - -import { findProjectById, getIntegrationCredential } from '../config/provider.js'; -import type { JiraCredentials } from '../jira/types.js'; -import { getJiraConfig } from '../pm/config.js'; -import type { TrelloCredentials } from '../trello/types.js'; -import { logger } from '../utils/logging.js'; - -// --------------------------------------------------------------------------- -// Credential resolution helpers -// --------------------------------------------------------------------------- - -export type { TrelloCredentials }; - -/** Extends JiraCredentials with a pre-computed Base64 Basic auth header value. */ -export interface JiraCredentialsWithAuth extends JiraCredentials { - /** 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, - }; -} - -// --------------------------------------------------------------------------- -// PlatformCommentClient — unified abstraction for cross-platform comments -// --------------------------------------------------------------------------- - -/** - * Unified interface for posting and deleting comments and reactions across - * GitHub and JIRA. Implementations are fire-and-forget safe — they never - * throw; all errors (including network failures) are caught and logged internally. - */ -export interface PlatformCommentClient { - /** - * Post a comment. Returns the new comment's ID (string or number) on - * success, or `null` on any failure. - */ - postComment(target: string, message: string): Promise; - - /** - * Delete a previously-posted comment by ID. - * Silently returns on missing credentials or any failure. - */ - deleteComment(target: string, commentId: string | number): Promise; - - /** - * Post a reaction on a comment / action. - * Silently returns on missing credentials or any failure. - */ - postReaction?(target: string, reactionPayload: unknown): Promise; -} - -// --------------------------------------------------------------------------- -// TrelloPlatformClient -// --------------------------------------------------------------------------- - -export class TrelloPlatformClient implements PlatformCommentClient { - constructor(private readonly projectId: string) {} - - async postComment(cardId: string, message: string): Promise { - const creds = await resolveTrelloCredentials(this.projectId); - if (!creds) { - logger.warn('[PlatformClient] Missing Trello credentials, skipping comment'); - return null; - } - - try { - 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' }, - body: JSON.stringify({ text: message }), - }); - - if (!response.ok) { - logger.warn( - '[PlatformClient] Trello comment failed:', - response.status, - await response.text(), - ); - return null; - } - - const data = (await response.json()) as { id?: string }; - logger.info('[PlatformClient] Trello comment posted for card:', cardId); - return data.id ?? null; - } catch (err) { - logger.warn('[PlatformClient] Failed to post Trello comment:', String(err)); - return null; - } - } - - async deleteComment(cardId: string, commentId: string): Promise { - const creds = await resolveTrelloCredentials(this.projectId); - if (!creds) return; - - const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${creds.apiKey}&token=${creds.token}`; - try { - await fetch(url, { method: 'DELETE' }); - logger.info('[PlatformClient] Trello comment deleted:', commentId); - } catch (err) { - logger.warn('[PlatformClient] Failed to delete Trello comment:', String(err)); - } - } -} - -// --------------------------------------------------------------------------- -// GitHubPlatformClient -// --------------------------------------------------------------------------- - -export class GitHubPlatformClient implements PlatformCommentClient { - constructor( - private readonly repoFullName: string, - private readonly token: string, - ) {} - - async postComment(prNumber: string | number, message: string): Promise { - try { - const url = `https://api.github.com/repos/${this.repoFullName}/issues/${prNumber}/comments`; - const response = await fetch(url, { - method: 'POST', - headers: resolveGitHubHeaders(this.token, { 'Content-Type': 'application/json' }), - body: JSON.stringify({ body: message }), - }); - - if (!response.ok) { - logger.warn( - '[PlatformClient] GitHub comment failed:', - response.status, - await response.text(), - ); - return null; - } - - const data = (await response.json()) as { id?: number }; - logger.info('[PlatformClient] GitHub comment posted for PR:', prNumber); - return data.id ?? null; - } catch (err) { - logger.warn('[PlatformClient] Failed to post GitHub comment:', String(err)); - return null; - } - } - - async deleteComment(_target: string, commentId: number): Promise { - const url = `https://api.github.com/repos/${this.repoFullName}/issues/comments/${commentId}`; - try { - await fetch(url, { - method: 'DELETE', - headers: resolveGitHubHeaders(this.token), - }); - logger.info('[PlatformClient] GitHub comment deleted:', commentId); - } catch (err) { - logger.warn('[PlatformClient] Failed to delete GitHub comment:', String(err)); - } - } -} - -// --------------------------------------------------------------------------- -// JiraPlatformClient -// --------------------------------------------------------------------------- - -/** In-memory JIRA CloudId cache keyed by baseUrl */ -const _jiraCloudIdCache = new Map(); - -/** @internal Visible for testing only */ -export function _resetJiraCloudIdCache(): void { - _jiraCloudIdCache.clear(); -} - -export class JiraPlatformClient implements PlatformCommentClient { - constructor(private readonly projectId: string) {} - - async postComment(issueKey: string, message: string): Promise { - const creds = await resolveJiraCredentials(this.projectId); - if (!creds) { - logger.warn('[PlatformClient] Missing JIRA credentials, skipping comment'); - return null; - } - - try { - const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment`; - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body: message }), - }); - - if (!response.ok) { - logger.warn( - '[PlatformClient] JIRA comment failed:', - response.status, - await response.text(), - ); - return null; - } - - const data = (await response.json()) as { id?: string }; - logger.info('[PlatformClient] JIRA comment posted for issue:', issueKey); - return data.id ?? null; - } catch (err) { - logger.warn('[PlatformClient] Failed to post JIRA comment:', String(err)); - return null; - } - } - - async deleteComment(issueKey: string, commentId: string): Promise { - const creds = await resolveJiraCredentials(this.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('[PlatformClient] JIRA comment deleted:', commentId); - } catch (err) { - logger.warn('[PlatformClient] Failed to delete JIRA comment:', String(err)); - } - } - - /** - * Post a JIRA reactions-API reaction on a comment. - * `target` is ignored (cloudId is resolved internally from credentials). - * `reactionPayload` is `{ issueId, commentId }`. - */ - async postReaction( - _target: string, - reactionPayload: { issueId: string; commentId: string }, - ): Promise { - const creds = await resolveJiraCredentials(this.projectId); - if (!creds) { - logger.warn('[PlatformClient] Missing JIRA credentials, skipping reaction'); - return; - } - - const cloudId = await this._getCloudId(creds.baseUrl, creds.auth); - if (!cloudId) return; - - try { - const { issueId, commentId } = reactionPayload; - const emojiId = 'atlassian-thought_balloon'; - const ari = `ari%3Acloud%3Ajira%3A${cloudId}%3Acomment%2F${issueId}%2F${commentId}`; - const reactionsUrl = `${creds.baseUrl}/rest/reactions/1.0/reactions/${ari}/${emojiId}`; - - const reactionResponse = await fetch(reactionsUrl, { - method: 'PUT', - headers: { - Authorization: `Basic ${creds.auth}`, - 'Content-Type': 'application/json', - }, - }); - - if (reactionResponse.ok) { - logger.info('[PlatformClient] JIRA reaction sent for comment:', commentId); - } else { - logger.warn( - '[PlatformClient] JIRA reactions API failed:', - reactionResponse.status, - '— skipping (no fallback to avoid webhook loops)', - ); - } - } catch (err) { - logger.warn('[PlatformClient] Failed to post JIRA reaction:', String(err)); - } - } - - private async _getCloudId(baseUrl: string, auth: string): Promise { - const cached = _jiraCloudIdCache.get(baseUrl); - if (cached) return cached; - - let response: Response; - try { - response = await fetch(`${baseUrl}/_edge/tenant_info`, { - headers: { Authorization: `Basic ${auth}` }, - }); - } catch (err) { - logger.warn('[PlatformClient] Failed to fetch JIRA cloudId:', String(err)); - return null; - } - - if (!response.ok) { - logger.warn('[PlatformClient] JIRA tenant_info returned', response.status); - return null; - } - - const data = (await response.json()) as { cloudId?: string }; - if (!data.cloudId) { - logger.warn('[PlatformClient] JIRA tenant_info missing cloudId'); - return null; - } - - _jiraCloudIdCache.set(baseUrl, data.cloudId); - return data.cloudId; - } - - /** @internal Visible for testing only */ - static _reset(): void { - _jiraCloudIdCache.clear(); - } -} diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts new file mode 100644 index 00000000..e2671cc0 --- /dev/null +++ b/src/router/platformClients/credentials.ts @@ -0,0 +1,64 @@ +/** + * Credential resolution helpers for router platform clients. + * + * 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 type { JiraCredentialsWithAuth, TrelloCredentials } from './types.js'; + +/** + * 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/platformClients/github.ts b/src/router/platformClients/github.ts new file mode 100644 index 00000000..50cdc5d1 --- /dev/null +++ b/src/router/platformClients/github.ts @@ -0,0 +1,54 @@ +/** + * GitHub platform client for posting/deleting PR/issue comments via the GitHub REST API. + */ + +import { logger } from '../../utils/logging.js'; +import { resolveGitHubHeaders } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +export class GitHubPlatformClient implements PlatformCommentClient { + constructor( + private readonly repoFullName: string, + private readonly token: string, + ) {} + + async postComment(prNumber: string | number, message: string): Promise { + try { + const url = `https://api.github.com/repos/${this.repoFullName}/issues/${prNumber}/comments`; + const response = await fetch(url, { + method: 'POST', + headers: resolveGitHubHeaders(this.token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ body: message }), + }); + + if (!response.ok) { + logger.warn( + '[PlatformClient] GitHub comment failed:', + response.status, + await response.text(), + ); + return null; + } + + const data = (await response.json()) as { id?: number }; + logger.info('[PlatformClient] GitHub comment posted for PR:', prNumber); + return data.id ?? null; + } catch (err) { + logger.warn('[PlatformClient] Failed to post GitHub comment:', String(err)); + return null; + } + } + + async deleteComment(_target: string, commentId: number): Promise { + const url = `https://api.github.com/repos/${this.repoFullName}/issues/comments/${commentId}`; + try { + await fetch(url, { + method: 'DELETE', + headers: resolveGitHubHeaders(this.token), + }); + logger.info('[PlatformClient] GitHub comment deleted:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete GitHub comment:', String(err)); + } + } +} diff --git a/src/router/platformClients/index.ts b/src/router/platformClients/index.ts new file mode 100644 index 00000000..e0c5efe4 --- /dev/null +++ b/src/router/platformClients/index.ts @@ -0,0 +1,19 @@ +/** + * Barrel export for the platform clients sub-module. + * + * Re-exports all public symbols from the focused sub-modules, preserving the + * same public API surface as the original `platformClients.ts` monolith. + * All existing imports (`from './platformClients.js'`) continue to work + * unchanged since Node.js resolves `./platformClients/index.js` from the + * directory path. + */ + +export type { JiraCredentialsWithAuth, PlatformCommentClient, TrelloCredentials } from './types.js'; +export { + resolveGitHubHeaders, + resolveJiraCredentials, + resolveTrelloCredentials, +} from './credentials.js'; +export { TrelloPlatformClient } from './trello.js'; +export { GitHubPlatformClient } from './github.js'; +export { JiraPlatformClient, _resetJiraCloudIdCache } from './jira.js'; diff --git a/src/router/platformClients/jira.ts b/src/router/platformClients/jira.ts new file mode 100644 index 00000000..2445bb49 --- /dev/null +++ b/src/router/platformClients/jira.ts @@ -0,0 +1,154 @@ +/** + * JIRA platform client for posting/deleting comments and reactions via the JIRA REST API. + */ + +import { logger } from '../../utils/logging.js'; +import { resolveJiraCredentials } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +/** In-memory JIRA CloudId cache keyed by baseUrl */ +const _jiraCloudIdCache = new Map(); + +/** @internal Visible for testing only */ +export function _resetJiraCloudIdCache(): void { + _jiraCloudIdCache.clear(); +} + +export class JiraPlatformClient implements PlatformCommentClient { + constructor(private readonly projectId: string) {} + + async postComment(issueKey: string, message: string): Promise { + const creds = await resolveJiraCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing JIRA credentials, skipping comment'); + return null; + } + + try { + const url = `${creds.baseUrl}/rest/api/2/issue/${issueKey}/comment`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${creds.auth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body: message }), + }); + + if (!response.ok) { + logger.warn( + '[PlatformClient] JIRA comment failed:', + response.status, + await response.text(), + ); + return null; + } + + const data = (await response.json()) as { id?: string }; + logger.info('[PlatformClient] JIRA comment posted for issue:', issueKey); + return data.id ?? null; + } catch (err) { + logger.warn('[PlatformClient] Failed to post JIRA comment:', String(err)); + return null; + } + } + + async deleteComment(issueKey: string, commentId: string): Promise { + const creds = await resolveJiraCredentials(this.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('[PlatformClient] JIRA comment deleted:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete JIRA comment:', String(err)); + } + } + + /** + * Post a JIRA reactions-API reaction on a comment. + * `target` is ignored (cloudId is resolved internally from credentials). + * `reactionPayload` is `{ issueId, commentId }`. + */ + async postReaction( + _target: string, + reactionPayload: { issueId: string; commentId: string }, + ): Promise { + const creds = await resolveJiraCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing JIRA credentials, skipping reaction'); + return; + } + + const cloudId = await this._getCloudId(creds.baseUrl, creds.auth); + if (!cloudId) return; + + try { + const { issueId, commentId } = reactionPayload; + const emojiId = 'atlassian-thought_balloon'; + const ari = `ari%3Acloud%3Ajira%3A${cloudId}%3Acomment%2F${issueId}%2F${commentId}`; + const reactionsUrl = `${creds.baseUrl}/rest/reactions/1.0/reactions/${ari}/${emojiId}`; + + const reactionResponse = await fetch(reactionsUrl, { + method: 'PUT', + headers: { + Authorization: `Basic ${creds.auth}`, + 'Content-Type': 'application/json', + }, + }); + + if (reactionResponse.ok) { + logger.info('[PlatformClient] JIRA reaction sent for comment:', commentId); + } else { + logger.warn( + '[PlatformClient] JIRA reactions API failed:', + reactionResponse.status, + '— skipping (no fallback to avoid webhook loops)', + ); + } + } catch (err) { + logger.warn('[PlatformClient] Failed to post JIRA reaction:', String(err)); + } + } + + private async _getCloudId(baseUrl: string, auth: string): Promise { + const cached = _jiraCloudIdCache.get(baseUrl); + if (cached) return cached; + + let response: Response; + try { + response = await fetch(`${baseUrl}/_edge/tenant_info`, { + headers: { Authorization: `Basic ${auth}` }, + }); + } catch (err) { + logger.warn('[PlatformClient] Failed to fetch JIRA cloudId:', String(err)); + return null; + } + + if (!response.ok) { + logger.warn('[PlatformClient] JIRA tenant_info returned', response.status); + return null; + } + + const data = (await response.json()) as { cloudId?: string }; + if (!data.cloudId) { + logger.warn('[PlatformClient] JIRA tenant_info missing cloudId'); + return null; + } + + _jiraCloudIdCache.set(baseUrl, data.cloudId); + return data.cloudId; + } + + /** @internal Visible for testing only */ + static _reset(): void { + _jiraCloudIdCache.clear(); + } +} diff --git a/src/router/platformClients/trello.ts b/src/router/platformClients/trello.ts new file mode 100644 index 00000000..8e01d462 --- /dev/null +++ b/src/router/platformClients/trello.ts @@ -0,0 +1,57 @@ +/** + * Trello platform client for posting/deleting comments via the Trello REST API. + */ + +import { logger } from '../../utils/logging.js'; +import { resolveTrelloCredentials } from './credentials.js'; +import type { PlatformCommentClient } from './types.js'; + +export class TrelloPlatformClient implements PlatformCommentClient { + constructor(private readonly projectId: string) {} + + async postComment(cardId: string, message: string): Promise { + const creds = await resolveTrelloCredentials(this.projectId); + if (!creds) { + logger.warn('[PlatformClient] Missing Trello credentials, skipping comment'); + return null; + } + + try { + 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' }, + body: JSON.stringify({ text: message }), + }); + + if (!response.ok) { + logger.warn( + '[PlatformClient] Trello comment failed:', + response.status, + await response.text(), + ); + return null; + } + + const data = (await response.json()) as { id?: string }; + logger.info('[PlatformClient] Trello comment posted for card:', cardId); + return data.id ?? null; + } catch (err) { + logger.warn('[PlatformClient] Failed to post Trello comment:', String(err)); + return null; + } + } + + async deleteComment(cardId: string, commentId: string): Promise { + const creds = await resolveTrelloCredentials(this.projectId); + if (!creds) return; + + const url = `https://api.trello.com/1/cards/${cardId}/actions/${commentId}/comments?key=${creds.apiKey}&token=${creds.token}`; + try { + await fetch(url, { method: 'DELETE' }); + logger.info('[PlatformClient] Trello comment deleted:', commentId); + } catch (err) { + logger.warn('[PlatformClient] Failed to delete Trello comment:', String(err)); + } + } +} diff --git a/src/router/platformClients/types.ts b/src/router/platformClients/types.ts new file mode 100644 index 00000000..50d91793 --- /dev/null +++ b/src/router/platformClients/types.ts @@ -0,0 +1,37 @@ +/** + * Shared types for the platform client abstraction layer. + */ + +import type { JiraCredentials } from '../../jira/types.js'; +export type { TrelloCredentials } from '../../trello/types.js'; + +/** Extends JiraCredentials with a pre-computed Base64 Basic auth header value. */ +export interface JiraCredentialsWithAuth extends JiraCredentials { + /** Pre-computed Base64 Basic auth value: `email:apiToken` */ + auth: string; +} + +/** + * Unified interface for posting and deleting comments and reactions across + * GitHub and JIRA. Implementations are fire-and-forget safe — they never + * throw; all errors (including network failures) are caught and logged internally. + */ +export interface PlatformCommentClient { + /** + * Post a comment. Returns the new comment's ID (string or number) on + * success, or `null` on any failure. + */ + postComment(target: string, message: string): Promise; + + /** + * Delete a previously-posted comment by ID. + * Silently returns on missing credentials or any failure. + */ + deleteComment(target: string, commentId: string | number): Promise; + + /** + * Post a reaction on a comment / action. + * Silently returns on missing credentials or any failure. + */ + postReaction?(target: string, reactionPayload: unknown): Promise; +} diff --git a/src/router/pre-actions.ts b/src/router/pre-actions.ts index d10e8a0e..114bd14b 100644 --- a/src/router/pre-actions.ts +++ b/src/router/pre-actions.ts @@ -1,7 +1,7 @@ import { findProjectByRepo, getIntegrationCredential } from '../config/provider.js'; import { logger } from '../utils/logging.js'; import { parseRepoFullName } from '../utils/repo.js'; -import { resolveGitHubHeaders } from './platformClients.js'; +import { resolveGitHubHeaders } from './platformClients/index.js'; import type { GitHubJob } from './queue.js'; /** diff --git a/src/router/reactions.ts b/src/router/reactions.ts index c12c4a01..9f871e00 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -19,7 +19,7 @@ import { _resetJiraCloudIdCache, resolveGitHubHeaders, resolveTrelloCredentials, -} from './platformClients.js'; +} from './platformClients/index.js'; /** @internal Visible for testing only — re-exported from JiraPlatformClient */ export { _resetJiraCloudIdCache }; diff --git a/tests/unit/router/adapters/jira.test.ts b/tests/unit/router/adapters/jira.test.ts index cee1bc9a..056a240a 100644 --- a/tests/unit/router/adapters/jira.test.ts +++ b/tests/unit/router/adapters/jira.test.ts @@ -26,7 +26,7 @@ vi.mock('../../../../src/router/ackMessageGenerator.js', () => ({ extractJiraContext: vi.fn().mockReturnValue('Issue: PROJ-1'), generateAckMessage: vi.fn().mockResolvedValue('Working on it...'), })); -vi.mock('../../../../src/router/platformClients.js', () => ({ +vi.mock('../../../../src/router/platformClients/index.js', () => ({ resolveJiraCredentials: vi.fn().mockResolvedValue({ email: 'bot@example.com', apiToken: 'tok', diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index d07765aa..beb4048e 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -26,7 +26,7 @@ vi.mock('../../../../src/router/ackMessageGenerator.js', () => ({ extractTrelloContext: vi.fn().mockReturnValue('Card: Test card'), generateAckMessage: vi.fn().mockResolvedValue('Starting implementation...'), })); -vi.mock('../../../../src/router/platformClients.js', () => ({ +vi.mock('../../../../src/router/platformClients/index.js', () => ({ resolveTrelloCredentials: vi.fn().mockResolvedValue({ apiKey: 'key', token: 'tok' }), })); vi.mock('../../../../src/trello/client.js', () => ({ diff --git a/tests/unit/router/platformClients.test.ts b/tests/unit/router/platformClients.test.ts index 85c5c6de..0291ccf7 100644 --- a/tests/unit/router/platformClients.test.ts +++ b/tests/unit/router/platformClients.test.ts @@ -35,7 +35,7 @@ import { resolveGitHubHeaders, resolveJiraCredentials, resolveTrelloCredentials, -} from '../../../src/router/platformClients.js'; +} from '../../../src/router/platformClients/index.js'; import { logger } from '../../../src/utils/logging.js'; const mockLogger = vi.mocked(logger);