diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index 2d6beabc..c75252a6 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -1,3 +1,4 @@ +import { getTrelloConfig } from '../../pm/config.js'; import { getPMProvider } from '../../pm/index.js'; import type { ProjectConfig } from '../../types/index.js'; import type { PromptContext } from '../prompts/index.js'; @@ -29,8 +30,8 @@ export function buildPromptContext( cardUrl: cardId ? pmProvider.getWorkItemUrl(cardId) : undefined, projectId: project.id, baseBranch: project.baseBranch, - storiesListId: project.trello?.lists?.stories, - processedLabelId: project.trello?.labels?.processed, + storiesListId: getTrelloConfig(project)?.lists?.stories, + processedLabelId: getTrelloConfig(project)?.labels?.processed, pmType: pmProvider.type, workItemNoun: isJira ? 'issue' : 'card', workItemNounPlural: isJira ? 'issues' : 'cards', @@ -50,7 +51,7 @@ export function buildPromptContext( originalCardName: debugContext.originalCardName, originalCardUrl: debugContext.originalCardUrl, detectedAgentType: debugContext.detectedAgentType, - debugListId: project.trello?.lists?.debug, + debugListId: getTrelloConfig(project)?.lists?.debug, }), }; } diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index 2c0da047..df218c1c 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -6,6 +6,7 @@ import { getAllProjectCredentials } from '../../config/provider.js'; import { getDb } from '../../db/client.js'; import { findProjectByIdFromDb } from '../../db/repositories/configRepository.js'; import { projects } from '../../db/schema/index.js'; +import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; import { parseRepoFullName } from '../../utils/repo.js'; import { protectedProcedure, router } from '../trpc.js'; @@ -78,12 +79,14 @@ async function resolveProjectContext( const creds = await getAllProjectCredentials(projectId); // Resolve JIRA label names from config (with defaults) - const jiraLabels = project.jira + const jiraConfig = getJiraConfig(project); + const trelloConfig = getTrelloConfig(project); + const jiraLabels = jiraConfig ? [ - project.jira.labels?.processing ?? 'cascade-processing', - project.jira.labels?.processed ?? 'cascade-processed', - project.jira.labels?.error ?? 'cascade-error', - project.jira.labels?.readyToProcess ?? 'cascade-ready', + jiraConfig.labels?.processing ?? 'cascade-processing', + jiraConfig.labels?.processed ?? 'cascade-processed', + jiraConfig.labels?.error ?? 'cascade-error', + jiraConfig.labels?.readyToProcess ?? 'cascade-ready', ] : undefined; @@ -92,9 +95,9 @@ async function resolveProjectContext( orgId: project.orgId, repo: project.repo, pmType: project.pm?.type ?? 'trello', - boardId: project.trello?.boardId, - jiraBaseUrl: project.jira?.baseUrl, - jiraProjectKey: project.jira?.projectKey, + boardId: trelloConfig?.boardId, + jiraBaseUrl: jiraConfig?.baseUrl, + jiraProjectKey: jiraConfig?.projectKey, jiraLabels, trelloApiKey: creds.TRELLO_API_KEY ?? '', trelloToken: creds.TRELLO_TOKEN ?? '', diff --git a/src/backends/secretBuilder.ts b/src/backends/secretBuilder.ts index 58666d62..682136ac 100644 --- a/src/backends/secretBuilder.ts +++ b/src/backends/secretBuilder.ts @@ -1,5 +1,6 @@ import { getAllProjectCredentials } from '../config/provider.js'; import { getPersonaToken } from '../github/personas.js'; +import { getJiraConfig } from '../pm/config.js'; import type { AgentInput, ProjectConfig } from '../types/index.js'; import { parseRepoFullName } from '../utils/repo.js'; import type { AgentProfile } from './agent-profiles.js'; @@ -41,11 +42,12 @@ export async function augmentProjectSecrets( } // Inject JIRA integration config so cascade-tools can construct JiraPMProvider - if (project.jira) { - projectSecrets.CASCADE_JIRA_PROJECT_KEY = project.jira.projectKey; - projectSecrets.CASCADE_JIRA_BASE_URL = project.jira.baseUrl; - if (project.jira.statuses) { - projectSecrets.CASCADE_JIRA_STATUSES = JSON.stringify(project.jira.statuses); + const jiraConfig = getJiraConfig(project); + if (jiraConfig) { + projectSecrets.CASCADE_JIRA_PROJECT_KEY = jiraConfig.projectKey; + projectSecrets.CASCADE_JIRA_BASE_URL = jiraConfig.baseUrl; + if (jiraConfig.statuses) { + projectSecrets.CASCADE_JIRA_STATUSES = JSON.stringify(jiraConfig.statuses); } } diff --git a/src/gadgets/index.ts b/src/gadgets/index.ts index 3e2bb328..bc147e29 100644 --- a/src/gadgets/index.ts +++ b/src/gadgets/index.ts @@ -9,17 +9,5 @@ export { VerifyChanges } from './VerifyChanges.js'; // Search gadgets export { RipGrep } from './RipGrep.js'; export { AstGrep } from './AstGrep.js'; - -// Trello gadgets -export { - ReadTrelloCard, - PostTrelloComment, - UpdateTrelloCard, - CreateTrelloCard, - ListTrelloCards, - GetMyRecentActivity, - AddChecklistToCard, -} from './trello/index.js'; - // GitHub gadgets export { GetPRDetails, GetPRComments, ReplyToReviewComment } from './github/index.js'; diff --git a/src/gadgets/trello/AddChecklistToCard.ts b/src/gadgets/trello/AddChecklistToCard.ts deleted file mode 100644 index 75d6c240..00000000 --- a/src/gadgets/trello/AddChecklistToCard.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { addChecklist } from './core/addChecklist.js'; - -export class AddChecklistToCard extends Gadget({ - name: 'AddChecklistToCard', - description: - 'Add a checklist with items to a Trello card. Use this to create interactive checklists for acceptance criteria or implementation steps.', - timeoutMs: 30000, - schema: z.object({ - cardId: z.string().describe('The Trello card ID'), - checklistName: z - .string() - .describe( - 'Name of the checklist (e.g., "✅ Acceptance Criteria" or "📋 Implementation Steps")', - ), - items: z.array(z.string()).min(1).describe('List of checklist items to add'), - }), - examples: [ - { - params: { - cardId: 'abc123', - checklistName: '✅ Acceptance Criteria', - items: [ - 'User can request password reset via email', - 'Reset link expires after 24 hours', - 'User must set a new password meeting security requirements', - ], - }, - comment: 'Add acceptance criteria checklist to a story card', - }, - { - params: { - cardId: 'abc123', - checklistName: '📋 Implementation Steps', - items: [ - 'Add reset password endpoint to API', - 'Create email template for reset link', - 'Add password validation logic', - ], - }, - comment: 'Add implementation steps checklist to a story card', - }, - ], -}) { - override async execute(params: this['params']): Promise { - return addChecklist({ - cardId: params.cardId, - checklistName: params.checklistName, - items: params.items, - }); - } -} diff --git a/src/gadgets/trello/CreateTrelloCard.ts b/src/gadgets/trello/CreateTrelloCard.ts deleted file mode 100644 index 482b4e6d..00000000 --- a/src/gadgets/trello/CreateTrelloCard.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { createCard } from './core/createCard.js'; - -export class CreateTrelloCard extends Gadget({ - name: 'CreateTrelloCard', - description: - 'Create a new Trello card in a specific list. Use this to create user story cards or break down work into smaller tasks.', - timeoutMs: 30000, - schema: z.object({ - listId: z.string().describe('The Trello list ID where the card should be created'), - title: z - .string() - .max(200) - .describe( - 'Card title. For user stories, use format: "As a [role], I want [action] so that [benefit]"', - ), - description: z - .string() - .optional() - .describe( - 'Card description (markdown supported). Include acceptance criteria and technical notes.', - ), - }), - examples: [ - { - params: { - listId: 'abc123', - title: 'As a user, I want to reset my password so that I can recover my account', - description: - '## Acceptance Criteria\n\n- [ ] User can request password reset via email\n- [ ] Reset link expires after 24 hours\n- [ ] User must set a new password meeting security requirements\n\n## Technical Notes\n\n- Use existing email service\n- Store reset tokens in database with expiry', - }, - comment: 'Create an INVEST-compatible user story card', - }, - { - params: { - listId: 'abc123', - title: 'Add email validation to signup form', - description: - '## Acceptance Criteria\n\n- [ ] Email format is validated on blur\n- [ ] Error message is shown for invalid emails', - }, - comment: 'Create a simple task card', - }, - ], -}) { - override async execute(params: this['params']): Promise { - return createCard({ - listId: params.listId, - title: params.title, - description: params.description, - }); - } -} diff --git a/src/gadgets/trello/GetMyRecentActivity.ts b/src/gadgets/trello/GetMyRecentActivity.ts deleted file mode 100644 index 7a0f25d2..00000000 --- a/src/gadgets/trello/GetMyRecentActivity.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { trelloClient } from '../../trello/client.js'; -import { formatGadgetError } from '../utils.js'; - -export class GetMyRecentActivity extends Gadget({ - name: 'GetMyRecentActivity', - description: - 'Get your recent Trello activity (cards created, updated, comments posted). Use this to find cards you recently worked on or to understand context when the user refers to previous work.', - timeoutMs: 30000, - schema: z.object({ - limit: z - .number() - .int() - .min(1) - .max(50) - .optional() - .default(20) - .describe('Number of recent actions to retrieve (default 20, max 50)'), - }), - examples: [ - { - params: { limit: 10 }, - comment: 'Get my last 10 actions to find cards I recently created', - }, - { - params: { limit: 20 }, - comment: 'Get my recent activity with default limit of 20', - }, - ], -}) { - override async execute(params: this['params']): Promise { - try { - const actions = await trelloClient.getMyActions(params.limit); - - if (actions.length === 0) { - return 'No recent activity found.'; - } - - let result = `# My Recent Activity (${actions.length} actions)\n\n`; - - for (const action of actions) { - const date = new Date(action.date).toISOString(); - const actionDesc = this.formatAction(action); - result += `- **${action.type}** (${date}): ${actionDesc}\n`; - } - - return result; - } catch (error) { - return formatGadgetError('getting recent activity', error); - } - } - - private formatAction(action: { - type: string; - data: { - card?: { id: string; name: string; shortLink?: string }; - list?: { name: string }; - text?: string; - }; - }): string { - const card = action.data.card; - const list = action.data.list; - - switch (action.type) { - case 'createCard': - return `Created card "${card?.name}" (ID: ${card?.id})${list ? ` in list "${list.name}"` : ''}`; - case 'updateCard': - return `Updated card "${card?.name}" (ID: ${card?.id})`; - case 'commentCard': - return `Commented on "${card?.name}" (ID: ${card?.id}): "${action.data.text?.slice(0, 50)}${(action.data.text?.length || 0) > 50 ? '...' : ''}"`; - case 'addLabelToCard': - return `Added label to "${card?.name}" (ID: ${card?.id})`; - case 'removeLabelFromCard': - return `Removed label from "${card?.name}" (ID: ${card?.id})`; - case 'moveCardToBoard': - case 'moveCardFromBoard': - return `Moved card "${card?.name}" (ID: ${card?.id})`; - default: - return card ? `"${card.name}" (ID: ${card.id})` : 'Unknown action'; - } - } -} diff --git a/src/gadgets/trello/ListTrelloCards.ts b/src/gadgets/trello/ListTrelloCards.ts deleted file mode 100644 index 5b1b28d4..00000000 --- a/src/gadgets/trello/ListTrelloCards.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { listCards } from './core/listCards.js'; - -export class ListTrelloCards extends Gadget({ - name: 'ListTrelloCards', - description: - 'List all cards on a Trello list. Use this to see cards you created or to find cards to update.', - timeoutMs: 30000, - schema: z.object({ - listId: z.string().describe('The Trello list ID'), - }), - examples: [ - { - params: { listId: 'abc123' }, - comment: 'List all cards in the STORIES list to find ones to update', - }, - ], -}) { - override async execute(params: this['params']): Promise { - return listCards(params.listId); - } -} diff --git a/src/gadgets/trello/PostTrelloComment.ts b/src/gadgets/trello/PostTrelloComment.ts deleted file mode 100644 index 95ffc2f2..00000000 --- a/src/gadgets/trello/PostTrelloComment.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { postComment } from './core/postComment.js'; - -export class PostTrelloComment extends Gadget({ - name: 'PostTrelloComment', - description: - 'Post a comment to a Trello card. Use this to communicate with the user, ask questions, or provide status updates.', - timeoutMs: 30000, - schema: z.object({ - cardId: z.string().describe('The Trello card ID'), - text: z.string().describe('The comment text to post (supports markdown)'), - }), - examples: [ - { - params: { - cardId: 'abc123', - text: '📋 **Brief Ready for Review**\n\nI have analyzed the codebase and updated the card description.', - }, - comment: 'Post a status update to the card', - }, - ], -}) { - override async execute(params: this['params']): Promise { - return postComment(params.cardId, params.text); - } -} diff --git a/src/gadgets/trello/ReadTrelloCard.ts b/src/gadgets/trello/ReadTrelloCard.ts deleted file mode 100644 index cf4527b3..00000000 --- a/src/gadgets/trello/ReadTrelloCard.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { readCard } from './core/readCard.js'; - -export class ReadTrelloCard extends Gadget({ - name: 'ReadTrelloCard', - description: - 'Read a Trello card to retrieve its title, description, comments, checklists, and attachments. Use this to understand the current state of the card before making changes.', - timeoutMs: 30000, - schema: z.object({ - cardId: z.string().describe('The Trello card ID'), - includeComments: z - .boolean() - .optional() - .default(true) - .describe('Whether to include comments in the response'), - }), - examples: [ - { - params: { cardId: 'abc123', includeComments: true }, - comment: 'Read the card with its comments to understand context', - }, - { - params: { cardId: 'abc123', includeComments: false }, - comment: 'Read just the card title and description', - }, - ], -}) { - override async execute(params: this['params']): Promise { - return readCard(params.cardId, params.includeComments); - } -} - -/** @deprecated Use readCard from './core/readCard.js' instead */ -export { readCard as formatCardData } from './core/readCard.js'; diff --git a/src/gadgets/trello/UpdateChecklistItem.ts b/src/gadgets/trello/UpdateChecklistItem.ts deleted file mode 100644 index 5d75faa5..00000000 --- a/src/gadgets/trello/UpdateChecklistItem.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { updateChecklistItem } from './core/updateChecklistItem.js'; - -export class UpdateChecklistItem extends Gadget({ - name: 'UpdateChecklistItem', - description: - 'Update a checklist item state on a Trello card. Use this to mark acceptance criteria as complete or incomplete.', - timeoutMs: 15000, - schema: z.object({ - cardId: z.string().describe('The Trello card ID'), - checkItemId: z.string().describe('The checklist item ID to update'), - state: z.enum(['complete', 'incomplete']).describe('The new state for the checklist item'), - }), - examples: [ - { - params: { - cardId: 'abc123', - checkItemId: 'item456', - state: 'complete', - }, - comment: 'Mark an acceptance criterion as complete', - }, - { - params: { - cardId: 'abc123', - checkItemId: 'item789', - state: 'incomplete', - }, - comment: 'Mark an acceptance criterion as incomplete', - }, - ], -}) { - override async execute(params: this['params']): Promise { - return updateChecklistItem(params.cardId, params.checkItemId, params.state); - } -} diff --git a/src/gadgets/trello/UpdateTrelloCard.ts b/src/gadgets/trello/UpdateTrelloCard.ts deleted file mode 100644 index 30549f96..00000000 --- a/src/gadgets/trello/UpdateTrelloCard.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Gadget, z } from 'llmist'; -import { updateCard } from './core/updateCard.js'; - -export class UpdateTrelloCard extends Gadget({ - name: 'UpdateTrelloCard', - description: - 'Update a Trello card title and/or description. Use this to save your analysis, brief, or plan to the card.', - timeoutMs: 30000, - schema: z.object({ - cardId: z.string().describe('The Trello card ID'), - title: z - .string() - .max(80) - .optional() - .describe('New card title (max 80 chars). Should be action-oriented.'), - description: z - .string() - .optional() - .describe( - 'New card description (markdown supported). Use this to save the full brief or plan.', - ), - addLabelIds: z - .array(z.string()) - .optional() - .describe('Label IDs to add to the card (e.g., for marking as processed)'), - }), - examples: [ - { - params: { - cardId: 'abc123', - description: '## Context\n\nBackground info...\n\n## Requirements\n\n- Item 1\n- Item 2', - }, - comment: 'Update the card description with a structured brief', - }, - { - params: { - cardId: 'abc123', - title: 'Add user authentication flow', - }, - comment: 'Update just the card title', - }, - ], -}) { - override async execute(params: this['params']): Promise { - return updateCard({ - cardId: params.cardId, - title: params.title, - description: params.description, - addLabelIds: params.addLabelIds, - }); - } -} diff --git a/src/gadgets/trello/core/addChecklist.ts b/src/gadgets/trello/core/addChecklist.ts deleted file mode 100644 index c3f36006..00000000 --- a/src/gadgets/trello/core/addChecklist.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { trelloClient } from '../../../trello/client.js'; - -export interface AddChecklistParams { - cardId: string; - checklistName: string; - items: string[]; -} - -export async function addChecklist(params: AddChecklistParams): Promise { - try { - const checklist = await trelloClient.createChecklist(params.cardId, params.checklistName); - - for (const item of params.items) { - await trelloClient.addChecklistItem(checklist.id, item); - } - - return `Checklist "${params.checklistName}" created with ${params.items.length} items on card ${params.cardId}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error adding checklist: ${message}`; - } -} diff --git a/src/gadgets/trello/core/createCard.ts b/src/gadgets/trello/core/createCard.ts deleted file mode 100644 index 90c05ad1..00000000 --- a/src/gadgets/trello/core/createCard.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { trelloClient } from '../../../trello/client.js'; - -export interface CreateCardParams { - listId: string; - title: string; - description?: string; -} - -export async function createCard(params: CreateCardParams): Promise { - try { - const card = await trelloClient.createCard(params.listId, { - name: params.title, - desc: params.description, - }); - - return `Card created successfully: "${card.name}" - ${card.shortUrl}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error creating card: ${message}`; - } -} diff --git a/src/gadgets/trello/core/listCards.ts b/src/gadgets/trello/core/listCards.ts deleted file mode 100644 index 1da8a3f9..00000000 --- a/src/gadgets/trello/core/listCards.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { trelloClient } from '../../../trello/client.js'; - -export async function listCards(listId: string): Promise { - try { - const cards = await trelloClient.getListCards(listId); - - if (cards.length === 0) { - return 'No cards found in this list.'; - } - - let result = `# Cards (${cards.length})\n\n`; - for (const card of cards) { - result += `## ${card.name}\n`; - result += `- **ID:** ${card.id}\n`; - result += `- **URL:** ${card.shortUrl}\n`; - if (card.desc) { - result += `- **Description:** ${card.desc.slice(0, 100)}${card.desc.length > 100 ? '...' : ''}\n`; - } - result += '\n'; - } - - return result; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error listing cards: ${message}`; - } -} diff --git a/src/gadgets/trello/core/postComment.ts b/src/gadgets/trello/core/postComment.ts deleted file mode 100644 index d660fbac..00000000 --- a/src/gadgets/trello/core/postComment.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { trelloClient } from '../../../trello/client.js'; - -export async function postComment(cardId: string, text: string): Promise { - try { - await trelloClient.addComment(cardId, text); - return 'Comment posted successfully'; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error posting comment: ${message}`; - } -} diff --git a/src/gadgets/trello/core/readCard.ts b/src/gadgets/trello/core/readCard.ts deleted file mode 100644 index eba42bbb..00000000 --- a/src/gadgets/trello/core/readCard.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { trelloClient } from '../../../trello/client.js'; - -type CardData = Awaited>; -type ChecklistData = Awaited>; -type AttachmentData = Awaited>; -type CommentData = Awaited>; - -export async function readCard(cardId: string, includeComments = true): Promise { - try { - const [card, checklists, attachments] = await Promise.all([ - trelloClient.getCard(cardId), - trelloClient.getCardChecklists(cardId), - trelloClient.getCardAttachments(cardId), - ]); - - let result = formatHeader(card); - result += formatLabels(card.labels); - result += formatChecklists(checklists); - result += formatAttachments(attachments); - - if (includeComments) { - const comments = await trelloClient.getCardComments(cardId); - result += formatComments(comments); - } - - return result; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error reading card: ${message}`; - } -} - -function formatHeader(card: CardData): string { - return `# ${card.name}\n\n**URL:** ${card.url}\n\n## Description\n\n${card.desc || '(No description)'}\n\n`; -} - -function formatLabels(labels: CardData['labels']): string { - if (labels.length === 0) return ''; - return `## Labels\n\n${labels.map((l) => `- ${l.name} (${l.color})`).join('\n')}\n\n`; -} - -function formatChecklists(checklists: ChecklistData): string { - if (checklists.length === 0) return ''; - - let result = '## Checklists\n\n'; - for (const checklist of checklists) { - result += `### ${checklist.name} [checklistId: ${checklist.id}]\n\n`; - for (const item of checklist.checkItems) { - const checkbox = item.state === 'complete' ? '[x]' : '[ ]'; - result += `- ${checkbox} ${item.name} [checkItemId: ${item.id}]\n`; - } - result += '\n'; - } - return result; -} - -function formatAttachments(attachments: AttachmentData): string { - if (attachments.length === 0) return ''; - - let result = '## Attachments\n\n'; - for (const att of attachments) { - result += `- [${att.name}](${att.url})`; - if (att.date) { - result += ` (${new Date(att.date).toISOString()})`; - } - result += '\n'; - } - return `${result}\n`; -} - -function formatComments(comments: CommentData): string { - if (comments.length === 0) { - return '## Comments\n\n(No comments)\n\n'; - } - - let result = `## Comments (${comments.length})\n\n`; - for (const comment of comments.slice().reverse()) { - const date = new Date(comment.date).toISOString(); - result += `### ${comment.memberCreator.fullName} (${date})\n\n`; - result += `${comment.data.text}\n\n`; - } - return result; -} diff --git a/src/gadgets/trello/core/updateCard.ts b/src/gadgets/trello/core/updateCard.ts deleted file mode 100644 index cefe2044..00000000 --- a/src/gadgets/trello/core/updateCard.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { trelloClient } from '../../../trello/client.js'; - -export interface UpdateCardParams { - cardId: string; - title?: string; - description?: string; - addLabelIds?: string[]; -} - -export async function updateCard(params: UpdateCardParams): Promise { - if (!params.title && !params.description && !params.addLabelIds?.length) { - return 'Nothing to update - provide title, description, or labels'; - } - - try { - if (params.title || params.description) { - await trelloClient.updateCard(params.cardId, { - name: params.title, - desc: params.description, - }); - } - - if (params.addLabelIds?.length) { - for (const labelId of params.addLabelIds) { - await trelloClient.addLabelToCard(params.cardId, labelId); - } - } - - const updated: string[] = []; - if (params.title) updated.push('title'); - if (params.description) updated.push('description'); - if (params.addLabelIds?.length) updated.push(`${params.addLabelIds.length} label(s)`); - - return `Card updated: ${updated.join(', ')}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error updating card: ${message}`; - } -} diff --git a/src/gadgets/trello/core/updateChecklistItem.ts b/src/gadgets/trello/core/updateChecklistItem.ts deleted file mode 100644 index e4aebbd8..00000000 --- a/src/gadgets/trello/core/updateChecklistItem.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { trelloClient } from '../../../trello/client.js'; - -export async function updateChecklistItem( - cardId: string, - checkItemId: string, - state: 'complete' | 'incomplete', -): Promise { - try { - await trelloClient.updateChecklistItem(cardId, checkItemId, state); - - const action = state === 'complete' ? 'marked complete' : 'marked incomplete'; - return `Checklist item ${checkItemId} ${action} on card ${cardId}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `Error updating checklist item: ${message}`; - } -} diff --git a/src/gadgets/trello/index.ts b/src/gadgets/trello/index.ts deleted file mode 100644 index 8aa2877f..00000000 --- a/src/gadgets/trello/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { ReadTrelloCard, formatCardData } from './ReadTrelloCard.js'; -export { PostTrelloComment } from './PostTrelloComment.js'; -export { UpdateTrelloCard } from './UpdateTrelloCard.js'; -export { CreateTrelloCard } from './CreateTrelloCard.js'; -export { ListTrelloCards } from './ListTrelloCards.js'; -export { GetMyRecentActivity } from './GetMyRecentActivity.js'; -export { AddChecklistToCard } from './AddChecklistToCard.js'; -export { UpdateChecklistItem } from './UpdateChecklistItem.js'; diff --git a/src/pm/config.ts b/src/pm/config.ts new file mode 100644 index 00000000..24207fae --- /dev/null +++ b/src/pm/config.ts @@ -0,0 +1,75 @@ +/** + * Type-safe accessor functions for provider-specific PM config. + * + * Instead of accessing `project.trello?.xxx` or `project.jira?.xxx` directly, + * consumers use these accessors which extract the config from either the + * unified `project.pm.config` or the legacy top-level fields. + */ + +import type { ProjectConfig } from '../types/index.js'; + +/** Trello-specific configuration (from project_integrations JSONB) */ +export interface TrelloConfig { + boardId: string; + lists: Record; + labels: Record; + customFields?: { cost?: string }; + triggers?: Record; +} + +/** JIRA-specific configuration (from project_integrations JSONB) */ +export interface JiraConfig { + projectKey: string; + baseUrl: string; + statuses: Record; + issueTypes?: Record; + customFields?: { cost?: string }; + labels?: { + processing?: string; + processed?: string; + error?: string; + readyToProcess?: string; + }; + triggers?: Record; +} + +/** + * Get the Trello config for a project. + * Returns the config or undefined if this is not a Trello project. + */ +export function getTrelloConfig(project: ProjectConfig): TrelloConfig | undefined { + if (project.pm?.type !== 'trello' && project.pm?.type !== undefined) return undefined; + return project.trello as TrelloConfig | undefined; +} + +/** + * Get the JIRA config for a project. + * Returns the config or undefined if this is not a JIRA project. + * + * Falls back to checking `project.jira` directly when `pm.type` is unset + * (legacy projects / test fixtures that don't set `pm.type`). + */ +export function getJiraConfig(project: ProjectConfig): JiraConfig | undefined { + if (project.pm?.type !== undefined && project.pm?.type !== 'jira') return undefined; + return project.jira as JiraConfig | undefined; +} + +/** + * Get the cost custom field ID for a project, regardless of PM type. + */ +export function getCostFieldId(project: ProjectConfig): string | undefined { + if (project.pm?.type === 'jira') { + return getJiraConfig(project)?.customFields?.cost; + } + return getTrelloConfig(project)?.customFields?.cost; +} + +/** + * Get PM-specific trigger config for a project. + */ +export function getPMTriggerConfig(project: ProjectConfig): Record | undefined { + if (project.pm?.type === 'jira') { + return getJiraConfig(project)?.triggers; + } + return getTrelloConfig(project)?.triggers; +} diff --git a/src/pm/factory.ts b/src/pm/factory.ts deleted file mode 100644 index 6a2cc45a..00000000 --- a/src/pm/factory.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Factory for creating PM providers based on project configuration. - */ - -import type { ProjectConfig } from '../types/index.js'; -import { JiraPMProvider } from './jira/adapter.js'; -import { TrelloPMProvider } from './trello/adapter.js'; -import type { PMProvider } from './types.js'; - -export function createPMProvider(project: ProjectConfig): PMProvider { - const pmType = project.pm?.type ?? 'trello'; - - switch (pmType) { - case 'trello': - return new TrelloPMProvider(); - case 'jira': { - if (!project.jira) { - throw new Error(`Project '${project.id}' has pm.type=jira but no jira config`); - } - return new JiraPMProvider(project.jira); - } - default: - throw new Error(`Unknown PM type: ${pmType}`); - } -} diff --git a/src/pm/index.ts b/src/pm/index.ts index ba83dd6c..1cc44b72 100644 --- a/src/pm/index.ts +++ b/src/pm/index.ts @@ -11,8 +11,25 @@ export type { } from './types.js'; export { withPMProvider, getPMProvider, getPMProviderOrNull } from './context.js'; -export { createPMProvider } from './factory.js'; export { TrelloPMProvider } from './trello/adapter.js'; export { JiraPMProvider } from './jira/adapter.js'; export { PMLifecycleManager, resolveProjectPMConfig } from './lifecycle.js'; export type { ProjectPMConfig } from './lifecycle.js'; + +// PMIntegration interface + registry +export type { PMIntegration, PMWebhookEvent } from './integration.js'; +export { pmRegistry } from './registry.js'; +export { processPMWebhook } from './webhook-handler.js'; + +import type { ProjectConfig } from '../types/index.js'; +import { JiraIntegration } from './jira/integration.js'; +import { pmRegistry } from './registry.js'; +// Register built-in integrations at import time +import { TrelloIntegration } from './trello/integration.js'; +import type { PMProvider } from './types.js'; +pmRegistry.register(new TrelloIntegration()); +pmRegistry.register(new JiraIntegration()); + +export function createPMProvider(project: ProjectConfig): PMProvider { + return pmRegistry.createProvider(project); +} diff --git a/src/pm/integration.ts b/src/pm/integration.ts new file mode 100644 index 00000000..b315623c --- /dev/null +++ b/src/pm/integration.ts @@ -0,0 +1,72 @@ +/** + * PMIntegration — the higher-level contract that encapsulates everything a PM + * provider needs: data operations, credential scoping, webhook parsing, + * router-side operations, config resolution, and trigger registration. + * + * Each PM provider (Trello, JIRA, future ClickUp/Linear) implements this + * interface as a single self-contained class. Generic infrastructure (router, + * webhook handler, lifecycle manager) consumes the interface without + * provider-specific branching. + */ + +import type { CascadeConfig, ProjectConfig } from '../types/index.js'; +import type { ProjectPMConfig } from './lifecycle.js'; +import type { PMProvider } from './types.js'; + +/** + * Normalized webhook event — what the generic webhook handler operates on. + */ +export interface PMWebhookEvent { + /** Provider-specific event type (e.g. 'updateCard', 'jira:issue_updated') */ + eventType: string; + /** Provider-specific identifier for matching a project (boardId, projectKey) */ + projectIdentifier: string; + /** Work item ID when available (cardId, issueKey) */ + workItemId?: string; + /** Original payload, passed to trigger dispatch */ + raw: unknown; +} + +export interface PMIntegration { + /** Provider identifier — matches the string stored in project_integrations.provider */ + readonly type: string; + + // --- Data operations --- + /** Create a PMProvider instance from the project config */ + createProvider(project: ProjectConfig): PMProvider; + + // --- Credential lifecycle --- + /** Resolve credentials from DB and run `fn` within the credential scope */ + withCredentials(projectId: string, fn: () => Promise): Promise; + + // --- Config --- + /** Extract normalized lifecycle config (labels, statuses) from provider-specific config */ + resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig; + + // --- Webhook processing --- + /** Parse a raw webhook body into a normalized event, or null if irrelevant */ + parseWebhookPayload(raw: unknown): PMWebhookEvent | null; + + /** Check if a webhook event was authored by the integration's own bot account */ + isSelfAuthored(event: PMWebhookEvent, projectId: string): Promise; + + // --- Router-side operations (lightweight, no SDK) --- + /** Post an acknowledgment comment; returns comment ID or null on failure */ + postAckComment(projectId: string, workItemId: string, message: string): Promise; + + /** Delete an acknowledgment comment (cleanup on no-match) */ + deleteAckComment(projectId: string, workItemId: string, commentId: string): Promise; + + /** Send an acknowledgment reaction (e.g. 👀 emoji) on the source event */ + sendReaction(projectId: string, event: PMWebhookEvent): Promise; + + // --- Project lookup --- + /** Find the project config + cascade config from a webhook identifier */ + lookupProject( + identifier: string, + ): Promise<{ project: ProjectConfig; config: CascadeConfig } | null>; + + // --- Work item ID extraction --- + /** Extract a work item ID from text (e.g. PR body). Returns null if not found. */ + extractWorkItemId(text: string): string | null; +} diff --git a/src/pm/jira/integration.ts b/src/pm/jira/integration.ts new file mode 100644 index 00000000..36662ada --- /dev/null +++ b/src/pm/jira/integration.ts @@ -0,0 +1,136 @@ +/** + * JiraIntegration — implements PMIntegration for JIRA. + * + * Encapsulates all JIRA-specific concerns: credential resolution, + * webhook parsing, ack comments, reactions, project lookup, and triggers. + * + * Router-side operations (ack comments, reactions, bot identity) delegate + * to the single-source-of-truth functions in router/acknowledgments.ts + * and router/reactions.ts. + */ + +import { + findProjectById, + getIntegrationCredential, + loadProjectConfigByJiraProjectKey, +} from '../../config/provider.js'; +import { withJiraCredentials } from '../../jira/client.js'; +import { + deleteJiraAck, + postJiraAck, + resolveJiraBotAccountId, +} from '../../router/acknowledgments.js'; +import { sendAcknowledgeReaction } from '../../router/reactions.js'; +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { getJiraConfig } from '../config.js'; +import type { PMIntegration, PMWebhookEvent } from '../integration.js'; +import type { ProjectPMConfig } from '../lifecycle.js'; +import type { PMProvider } from '../types.js'; +import { JiraPMProvider } from './adapter.js'; + +// JIRA issue key pattern +const JIRA_ISSUE_KEY_REGEX = /\b([A-Z][A-Z0-9]+-\d+)\b/; + +export class JiraIntegration implements PMIntegration { + readonly type = 'jira'; + + createProvider(project: ProjectConfig): PMProvider { + const jiraConfig = getJiraConfig(project); + if (!jiraConfig?.projectKey) { + throw new Error('JIRA integration requires projectKey in config'); + } + return new JiraPMProvider(jiraConfig); + } + + async withCredentials(projectId: string, fn: () => Promise): Promise { + 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) ?? ''; + return withJiraCredentials({ email, apiToken, baseUrl }, fn); + } + + resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { + const jiraConfig = getJiraConfig(project); + const jiraLabels = jiraConfig?.labels; + return { + labels: { + processing: jiraLabels?.processing ?? 'cascade-processing', + processed: jiraLabels?.processed ?? 'cascade-processed', + error: jiraLabels?.error ?? 'cascade-error', + readyToProcess: jiraLabels?.readyToProcess ?? 'cascade-ready', + }, + statuses: { + inProgress: jiraConfig?.statuses?.inProgress, + inReview: jiraConfig?.statuses?.inReview, + done: jiraConfig?.statuses?.done, + merged: jiraConfig?.statuses?.merged, + }, + }; + } + + parseWebhookPayload(raw: unknown): PMWebhookEvent | null { + if (!raw || typeof raw !== 'object') return null; + const p = raw as Record; + const webhookEvent = p.webhookEvent as string | undefined; + if (typeof webhookEvent !== 'string') return null; + + const issue = p.issue as Record | undefined; + const issueKey = issue?.key as string | undefined; + const fields = issue?.fields as Record | undefined; + const projectField = fields?.project as Record | undefined; + const projectKey = projectField?.key as string | undefined; + + if (!projectKey) return null; + + return { + eventType: webhookEvent, + projectIdentifier: projectKey, + workItemId: issueKey, + raw, + }; + } + + async isSelfAuthored(event: PMWebhookEvent, projectId: string): Promise { + if (!event.eventType.startsWith('comment_')) return false; + const p = event.raw as Record; + const comment = p.comment as Record | undefined; + const author = comment?.author as Record | undefined; + const commentAuthorId = author?.accountId as string | undefined; + if (!commentAuthorId) return false; + + try { + const botId = await resolveJiraBotAccountId(projectId); + return !!botId && commentAuthorId === botId; + } catch { + return false; + } + } + + async postAckComment( + projectId: string, + workItemId: string, + message: string, + ): Promise { + return postJiraAck(projectId, workItemId, message); + } + + async deleteAckComment(projectId: string, workItemId: string, commentId: string): Promise { + return deleteJiraAck(projectId, workItemId, commentId); + } + + async sendReaction(projectId: string, event: PMWebhookEvent): Promise { + return sendAcknowledgeReaction('jira', projectId, event.raw); + } + + async lookupProject( + identifier: string, + ): Promise<{ project: ProjectConfig; config: CascadeConfig } | null> { + return (await loadProjectConfigByJiraProjectKey(identifier)) ?? null; + } + + extractWorkItemId(text: string): string | null { + const match = text.match(JIRA_ISSUE_KEY_REGEX); + return match ? match[1] : null; + } +} diff --git a/src/pm/lifecycle.ts b/src/pm/lifecycle.ts index 17f638f3..cbd3c6c5 100644 --- a/src/pm/lifecycle.ts +++ b/src/pm/lifecycle.ts @@ -8,6 +8,7 @@ import type { ProjectConfig } from '../types/index.js'; import { safeOperation, silentOperation } from '../utils/safeOperation.js'; +import { pmRegistry } from './registry.js'; import type { PMProvider } from './types.js'; /** @@ -30,42 +31,10 @@ export interface ProjectPMConfig { /** * Resolve PM-specific config (labels, statuses) from project configuration. + * Delegates to the registered integration's resolveLifecycleConfig(). */ export function resolveProjectPMConfig(project: ProjectConfig): ProjectPMConfig { - if (project.pm?.type === 'jira' && project.jira) { - // JIRA uses label strings (not IDs) and status names from config - const jiraLabels = project.jira.labels; - return { - labels: { - processing: jiraLabels?.processing ?? 'cascade-processing', - processed: jiraLabels?.processed ?? 'cascade-processed', - error: jiraLabels?.error ?? 'cascade-error', - readyToProcess: jiraLabels?.readyToProcess ?? 'cascade-ready', - }, - statuses: { - inProgress: project.jira.statuses.inProgress, - inReview: project.jira.statuses.inReview, - done: project.jira.statuses.done, - merged: project.jira.statuses.merged, - }, - }; - } - - // Trello — labels are IDs from project config - return { - labels: { - processing: project.trello?.labels?.processing, - processed: project.trello?.labels?.processed, - error: project.trello?.labels?.error, - readyToProcess: project.trello?.labels?.readyToProcess, - }, - statuses: { - inProgress: project.trello?.lists?.inProgress, - inReview: project.trello?.lists?.inReview, - done: project.trello?.lists?.done, - merged: project.trello?.lists?.merged, - }, - }; + return pmRegistry.resolveLifecycleConfig(project); } export class PMLifecycleManager { diff --git a/src/pm/registry.ts b/src/pm/registry.ts new file mode 100644 index 00000000..9737498a --- /dev/null +++ b/src/pm/registry.ts @@ -0,0 +1,53 @@ +/** + * PMIntegrationRegistry — singleton that holds all registered PM integrations. + * + * Populated at import time by each integration module. The router, worker, + * and shared infrastructure use `pmRegistry.get(type)` to obtain the + * integration instance without provider-specific branching. + */ + +import type { ProjectConfig } from '../types/index.js'; +import type { PMIntegration } from './integration.js'; +import type { ProjectPMConfig } from './lifecycle.js'; +import type { PMProvider } from './types.js'; + +class PMIntegrationRegistry { + private integrations = new Map(); + + register(integration: PMIntegration): void { + this.integrations.set(integration.type, integration); + } + + get(type: string): PMIntegration { + const integration = this.integrations.get(type); + if (!integration) { + throw new Error( + `Unknown PM integration type: '${type}'. Registered: ${[...this.integrations.keys()].join(', ')}`, + ); + } + return integration; + } + + getOrNull(type: string): PMIntegration | null { + return this.integrations.get(type) ?? null; + } + + all(): PMIntegration[] { + return [...this.integrations.values()]; + } + + /** Convenience: get the integration for a project and create its PMProvider */ + createProvider(project: ProjectConfig): PMProvider { + const type = project.pm?.type ?? 'trello'; + return this.get(type).createProvider(project); + } + + /** Convenience: resolve lifecycle config from project */ + resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { + const type = project.pm?.type ?? 'trello'; + return this.get(type).resolveLifecycleConfig(project); + } +} + +/** Singleton registry, populated at import time */ +export const pmRegistry = new PMIntegrationRegistry(); diff --git a/src/pm/trello/adapter.ts b/src/pm/trello/adapter.ts index 00c44f5b..ff42461f 100644 --- a/src/pm/trello/adapter.ts +++ b/src/pm/trello/adapter.ts @@ -28,6 +28,7 @@ export class TrelloPMProvider implements PMProvider { title: card.name, description: card.desc, url: card.url, + status: card.idList, labels: card.labels.map( (l): WorkItemLabel => ({ id: l.id, diff --git a/src/pm/trello/integration.ts b/src/pm/trello/integration.ts new file mode 100644 index 00000000..170e5f20 --- /dev/null +++ b/src/pm/trello/integration.ts @@ -0,0 +1,119 @@ +/** + * TrelloIntegration — implements PMIntegration for Trello. + * + * Encapsulates all Trello-specific concerns: credential resolution, + * webhook parsing, ack comments, reactions, project lookup, and triggers. + * + * Router-side operations (ack comments, reactions, bot identity) delegate + * to the single-source-of-truth functions in router/acknowledgments.ts + * and router/reactions.ts. + */ + +import { getIntegrationCredential, loadProjectConfigByBoardId } from '../../config/provider.js'; +import { + deleteTrelloAck, + postTrelloAck, + resolveTrelloBotMemberId, +} from '../../router/acknowledgments.js'; +import { sendAcknowledgeReaction } from '../../router/reactions.js'; +import { withTrelloCredentials } from '../../trello/client.js'; +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { getTrelloConfig } from '../config.js'; +import type { PMIntegration, PMWebhookEvent } from '../integration.js'; +import type { ProjectPMConfig } from '../lifecycle.js'; +import type { PMProvider } from '../types.js'; +import { TrelloPMProvider } from './adapter.js'; + +export class TrelloIntegration implements PMIntegration { + readonly type = 'trello'; + + createProvider(_project: ProjectConfig): PMProvider { + return new TrelloPMProvider(); + } + + async withCredentials(projectId: string, fn: () => Promise): Promise { + const apiKey = await getIntegrationCredential(projectId, 'pm', 'api_key'); + const token = await getIntegrationCredential(projectId, 'pm', 'token'); + return withTrelloCredentials({ apiKey, token }, fn); + } + + resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { + const trelloConfig = getTrelloConfig(project); + return { + labels: { + processing: trelloConfig?.labels?.processing, + processed: trelloConfig?.labels?.processed, + error: trelloConfig?.labels?.error, + readyToProcess: trelloConfig?.labels?.readyToProcess, + }, + statuses: { + inProgress: trelloConfig?.lists?.inProgress, + inReview: trelloConfig?.lists?.inReview, + done: trelloConfig?.lists?.done, + merged: trelloConfig?.lists?.merged, + }, + }; + } + + parseWebhookPayload(raw: unknown): PMWebhookEvent | null { + if (!raw || typeof raw !== 'object') return null; + const p = raw as Record; + const action = p.action as Record | undefined; + const model = p.model as Record | undefined; + if (!action || !model) return null; + + const boardId = model.id as string; + const actionType = action.type as string; + const data = action.data as Record | undefined; + const card = data?.card as Record | undefined; + const cardId = card?.id as string | undefined; + + return { + eventType: actionType, + projectIdentifier: boardId, + workItemId: cardId, + raw, + }; + } + + async isSelfAuthored(event: PMWebhookEvent, projectId: string): Promise { + const p = event.raw as Record; + const action = p.action as Record | undefined; + const commentAuthorId = action?.idMemberCreator as string | undefined; + if (!commentAuthorId) return false; + + try { + const botId = await resolveTrelloBotMemberId(projectId); + return !!botId && commentAuthorId === botId; + } catch { + return false; + } + } + + async postAckComment( + projectId: string, + workItemId: string, + message: string, + ): Promise { + return postTrelloAck(projectId, workItemId, message); + } + + async deleteAckComment(projectId: string, workItemId: string, commentId: string): Promise { + return deleteTrelloAck(projectId, workItemId, commentId); + } + + async sendReaction(projectId: string, event: PMWebhookEvent): Promise { + return sendAcknowledgeReaction('trello', projectId, event.raw); + } + + async lookupProject( + identifier: string, + ): Promise<{ project: ProjectConfig; config: CascadeConfig } | null> { + return (await loadProjectConfigByBoardId(identifier)) ?? null; + } + + extractWorkItemId(text: string): string | null { + const match = text.match(/https:\/\/trello\.com\/c\/([a-zA-Z0-9]+)/); + return match ? match[1] : null; + } +} diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts new file mode 100644 index 00000000..ce29950b --- /dev/null +++ b/src/pm/webhook-handler.ts @@ -0,0 +1,211 @@ +/** + * Generic PM webhook processor. + * + * Extracts the common webhook processing flow from the Trello and JIRA + * webhook handlers into a single PM-agnostic function. Provider-specific + * behavior (credential resolution, payload parsing, project lookup, + * ack comment management) is delegated to the PMIntegration interface. + */ + +import { withGitHubToken } from '../github/client.js'; +import { getPersonaToken } from '../github/personas.js'; +import type { TriggerRegistry } from '../triggers/registry.js'; +import { runAgentExecutionPipeline } from '../triggers/shared/agent-execution.js'; +import { processNextQueuedWebhook } from '../triggers/shared/webhook-queue.js'; +import type { TriggerResult } from '../triggers/types.js'; +import type { + CascadeConfig, + ProjectConfig, + TriggerContext, + TriggerSource, +} from '../types/index.js'; +import { + clearCardActive, + enqueueWebhook, + getQueueLength, + isCardActive, + isCurrentlyProcessing, + logger, + setCardActive, + setProcessing, + startWatchdog, +} from '../utils/index.js'; +import { injectLlmApiKeys } from '../utils/llmEnv.js'; +import { getPMProvider, withPMProvider } from './context.js'; +import type { PMIntegration } from './integration.js'; +import { PMLifecycleManager, resolveProjectPMConfig } from './lifecycle.js'; +import { pmRegistry } from './registry.js'; + +// ============================================================================ +// Agent Execution +// ============================================================================ + +async function executeAgent( + integration: PMIntegration, + result: TriggerResult, + project: ProjectConfig, + config: CascadeConfig, +): Promise { + if (!result.agentType) return; + const githubToken = await getPersonaToken(project.id, result.agentType); + const restoreLlmEnv = await injectLlmApiKeys(project.id); + + try { + await integration.withCredentials(project.id, () => + withGitHubToken(githubToken, () => + runAgentExecutionPipeline(result, project, config, { + logLabel: `${integration.type} agent`, + }), + ), + ); + } finally { + restoreLlmEnv(); + } +} + +// ============================================================================ +// Webhook Processing +// ============================================================================ + +function processNextQueued(integration: PMIntegration, registry: TriggerRegistry): void { + processNextQueuedWebhook( + (payload, _eventType, ackCommentId) => + processPMWebhook(integration, payload, registry, ackCommentId as string | undefined), + integration.type.charAt(0).toUpperCase() + integration.type.slice(1), + ); +} + +async function cleanupOrphanAck( + integration: PMIntegration, + projectId: string, + payload: unknown, + ackCommentId: string, +): Promise { + const event = integration.parseWebhookPayload(payload); + if (event?.workItemId) { + logger.info('Cleaning up orphan ack comment', { ackCommentId }); + await integration.deleteAckComment(projectId, event.workItemId, ackCommentId).catch(() => {}); + } +} + +async function handleMatchedTrigger( + integration: PMIntegration, + registry: TriggerRegistry, + payload: unknown, + project: ProjectConfig, + config: CascadeConfig, + ackCommentId?: string, +): Promise { + const ctx: TriggerContext = { project, source: integration.type as TriggerSource, payload }; + const result = await registry.dispatch(ctx); + if (!result) { + logger.info(`No trigger matched for ${integration.type} webhook`); + if (ackCommentId) { + await cleanupOrphanAck(integration, project.id, payload, ackCommentId); + } + return; + } + + // Pass ack comment ID into agent input for ProgressMonitor pre-seeding + if (ackCommentId) { + result.agentInput.ackCommentId = ackCommentId; + } + + const workItemId = result.workItemId; + if (workItemId && isCardActive(workItemId)) { + logger.info('Work item already being processed, skipping', { workItemId }); + return; + } + + logger.info(`${integration.type} trigger matched`, { + agentType: result.agentType, + workItemId, + }); + + setProcessing(true); + startWatchdog(config.defaults.watchdogTimeoutMs); + + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(getPMProvider(), pmConfig); + + try { + if (workItemId) { + setCardActive(workItemId); + } + await executeAgent(integration, result, project, config); + } catch (err) { + logger.error(`Failed to process ${integration.type} webhook`, { error: String(err) }); + if (workItemId) { + await lifecycle.handleError(workItemId, String(err)); + } + } finally { + if (workItemId) { + clearCardActive(workItemId); + } + setProcessing(false); + processNextQueued(integration, registry); + } +} + +/** + * Generic PM webhook processor. + * + * Validates the payload via the integration's `parseWebhookPayload()`, + * looks up the project, establishes credential + PM provider scope, + * dispatches to the trigger registry, and runs the matched agent. + * + * Used by both Trello and JIRA webhook handlers. + */ +export async function processPMWebhook( + integration: PMIntegration, + payload: unknown, + registry: TriggerRegistry, + ackCommentId?: string, +): Promise { + logger.info(`Processing ${integration.type} webhook`); + + const event = integration.parseWebhookPayload(payload); + if (!event) { + logger.warn(`Invalid ${integration.type} webhook payload`, { + payload: JSON.stringify(payload).slice(0, 200), + }); + return; + } + + if (isCurrentlyProcessing()) { + const queued = enqueueWebhook(payload, undefined, ackCommentId); + if (queued) { + logger.info(`Currently processing, ${integration.type} webhook queued`, { + queueLength: getQueueLength(), + }); + } else { + logger.warn(`Queue full, ${integration.type} webhook rejected`, { + queueLength: getQueueLength(), + }); + } + return; + } + + logger.info(`${integration.type} webhook details`, { + projectIdentifier: event.projectIdentifier, + workItemId: event.workItemId, + eventType: event.eventType, + }); + + const projectConfig = await integration.lookupProject(event.projectIdentifier); + if (!projectConfig) { + logger.warn(`No project configured for ${integration.type} identifier`, { + identifier: event.projectIdentifier, + }); + return; + } + const { project, config } = projectConfig; + + // Establish credential + PM provider scope for trigger dispatch + const pmProvider = pmRegistry.createProvider(project); + await integration.withCredentials(project.id, () => + withPMProvider(pmProvider, () => + handleMatchedTrigger(integration, registry, payload, project, config, ackCommentId), + ), + ); +} diff --git a/src/router/acknowledgments.ts b/src/router/acknowledgments.ts index 173b7151..fd1f059f 100644 --- a/src/router/acknowledgments.ts +++ b/src/router/acknowledgments.ts @@ -16,6 +16,7 @@ import { findProjectByRepo, getIntegrationCredential, } from '../config/provider.js'; +import { getJiraConfig } from '../pm/config.js'; import { markdownToAdf } from '../pm/jira/adf.js'; import type { ProjectConfig } from '../types/index.js'; @@ -147,7 +148,7 @@ export async function postJiraAck( jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); const project = await findProjectById(projectId); - jiraBaseUrl = project?.jira?.baseUrl ?? ''; + jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); } catch { console.warn('[Ack] Missing JIRA credentials, skipping ack comment'); @@ -188,7 +189,7 @@ export async function deleteJiraAck( jiraEmail = await getIntegrationCredential(projectId, 'pm', 'email'); jiraApiToken = await getIntegrationCredential(projectId, 'pm', 'api_token'); const project = await findProjectById(projectId); - jiraBaseUrl = project?.jira?.baseUrl ?? ''; + jiraBaseUrl = (project ? getJiraConfig(project)?.baseUrl : undefined) ?? ''; if (!jiraBaseUrl) throw new Error('Missing JIRA base URL'); } catch { return; @@ -233,7 +234,7 @@ export async function resolveJiraBotAccountId(projectId: string): Promise { const config: CascadeConfig = await loadConfig(); return { - projects: config.projects.map((p) => ({ - id: p.id, - repo: p.repo, - pmType: p.pm?.type ?? 'trello', - ...(p.trello && { - trello: { - boardId: p.trello.boardId, - lists: p.trello.lists, - labels: p.trello.labels, - }, - }), - ...(p.jira && { - jira: { - projectKey: p.jira.projectKey, - baseUrl: p.jira.baseUrl, - }, - }), - })), + projects: config.projects.map((p) => { + const trelloConfig = getTrelloConfig(p); + const jiraConfig = getJiraConfig(p); + return { + id: p.id, + repo: p.repo, + pmType: p.pm?.type ?? 'trello', + ...(trelloConfig && { + trello: { + boardId: trelloConfig.boardId, + lists: trelloConfig.lists, + labels: trelloConfig.labels, + }, + }), + ...(jiraConfig && { + jira: { + projectKey: jiraConfig.projectKey, + baseUrl: jiraConfig.baseUrl, + }, + }), + }; + }), fullProjects: config.projects, }; } diff --git a/src/router/reactions.ts b/src/router/reactions.ts index d0855f1d..976f1757 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -11,6 +11,7 @@ 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'; @@ -210,7 +211,7 @@ async function sendJiraReaction(projectId: string, payload: unknown): Promise { } } +/** + * Establish PM credential scope for the project. + * Uses the integration's withCredentials() for the correct PM type. + * Falls through to running fn() directly if no PM type is configured. + */ +async function withPMCredentials(project: ProjectConfig, fn: () => Promise): Promise { + const pmType = project.pm?.type; + if (!pmType) return fn(); + const integration = pmRegistry.getOrNull(pmType); + if (!integration) return fn(); + return integration.withCredentials(project.id, fn); +} + async function executeGitHubAgent( result: TriggerResult, project: ProjectConfig, config: CascadeConfig, ): Promise { - const trelloApiKey = await getIntegrationCredentialOrNull(project.id, 'pm', 'api_key').then( - (v) => v ?? '', - ); - const trelloToken = await getIntegrationCredentialOrNull(project.id, 'pm', 'token').then( - (v) => v ?? '', - ); + if (!result.agentType) return; const githubToken = await getPersonaToken(project.id, result.agentType); - const restoreLlmEnv = await injectLlmApiKeys(project.id); const executionConfig: AgentExecutionConfig = { @@ -104,7 +110,7 @@ async function executeGitHubAgent( try { const pmProvider = createPMProvider(project); - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => + await withPMCredentials(project, () => withPMProvider(pmProvider, () => withGitHubToken(githubToken, () => runAgentExecutionPipeline(result, project, config, executionConfig), @@ -124,6 +130,7 @@ async function runGitHubAgentJob( registry: TriggerRegistry, routerAckCommentId?: number, ): Promise { + if (!result.agentType) return; // Use the persona token for the agent that will do the work (for ack comments) let prCommentToken: string; try { @@ -205,21 +212,14 @@ export async function processGitHubWebhook( } const { project, config } = projectConfig; - // Resolve credentials early — trigger handlers may call GitHub/Trello APIs - const trelloApiKey = await getIntegrationCredentialOrNull(project.id, 'pm', 'api_key').then( - (v) => v ?? '', - ); - const trelloToken = await getIntegrationCredentialOrNull(project.id, 'pm', 'token').then( - (v) => v ?? '', - ); - // Resolve persona identities and use implementer token for webhook processing const personaIdentities = await resolvePersonaIdentities(project.id); const githubToken = await getPersonaToken(project.id, 'implementation'); const pmProvider = createPMProvider(project); + // Establish PM credential + provider scope for trigger dispatch const ctx: TriggerContext = { project, source: 'github', payload, personaIdentities }; - const result = await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => + const result = await withPMCredentials(project, () => withPMProvider(pmProvider, () => withGitHubToken(githubToken, () => registry.dispatch(ctx))), ); diff --git a/src/triggers/jira/comment-mention.ts b/src/triggers/jira/comment-mention.ts index e84e26e5..a4982984 100644 --- a/src/triggers/jira/comment-mention.ts +++ b/src/triggers/jira/comment-mention.ts @@ -7,6 +7,7 @@ import { resolveJiraTriggerEnabled } from '../../config/triggerConfig.js'; import { jiraClient } from '../../jira/client.js'; +import { getJiraConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -111,7 +112,7 @@ export class JiraCommentMentionTrigger implements TriggerHandler { if (ctx.source !== 'jira') return false; // Check trigger config — default enabled for backward compatibility - if (!resolveJiraTriggerEnabled(ctx.project.jira?.triggers, 'commentMention')) { + if (!resolveJiraTriggerEnabled(getJiraConfig(ctx.project)?.triggers, 'commentMention')) { return false; } @@ -190,7 +191,6 @@ export class JiraCommentMentionTrigger implements TriggerHandler { triggerCommentAuthor: authorName, }, workItemId: issueKey, - cardId: issueKey, }; } } diff --git a/src/triggers/jira/issue-transitioned.ts b/src/triggers/jira/issue-transitioned.ts index 1f9f6017..1c3d647c 100644 --- a/src/triggers/jira/issue-transitioned.ts +++ b/src/triggers/jira/issue-transitioned.ts @@ -6,6 +6,7 @@ */ import { resolveJiraTriggerEnabled } from '../../config/triggerConfig.js'; +import { getJiraConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -51,7 +52,7 @@ export class JiraIssueTransitionedTrigger implements TriggerHandler { if (ctx.source !== 'jira') return false; // Check trigger config — default enabled for backward compatibility - if (!resolveJiraTriggerEnabled(ctx.project.jira?.triggers, 'issueTransitioned')) { + if (!resolveJiraTriggerEnabled(getJiraConfig(ctx.project)?.triggers, 'issueTransitioned')) { return false; } @@ -69,7 +70,7 @@ export class JiraIssueTransitionedTrigger implements TriggerHandler { const newStatus = statusChange?.toString; if (!newStatus) return null; - const jiraConfig = ctx.project.jira; + const jiraConfig = getJiraConfig(ctx.project); if (!jiraConfig?.statuses) return null; for (const [cascadeStatus, jiraStatus] of Object.entries(jiraConfig.statuses)) { @@ -94,7 +95,7 @@ export class JiraIssueTransitionedTrigger implements TriggerHandler { return null; } - const jiraConfig = ctx.project.jira; + const jiraConfig = getJiraConfig(ctx.project); if (!jiraConfig?.statuses) { logger.debug('No JIRA status configuration, skipping issue transition trigger', { projectId: ctx.project.id, @@ -131,7 +132,6 @@ export class JiraIssueTransitionedTrigger implements TriggerHandler { agentType, agentInput: { cardId: issueKey }, workItemId: issueKey, - cardId: issueKey, }; } } diff --git a/src/triggers/jira/label-added.ts b/src/triggers/jira/label-added.ts index 56b4ab59..672ac763 100644 --- a/src/triggers/jira/label-added.ts +++ b/src/triggers/jira/label-added.ts @@ -14,6 +14,7 @@ import { resolveJiraTriggerEnabled, resolveReadyToProcessEnabled, } from '../../config/triggerConfig.js'; +import { getJiraConfig } from '../../pm/config.js'; import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -69,7 +70,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { if (ctx.source !== 'jira') return false; // Check trigger config — default enabled for backward compatibility - if (!resolveJiraTriggerEnabled(ctx.project.jira?.triggers, 'readyToProcessLabel')) { + if (!resolveJiraTriggerEnabled(getJiraConfig(ctx.project)?.triggers, 'readyToProcessLabel')) { return false; } @@ -101,7 +102,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { const currentStatus = payload.issue?.fields?.status?.name; if (!currentStatus) return null; - const jiraConfig = ctx.project.jira; + const jiraConfig = getJiraConfig(ctx.project); if (!jiraConfig?.statuses) return null; for (const [cascadeStatus, jiraStatus] of Object.entries(jiraConfig.statuses)) { @@ -126,7 +127,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return null; } - const jiraConfig = ctx.project.jira; + const jiraConfig = getJiraConfig(ctx.project); if (!jiraConfig?.statuses) { logger.debug('No JIRA status configuration, skipping label trigger', { projectId: ctx.project.id, @@ -153,7 +154,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { } // Check per-agent ready-to-process toggle - if (!resolveReadyToProcessEnabled(ctx.project.jira?.triggers, agentType)) { + if (!resolveReadyToProcessEnabled(getJiraConfig(ctx.project)?.triggers, agentType)) { logger.info('JIRA ready-to-process disabled for agent type, skipping', { issueKey, agentType, @@ -171,7 +172,6 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { agentType, agentInput: { cardId: issueKey }, workItemId: issueKey, - cardId: issueKey, }; } } diff --git a/src/triggers/jira/webhook-handler.ts b/src/triggers/jira/webhook-handler.ts index 44dc8b9a..9f034b88 100644 --- a/src/triggers/jira/webhook-handler.ts +++ b/src/triggers/jira/webhook-handler.ts @@ -1,221 +1,19 @@ /** * JIRA webhook handler. * - * Processes JIRA webhooks: validates payload, extracts project key, - * finds project via findProjectByJiraProjectKey(), resolves creds, - * and dispatches to the trigger registry. + * Thin wrapper around the generic PM webhook processor. + * Resolves the JIRA integration from the registry and delegates. */ -import { - getIntegrationCredential, - loadProjectConfigByJiraProjectKey, -} from '../../config/provider.js'; -import { withGitHubToken } from '../../github/client.js'; -import { getPersonaToken } from '../../github/personas.js'; -import { withJiraCredentials } from '../../jira/client.js'; -import { - PMLifecycleManager, - createPMProvider, - resolveProjectPMConfig, - withPMProvider, -} from '../../pm/index.js'; -import type { CascadeConfig, ProjectConfig, TriggerContext } from '../../types/index.js'; -import { - enqueueWebhook, - getQueueLength, - isCurrentlyProcessing, - logger, - setProcessing, - startWatchdog, -} from '../../utils/index.js'; -import { injectLlmApiKeys } from '../../utils/llmEnv.js'; +import { pmRegistry } from '../../pm/index.js'; +import { processPMWebhook } from '../../pm/webhook-handler.js'; import type { TriggerRegistry } from '../registry.js'; -import { runAgentExecutionPipeline } from '../shared/agent-execution.js'; -import { processNextQueuedWebhook } from '../shared/webhook-queue.js'; -import type { TriggerResult } from '../types.js'; - -interface JiraWebhookPayload { - webhookEvent: string; - issue?: { - id?: string; - key: string; - fields?: { - project?: { key?: string }; - status?: { name?: string }; - summary?: string; - comment?: { comments?: unknown[] }; - }; - }; - comment?: { - id?: string; - body?: unknown; - author?: { displayName?: string; accountId?: string }; - }; - changelog?: { - items?: Array<{ - field?: string; - fromString?: string; - toString?: string; - }>; - }; -} - -function isJiraWebhookPayload(payload: unknown): payload is JiraWebhookPayload { - const p = payload as Record; - return typeof p?.webhookEvent === 'string'; -} - -function extractProjectKey(payload: JiraWebhookPayload): string | undefined { - return payload.issue?.fields?.project?.key; -} - -async function executeJiraAgent( - result: TriggerResult, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - const jiraEmail = await getIntegrationCredential(project.id, 'pm', 'email'); - const jiraApiToken = await getIntegrationCredential(project.id, 'pm', 'api_token'); - const jiraBaseUrl = project.jira?.baseUrl ?? ''; - const githubToken = await getPersonaToken(project.id, result.agentType); - - const restoreLlmEnv = await injectLlmApiKeys(project.id); - - try { - const pmProvider = createPMProvider(project); - await withJiraCredentials( - { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, - () => - withPMProvider(pmProvider, () => - withGitHubToken(githubToken, () => - runAgentExecutionPipeline(result, project, config, { logLabel: 'JIRA agent' }), - ), - ), - ); - } finally { - restoreLlmEnv(); - } -} - -async function handleMatchedJiraTrigger( - registry: TriggerRegistry, - payload: JiraWebhookPayload, - project: ProjectConfig, - config: CascadeConfig, - pmProvider: ReturnType, - ackCommentId?: string, -): Promise { - const ctx: TriggerContext = { project, source: 'jira', payload }; - const result = await registry.dispatch(ctx); - if (!result) { - logger.info('No trigger matched for JIRA webhook', { event: payload.webhookEvent }); - if (ackCommentId && payload.issue?.key) { - await cleanupOrphanJiraAck(project.id, payload.issue.key, ackCommentId); - } - return; - } - - // Pass ack comment ID into agent input for ProgressMonitor pre-seeding - if (ackCommentId) { - result.agentInput.ackCommentId = ackCommentId; - } - - logger.info('JIRA trigger matched', { - agentType: result.agentType, - workItemId: result.workItemId, - }); - - setProcessing(true); - startWatchdog(config.defaults.watchdogTimeoutMs); - - const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - - try { - await executeJiraAgent(result, project, config); - } catch (err) { - logger.error('Failed to process JIRA webhook', { error: String(err) }); - if (result.workItemId) { - await lifecycle.handleError(result.workItemId, String(err)); - } - } finally { - setProcessing(false); - processNextQueuedJiraWebhook(registry); - } -} - -async function cleanupOrphanJiraAck( - projectId: string, - issueKey: string, - ackCommentId: string, -): Promise { - logger.info('Cleaning up orphan ack comment', { ackCommentId, issueKey }); - const { deleteJiraAck } = await import('../../router/acknowledgments.js'); - await deleteJiraAck(projectId, issueKey, ackCommentId).catch(() => {}); -} - -function processNextQueuedJiraWebhook(registry: TriggerRegistry): void { - processNextQueuedWebhook( - (payload, _eventType, ackCommentId) => - processJiraWebhook(payload, registry, ackCommentId as string | undefined), - 'JIRA', - ); -} export async function processJiraWebhook( payload: unknown, registry: TriggerRegistry, ackCommentId?: string, ): Promise { - logger.info('Processing JIRA webhook'); - - if (!isJiraWebhookPayload(payload)) { - logger.warn('Invalid JIRA webhook payload', { - payload: JSON.stringify(payload).slice(0, 200), - }); - return; - } - - if (isCurrentlyProcessing()) { - const queued = enqueueWebhook(payload, undefined, ackCommentId); - if (queued) { - logger.info('Currently processing, JIRA webhook queued', { queueLength: getQueueLength() }); - } else { - logger.warn('Queue full, JIRA webhook rejected', { queueLength: getQueueLength() }); - } - return; - } - - const projectKey = extractProjectKey(payload); - if (!projectKey) { - logger.warn('JIRA webhook missing project key'); - return; - } - - logger.info('JIRA webhook details', { - event: payload.webhookEvent, - issueKey: payload.issue?.key, - projectKey, - }); - - const projectConfig = await loadProjectConfigByJiraProjectKey(projectKey); - if (!projectConfig) { - logger.warn('No project configured for JIRA project key', { projectKey }); - return; - } - const { project, config } = projectConfig; - - // Establish JIRA credential + PM provider scope - const jiraEmail = await getIntegrationCredential(project.id, 'pm', 'email'); - const jiraApiToken = await getIntegrationCredential(project.id, 'pm', 'api_token'); - const jiraBaseUrl = project.jira?.baseUrl ?? ''; - const pmProvider = createPMProvider(project); - - await withJiraCredentials( - { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, - () => - withPMProvider(pmProvider, () => - handleMatchedJiraTrigger(registry, payload, project, config, pmProvider, ackCommentId), - ), - ); + const integration = pmRegistry.get('jira'); + await processPMWebhook(integration, payload, registry, ackCommentId); } diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index 81503620..1ea6adab 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -73,7 +73,7 @@ async function checkPreRunBudget( */ async function runPostAgentLifecycle( workItemId: string, - result: TriggerResult, + agentType: string, agentResult: AgentResult, project: ProjectConfig, config: CascadeConfig, @@ -86,7 +86,7 @@ async function runPostAgentLifecycle( handleSuccessOnlyForAgentType, } = executionConfig; - await handleAgentResultArtifacts(workItemId, result.agentType, agentResult, project); + await handleAgentResultArtifacts(workItemId, agentType, agentResult, project); const postBudgetCheck = await checkBudgetExceeded(workItemId, project, config); if (postBudgetCheck?.exceeded) { @@ -103,12 +103,12 @@ async function runPostAgentLifecycle( const shouldCallHandleSuccess = agentResult.success && - (!handleSuccessOnlyForAgentType || result.agentType === handleSuccessOnlyForAgentType); + (!handleSuccessOnlyForAgentType || agentType === handleSuccessOnlyForAgentType); if (shouldCallHandleSuccess) { await lifecycle.handleSuccess( workItemId, - result.agentType, + agentType, agentResult.prUrl, agentResult.progressCommentId, ); @@ -143,9 +143,15 @@ export async function runAgentExecutionPipeline( config: CascadeConfig, executionConfig: AgentExecutionConfig = {}, ): Promise { + if (!result.agentType) { + logger.warn('No agent type in trigger result, skipping execution pipeline'); + return; + } + const agentType = result.agentType; + const { skipPrepareForAgent = false, onFailure, logLabel = 'Agent' } = executionConfig; - const workItemId = result.cardId ?? result.workItemId; + const workItemId = result.workItemId; const pmProvider = createPMProvider(project); const pmConfig = resolveProjectPMConfig(project); const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); @@ -158,10 +164,10 @@ export async function runAgentExecutionPipeline( } if (workItemId && !skipPrepareForAgent) { - await lifecycle.prepareForAgent(workItemId, result.agentType); + await lifecycle.prepareForAgent(workItemId, agentType); } - const agentResult = await runAgent(result.agentType, { + const agentResult = await runAgent(agentType, { ...result.agentInput, remainingBudgetUsd, project, @@ -171,7 +177,7 @@ export async function runAgentExecutionPipeline( if (workItemId) { await runPostAgentLifecycle( workItemId, - result, + agentType, agentResult, project, config, @@ -181,7 +187,7 @@ export async function runAgentExecutionPipeline( } logger.info(`${logLabel} completed`, { - agentType: result.agentType, + agentType, success: agentResult.success, runId: agentResult.runId, }); diff --git a/src/triggers/shared/agent-result-handler.ts b/src/triggers/shared/agent-result-handler.ts index 777bca14..a526ba05 100644 --- a/src/triggers/shared/agent-result-handler.ts +++ b/src/triggers/shared/agent-result-handler.ts @@ -1,19 +1,9 @@ +import { getCostFieldId } from '../../pm/config.js'; import { getPMProvider } from '../../pm/index.js'; import type { AgentResult, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { safeOperation } from '../../utils/safeOperation.js'; -/** - * Resolve the cost custom field ID from the project config. - * Supports both Trello and JIRA projects. - */ -function getCostFieldId(project: ProjectConfig): string | undefined { - if (project.pm?.type === 'jira') { - return project.jira?.customFields?.cost; - } - return project.trello?.customFields?.cost; -} - /** * Update cost custom field on the work item (card/issue). * Shared between GitHub, Trello, and JIRA webhook handlers. diff --git a/src/triggers/shared/budget.ts b/src/triggers/shared/budget.ts index 2aad72c3..9ba5e638 100644 --- a/src/triggers/shared/budget.ts +++ b/src/triggers/shared/budget.ts @@ -1,3 +1,4 @@ +import { getCostFieldId } from '../../pm/config.js'; import { getPMProvider } from '../../pm/index.js'; import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; @@ -8,16 +9,6 @@ export interface BudgetCheckResult { remaining: number; } -/** - * Resolve the cost custom field ID from the project config. - */ -function getCostFieldId(project: ProjectConfig): string | undefined { - if (project.pm?.type === 'jira') { - return project.jira?.customFields?.cost; - } - return project.trello?.customFields?.cost; -} - /** * Resolve the card budget for a project. * Returns `null` if no cost custom field is configured (budget enforcement not applicable). diff --git a/src/triggers/trello/card-moved.ts b/src/triggers/trello/card-moved.ts index 47c20a8e..3f90e79e 100644 --- a/src/triggers/trello/card-moved.ts +++ b/src/triggers/trello/card-moved.ts @@ -1,4 +1,5 @@ import { resolveTrelloTriggerEnabled } from '../../config/triggerConfig.js'; +import { getTrelloConfig } from '../../pm/config.js'; import type { TrelloWebhookPayload, TriggerContext, @@ -29,12 +30,13 @@ function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { if (!isTrelloWebhookPayload(ctx.payload)) return false; // Check trigger config — default enabled for backward compatibility - if (!resolveTrelloTriggerEnabled(ctx.project.trello?.triggers, config.triggerConfigKey)) { + const trelloConfig = getTrelloConfig(ctx.project); + if (!resolveTrelloTriggerEnabled(trelloConfig?.triggers, config.triggerConfigKey)) { return false; } const payload = ctx.payload; - const targetListId = ctx.project.trello?.lists[config.listKey]; + const targetListId = trelloConfig?.lists[config.listKey]; // Card moved into the target list const isMove = @@ -64,7 +66,7 @@ function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { return { agentType: config.agentType, agentInput: { cardId }, - cardId, + workItemId: cardId, }; }, }; diff --git a/src/triggers/trello/comment-mention.ts b/src/triggers/trello/comment-mention.ts index 63e15010..5ecfc741 100644 --- a/src/triggers/trello/comment-mention.ts +++ b/src/triggers/trello/comment-mention.ts @@ -1,4 +1,5 @@ import { resolveTrelloTriggerEnabled } from '../../config/triggerConfig.js'; +import { getTrelloConfig } from '../../pm/config.js'; import { trelloClient } from '../../trello/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -37,7 +38,7 @@ export class TrelloCommentMentionTrigger implements TriggerHandler { if (!isTrelloWebhookPayload(ctx.payload)) return false; // Check trigger config — default enabled for backward compatibility - if (!resolveTrelloTriggerEnabled(ctx.project.trello?.triggers, 'commentMention')) { + if (!resolveTrelloTriggerEnabled(getTrelloConfig(ctx.project)?.triggers, 'commentMention')) { return false; } @@ -76,7 +77,7 @@ export class TrelloCommentMentionTrigger implements TriggerHandler { } // Fetch card to verify it's in the PLANNING list - const planningListId = ctx.project.trello?.lists.planning; + const planningListId = getTrelloConfig(ctx.project)?.lists.planning; if (!planningListId) { logger.debug('Planning list not configured, skipping comment mention trigger', { projectId: ctx.project.id, @@ -110,7 +111,7 @@ export class TrelloCommentMentionTrigger implements TriggerHandler { triggerCommentText: commentText, triggerCommentAuthor: commentAuthor, }, - cardId, + workItemId: cardId, }; } } diff --git a/src/triggers/trello/label-added.ts b/src/triggers/trello/label-added.ts index 9fa90a9d..27e4afbf 100644 --- a/src/triggers/trello/label-added.ts +++ b/src/triggers/trello/label-added.ts @@ -2,6 +2,7 @@ import { resolveReadyToProcessEnabled, resolveTrelloTriggerEnabled, } from '../../config/triggerConfig.js'; +import { getTrelloConfig } from '../../pm/config.js'; import { trelloClient } from '../../trello/client.js'; import { logger } from '../../utils/logging.js'; import type { @@ -22,12 +23,13 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { // Check trigger config — default enabled for backward compatibility // (checks if any agent has readyToProcessLabel enabled) - if (!resolveTrelloTriggerEnabled(ctx.project.trello?.triggers, 'readyToProcessLabel')) { + const trelloConfig = getTrelloConfig(ctx.project); + if (!resolveTrelloTriggerEnabled(trelloConfig?.triggers, 'readyToProcessLabel')) { return false; } const payload = ctx.payload; - const readyLabelId = ctx.project.trello?.labels.readyToProcess; + const readyLabelId = trelloConfig?.labels.readyToProcess; return ( payload.action.type === 'addLabelToCard' && payload.action.data.label?.id === readyLabelId @@ -54,7 +56,7 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { logger.info('Determining agent type from list', { cardId, currentListId }); // Determine agent type based on current list - const lists = ctx.project.trello?.lists ?? {}; + const lists = getTrelloConfig(ctx.project)?.lists ?? {}; let agentType: string; if (currentListId === lists.briefing) { @@ -72,7 +74,7 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { logger.info('Agent type determined', { agentType, cardId, listId: currentListId }); // Check per-agent ready-to-process toggle - if (!resolveReadyToProcessEnabled(ctx.project.trello?.triggers, agentType)) { + if (!resolveReadyToProcessEnabled(getTrelloConfig(ctx.project)?.triggers, agentType)) { logger.info('Ready-to-process disabled for agent type, skipping', { agentType, cardId }); return null; } @@ -80,7 +82,7 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { return { agentType, agentInput: { cardId }, - cardId, + workItemId: cardId, }; } } diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 25f6f8bc..9b3f2b36 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -1,201 +1,19 @@ -import { getIntegrationCredential, loadProjectConfigByBoardId } from '../../config/provider.js'; -import { withGitHubToken } from '../../github/client.js'; -import { getPersonaToken } from '../../github/personas.js'; -import { - PMLifecycleManager, - createPMProvider, - resolveProjectPMConfig, - withPMProvider, -} from '../../pm/index.js'; -import { withTrelloCredentials } from '../../trello/client.js'; -import type { CascadeConfig, ProjectConfig, TriggerContext } from '../../types/index.js'; -import { - clearCardActive, - enqueueWebhook, - getQueueLength, - isCardActive, - isCurrentlyProcessing, - logger, - setCardActive, - setProcessing, - startWatchdog, -} from '../../utils/index.js'; -import { injectLlmApiKeys } from '../../utils/llmEnv.js'; +/** + * Trello webhook handler. + * + * Thin wrapper around the generic PM webhook processor. + * Resolves the Trello integration from the registry and delegates. + */ + +import { pmRegistry } from '../../pm/index.js'; +import { processPMWebhook } from '../../pm/webhook-handler.js'; import type { TriggerRegistry } from '../registry.js'; -import { runAgentExecutionPipeline } from '../shared/agent-execution.js'; -import { processNextQueuedWebhook } from '../shared/webhook-queue.js'; -import type { TrelloWebhookPayload, TriggerResult } from '../types.js'; -import { isTrelloWebhookPayload } from '../types.js'; - -// ============================================================================ -// Agent Execution -// ============================================================================ - -async function executeAgent( - result: TriggerResult, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - const trelloApiKey = await getIntegrationCredential(project.id, 'pm', 'api_key'); - const trelloToken = await getIntegrationCredential(project.id, 'pm', 'token'); - const githubToken = await getPersonaToken(project.id, result.agentType); - - const restoreLlmEnv = await injectLlmApiKeys(project.id); - - try { - const pmProvider = createPMProvider(project); - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withPMProvider(pmProvider, () => - withGitHubToken(githubToken, () => - runAgentExecutionPipeline(result, project, config, { logLabel: 'Agent' }), - ), - ), - ); - } finally { - restoreLlmEnv(); - } -} - -// ============================================================================ -// Webhook Processing -// ============================================================================ - -function processNextQueued(registry: TriggerRegistry): void { - processNextQueuedWebhook( - (payload, _eventType, ackCommentId) => - processTrelloWebhook(payload, registry, ackCommentId as string | undefined), - 'Trello', - ); -} - -function tryQueueWebhook(payload: TrelloWebhookPayload, ackCommentId?: string): boolean { - if (!isCurrentlyProcessing()) return false; - - const queued = enqueueWebhook(payload, undefined, ackCommentId); - if (queued) { - logger.info('Currently processing, webhook queued', { queueLength: getQueueLength() }); - } else { - logger.warn('Queue full, webhook rejected', { queueLength: getQueueLength() }); - } - return true; -} - -async function cleanupOrphanTrelloAck( - projectId: string, - payload: TrelloWebhookPayload, - ackCommentId: string, -): Promise { - const cardId = (payload.action?.data?.card as Record | undefined)?.id as - | string - | undefined; - if (cardId) { - logger.info('Cleaning up orphan ack comment', { ackCommentId, cardId }); - const { deleteTrelloAck } = await import('../../router/acknowledgments.js'); - await deleteTrelloAck(projectId, cardId, ackCommentId).catch(() => {}); - } -} - -async function handleMatchedTrigger( - registry: TriggerRegistry, - payload: TrelloWebhookPayload, - actionType: string | undefined, - project: ProjectConfig, - config: CascadeConfig, - pmProvider: ReturnType, - ackCommentId?: string, -): Promise { - const ctx: TriggerContext = { project, source: 'trello', payload }; - const result = await registry.dispatch(ctx); - if (!result) { - logger.info('No trigger matched for webhook', { actionType }); - if (ackCommentId) { - await cleanupOrphanTrelloAck(project.id, payload, ackCommentId); - } - return; - } - - // Pass ack comment ID into agent input for ProgressMonitor pre-seeding - if (ackCommentId) { - result.agentInput.ackCommentId = ackCommentId; - } - - const cardId = result.cardId ?? result.workItemId; - if (cardId && isCardActive(cardId)) { - logger.info('Card already being processed, skipping', { cardId }); - return; - } - - logger.info('Trigger matched', { agentType: result.agentType, cardId }); - setProcessing(true); - startWatchdog(config.defaults.watchdogTimeoutMs); - - const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - - try { - if (cardId) { - setCardActive(cardId); - } - await executeAgent(result, project, config); - } catch (err) { - logger.error('Failed to process webhook', { error: String(err) }); - if (cardId) { - await lifecycle.handleError(cardId, String(err)); - } - } finally { - if (cardId) { - clearCardActive(cardId); - } - setProcessing(false); - processNextQueued(registry); - } -} export async function processTrelloWebhook( payload: unknown, registry: TriggerRegistry, ackCommentId?: string, ): Promise { - logger.info('Processing Trello webhook'); - - if (!isTrelloWebhookPayload(payload)) { - logger.warn('Invalid Trello webhook payload', { - payload: JSON.stringify(payload).slice(0, 200), - }); - return; - } - - if (tryQueueWebhook(payload, ackCommentId)) { - return; - } - - const boardId = payload.model.id; - const actionType = payload.action?.type; - logger.info('Webhook details', { boardId, actionType }); - - const projectConfig = await loadProjectConfigByBoardId(boardId); - if (!projectConfig) { - logger.warn('No project configured for board', { boardId }); - return; - } - const { project, config } = projectConfig; - - // Establish Trello credential + PM provider scope for all downstream operations - const trelloApiKey = await getIntegrationCredential(project.id, 'pm', 'api_key'); - const trelloToken = await getIntegrationCredential(project.id, 'pm', 'token'); - const pmProvider = createPMProvider(project); - - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withPMProvider(pmProvider, () => - handleMatchedTrigger( - registry, - payload, - actionType, - project, - config, - pmProvider, - ackCommentId, - ), - ), - ); + const integration = pmRegistry.get('trello'); + await processPMWebhook(integration, payload, registry, ackCommentId); } diff --git a/src/types/index.ts b/src/types/index.ts index 0a5b8db0..ee72b897 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,11 +68,8 @@ export interface TriggerContext { } export interface TriggerResult { - agentType: string; + agentType: string | null; agentInput: AgentInput; - /** @deprecated Use workItemId instead */ - cardId?: string; - /** Alias for cardId — preferred name for PM-agnostic code */ workItemId?: string; prNumber?: number; } diff --git a/tests/unit/gadgets/trello.test.ts b/tests/unit/gadgets/trello.test.ts deleted file mode 100644 index a04eca5b..00000000 --- a/tests/unit/gadgets/trello.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - AddChecklistToCard, - CreateTrelloCard, - GetMyRecentActivity, - ListTrelloCards, - PostTrelloComment, - ReadTrelloCard, - UpdateTrelloCard, -} from '../../../src/gadgets/trello/index.js'; - -describe('Trello Gadgets', () => { - describe('ReadTrelloCard', () => { - it('is a valid llmist Gadget class', () => { - const gadget = new ReadTrelloCard(); - expect(gadget).toBeDefined(); - expect(typeof gadget.execute).toBe('function'); - }); - - it('has correct metadata', () => { - const gadget = new ReadTrelloCard(); - expect(gadget.name).toBe('ReadTrelloCard'); - expect(gadget.description).toContain('Trello card'); - }); - }); - - describe('PostTrelloComment', () => { - it('is a valid llmist Gadget class', () => { - const gadget = new PostTrelloComment(); - expect(gadget).toBeDefined(); - expect(typeof gadget.execute).toBe('function'); - }); - - it('has correct metadata', () => { - const gadget = new PostTrelloComment(); - expect(gadget.name).toBe('PostTrelloComment'); - expect(gadget.description).toContain('comment'); - }); - }); - - describe('UpdateTrelloCard', () => { - it('is a valid llmist Gadget class', () => { - const gadget = new UpdateTrelloCard(); - expect(gadget).toBeDefined(); - expect(typeof gadget.execute).toBe('function'); - }); - - it('has correct metadata', () => { - const gadget = new UpdateTrelloCard(); - expect(gadget.name).toBe('UpdateTrelloCard'); - expect(gadget.description).toContain('Update'); - }); - }); - - describe('CreateTrelloCard', () => { - it('is a valid llmist Gadget class', () => { - const gadget = new CreateTrelloCard(); - expect(gadget).toBeDefined(); - expect(typeof gadget.execute).toBe('function'); - }); - - it('has correct metadata', () => { - const gadget = new CreateTrelloCard(); - expect(gadget.name).toBe('CreateTrelloCard'); - expect(gadget.description).toContain('Create'); - }); - - it('has user story format in description', () => { - const gadget = new CreateTrelloCard(); - expect(gadget.description).toContain('user story'); - }); - }); - - describe('ListTrelloCards', () => { - it('is a valid llmist Gadget class', () => { - const gadget = new ListTrelloCards(); - expect(gadget).toBeDefined(); - expect(typeof gadget.execute).toBe('function'); - }); - - it('has correct metadata', () => { - const gadget = new ListTrelloCards(); - expect(gadget.name).toBe('ListTrelloCards'); - expect(gadget.description).toContain('List'); - }); - - it('mentions finding cards in description', () => { - const gadget = new ListTrelloCards(); - expect(gadget.description).toContain('find cards'); - }); - }); - - describe('GetMyRecentActivity', () => { - it('is a valid llmist Gadget class', () => { - const gadget = new GetMyRecentActivity(); - expect(gadget).toBeDefined(); - expect(typeof gadget.execute).toBe('function'); - }); - - it('has correct metadata', () => { - const gadget = new GetMyRecentActivity(); - expect(gadget.name).toBe('GetMyRecentActivity'); - expect(gadget.description).toContain('recent'); - }); - - it('mentions activity types in description', () => { - const gadget = new GetMyRecentActivity(); - expect(gadget.description).toContain('created'); - expect(gadget.description).toContain('updated'); - }); - }); - - describe('AddChecklistToCard', () => { - it('is a valid llmist Gadget class', () => { - const gadget = new AddChecklistToCard(); - expect(gadget).toBeDefined(); - expect(typeof gadget.execute).toBe('function'); - }); - - it('has correct metadata', () => { - const gadget = new AddChecklistToCard(); - expect(gadget.name).toBe('AddChecklistToCard'); - expect(gadget.description).toContain('checklist'); - }); - - it('mentions acceptance criteria in description', () => { - const gadget = new AddChecklistToCard(); - expect(gadget.description).toContain('acceptance criteria'); - }); - - it('mentions implementation steps in description', () => { - const gadget = new AddChecklistToCard(); - expect(gadget.description).toContain('implementation steps'); - }); - }); -}); diff --git a/tests/unit/gadgets/trello/core.test.ts b/tests/unit/gadgets/trello/core.test.ts deleted file mode 100644 index c05dec79..00000000 --- a/tests/unit/gadgets/trello/core.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../../../src/trello/client.js', () => ({ - trelloClient: { - getCard: vi.fn(), - getCardChecklists: vi.fn(), - getCardAttachments: vi.fn(), - getCardComments: vi.fn(), - addComment: vi.fn(), - updateCard: vi.fn(), - addLabelToCard: vi.fn(), - createCard: vi.fn(), - getListCards: vi.fn(), - createChecklist: vi.fn(), - addChecklistItem: vi.fn(), - updateChecklistItem: vi.fn(), - }, -})); - -import { addChecklist } from '../../../../src/gadgets/trello/core/addChecklist.js'; -import { createCard } from '../../../../src/gadgets/trello/core/createCard.js'; -import { listCards } from '../../../../src/gadgets/trello/core/listCards.js'; -import { postComment } from '../../../../src/gadgets/trello/core/postComment.js'; -import { readCard } from '../../../../src/gadgets/trello/core/readCard.js'; -import { updateCard } from '../../../../src/gadgets/trello/core/updateCard.js'; -import { updateChecklistItem } from '../../../../src/gadgets/trello/core/updateChecklistItem.js'; -import { trelloClient } from '../../../../src/trello/client.js'; - -const mockTrello = vi.mocked(trelloClient); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('readCard', () => { - const mockCard = { - name: 'Test Card', - url: 'https://trello.com/c/abc123', - desc: 'A description', - labels: [{ name: 'Bug', color: 'red' }], - }; - - it('formats card with title, description, labels, checklists, attachments', async () => { - mockTrello.getCard.mockResolvedValue(mockCard as ReturnType); - mockTrello.getCardChecklists.mockResolvedValue([ - { - id: 'cl1', - name: 'Tasks', - checkItems: [{ id: 'ci1', name: 'Item 1', state: 'incomplete' }], - }, - ] as Awaited>); - mockTrello.getCardAttachments.mockResolvedValue([ - { name: 'file.txt', url: 'https://example.com/file.txt', date: '2024-01-01T00:00:00Z' }, - ] as Awaited>); - mockTrello.getCardComments.mockResolvedValue([]); - - const result = await readCard('abc123', false); - - expect(result).toContain('# Test Card'); - expect(result).toContain('A description'); - expect(result).toContain('Bug (red)'); - expect(result).toContain('Tasks'); - expect(result).toContain('[ ] Item 1'); - expect(result).toContain('file.txt'); - }); - - it('shows "(No description)" when desc empty', async () => { - mockTrello.getCard.mockResolvedValue({ - ...mockCard, - desc: '', - } as ReturnType); - mockTrello.getCardChecklists.mockResolvedValue([]); - mockTrello.getCardAttachments.mockResolvedValue([]); - - const result = await readCard('abc123', false); - - expect(result).toContain('(No description)'); - }); - - it('fetches comments when includeComments=true', async () => { - mockTrello.getCard.mockResolvedValue(mockCard as ReturnType); - mockTrello.getCardChecklists.mockResolvedValue([]); - mockTrello.getCardAttachments.mockResolvedValue([]); - mockTrello.getCardComments.mockResolvedValue([ - { - date: '2024-01-01T00:00:00Z', - memberCreator: { fullName: 'John' }, - data: { text: 'Hello world' }, - }, - ] as Awaited>); - - const result = await readCard('abc123', true); - - expect(result).toContain('John'); - expect(result).toContain('Hello world'); - expect(mockTrello.getCardComments).toHaveBeenCalledWith('abc123'); - }); - - it('skips comments when includeComments=false', async () => { - mockTrello.getCard.mockResolvedValue(mockCard as ReturnType); - mockTrello.getCardChecklists.mockResolvedValue([]); - mockTrello.getCardAttachments.mockResolvedValue([]); - - await readCard('abc123', false); - - expect(mockTrello.getCardComments).not.toHaveBeenCalled(); - }); - - it('returns error message on API failure', async () => { - mockTrello.getCard.mockRejectedValue(new Error('API down')); - - const result = await readCard('abc123'); - - expect(result).toBe('Error reading card: API down'); - }); -}); - -describe('postComment', () => { - it('returns success message', async () => { - mockTrello.addComment.mockResolvedValue(undefined as never); - - const result = await postComment('card1', 'Hello'); - - expect(result).toBe('Comment posted successfully'); - expect(mockTrello.addComment).toHaveBeenCalledWith('card1', 'Hello'); - }); - - it('returns error message on failure', async () => { - mockTrello.addComment.mockRejectedValue(new Error('Network error')); - - const result = await postComment('card1', 'Hello'); - - expect(result).toBe('Error posting comment: Network error'); - }); -}); - -describe('updateCard', () => { - it('returns early when nothing to update', async () => { - const result = await updateCard({ cardId: 'card1' }); - - expect(result).toBe('Nothing to update - provide title, description, or labels'); - expect(mockTrello.updateCard).not.toHaveBeenCalled(); - }); - - it('updates title and description', async () => { - mockTrello.updateCard.mockResolvedValue(undefined as never); - - const result = await updateCard({ - cardId: 'card1', - title: 'New Title', - description: 'New Desc', - }); - - expect(mockTrello.updateCard).toHaveBeenCalledWith('card1', { - name: 'New Title', - desc: 'New Desc', - }); - expect(result).toContain('title'); - expect(result).toContain('description'); - }); - - it('adds labels', async () => { - mockTrello.addLabelToCard.mockResolvedValue(undefined as never); - - const result = await updateCard({ - cardId: 'card1', - addLabelIds: ['label1', 'label2'], - }); - - expect(mockTrello.addLabelToCard).toHaveBeenCalledTimes(2); - expect(result).toContain('2 label(s)'); - }); - - it('returns summary of what was updated', async () => { - mockTrello.updateCard.mockResolvedValue(undefined as never); - mockTrello.addLabelToCard.mockResolvedValue(undefined as never); - - const result = await updateCard({ - cardId: 'card1', - title: 'Title', - addLabelIds: ['l1'], - }); - - expect(result).toBe('Card updated: title, 1 label(s)'); - }); - - it('returns error message on failure', async () => { - mockTrello.updateCard.mockRejectedValue(new Error('Forbidden')); - - const result = await updateCard({ cardId: 'card1', title: 'New' }); - - expect(result).toBe('Error updating card: Forbidden'); - }); -}); - -describe('createCard', () => { - it('returns success with card name and URL', async () => { - mockTrello.createCard.mockResolvedValue({ - name: 'My Card', - shortUrl: 'https://trello.com/c/xyz', - } as Awaited>); - - const result = await createCard({ listId: 'list1', title: 'My Card' }); - - expect(result).toBe('Card created successfully: "My Card" - https://trello.com/c/xyz'); - }); - - it('returns error message on failure', async () => { - mockTrello.createCard.mockRejectedValue(new Error('List not found')); - - const result = await createCard({ listId: 'bad', title: 'Test' }); - - expect(result).toBe('Error creating card: List not found'); - }); -}); - -describe('listCards', () => { - it('formats cards with name, ID, URL', async () => { - mockTrello.getListCards.mockResolvedValue([ - { id: 'c1', name: 'Card 1', shortUrl: 'https://trello.com/c/1', desc: '' }, - { id: 'c2', name: 'Card 2', shortUrl: 'https://trello.com/c/2', desc: 'Short desc' }, - ] as Awaited>); - - const result = await listCards('list1'); - - expect(result).toContain('Cards (2)'); - expect(result).toContain('Card 1'); - expect(result).toContain('c1'); - expect(result).toContain('Card 2'); - }); - - it('returns empty message when no cards', async () => { - mockTrello.getListCards.mockResolvedValue([]); - - const result = await listCards('list1'); - - expect(result).toBe('No cards found in this list.'); - }); - - it('truncates long descriptions at 100 chars', async () => { - const longDesc = 'a'.repeat(150); - mockTrello.getListCards.mockResolvedValue([ - { id: 'c1', name: 'Card', shortUrl: 'https://trello.com/c/1', desc: longDesc }, - ] as Awaited>); - - const result = await listCards('list1'); - - expect(result).toContain(`${'a'.repeat(100)}...`); - expect(result).not.toContain('a'.repeat(101)); - }); - - it('returns error message on failure', async () => { - mockTrello.getListCards.mockRejectedValue(new Error('Board not found')); - - const result = await listCards('list1'); - - expect(result).toBe('Error listing cards: Board not found'); - }); -}); - -describe('addChecklist', () => { - it('creates checklist and adds items', async () => { - mockTrello.createChecklist.mockResolvedValue({ id: 'cl1' } as Awaited< - ReturnType - >); - mockTrello.addChecklistItem.mockResolvedValue(undefined as never); - - const result = await addChecklist({ - cardId: 'card1', - checklistName: 'Tasks', - items: ['Item 1', 'Item 2'], - }); - - expect(mockTrello.createChecklist).toHaveBeenCalledWith('card1', 'Tasks'); - expect(mockTrello.addChecklistItem).toHaveBeenCalledTimes(2); - expect(mockTrello.addChecklistItem).toHaveBeenCalledWith('cl1', 'Item 1'); - expect(mockTrello.addChecklistItem).toHaveBeenCalledWith('cl1', 'Item 2'); - }); - - it('returns success with item count', async () => { - mockTrello.createChecklist.mockResolvedValue({ id: 'cl1' } as Awaited< - ReturnType - >); - mockTrello.addChecklistItem.mockResolvedValue(undefined as never); - - const result = await addChecklist({ - cardId: 'card1', - checklistName: 'Checklist', - items: ['A', 'B', 'C'], - }); - - expect(result).toContain('3 items'); - }); - - it('returns error message on failure', async () => { - mockTrello.createChecklist.mockRejectedValue(new Error('Card not found')); - - const result = await addChecklist({ - cardId: 'bad', - checklistName: 'Tasks', - items: ['Item'], - }); - - expect(result).toBe('Error adding checklist: Card not found'); - }); -}); - -describe('updateChecklistItem', () => { - it('returns success for "complete" state', async () => { - mockTrello.updateChecklistItem.mockResolvedValue(undefined as never); - - const result = await updateChecklistItem('card1', 'ci1', 'complete'); - - expect(result).toContain('marked complete'); - expect(mockTrello.updateChecklistItem).toHaveBeenCalledWith('card1', 'ci1', 'complete'); - }); - - it('returns success for "incomplete" state', async () => { - mockTrello.updateChecklistItem.mockResolvedValue(undefined as never); - - const result = await updateChecklistItem('card1', 'ci1', 'incomplete'); - - expect(result).toContain('marked incomplete'); - }); - - it('returns error message on failure', async () => { - mockTrello.updateChecklistItem.mockRejectedValue(new Error('Not found')); - - const result = await updateChecklistItem('card1', 'ci1', 'complete'); - - expect(result).toBe('Error updating checklist item: Not found'); - }); -}); diff --git a/tests/unit/pm/factory.test.ts b/tests/unit/pm/factory.test.ts index 09e625d1..f2d4654c 100644 --- a/tests/unit/pm/factory.test.ts +++ b/tests/unit/pm/factory.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest'; -import { createPMProvider } from '../../../src/pm/factory.js'; import type { ProjectConfig } from '../../../src/types/index.js'; // Mock the adapters @@ -20,6 +19,27 @@ vi.mock('../../../src/pm/jira/adapter.js', () => ({ })), })); +// Mock provider.ts to avoid DB calls from integration constructors +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn().mockResolvedValue('mock-cred'), + loadProjectConfigByBoardId: vi.fn().mockResolvedValue(null), + loadProjectConfigByJiraProjectKey: vi.fn().mockResolvedValue(null), + findProjectById: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn((_creds, fn) => fn()), + trelloClient: {}, +})); + +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn((_creds, fn) => fn()), + jiraClient: {}, +})); + +// Import after mocks so the integrations register with mocked adapters +// factory.ts was removed; createPMProvider is now an inline function in index.ts +import { createPMProvider } from '../../../src/pm/index.js'; import { JiraPMProvider } from '../../../src/pm/jira/adapter.js'; import { TrelloPMProvider } from '../../../src/pm/trello/adapter.js'; @@ -107,7 +127,7 @@ describe('pm/factory', () => { }; expect(() => createPMProvider(project)).toThrow( - "Project 'proj1' has pm.type=jira but no jira config", + 'JIRA integration requires projectKey in config', ); }); @@ -122,7 +142,7 @@ describe('pm/factory', () => { pm: { type: 'unknown' }, } as ProjectConfig; - expect(() => createPMProvider(project)).toThrow('Unknown PM type: unknown'); + expect(() => createPMProvider(project)).toThrow("Unknown PM integration type: 'unknown'"); }); }); }); diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts index 61a9b180..f45c7374 100644 --- a/tests/unit/pm/lifecycle.test.ts +++ b/tests/unit/pm/lifecycle.test.ts @@ -1,4 +1,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies before imports +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn().mockResolvedValue('mock-cred'), + loadProjectConfigByBoardId: vi.fn().mockResolvedValue(null), + loadProjectConfigByJiraProjectKey: vi.fn().mockResolvedValue(null), + findProjectById: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn((_creds, fn) => fn()), + trelloClient: {}, +})); + +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn((_creds, fn) => fn()), + jiraClient: {}, +})); + +vi.mock('../../../src/utils/safeOperation.js', () => ({ + safeOperation: vi.fn((fn) => fn()), + silentOperation: vi.fn((fn) => fn()), +})); + +// Import after mocks — side-effect import registers integrations with pmRegistry +import '../../../src/pm/index.js'; import { PMLifecycleManager, type ProjectPMConfig, @@ -7,12 +33,6 @@ import { import type { PMProvider } from '../../../src/pm/types.js'; import type { ProjectConfig } from '../../../src/types/index.js'; -// Mock safeOperation utilities -vi.mock('../../../src/utils/safeOperation.js', () => ({ - safeOperation: vi.fn((fn) => fn()), - silentOperation: vi.fn((fn) => fn()), -})); - describe('pm/lifecycle', () => { describe('resolveProjectPMConfig', () => { it('returns JIRA config when project type is jira', () => { diff --git a/tests/unit/triggers/agent-execution.test.ts b/tests/unit/triggers/agent-execution.test.ts index 0e36d8da..5d5305b9 100644 --- a/tests/unit/triggers/agent-execution.test.ts +++ b/tests/unit/triggers/agent-execution.test.ts @@ -81,7 +81,7 @@ const mockConfig: CascadeConfig = { const mockTriggerResult: TriggerResult = { agentType: 'implementation', - cardId: 'card-123', + workItemId: 'card-123', agentInput: { someInput: 'value' }, }; @@ -304,7 +304,7 @@ describe('runAgentExecutionPipeline', () => { it('uses cardId when present', async () => { const result: TriggerResult = { agentType: 'implementation', - cardId: 'card-456', + workItemId: 'card-456', agentInput: {}, }; @@ -313,7 +313,7 @@ describe('runAgentExecutionPipeline', () => { expect(mockLifecycle.prepareForAgent).toHaveBeenCalledWith('card-456', 'implementation'); }); - it('falls back to workItemId when cardId is absent', async () => { + it('uses workItemId when present', async () => { const result: TriggerResult = { agentType: 'implementation', workItemId: 'issue-789', diff --git a/tests/unit/triggers/card-moved.test.ts b/tests/unit/triggers/card-moved.test.ts index aa29529c..8af247b4 100644 --- a/tests/unit/triggers/card-moved.test.ts +++ b/tests/unit/triggers/card-moved.test.ts @@ -1,4 +1,35 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +// Mocks required for PM integration registration (pm/index.js side-effect) +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), + loadProjectConfigByBoardId: vi.fn(), + loadProjectConfigByJiraProjectKey: vi.fn(), + findProjectById: vi.fn(), +})); +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn(), + trelloClient: { getCard: vi.fn() }, +})); +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn(), + jiraClient: {}, +})); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postTrelloAck: vi.fn(), + deleteTrelloAck: vi.fn(), + resolveTrelloBotMemberId: vi.fn(), + postJiraAck: vi.fn(), + deleteJiraAck: vi.fn(), + resolveJiraBotAccountId: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); + +// Register PM integrations in the registry +import '../../../src/pm/index.js'; + import { CardMovedToBriefingTrigger, CardMovedToPlanningTrigger, @@ -159,7 +190,7 @@ describe('CardMovedToBriefingTrigger', () => { const result = await trigger.handle(ctx); expect(result.agentType).toBe('briefing'); - expect(result.cardId).toBe('card123'); + expect(result.workItemId).toBe('card123'); expect(result.agentInput.cardId).toBe('card123'); }); }); @@ -240,6 +271,6 @@ describe('CardMovedToTodoTrigger', () => { const result = await trigger.handle(ctx); expect(result.agentType).toBe('implementation'); - expect(result.cardId).toBe('card456'); + expect(result.workItemId).toBe('card456'); }); }); diff --git a/tests/unit/triggers/check-suite-failure.test.ts b/tests/unit/triggers/check-suite-failure.test.ts index a4dff112..c0cd0ba5 100644 --- a/tests/unit/triggers/check-suite-failure.test.ts +++ b/tests/unit/triggers/check-suite-failure.test.ts @@ -179,7 +179,7 @@ describe('CheckSuiteFailureTrigger', () => { cardId: 'abc123', }, prNumber: 42, - cardId: 'abc123', + workItemId: 'abc123', }); }); diff --git a/tests/unit/triggers/check-suite-success.test.ts b/tests/unit/triggers/check-suite-success.test.ts index d9433245..bd8388ac 100644 --- a/tests/unit/triggers/check-suite-success.test.ts +++ b/tests/unit/triggers/check-suite-success.test.ts @@ -187,7 +187,7 @@ describe('CheckSuiteSuccessTrigger', () => { cardId: 'abc123', }, prNumber: 42, - cardId: 'abc123', + workItemId: 'abc123', }); }); diff --git a/tests/unit/triggers/github-pr-comment-mention.test.ts b/tests/unit/triggers/github-pr-comment-mention.test.ts index 373f8ed6..bdaedb79 100644 --- a/tests/unit/triggers/github-pr-comment-mention.test.ts +++ b/tests/unit/triggers/github-pr-comment-mention.test.ts @@ -211,7 +211,7 @@ describe('PRCommentMentionTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('respond-to-pr-comment'); - expect(result?.cardId).toBe(CARD_SHORT_ID); + expect(result?.workItemId).toBe(CARD_SHORT_ID); expect(result?.agentInput.prNumber).toBe(42); expect(result?.agentInput.repoFullName).toBe('owner/repo'); expect(result?.agentInput.triggerCommentBody).toContain(`@${IMPLEMENTER_USERNAME}`); diff --git a/tests/unit/triggers/jira-comment-mention.test.ts b/tests/unit/triggers/jira-comment-mention.test.ts index 977c8ee3..7af32e37 100644 --- a/tests/unit/triggers/jira-comment-mention.test.ts +++ b/tests/unit/triggers/jira-comment-mention.test.ts @@ -219,7 +219,6 @@ describe('JiraCommentMentionTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('respond-to-planning-comment'); expect(result?.workItemId).toBe('DAM-13'); - expect(result?.cardId).toBe('DAM-13'); expect(result?.agentInput.triggerCommentAuthor).toBe('Human User'); }); diff --git a/tests/unit/triggers/jira-issue-transitioned.test.ts b/tests/unit/triggers/jira-issue-transitioned.test.ts index 21b2bd80..d9a2efe0 100644 --- a/tests/unit/triggers/jira-issue-transitioned.test.ts +++ b/tests/unit/triggers/jira-issue-transitioned.test.ts @@ -158,7 +158,6 @@ describe('JiraIssueTransitionedTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('implementation'); expect(result?.workItemId).toBe('PROJ-42'); - expect(result?.cardId).toBe('PROJ-42'); expect(result?.agentInput).toEqual({ cardId: 'PROJ-42' }); }); diff --git a/tests/unit/triggers/jira-label-added.test.ts b/tests/unit/triggers/jira-label-added.test.ts index 52383d09..31691c8e 100644 --- a/tests/unit/triggers/jira-label-added.test.ts +++ b/tests/unit/triggers/jira-label-added.test.ts @@ -1,4 +1,35 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +// Mocks required for PM integration registration (pm/index.js side-effect) +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), + loadProjectConfigByBoardId: vi.fn(), + loadProjectConfigByJiraProjectKey: vi.fn(), + findProjectById: vi.fn(), +})); +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn(), + trelloClient: { getCard: vi.fn() }, +})); +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn(), + jiraClient: {}, +})); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postTrelloAck: vi.fn(), + deleteTrelloAck: vi.fn(), + resolveTrelloBotMemberId: vi.fn(), + postJiraAck: vi.fn(), + deleteJiraAck: vi.fn(), + resolveJiraBotAccountId: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); + +// Register PM integrations in the registry +import '../../../src/pm/index.js'; + import { JiraReadyToProcessLabelTrigger } from '../../../src/triggers/jira/label-added.js'; import type { TriggerContext } from '../../../src/types/index.js'; @@ -212,7 +243,6 @@ describe('JiraReadyToProcessLabelTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('briefing'); expect(result?.workItemId).toBe('TEST-42'); - expect(result?.cardId).toBe('TEST-42'); expect(result?.agentInput.cardId).toBe('TEST-42'); }); diff --git a/tests/unit/triggers/label-added.test.ts b/tests/unit/triggers/label-added.test.ts index b1515683..3a1e1578 100644 --- a/tests/unit/triggers/label-added.test.ts +++ b/tests/unit/triggers/label-added.test.ts @@ -1,13 +1,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { TriggerContext } from '../../../src/triggers/types.js'; -// Import the module first, then mock it -import * as trelloClientModule from '../../../src/trello/client.js'; +// Mocks required for PM integration registration (pm/index.js side-effect) +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), + loadProjectConfigByBoardId: vi.fn(), + loadProjectConfigByJiraProjectKey: vi.fn(), + findProjectById: vi.fn(), +})); +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn(), + trelloClient: { getCard: vi.fn() }, +})); +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn(), + jiraClient: {}, +})); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postTrelloAck: vi.fn(), + deleteTrelloAck: vi.fn(), + resolveTrelloBotMemberId: vi.fn(), + postJiraAck: vi.fn(), + deleteJiraAck: vi.fn(), + resolveJiraBotAccountId: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); + +// Register PM integrations in the registry +import '../../../src/pm/index.js'; + +import { trelloClient } from '../../../src/trello/client.js'; import { ReadyToProcessLabelTrigger } from '../../../src/triggers/trello/label-added.js'; describe('ReadyToProcessLabelTrigger', () => { const trigger = new ReadyToProcessLabelTrigger(); - let mockGetCard: ReturnType; + const mockGetCard = vi.mocked(trelloClient.getCard); const mockProject = { id: 'test', @@ -29,12 +58,7 @@ describe('ReadyToProcessLabelTrigger', () => { }; beforeEach(() => { - mockGetCard = vi.fn(); - vi.spyOn(trelloClientModule.trelloClient, 'getCard').mockImplementation(mockGetCard); - }); - - afterEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); describe('matches', () => { @@ -171,7 +195,7 @@ describe('ReadyToProcessLabelTrigger', () => { const result = await trigger.handle(ctx); expect(result.agentType).toBe('briefing'); - expect(result.cardId).toBe('card123'); + expect(result.workItemId).toBe('card123'); expect(mockGetCard).toHaveBeenCalledWith('card123'); }); @@ -207,7 +231,7 @@ describe('ReadyToProcessLabelTrigger', () => { const result = await trigger.handle(ctx); expect(result.agentType).toBe('planning'); - expect(result.cardId).toBe('card456'); + expect(result.workItemId).toBe('card456'); }); it('returns implementation agent when card is in todo list', async () => { @@ -242,7 +266,7 @@ describe('ReadyToProcessLabelTrigger', () => { const result = await trigger.handle(ctx); expect(result.agentType).toBe('implementation'); - expect(result.cardId).toBe('card789'); + expect(result.workItemId).toBe('card789'); }); it('defaults to briefing agent when card is in unknown list', async () => { diff --git a/tests/unit/triggers/pr-merged.test.ts b/tests/unit/triggers/pr-merged.test.ts index f86841b5..9fb198c5 100644 --- a/tests/unit/triggers/pr-merged.test.ts +++ b/tests/unit/triggers/pr-merged.test.ts @@ -1,6 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { PRMergedTrigger } from '../../../src/triggers/github/pr-merged.js'; -import type { TriggerContext } from '../../../src/triggers/types.js'; // Mock the GitHub client vi.mock('../../../src/github/client.js', () => ({ @@ -9,17 +7,50 @@ vi.mock('../../../src/github/client.js', () => ({ }, })); -// Mock the Trello client +// Mock the PM provider context +const mockProvider = { + getWorkItem: vi.fn(), + moveWorkItem: vi.fn(), + addComment: vi.fn(), +}; +vi.mock('../../../src/pm/context.js', () => ({ + getPMProvider: () => mockProvider, +})); + +// Mocks required for PM integration registration (pm/index.js side-effect) +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), + loadProjectConfigByBoardId: vi.fn(), + loadProjectConfigByJiraProjectKey: vi.fn(), + findProjectById: vi.fn(), +})); vi.mock('../../../src/trello/client.js', () => ({ - trelloClient: { - getCard: vi.fn(), - moveCardToList: vi.fn(), - addComment: vi.fn(), - }, + withTrelloCredentials: vi.fn(), + trelloClient: { getCard: vi.fn() }, +})); +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn(), + jiraClient: {}, })); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postTrelloAck: vi.fn(), + deleteTrelloAck: vi.fn(), + resolveTrelloBotMemberId: vi.fn(), + postJiraAck: vi.fn(), + deleteJiraAck: vi.fn(), + resolveJiraBotAccountId: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); + +// Register PM integrations in the registry +import '../../../src/pm/index.js'; + +import { PRMergedTrigger } from '../../../src/triggers/github/pr-merged.js'; +import type { TriggerContext } from '../../../src/triggers/types.js'; import { githubClient } from '../../../src/github/client.js'; -import { trelloClient } from '../../../src/trello/client.js'; describe('PRMergedTrigger', () => { const trigger = new PRMergedTrigger(); @@ -121,13 +152,12 @@ describe('PRMergedTrigger', () => { baseRef: 'main', merged: true, }); - vi.mocked(trelloClient.getCard).mockResolvedValue({ + mockProvider.getWorkItem.mockResolvedValue({ id: 'abc123', - name: 'Card', - desc: '', + title: 'Card', + description: '', url: '', - shortUrl: '', - idList: 'todo-list-id', + status: 'todo-list-id', labels: [], }); @@ -150,15 +180,15 @@ describe('PRMergedTrigger', () => { const result = await trigger.handle(ctx); expect(githubClient.getPR).toHaveBeenCalledWith('owner', 'repo', 123); - expect(trelloClient.moveCardToList).toHaveBeenCalledWith('abc123', 'merged-list-id'); - expect(trelloClient.addComment).toHaveBeenCalledWith( + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('abc123', 'merged-list-id'); + expect(mockProvider.addComment).toHaveBeenCalledWith( 'abc123', 'PR #123 has been merged to main', ); expect(result).toEqual({ - agentType: '', + agentType: null, agentInput: {}, - cardId: 'abc123', + workItemId: 'abc123', prNumber: 123, }); }); @@ -194,7 +224,7 @@ describe('PRMergedTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); it('returns null when PR has no Trello card URL', async () => { @@ -228,7 +258,7 @@ describe('PRMergedTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); it('skips move and comment when card is already in MERGED list', async () => { @@ -242,13 +272,12 @@ describe('PRMergedTrigger', () => { baseRef: 'main', merged: true, }); - vi.mocked(trelloClient.getCard).mockResolvedValue({ + mockProvider.getWorkItem.mockResolvedValue({ id: 'abc123', - name: 'Card', - desc: '', + title: 'Card', + description: '', url: '', - shortUrl: '', - idList: 'merged-list-id', + status: 'merged-list-id', labels: [], }); @@ -270,13 +299,13 @@ describe('PRMergedTrigger', () => { const result = await trigger.handle(ctx); - expect(trelloClient.getCard).toHaveBeenCalledWith('abc123'); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); - expect(trelloClient.addComment).not.toHaveBeenCalled(); + expect(mockProvider.getWorkItem).toHaveBeenCalledWith('abc123'); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); + expect(mockProvider.addComment).not.toHaveBeenCalled(); expect(result).toEqual({ - agentType: '', + agentType: null, agentInput: {}, - cardId: 'abc123', + workItemId: 'abc123', prNumber: 123, }); }); @@ -325,7 +354,7 @@ describe('PRMergedTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/unit/triggers/pr-opened.test.ts b/tests/unit/triggers/pr-opened.test.ts index ebb3c75c..c2b8027c 100644 --- a/tests/unit/triggers/pr-opened.test.ts +++ b/tests/unit/triggers/pr-opened.test.ts @@ -212,7 +212,7 @@ describe('PROpenedTrigger', () => { triggerCommentUrl: 'https://github.com/owner/repo/pull/42', }, prNumber: 42, - cardId: 'abc123', + workItemId: 'abc123', }); }); diff --git a/tests/unit/triggers/pr-ready-to-merge.test.ts b/tests/unit/triggers/pr-ready-to-merge.test.ts index fc14c5f9..63c34ac5 100644 --- a/tests/unit/triggers/pr-ready-to-merge.test.ts +++ b/tests/unit/triggers/pr-ready-to-merge.test.ts @@ -1,6 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { PRReadyToMergeTrigger } from '../../../src/triggers/github/pr-ready-to-merge.js'; -import type { TriggerContext } from '../../../src/triggers/types.js'; vi.mock('../../../src/github/client.js', () => ({ githubClient: { @@ -10,16 +8,50 @@ vi.mock('../../../src/github/client.js', () => ({ }, })); +// Mock the PM provider context +const mockProvider = { + getWorkItem: vi.fn(), + moveWorkItem: vi.fn(), + addComment: vi.fn(), +}; +vi.mock('../../../src/pm/context.js', () => ({ + getPMProvider: () => mockProvider, +})); + +// Mocks required for PM integration registration (pm/index.js side-effect) +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), + loadProjectConfigByBoardId: vi.fn(), + loadProjectConfigByJiraProjectKey: vi.fn(), + findProjectById: vi.fn(), +})); vi.mock('../../../src/trello/client.js', () => ({ - trelloClient: { - getCard: vi.fn(), - moveCardToList: vi.fn(), - addComment: vi.fn(), - }, + withTrelloCredentials: vi.fn(), + trelloClient: { getCard: vi.fn() }, +})); +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn(), + jiraClient: {}, })); +vi.mock('../../../src/router/acknowledgments.js', () => ({ + postTrelloAck: vi.fn(), + deleteTrelloAck: vi.fn(), + resolveTrelloBotMemberId: vi.fn(), + postJiraAck: vi.fn(), + deleteJiraAck: vi.fn(), + resolveJiraBotAccountId: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); + +// Register PM integrations in the registry +import '../../../src/pm/index.js'; + +import { PRReadyToMergeTrigger } from '../../../src/triggers/github/pr-ready-to-merge.js'; +import type { TriggerContext } from '../../../src/triggers/types.js'; import { githubClient } from '../../../src/github/client.js'; -import { trelloClient } from '../../../src/trello/client.js'; describe('PRReadyToMergeTrigger', () => { const trigger = new PRReadyToMergeTrigger(); @@ -246,13 +278,12 @@ describe('PRReadyToMergeTrigger', () => { commitId: 'sha123', }, ]); - vi.mocked(trelloClient.getCard).mockResolvedValue({ + mockProvider.getWorkItem.mockResolvedValue({ id: 'abc123', - name: 'Card', - desc: '', + title: 'Card', + description: '', url: '', - shortUrl: '', - idList: 'todo-list-id', + status: 'todo-list-id', labels: [], }); @@ -275,15 +306,15 @@ describe('PRReadyToMergeTrigger', () => { const result = await trigger.handle(ctx); - expect(trelloClient.moveCardToList).toHaveBeenCalledWith('abc123', 'done-list-id'); - expect(trelloClient.addComment).toHaveBeenCalledWith( + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('abc123', 'done-list-id'); + expect(mockProvider.addComment).toHaveBeenCalledWith( 'abc123', 'PR #42 approved and all checks passing - moved to DONE', ); expect(result).toEqual({ - agentType: '', + agentType: null, agentInput: {}, - cardId: 'abc123', + workItemId: 'abc123', prNumber: 42, }); }); @@ -307,13 +338,12 @@ describe('PRReadyToMergeTrigger', () => { commitId: 'sha123', }, ]); - vi.mocked(trelloClient.getCard).mockResolvedValue({ + mockProvider.getWorkItem.mockResolvedValue({ id: 'abc123', - name: 'Card', - desc: '', + title: 'Card', + description: '', url: '', - shortUrl: '', - idList: 'todo-list-id', + status: 'todo-list-id', labels: [], }); @@ -344,8 +374,8 @@ describe('PRReadyToMergeTrigger', () => { const result = await trigger.handle(ctx); - expect(trelloClient.moveCardToList).toHaveBeenCalledWith('abc123', 'done-list-id'); - expect(result?.cardId).toBe('abc123'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('abc123', 'done-list-id'); + expect(result?.workItemId).toBe('abc123'); }); it('returns null when PR has no Trello URL (check_suite path)', async () => { @@ -380,7 +410,7 @@ describe('PRReadyToMergeTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); it('returns null when checks are not all passing', async () => { @@ -423,7 +453,7 @@ describe('PRReadyToMergeTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); it('returns null when no approval exists', async () => { @@ -561,13 +591,12 @@ describe('PRReadyToMergeTrigger', () => { commitId: 'sha123', }, ]); - vi.mocked(trelloClient.getCard).mockResolvedValue({ + mockProvider.getWorkItem.mockResolvedValue({ id: 'abc123', - name: 'Card', - desc: '', + title: 'Card', + description: '', url: '', - shortUrl: '', - idList: 'done-list-id', + status: 'done-list-id', labels: [], }); @@ -590,13 +619,13 @@ describe('PRReadyToMergeTrigger', () => { const result = await trigger.handle(ctx); - expect(trelloClient.getCard).toHaveBeenCalledWith('abc123'); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); - expect(trelloClient.addComment).not.toHaveBeenCalled(); + expect(mockProvider.getWorkItem).toHaveBeenCalledWith('abc123'); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); + expect(mockProvider.addComment).not.toHaveBeenCalled(); expect(result).toEqual({ - agentType: '', + agentType: null, agentInput: {}, - cardId: 'abc123', + workItemId: 'abc123', prNumber: 42, }); }); @@ -661,7 +690,7 @@ describe('PRReadyToMergeTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(trelloClient.moveCardToList).not.toHaveBeenCalled(); + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); }); }); }); diff --git a/tests/unit/triggers/pr-review-submitted.test.ts b/tests/unit/triggers/pr-review-submitted.test.ts index 042cab41..97a816fa 100644 --- a/tests/unit/triggers/pr-review-submitted.test.ts +++ b/tests/unit/triggers/pr-review-submitted.test.ts @@ -161,7 +161,7 @@ describe('PRReviewSubmittedTrigger', () => { triggerCommentUrl: 'https://github.com/owner/repo/pull/42#pullrequestreview-100', }, prNumber: 42, - cardId: 'abc123', + workItemId: 'abc123', }); }); diff --git a/tests/unit/triggers/review-requested.test.ts b/tests/unit/triggers/review-requested.test.ts index 098d4922..ba96b259 100644 --- a/tests/unit/triggers/review-requested.test.ts +++ b/tests/unit/triggers/review-requested.test.ts @@ -186,7 +186,7 @@ describe('ReviewRequestedTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('review'); expect(result?.prNumber).toBe(42); - expect(result?.cardId).toBe('abc123'); + expect(result?.workItemId).toBe('abc123'); expect(result?.agentInput).toMatchObject({ prNumber: 42, repoFullName: 'owner/repo', diff --git a/tests/unit/triggers/trello-comment-mention.test.ts b/tests/unit/triggers/trello-comment-mention.test.ts index 7b9f0101..b8a8fb66 100644 --- a/tests/unit/triggers/trello-comment-mention.test.ts +++ b/tests/unit/triggers/trello-comment-mention.test.ts @@ -145,7 +145,7 @@ describe('TrelloCommentMentionTrigger', () => { expect(result).not.toBeNull(); expect(result?.agentType).toBe('respond-to-planning-comment'); - expect(result?.cardId).toBe('card-1'); + expect(result?.workItemId).toBe('card-1'); expect(result?.agentInput.cardId).toBe('card-1'); expect(result?.agentInput.triggerCommentText).toContain(`@${BOT_USERNAME}`); });