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
168 changes: 20 additions & 148 deletions src/router/acknowledgments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -76,166 +69,45 @@ export async function deleteGitHubAck(
}

// ---------------------------------------------------------------------------
// JIRA
// JIRA — delegates to JiraPlatformClient (ADF via api/3)
// ---------------------------------------------------------------------------

export async function postJiraAck(
projectId: string,
issueKey: string,
message: string,
): Promise<string | null> {
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(
projectId: string,
issueKey: string,
commentId: string,
): Promise<void> {
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<string>('accountId');
const trelloBotIdentityCache = new BotIdentityCache<string>('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<string | null> {
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<string | null> {
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';
72 changes: 72 additions & 0 deletions src/router/bot-identity-resolvers.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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<string | null> {
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<string>('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<string | null> {
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();
}
29 changes: 28 additions & 1 deletion src/router/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
65 changes: 65 additions & 0 deletions src/router/github-token-resolver.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedGitHubToken | 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<ResolvedGitHubToken | 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;
}
}
Loading