Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 29 additions & 85 deletions src/router/acknowledgments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,17 +29,13 @@ export async function postTrelloAck(
cardId: string,
message: string,
): Promise<string | null> {
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' },
Expand All @@ -61,16 +57,10 @@ export async function deleteTrelloAck(
cardId: string,
commentId: string,
): Promise<void> {
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);
Expand All @@ -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 }),
});

Expand All @@ -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) {
Expand All @@ -141,27 +122,18 @@ export async function postJiraAck(
issueKey: string,
message: string,
): Promise<string | null> {
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 }),
Expand All @@ -182,26 +154,15 @@ export async function deleteJiraAck(
issueKey: string,
commentId: string,
): Promise<void> {
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',
},
});
Expand All @@ -227,23 +188,12 @@ export async function resolveJiraBotAccountId(projectId: string): Promise<string
const cached = jiraBotCache.get(projectId);
if (cached && Date.now() < cached.expiresAt) return cached.accountId;

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 null;
}
const creds = await resolveJiraCredentials(projectId);
if (!creds) return null;

const auth = Buffer.from(`${jiraEmail}:${jiraApiToken}`).toString('base64');
try {
const response = await fetch(`${jiraBaseUrl}/rest/api/2/myself`, {
headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
const response = await fetch(`${creds.baseUrl}/rest/api/2/myself`, {
headers: { Authorization: `Basic ${creds.auth}`, Accept: 'application/json' },
});
if (!response.ok) return null;

Expand Down Expand Up @@ -275,18 +225,12 @@ export async function resolveTrelloBotMemberId(projectId: string): Promise<strin
const cached = trelloBotCache.get(projectId);
if (cached && Date.now() < cached.expiresAt) return cached.memberId;

let trelloApiKey: string;
let trelloToken: string;
try {
trelloApiKey = await getIntegrationCredential(projectId, 'pm', 'api_key');
trelloToken = await getIntegrationCredential(projectId, 'pm', 'token');
} catch {
return null;
}
const creds = await resolveTrelloCredentials(projectId);
if (!creds) return null;

try {
const response = await fetch(
`https://api.trello.com/1/members/me?key=${trelloApiKey}&token=${trelloToken}`,
`https://api.trello.com/1/members/me?key=${creds.apiKey}&token=${creds.token}`,
{ headers: { Accept: 'application/json' } },
);
if (!response.ok) return null;
Expand Down
46 changes: 15 additions & 31 deletions src/router/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { getProjectGitHubToken } from '../config/projects.js';
import { findProjectByRepo } from '../config/provider.js';
import {
findProjectById,
findProjectByRepo,
getIntegrationCredential,
} from '../config/provider.js';
resolveGitHubHeaders,
resolveJiraCredentials,
resolveTrelloCredentials,
} from './platformClients.js';
import type { CascadeJob, GitHubJob, JiraJob, TrelloJob } from './queue.js';

/**
Expand Down Expand Up @@ -76,12 +77,8 @@ interface TimeoutInfo {
}

async function notifyTrelloTimeout(job: TrelloJob, info: TimeoutInfo): Promise<void> {
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;
}
Expand All @@ -93,7 +90,7 @@ async function notifyTrelloTimeout(job: TrelloJob, info: TimeoutInfo): Promise<v
'Move this card back to the trigger list to retry.',
);

const url = `https://api.trello.com/1/cards/${job.cardId}/actions/comments?key=${trelloApiKey}&token=${trelloToken}`;
const url = `https://api.trello.com/1/cards/${job.cardId}/actions/comments?key=${creds.apiKey}&token=${creds.token}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand Down Expand Up @@ -143,11 +140,7 @@ async function notifyGitHubTimeout(job: GitHubJob, info: TimeoutInfo): Promise<v
const url = `https://api.github.com/repos/${job.repoFullName}/issues/${prNumber}/comments`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${githubToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
headers: resolveGitHubHeaders(githubToken),
body: JSON.stringify({ body: message }),
});

Expand All @@ -159,16 +152,8 @@ async function notifyGitHubTimeout(job: GitHubJob, info: TimeoutInfo): Promise<v
}

async function notifyJiraTimeout(job: JiraJob, info: TimeoutInfo): Promise<void> {
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;
}
Expand All @@ -180,14 +165,13 @@ async function notifyJiraTimeout(job: JiraJob, info: TimeoutInfo): Promise<void>
'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 }),
Expand Down
78 changes: 78 additions & 0 deletions src/router/platformClients.ts
Original file line number Diff line number Diff line change
@@ -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<TrelloCredentials | null> {
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<JiraCredentials | null> {
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<string, string>,
): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...extra,
};
}
Loading