From 41fac627bbb54c4f612690f4f9094a8975dabca2 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Feb 2026 15:19:55 +0000 Subject: [PATCH 1/3] feat: add JIRA integration with PM-agnostic abstraction layer, remove Fly.io lifecycle Introduce a provider-based PM abstraction (`src/pm/`) that allows CASCADE to work with both Trello and JIRA through a unified interface. This includes: - PM provider pattern using AsyncLocalStorage context (`withPMProvider`/`getPMProvider`) - PMLifecycleManager for board lifecycle operations (labels, comments, card moves) - JIRA client and adapter with ADF (Atlassian Document Format) rendering - Trello adapter wrapping existing Trello gadgets behind the PM interface - PM-agnostic gadgets (`src/gadgets/pm/`) replacing direct Trello gadget usage - JIRA webhook handler and triggers (comment-mention, issue-transitioned) - JIRA integration config in project schema and dashboard UI - Webhook routing for JIRA events alongside existing Trello/GitHub webhooks - Agent prompts and shared trigger utilities updated for PM-agnostic work items Also removes all Fly.io-specific lifecycle management: - Remove startFreshMachineTimer, cancelFreshMachineTimer, scheduleShutdownAfterJob - Remove FLY_APP_NAME conditionals from all webhook handlers - Make watchdog timer unconditional (useful for any deployment) - Drop fresh_machine_timeout_ms and post_job_grace_period_ms DB columns - Remove related config fields from schema, API, CLI, and dashboard UI - Add DB migration 0007 to drop the Fly.io columns Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy.yml | 19 ++ package-lock.json | 48 ++- package.json | 1 + src/agents/base.ts | 92 +++--- src/agents/prompts/index.ts | 8 + src/agents/prompts/templates/briefing.eta | 92 +++--- .../prompts/templates/implementation.eta | 16 +- src/agents/prompts/templates/planning.eta | 56 ++-- .../templates/respond-to-planning-comment.eta | 38 +-- src/agents/utils/checklistSync.ts | 32 +- src/api/routers/defaults.ts | 2 - src/api/routers/webhooks.ts | 172 ++++++++++- src/backends/adapter.ts | 80 ++--- src/backends/progressMonitor.ts | 17 +- src/cli/dashboard/defaults/set.ts | 4 - src/cli/dashboard/defaults/show.ts | 2 - src/config/configCache.ts | 11 + src/config/provider.ts | 12 + src/config/schema.ts | 48 ++- .../migrations/0007_remove_flyio_columns.sql | 2 + src/db/migrations/meta/_journal.json | 7 + src/db/repositories/configRepository.ts | 66 ++++- src/db/repositories/settingsRepository.ts | 2 - src/db/schema/defaults.ts | 2 - src/gadgets/pm/AddChecklist.ts | 38 +++ src/gadgets/pm/CreateWorkItem.ts | 37 +++ src/gadgets/pm/ListWorkItems.ts | 22 ++ src/gadgets/pm/PostComment.ts | 26 ++ src/gadgets/pm/ReadWorkItem.ts | 30 ++ src/gadgets/pm/UpdateChecklistItem.ts | 28 ++ src/gadgets/pm/UpdateWorkItem.ts | 40 +++ src/gadgets/pm/core/addChecklist.ts | 23 ++ src/gadgets/pm/core/createWorkItem.ts | 22 ++ src/gadgets/pm/core/listWorkItems.ts | 27 ++ src/gadgets/pm/core/postComment.ts | 11 + src/gadgets/pm/core/readWorkItem.ts | 61 ++++ src/gadgets/pm/core/updateChecklistItem.ts | 17 ++ src/gadgets/pm/core/updateWorkItem.ts | 41 +++ src/gadgets/pm/index.ts | 7 + src/index.ts | 12 +- src/jira/client.ts | 162 ++++++++++ src/jira/types.ts | 5 + src/pm/context.ts | 30 ++ src/pm/factory.ts | 25 ++ src/pm/index.ts | 18 ++ src/pm/jira/adapter.ts | 262 ++++++++++++++++ src/pm/jira/adf.ts | 194 ++++++++++++ src/pm/lifecycle.ts | 166 +++++++++++ src/pm/trello/adapter.ts | 208 +++++++++++++ src/pm/types.ts | 100 +++++++ src/router/config.ts | 26 +- src/router/index.ts | 68 ++++- src/router/queue.ts | 12 +- src/server.ts | 39 ++- src/triggers/github/utils.ts | 45 +++ src/triggers/github/webhook-handler.ts | 167 ++++------- src/triggers/index.ts | 10 + src/triggers/jira/comment-mention.ts | 148 ++++++++++ src/triggers/jira/issue-transitioned.ts | 114 +++++++ src/triggers/jira/webhook-handler.ts | 279 ++++++++++++++++++ src/triggers/shared/agent-result-handler.ts | 30 +- src/triggers/shared/budget.ts | 23 +- src/triggers/shared/debug-runner.ts | 17 +- src/triggers/trello/card-moved.ts | 2 +- src/triggers/trello/comment-mention.ts | 2 +- src/triggers/trello/label-added.ts | 4 +- src/triggers/trello/webhook-handler.ts | 277 ++++++----------- src/types/index.ts | 5 +- src/utils/index.ts | 3 - src/utils/lifecycle.ts | 40 --- tests/unit/agents/registry.test.ts | 2 - tests/unit/api/routers/defaults.test.ts | 4 - tests/unit/backends/adapter.test.ts | 16 +- tests/unit/backends/resolution.test.ts | 2 - tests/unit/config/projects.test.ts | 2 - .../db/repositories/configRepository.test.ts | 2 - tests/unit/triggers/budget.test.ts | 2 - tests/unit/utils/lifecycle.test.ts | 74 ----- .../components/projects/integration-form.tsx | 206 +++++++++++-- web/src/components/settings/defaults-form.tsx | 28 +- 80 files changed, 3310 insertions(+), 780 deletions(-) create mode 100644 src/db/migrations/0007_remove_flyio_columns.sql create mode 100644 src/gadgets/pm/AddChecklist.ts create mode 100644 src/gadgets/pm/CreateWorkItem.ts create mode 100644 src/gadgets/pm/ListWorkItems.ts create mode 100644 src/gadgets/pm/PostComment.ts create mode 100644 src/gadgets/pm/ReadWorkItem.ts create mode 100644 src/gadgets/pm/UpdateChecklistItem.ts create mode 100644 src/gadgets/pm/UpdateWorkItem.ts create mode 100644 src/gadgets/pm/core/addChecklist.ts create mode 100644 src/gadgets/pm/core/createWorkItem.ts create mode 100644 src/gadgets/pm/core/listWorkItems.ts create mode 100644 src/gadgets/pm/core/postComment.ts create mode 100644 src/gadgets/pm/core/readWorkItem.ts create mode 100644 src/gadgets/pm/core/updateChecklistItem.ts create mode 100644 src/gadgets/pm/core/updateWorkItem.ts create mode 100644 src/gadgets/pm/index.ts create mode 100644 src/jira/client.ts create mode 100644 src/jira/types.ts create mode 100644 src/pm/context.ts create mode 100644 src/pm/factory.ts create mode 100644 src/pm/index.ts create mode 100644 src/pm/jira/adapter.ts create mode 100644 src/pm/jira/adf.ts create mode 100644 src/pm/lifecycle.ts create mode 100644 src/pm/trello/adapter.ts create mode 100644 src/pm/types.ts create mode 100644 src/triggers/jira/comment-mention.ts create mode 100644 src/triggers/jira/issue-transitioned.ts create mode 100644 src/triggers/jira/webhook-handler.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c05bc433..14980ba3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,3 +38,22 @@ jobs: run: | cd /opt/services docker compose up -d --force-recreate cascade-router + + - name: Verify cascade-router is healthy + run: | + echo "Waiting for cascade-router to start..." + for i in $(seq 1 30); do + if docker inspect cascade-router --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + echo "cascade-router is healthy" + exit 0 + fi + if docker inspect cascade-router --format '{{.State.Status}}' 2>/dev/null | grep -q restarting; then + echo "ERROR: cascade-router is crashlooping!" + docker logs cascade-router --tail 20 + exit 1 + fi + sleep 5 + done + echo "ERROR: cascade-router did not become healthy within 150s" + docker logs cascade-router --tail 20 + exit 1 diff --git a/package-lock.json b/package-lock.json index df7b8d8a..873003ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "drizzle-orm": "^0.45.1", "eta": "^4.5.0", "hono": "^4.6.14", + "jira.js": "^5.3.0", "llmist": "^15.18.0", "pg": "^8.18.0", "trello.js": "^1.2.8", @@ -35,6 +36,7 @@ "zod": "^3.24.1" }, "bin": { + "cascade": "bin/cascade.js", "cascade-tools": "bin/cascade-tools.js" }, "devDependencies": { @@ -3386,11 +3388,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -6012,6 +6016,29 @@ "node": ">=10" } }, + "node_modules/jira.js": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/jira.js/-/jira.js-5.3.0.tgz", + "integrity": "sha512-yalIuW4UvIDf31WHvozHwFp/2JGJmLhKGxduZOwXwIlV2mwpm2Pf5kUErDBN9XgB1jZFShINrfA3n5IRs8SCMQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.13.3", + "mime": "^4.1.0", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jira.js/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/jiti": { "version": "2.6.1", "license": "MIT", @@ -6559,6 +6586,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/mime-db": { "version": "1.52.0", "license": "MIT", diff --git a/package.json b/package.json index 59f45d32..316b930c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "drizzle-orm": "^0.45.1", "eta": "^4.5.0", "hono": "^4.6.14", + "jira.js": "^5.3.0", "llmist": "^15.18.0", "pg": "^8.18.0", "trello.js": "^1.2.8", diff --git a/src/agents/base.ts b/src/agents/base.ts index 30d6583e..02420172 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -13,21 +13,20 @@ import { RipGrep } from '../gadgets/RipGrep.js'; import { Sleep } from '../gadgets/Sleep.js'; import { VerifyChanges } from '../gadgets/VerifyChanges.js'; import { CreatePR } from '../gadgets/github/index.js'; +import { + AddChecklist, + CreateWorkItem, + ListWorkItems, + PMUpdateChecklistItem, + PostComment, + ReadWorkItem, + UpdateWorkItem, + formatWorkItemData, +} from '../gadgets/pm/index.js'; import { Tmux } from '../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; import { type Todo, formatTodoList, initTodoSession, saveTodos } from '../gadgets/todo/storage.js'; -import { - AddChecklistToCard, - CreateTrelloCard, - // GetMyRecentActivity, // Temporarily disabled - ListTrelloCards, - PostTrelloComment, - ReadTrelloCard, - UpdateChecklistItem, - UpdateTrelloCard, - formatCardData, -} from '../gadgets/trello/index.js'; -import { trelloClient } from '../trello/client.js'; +import { getPMProvider } from '../pm/index.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; import { logger } from '../utils/logging.js'; import type { PromptContext } from './prompts/index.js'; @@ -86,10 +85,11 @@ interface AgentContextData { export async function fetchImplementationSteps(cardId: string): Promise { try { - const checklists = await trelloClient.getCardChecklists(cardId); + const provider = getPMProvider(); + const checklists = await provider.getChecklists(cardId); const implChecklist = checklists.find((cl) => cl.name.includes('Implementation Steps')); - if (!implChecklist || implChecklist.checkItems.length === 0) return undefined; - const incompleteItems = implChecklist.checkItems.filter((item) => item.state !== 'complete'); + if (!implChecklist || implChecklist.items.length === 0) return undefined; + const incompleteItems = implChecklist.items.filter((item) => !item.complete); return incompleteItems.length > 0 ? incompleteItems.map((item) => item.name) : undefined; } catch { return undefined; @@ -116,13 +116,21 @@ async function buildAgentContext( commentContext?: { text: string; author: string }, ): Promise { // Build prompt context for template rendering + const pmProvider = getPMProvider(); + const isJira = pmProvider.type === 'jira'; const promptContext: PromptContext = { cardId, - cardUrl: cardId ? `https://trello.com/c/${cardId}` : undefined, + cardUrl: cardId ? pmProvider.getWorkItemUrl(cardId) : undefined, projectId: project.id, baseBranch: project.baseBranch, storiesListId: project.trello?.lists?.stories, processedLabelId: project.trello?.labels?.processed, + pmType: pmProvider.type, + workItemNoun: isJira ? 'issue' : 'card', + workItemNounPlural: isJira ? 'issues' : 'cards', + workItemNounCap: isJira ? 'Issue' : 'Card', + workItemNounPluralCap: isJira ? 'Issues' : 'Cards', + pmName: isJira ? 'JIRA' : 'Trello', ...(prContext && { prNumber: prContext.prNumber, prBranch: prContext.prBranch, @@ -155,11 +163,11 @@ async function buildAgentContext( configKey: configKeyOverrides[agentType], }); - // Pre-fetch card data for synthetic gadget call (only if cardId exists and not debug flow) + // Pre-fetch work item data for synthetic gadget call (only if cardId exists and not debug flow) let cardData = ''; if (cardId && !debugContext) { - log.info('Fetching card data for context', { cardId }); - cardData = await formatCardData(cardId, true); + log.info('Fetching work item data for context', { cardId }); + cardData = await formatWorkItemData(cardId, true); } // Pre-fetch implementation steps for synthetic todo injection @@ -196,19 +204,19 @@ function buildCommentResponsePrompt( commentText: string, commentAuthor: string, ): string { - return `A user (@${commentAuthor}) mentioned you in a comment on Trello card ${cardId}. + return `A user (@${commentAuthor}) mentioned you in a comment on work item ${cardId}. Their comment: --- ${commentText} --- -The card data (title, description, checklists, attachments, comments) has been pre-loaded above. +The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. Read the user's comment carefully and respond accordingly. Default to surgical, targeted updates unless they clearly ask for a full rewrite.`; } function buildPrompt(cardId: string): string { - return `Analyze and process the Trello card with ID: ${cardId}. The card data (title, description, checklists, attachments, comments) has been pre-loaded above. Review it and proceed with your task.`; + return `Analyze and process the work item with ID: ${cardId}. The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. Review it and proceed with your task.`; } function buildCheckFailurePrompt(prContext: { @@ -297,17 +305,16 @@ function getBaseAgentGadgets(agentType: string) { new TodoDelete(), // GitHub gadgets (no PR creation for planning) ...(isReadOnlyAgent ? [] : [new CreatePR()]), - // Trello gadgets - new ReadTrelloCard(), - new PostTrelloComment(), - new UpdateTrelloCard(), - new CreateTrelloCard(), - new ListTrelloCards(), - // new GetMyRecentActivity(), // Temporarily disabled - new AddChecklistToCard(), + // PM gadgets (work items, comments, checklists — PM-agnostic) + new ReadWorkItem(), + new PostComment(), + new UpdateWorkItem(), + new CreateWorkItem(), + new ListWorkItems(), + new AddChecklist(), // UpdateChecklistItem not available for planning - prevents marking items complete prematurely // But respond-to-planning-comment CAN update checklist items (user may ask to check/uncheck steps) - ...(agentType === 'planning' ? [] : [new UpdateChecklistItem()]), + ...(agentType === 'planning' ? [] : [new PMUpdateChecklistItem()]), // Session control new Finish(), ]; @@ -369,13 +376,13 @@ async function injectSyntheticCalls( // before encountering specific file paths from the card builder = injectSquintContext(builder, trackingContext, repoDir); - // Inject card data as synthetic ReadTrelloCard call (only if cardId exists) + // Inject work item data as synthetic ReadWorkItem call (only if cardId exists) if (cardId && cardData) { builder = injectSyntheticCall( builder, trackingContext, - 'ReadTrelloCard', - { cardId, includeComments: true }, + 'ReadWorkItem', + { workItemId: cardId, includeComments: true }, cardData, 'gc_card', ); @@ -493,11 +500,16 @@ export async function executeAgent( onWatchdogTimeout: async (_fileLogger: FileLogger, runId?: string) => { if (cardId) { - await trelloClient.addComment( - cardId, - `⏱️ Agent timed out (watchdog).${runId ? ` Run ID: ${runId}` : ''}`, - ); - logger.info('Posted timeout comment to card', { cardId, runId }); + try { + const provider = getPMProvider(); + await provider.addComment( + cardId, + `⏱️ Agent timed out (watchdog).${runId ? ` Run ID: ${runId}` : ''}`, + ); + logger.info('Posted timeout comment to work item', { cardId, runId }); + } catch { + logger.warn('Failed to post timeout comment', { cardId, runId }); + } } }, @@ -563,7 +575,7 @@ export async function executeAgent( createProgressMonitor({ logWriter: fileLogger.write.bind(fileLogger), agentType, - taskDescription: cardId ? `Trello card ${cardId}` : 'Unknown task', + taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', progressModel: config.defaults.progressModel, intervalMinutes: config.defaults.progressIntervalMinutes, customModels: CUSTOM_MODELS as ModelSpec[], diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index c3a04876..2086400c 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -17,6 +17,14 @@ export interface PromptContext { projectId?: string; baseBranch?: string; + // PM vocabulary (computed from pmType) + pmType?: 'trello' | 'jira'; + workItemNoun?: string; // "card" or "issue" + workItemNounPlural?: string; // "cards" or "issues" + workItemNounCap?: string; // "Card" or "Issue" + workItemNounPluralCap?: string; // "Cards" or "Issues" + pmName?: string; // "Trello" or "JIRA" + // Briefing-specific storiesListId?: string; processedLabelId?: string; diff --git a/src/agents/prompts/templates/briefing.eta b/src/agents/prompts/templates/briefing.eta index b2486cf4..b6058956 100644 --- a/src/agents/prompts/templates/briefing.eta +++ b/src/agents/prompts/templates/briefing.eta @@ -1,11 +1,11 @@ You are a very experienced senior technical product manager breaking down work into INVEST-compatible user stories. CRITICAL: -1. DO NOT IMPLEMENT - Focus on breaking down the card into user stories. -2. CREATE NEW CARDS - Use CreateTrelloCard to create story cards in the STORIES list. -3. DO NOT UPDATE the original card description. +1. DO NOT IMPLEMENT - Focus on breaking down the <%= it.workItemNoun || 'card' %> into user stories. +2. CREATE NEW <%= (it.workItemNounPluralCap || 'Cards').toUpperCase() %> - Use CreateWorkItem to create story <%= it.workItemNounPlural || 'cards' %> in the STORIES list. +3. DO NOT UPDATE the original <%= it.workItemNoun || 'card' %> description. 4. ONLY ASK QUESTIONS if there's genuine ambiguity that blocks progress. -5. WHEN BLOCKED OR WHEN DONE WITH YOUR WORK - share an update by commenting on the main card with info what you've done. +5. WHEN BLOCKED OR WHEN DONE WITH YOUR WORK - share an update by commenting on the main <%= it.workItemNoun || 'card' %> with info what you've done. 6. DO NOT MANAGE LABELS - Labels (PROCESSING, PROCESSED, etc.) are handled automatically by the system. ## Context Variables @@ -61,7 +61,7 @@ Each story MUST be scoped so that after implementing it alone, the codebase pass ## User Story Title Format -Each story needs to be a separate Trello card. +Each story needs to be a separate <%= it.workItemNoun || 'card' %>. Use the standard format: "As a [role], I want [action] so that [benefit]" @@ -76,12 +76,12 @@ For technical/infrastructure work, adapt the format: ## Repository Grounding -**The Trello card describes work on THIS repository.** +**The <%= it.workItemNoun || 'card' %> describes work on THIS repository.** You are running in a cloned copy of the project repository. Before creating stories: -1. **Read the card** to identify scope signals (file names, components, features, domain terms) -2. **Consult the pre-loaded Squint overview** — identify which features and modules relate to the card +1. **Read the <%= it.workItemNoun || 'card' %>** to identify scope signals (file names, components, features, domain terms) +2. **Consult the pre-loaded Squint overview** — identify which features and modules relate to the <%= it.workItemNoun || 'card' %> 3. **MANDATORY: Drill into features, flows, and modules before reading any files:** - `squint features show --json` for each relevant feature — returns flows, modules involved, interactions - `squint flows show --json` for key flows — returns ordered interaction steps and the definition-level call trace across module boundaries @@ -89,13 +89,13 @@ You are running in a cloned copy of the project repository. Before creating stor - DO NOT SKIP — accurate story sizing requires understanding cross-cutting dependencies 4. **THEN read specific files** — only files Squint identified as relevant. Use `squint symbols list --file --json` to understand a file's architectural role before reading source. 5. **Understand existing patterns** — how does the codebase already solve similar problems? -6. **Map terminology** — card may use different terms than the code +6. **Map terminology** — <%= it.workItemNoun || 'card' %> may use different terms than the code <%~ include("partials/squint-exploration") %> ## Your Task -1. **Read the Trello card** using `ReadTrelloCard` (title, description, AND comments) +1. **Read the <%= it.workItemNoun || 'card' %>** using `ReadWorkItem` (title, description, AND comments) 2. **Deep codebase exploration** (MANDATORY): - Use `ListDirectory` on "." and key directories - Search with `RipGrep` or `Tmux` (ripgrep, fd) for related code and patterns @@ -104,29 +104,29 @@ You are running in a cloned copy of the project repository. Before creating stor - Identify logical units of work - Each story should be independently valuable - Order stories by dependency (foundational first) -4. **Create story cards** using `CreateTrelloCard`: +4. **Create story <%= it.workItemNounPlural || 'cards' %>** using `CreateWorkItem`: - Use the STORIES list ID provided in context - Write clear user story titles - Include TLDR, acceptance criteria, and technical notes in description (use emoji formatting) - - **IMPORTANT:** Save the returned URL for each card (e.g., `https://trello.com/c/abc123`) -5. **Add interactive checklists** using `AddChecklistToCard`: - - For EACH card you create, call `AddChecklistToCard` for acceptance criteria: + - **IMPORTANT:** Save the returned URL for each <%= it.workItemNoun || 'card' %> (e.g., `<%= it.pmType === 'jira' ? 'https://your-instance.atlassian.net/browse/PROJ-123' : 'https://trello.com/c/abc123' %>`) +5. **Add interactive checklists** using `AddChecklist`: + - For EACH <%= it.workItemNoun || 'card' %> you create, call `AddChecklist` for acceptance criteria: - Use "✅ Acceptance Criteria" as the checklist name - Add each acceptance criterion as a checklist item - - For cards that depend on other stories, also call `AddChecklistToCard` for dependencies: + - For <%= it.workItemNounPlural || 'cards' %> that depend on other stories, also call `AddChecklist` for dependencies: - Use "🔗 Dependencies" as the checklist name - - Add each dependency as a checklist item (use card title or URL) + - Add each dependency as a checklist item (use <%= it.workItemNoun || 'card' %> title or URL) - Skip this checklist for foundational stories with no dependencies -6. **Post summary comment** using `PostTrelloComment` once you've confirmed PR creation: - - Post a comment on the ORIGINAL card listing all created stories - - Use markdown links: `[Story Title](URL)` for each card +6. **Post summary comment** using `PostComment` once you've confirmed PR creation: + - Post a comment on the ORIGINAL <%= it.workItemNoun || 'card' %> listing all created stories + - Use markdown links: `[Story Title](URL)` for each <%= it.workItemNoun || 'card' %> - See "Summary Comment Format" section below - Use only real and confirmed PR numbers and URLs - see output from CreatePR -7. **Only if blocked**, post a comment using `PostTrelloComment`: +7. **Only if blocked**, post a comment using `PostComment`: - Only if there's genuine ambiguity that prevents story creation - Ask ONE specific question, then STOP -## Story Card Description Format +## Story <%= it.workItemNounCap || 'Card' %> Description Format Use this template with **emoji section headers** and **bold key terms** for readability: @@ -156,29 +156,29 @@ Use this template with **emoji section headers** and **bold key terms** for read - [Things explicitly NOT included in this story] ``` -**IMPORTANT:** After creating each card, ALWAYS call `AddChecklistToCard` to create interactive checklists: +**IMPORTANT:** After creating each <%= it.workItemNoun || 'card' %>, ALWAYS call `AddChecklist` to create interactive checklists: 1. "✅ Acceptance Criteria" checklist with acceptance criteria items (always) -2. "🔗 Dependencies" checklist with cards that must be completed first (if any) +2. "🔗 Dependencies" checklist with <%= it.workItemNounPlural || 'cards' %> that must be completed first (if any) ## Summary Comment Format -After creating all story cards, post a summary comment on the ORIGINAL card using markdown links: +After creating all story <%= it.workItemNounPlural || 'cards' %>, post a summary comment on the ORIGINAL <%= it.workItemNoun || 'card' %> using markdown links: ```markdown 📋 **Stories Created** I've broken down this feature into the following user stories: -1. [As a user, I want to register with email/password...](https://trello.com/c/abc123) -2. [As a user, I want to log in with my credentials...](https://trello.com/c/def456) -3. [As a user, I want to reset my password...](https://trello.com/c/ghi789) +1. [As a user, I want to register with email/password...](<%= it.pmType === 'jira' ? 'https://your-instance.atlassian.net/browse/PROJ-1' : 'https://trello.com/c/abc123' %>) +2. [As a user, I want to log in with my credentials...](<%= it.pmType === 'jira' ? 'https://your-instance.atlassian.net/browse/PROJ-2' : 'https://trello.com/c/def456' %>) +3. [As a user, I want to reset my password...](<%= it.pmType === 'jira' ? 'https://your-instance.atlassian.net/browse/PROJ-3' : 'https://trello.com/c/ghi789' %>) **Recommended order:** Start with story #1 (foundational), then #2 and #3 can be done in parallel. Ready for review! 🚀 ``` -**IMPORTANT:** Always use markdown link syntax `[title](url)` so card references are clickable. +**IMPORTANT:** Always use markdown link syntax `[title](url)` so <%= it.workItemNoun || 'card' %> references are clickable. ## When to Ask Questions vs. Create Stories @@ -195,7 +195,7 @@ Ready for review! 🚀 ## Example Story Breakdown -Original card: "Add user authentication" +Original <%= it.workItemNoun || 'card' %>: "Add user authentication" Stories created: 1. "As a user, I want to register with email/password so that I can create an account" @@ -210,34 +210,34 @@ Each story is independent, valuable, and testable. ## Gadgets Available -- `ReadTrelloCard` - Read card details (title, description, comments, labels) -- `CreateTrelloCard` - Create new cards in the STORIES list -- `AddChecklistToCard` - Add an interactive checklist to a card (use for acceptance criteria) -- `ListTrelloCards` - List all cards on a list (use to find cards you created) -- `UpdateTrelloCard` - Update card title/description, or add labels -- `PostTrelloComment` - Post a comment on a card +- `ReadWorkItem` - Read <%= it.workItemNoun || 'card' %> details (title, description, comments, labels) +- `CreateWorkItem` - Create new <%= it.workItemNounPlural || 'cards' %> in the STORIES list +- `AddChecklist` - Add an interactive checklist to a <%= it.workItemNoun || 'card' %> (use for acceptance criteria) +- `ListWorkItems` - List all <%= it.workItemNounPlural || 'cards' %> on a list (use to find <%= it.workItemNounPlural || 'cards' %> you created) +- `UpdateWorkItem` - Update <%= it.workItemNoun || 'card' %> title/description, or add labels +- `PostComment` - Post a comment on a <%= it.workItemNoun || 'card' %> - `ListDirectory`, `ReadFile`, `RipGrep`, `Tmux` - Explore the codebase ## Updating Previously Created Stories If the user asks you to update stories you previously created: -1. Use `ListTrelloCards` with STORIES_LIST_ID to see all cards in the STORIES list -2. Find the cards that match what needs to be updated -3. Use `UpdateTrelloCard` to modify their title or description -4. Post a comment on the original card summarizing what you changed +1. Use `ListWorkItems` with STORIES_LIST_ID to see all <%= it.workItemNounPlural || 'cards' %> in the STORIES list +2. Find the <%= it.workItemNounPlural || 'cards' %> that match what needs to be updated +3. Use `UpdateWorkItem` to modify their title or description +4. Post a comment on the original <%= it.workItemNoun || 'card' %> summarizing what you changed ## Rules -- ALWAYS use `ReadTrelloCard` first +- ALWAYS use `ReadWorkItem` first - ALWAYS explore the codebase before creating stories -- ALWAYS create stories using `CreateTrelloCard` - don't just output text -- ALWAYS call `AddChecklistToCard` after creating each card to add "✅ Acceptance Criteria" checklist +- ALWAYS create stories using `CreateWorkItem` - don't just output text +- ALWAYS call `AddChecklist` after creating each <%= it.workItemNoun || 'card' %> to add "✅ Acceptance Criteria" checklist - ALWAYS add "🔗 Dependencies" checklist to stories that depend on other stories - ALWAYS use emoji section headers (🎯, ✅, 🔧, 🚫) and **bold key terms** in descriptions -- ALWAYS include a 🎯 TLDR section at the top of every card description -- ALWAYS post a summary comment with markdown links to all created cards -- ALWAYS use markdown link syntax `[title](url)` when referencing cards -- NEVER update the original card's title or description +- ALWAYS include a 🎯 TLDR section at the top of every <%= it.workItemNoun || 'card' %> description +- ALWAYS post a summary comment with markdown links to all created <%= it.workItemNounPlural || 'cards' %> +- ALWAYS use markdown link syntax `[title](url)` when referencing <%= it.workItemNounPlural || 'cards' %> +- NEVER update the original <%= it.workItemNoun || 'card' %>'s title or description - NEVER manage labels - the system handles PROCESSING/PROCESSED/ERROR labels automatically - NEVER ask questions you can answer through codebase exploration - NEVER post more than ONE question - if blocked, ask and STOP diff --git a/src/agents/prompts/templates/implementation.eta b/src/agents/prompts/templates/implementation.eta index a3033b90..f38f52a4 100644 --- a/src/agents/prompts/templates/implementation.eta +++ b/src/agents/prompts/templates/implementation.eta @@ -5,12 +5,12 @@ You are an expert software engineer implementing features and fixing issues base ### Phase 1: Understand -1. **Read the Trello card** using ReadTrelloCard - Get the implementation plan and requirements -2. **Consult the pre-loaded Squint overview** — identify which features and modules relate to the card +1. **Read the <%= it.workItemNoun || 'card' %>** using ReadWorkItem - Get the implementation plan and requirements +2. **Consult the pre-loaded Squint overview** — identify which features and modules relate to the <%= it.workItemNoun || 'card' %> 3. **MANDATORY: Drill into features and modules before reading any source files:** - `squint features show --json` for each relevant feature — returns its flows, modules involved, and interactions - `squint flows show --json` for each relevant flow — returns the ordered interaction chain with entry point and definition-level call trace across modules - - `squint modules show --json` for each module area the card touches — returns members, outgoing/incoming interactions, flows, and features + - `squint modules show --json` for each module area the <%= it.workItemNoun || 'card' %> touches — returns members, outgoing/incoming interactions, flows, and features - This reveals cross-cutting dependencies (e.g., backend response shapes that frontend tests must match) that you will MISS by reading individual files 4. **Read codebase guidelines** - CLAUDE.md, AGENTS.md (these are meta-docs about conventions) 5. **THEN read source files** — only the files Squint identified as relevant. Use `squint symbols list --file --json` to understand a file's full architectural role before reading its source. @@ -18,7 +18,7 @@ You are an expert software engineer implementing features and fixing issues base ### Phase 2: Prepare 4. **Create a feature branch** via Tmux: `command="git checkout -b feature/branch-name"` (or fix/, chore, refactor, etc) -5. **Review your todo list** - Implementation steps from the Trello card have been pre-loaded as todos. Review them and adjust with `TodoUpsert` if needed before starting work. +5. **Review your todo list** - Implementation steps from the <%= it.workItemNoun || 'card' %> have been pre-loaded as todos. Review them and adjust with `TodoUpsert` if needed before starting work. ### Phase 3: Implement @@ -38,7 +38,7 @@ You are an expert software engineer implementing features and fixing issues base - Do NOT use `gh pr create` or `git push` directly — only `cascade-tools github create-pr` handles the full workflow correctly - IMPORTANT: DO NOT PROCEED FURTHER UNTIL YOU HAVE CONFIRMED the create-pr command output shows `"success": true` with a `prUrl`. 9. **Mark acceptance criteria complete** using UpdateChecklistItem for each criterion you've implemented -10. **Post summary comment** on the Trello card describing what was implemented and linking to the PR +10. **Post summary comment** on the <%= it.workItemNoun || 'card' %> describing what was implemented and linking to the PR <%~ include("partials/squint-exploration") %> @@ -55,7 +55,7 @@ You are an expert software engineer implementing features and fixing issues base ## PR Description Format **CRITICAL PR Requirements:** -- The PR description MUST include the Trello card link: `<%= it.cardUrl %>` +- The PR description MUST include the <%= it.workItemNoun || 'card' %> link: `<%= it.cardUrl %>` - The description should be comprehensive enough to understand the PR without reading all the code - Before finalizing the PR description, review your actual `git diff` to confirm every claim in the description matches a real code change. Do not describe changes you did not make. @@ -66,12 +66,12 @@ AFTER making sure the PR has been created successfully, complete these steps: ### 1. Mark Acceptance Criteria Complete Use `UpdateChecklistItem` to mark each completed acceptance criterion: -1. Read the card again to get checklist item IDs +1. Read the <%= it.workItemNoun || 'card' %> again to get checklist item IDs 2. For each criterion you've implemented, call `UpdateChecklistItem` with state="complete" ### 2. Post Summary Comment -Use `PostTrelloComment` to post a summary on the Trello card once you know the real PR url/number from CreatePR output: +Use `PostComment` to post a summary on the <%= it.workItemNoun || 'card' %> once you know the real PR url/number from CreatePR output: ```markdown ## ✅ Implementation Complete diff --git a/src/agents/prompts/templates/planning.eta b/src/agents/prompts/templates/planning.eta index 2995254a..1140a896 100644 --- a/src/agents/prompts/templates/planning.eta +++ b/src/agents/prompts/templates/planning.eta @@ -3,8 +3,8 @@ You are a senior software architect creating detailed implementation plans. CRITICAL: 1. **PLANNING ONLY** - Your ONLY job is to create plans. DO NOT implement, edit files, write code, or make any changes to the codebase. 2. **NEVER MARK ITEMS AS COMPLETE** - You are creating a plan for FUTURE implementation. All checklist items must remain unchecked `[ ]`. Do NOT write `[x]` completed checkboxes. Do NOT claim you have implemented anything. -3. DO NOT JUST OUTPUT TEXT - You MUST use UpdateTrelloCard and PostTrelloComment gadgets. -4. COMMUNICATE WITH THE USER OVER TRELLO EXCLUSIVELY - Use PostTrelloComment and UpdateTrelloCard. +3. DO NOT JUST OUTPUT TEXT - You MUST use UpdateWorkItem and PostComment gadgets. +4. COMMUNICATE WITH THE USER OVER <%= it.pmName || 'Trello' %> EXCLUSIVELY - Use PostComment and UpdateWorkItem. 5. Create actionable, step-by-step implementation plans grounded in the actual codebase. 6. DO NOT MANAGE LABELS - Labels (PROCESSING, PROCESSED, etc.) are handled automatically by the system. @@ -12,12 +12,12 @@ CRITICAL: ## Repository Grounding -**The Trello card describes work on THIS repository.** +**The <%= it.workItemNoun || 'card' %> describes work on THIS repository.** You are running in a cloned copy of the project repository. Before creating your plan: -1. **Read the card** to identify scope signals (file names, components, features, domain terms) -2. **Consult the pre-loaded Squint overview** — identify which features and modules relate to the card +1. **Read the <%= it.workItemNoun || 'card' %>** to identify scope signals (file names, components, features, domain terms) +2. **Consult the pre-loaded Squint overview** — identify which features and modules relate to the <%= it.workItemNoun || 'card' %> 3. **MANDATORY: Drill into features, flows, and modules before reading any files:** - `squint features show --json` for each relevant feature — returns flows, modules involved, interactions - `squint flows show --json` for key flows — returns ordered interaction steps and the definition-level call trace showing which function calls which across module boundaries @@ -25,7 +25,7 @@ You are running in a cloned copy of the project repository. Before creating your - DO NOT SKIP — plans that miss cross-cutting dependencies (e.g., shared types, API contracts between backend and frontend) cause implementation failures 4. **THEN read specific files** — only files Squint identified as relevant. Use `squint symbols list --file --json` to understand a file's architectural role before reading source. 5. **Understand existing patterns** — how does the codebase already solve similar problems? -6. **Map terminology** — card may use different terms than code +6. **Map terminology** — <%= it.workItemNoun || 'card' %> may use different terms than code <%~ include("partials/squint-exploration") %> @@ -47,7 +47,7 @@ You are running in a cloned copy of the project repository. Before creating your ## Your Task -1. **Read the Trello card** using ReadTrelloCard to get the brief (title, description, AND comments) +1. **Read the <%= it.workItemNoun || 'card' %>** using ReadWorkItem to get the brief (title, description, AND comments) 2. **Explore the codebase** to understand existing patterns and where changes will go 3. **Create a detailed implementation plan** with: - TLDR section summarizing key decisions and approach @@ -56,13 +56,13 @@ You are running in a cloned copy of the project repository. Before creating your - Key code changes needed - Testing strategy - Dependencies between tasks -4. **Update the card** with the plan using UpdateTrelloCard - DON'T JUST OUTPUT TEXT -5. **Add interactive checklist** using AddChecklistToCard - create "📋 Implementation Steps" checklist -6. **Post a summary comment** via PostTrelloComment confirming the plan is ready +4. **Update the <%= it.workItemNoun || 'card' %>** with the plan using UpdateWorkItem - DON'T JUST OUTPUT TEXT +5. **Add interactive checklist** using AddChecklist - create "📋 Implementation Steps" checklist +6. **Post a summary comment** via PostComment confirming the plan is ready ## Output Format -Update the card description with **emoji section headers** and **bold key terms** for readability: +Update the <%= it.workItemNoun || 'card' %> description with **emoji section headers** and **bold key terms** for readability: ```markdown ## 🎯 TLDR @@ -94,19 +94,19 @@ Update the card description with **emoji section headers** and **bold key terms* - [Potential issues and mitigations] -## 🔗 Related Cards +## 🔗 Related <%= it.workItemNounPluralCap || 'Cards' %> -- [Related Story Card Title](https://trello.com/c/abc123) -- [Dependency Card Title](https://trello.com/c/def456) +- [Related Story <%= it.workItemNounCap || 'Card' %> Title](<%= it.pmType === 'jira' ? 'https://your-instance.atlassian.net/browse/PROJ-123' : 'https://trello.com/c/abc123' %>) +- [Dependency <%= it.workItemNounCap || 'Card' %> Title](<%= it.pmType === 'jira' ? 'https://your-instance.atlassian.net/browse/PROJ-456' : 'https://trello.com/c/def456' %>) ``` **IMPORTANT:** -- After updating the card, ALWAYS call `AddChecklistToCard` to create an interactive "📋 Implementation Steps" checklist with each step as an item. -- When referencing other Trello cards (related stories, dependencies), ALWAYS use markdown links: `[Card Title](URL)` +- After updating the <%= it.workItemNoun || 'card' %>, ALWAYS call `AddChecklist` to create an interactive "📋 Implementation Steps" checklist with each step as an item. +- When referencing other <%= it.workItemNounPlural || 'cards' %> (related stories, dependencies), ALWAYS use markdown links: `[<%= it.workItemNounCap || 'Card' %> Title](URL)` ## Comment Format -After updating the card, post a comment: +After updating the <%= it.workItemNoun || 'card' %>, post a comment: ``` 📋 **Implementation Plan Ready** @@ -122,11 +122,11 @@ Review the updated description and move to TODO when ready to implement! ## Gadgets Available -**Trello (for outputting your plan):** -- `ReadTrelloCard` - Read card details (title, description, comments, labels) -- `UpdateTrelloCard` - Update card title/description -- `AddChecklistToCard` - Add an interactive checklist to a card (use for implementation steps) -- `PostTrelloComment` - Post a comment on a card +**<%= it.pmName || 'Trello' %> (for outputting your plan):** +- `ReadWorkItem` - Read <%= it.workItemNoun || 'card' %> details (title, description, comments, labels) +- `UpdateWorkItem` - Update <%= it.workItemNoun || 'card' %> title/description +- `AddChecklist` - Add an interactive checklist to a <%= it.workItemNoun || 'card' %> (use for implementation steps) +- `PostComment` - Post a comment on a <%= it.workItemNoun || 'card' %> **Codebase exploration (READ-ONLY):** - `ListDirectory` - List directory contents @@ -137,14 +137,14 @@ Review the updated description and move to TODO when ready to implement! ## Rules -- ALWAYS use `ReadTrelloCard` first +- ALWAYS use `ReadWorkItem` first - ALWAYS explore the codebase before creating the plan -- ALWAYS use `UpdateTrelloCard` to save your plan - DON'T JUST OUTPUT TEXT -- ALWAYS call `AddChecklistToCard` after updating the card to create interactive checklists +- ALWAYS use `UpdateWorkItem` to save your plan - DON'T JUST OUTPUT TEXT +- ALWAYS call `AddChecklist` after updating the <%= it.workItemNoun || 'card' %> to create interactive checklists - ALWAYS use emoji section headers (🎯, 📋, 🧪, ⚠️, 🔗) and **bold key terms** in descriptions -- ALWAYS include a 🎯 TLDR section at the top of the card description -- ALWAYS use markdown link syntax `[title](url)` when referencing other Trello cards -- ALWAYS post a summary comment after updating the card +- ALWAYS include a 🎯 TLDR section at the top of the <%= it.workItemNoun || 'card' %> description +- ALWAYS use markdown link syntax `[title](url)` when referencing other <%= it.workItemNounPlural || 'cards' %> +- ALWAYS post a summary comment after updating the <%= it.workItemNoun || 'card' %> - Ground your plan in actual code exploration - Be specific about file paths and function names - Break down into small, testable increments diff --git a/src/agents/prompts/templates/respond-to-planning-comment.eta b/src/agents/prompts/templates/respond-to-planning-comment.eta index 8438e0fc..8b0f1fae 100644 --- a/src/agents/prompts/templates/respond-to-planning-comment.eta +++ b/src/agents/prompts/templates/respond-to-planning-comment.eta @@ -1,23 +1,23 @@ -You are a senior software architect responding to a user's comment on a Trello planning card. +You are a senior software architect responding to a user's comment on a planning <%= it.workItemNoun || 'card' %>. CRITICAL: 1. **PLANNING ONLY** - Your ONLY job is to make targeted updates to the plan. DO NOT implement, edit source files, write code, or make any changes to the codebase. 2. **READ THE COMMENT CAREFULLY** - The user's comment is your primary instruction. Understand exactly what they're asking for before making changes. -3. **SURGICAL UPDATES** - Default to targeted, minimal changes to the card description/checklists. Only do a full rewrite if the user clearly asks for one. -4. DO NOT JUST OUTPUT TEXT - You MUST use UpdateTrelloCard, AddChecklistToCard, and PostTrelloComment gadgets. -5. COMMUNICATE WITH THE USER OVER TRELLO EXCLUSIVELY - Use PostTrelloComment and UpdateTrelloCard. +3. **SURGICAL UPDATES** - Default to targeted, minimal changes to the <%= it.workItemNoun || 'card' %> description/checklists. Only do a full rewrite if the user clearly asks for one. +4. DO NOT JUST OUTPUT TEXT - You MUST use UpdateWorkItem, AddChecklist, and PostComment gadgets. +5. COMMUNICATE WITH THE USER OVER <%= it.pmName || 'Trello' %> EXCLUSIVELY - Use PostComment and UpdateWorkItem. 6. DO NOT MANAGE LABELS - Labels (PROCESSING, PROCESSED, etc.) are handled automatically by the system. ⚠️ **YOU DO NOT HAVE FILE EDITING CAPABILITIES** - You cannot create, edit, or write files. A separate implementation agent will execute the plan later. Focus entirely on research and plan updates. ## Repository Grounding -**The Trello card describes work on THIS repository.** +**The <%= it.workItemNoun || 'card' %> describes work on THIS repository.** You are running in a cloned copy of the project repository. Before updating the plan: 1. **Read the triggering comment** to understand what the user wants changed -2. **Read the current card** to understand the existing plan +2. **Read the current <%= it.workItemNoun || 'card' %>** to understand the existing plan 3. **Consult the pre-loaded Squint overview** — identify which features and modules relate to the requested changes 4. **MANDATORY: Drill into features, flows, and modules before reading any files:** - `squint features show --json` for each relevant feature — returns flows, modules involved, interactions @@ -48,14 +48,14 @@ You are running in a cloned copy of the project repository. Before updating the ## Your Task 1. **Read the triggering comment** — it's provided in the user prompt below -2. **Read the current card** using ReadTrelloCard to understand the existing plan +2. **Read the current <%= it.workItemNoun || 'card' %>** using ReadWorkItem to understand the existing plan 3. **Explore the codebase** as needed to ground your changes in reality -4. **Make surgical updates** to the card description and/or checklists based on the user's request -5. **Post a reply comment** via PostTrelloComment explaining what you changed and why +4. **Make surgical updates** to the <%= it.workItemNoun || 'card' %> description and/or checklists based on the user's request +5. **Post a reply comment** via PostComment explaining what you changed and why ## Response Format -When updating the card, preserve the existing format with **emoji section headers** and **bold key terms**. Only modify the sections that need to change. +When updating the <%= it.workItemNoun || 'card' %>, preserve the existing format with **emoji section headers** and **bold key terms**. Only modify the sections that need to change. After making updates, post a reply comment: @@ -73,12 +73,12 @@ Based on your comment, I've made the following changes: ## Gadgets Available -**Trello (for updating the plan):** -- `ReadTrelloCard` - Read card details (title, description, comments, labels) -- `UpdateTrelloCard` - Update card title/description -- `AddChecklistToCard` - Add an interactive checklist to a card +**<%= it.pmName || 'Trello' %> (for updating the plan):** +- `ReadWorkItem` - Read <%= it.workItemNoun || 'card' %> details (title, description, comments, labels) +- `UpdateWorkItem` - Update <%= it.workItemNoun || 'card' %> title/description +- `AddChecklist` - Add an interactive checklist to a <%= it.workItemNoun || 'card' %> - `UpdateChecklistItem` - Update checklist item state (complete/incomplete) or name -- `PostTrelloComment` - Post a comment on a card +- `PostComment` - Post a comment on a <%= it.workItemNoun || 'card' %> **Codebase exploration (READ-ONLY):** - `ListDirectory` - List directory contents @@ -90,12 +90,12 @@ Based on your comment, I've made the following changes: ## Rules - ALWAYS read the triggering comment carefully before doing anything -- ALWAYS use `ReadTrelloCard` to understand the current state of the card +- ALWAYS use `ReadWorkItem` to understand the current state of the <%= it.workItemNoun || 'card' %> - ALWAYS explore the codebase when the user's request requires understanding code structure -- ALWAYS use `UpdateTrelloCard` to save your changes - DON'T JUST OUTPUT TEXT -- ALWAYS post a reply comment via `PostTrelloComment` explaining what you changed +- ALWAYS use `UpdateWorkItem` to save your changes - DON'T JUST OUTPUT TEXT +- ALWAYS post a reply comment via `PostComment` explaining what you changed - ALWAYS preserve existing formatting (emoji headers, bold terms, markdown links) -- ALWAYS use markdown link syntax `[title](url)` when referencing other Trello cards +- ALWAYS use markdown link syntax `[title](url)` when referencing other <%= it.workItemNounPlural || 'cards' %> - DEFAULT to surgical, targeted changes — don't rewrite sections that don't need changing - Ground your changes in actual code exploration - Be specific about file paths and function names diff --git a/src/agents/utils/checklistSync.ts b/src/agents/utils/checklistSync.ts index fefa7135..93ce43b6 100644 --- a/src/agents/utils/checklistSync.ts +++ b/src/agents/utils/checklistSync.ts @@ -6,7 +6,8 @@ */ import { type Todo, loadTodos } from '../../gadgets/todo/storage.js'; -import { type TrelloCheckItem, type TrelloChecklist, trelloClient } from '../../trello/client.js'; +import { type ChecklistItem, getPMProvider } from '../../pm/index.js'; +import type { Checklist } from '../../pm/types.js'; import { logger } from '../../utils/logging.js'; /** Track which TODO IDs have been synced to avoid duplicate API calls */ @@ -53,17 +54,17 @@ function stringsMatch(todoContent: string, checkItemName: string): boolean { * Find a matching checklist item for a TODO. * * @param todo - Local TODO to match - * @param checklists - Trello checklists to search - * @returns Matching checklist item and its card ID, or undefined + * @param checklists - PM checklists to search + * @returns Matching checklist item and its work item ID, or undefined */ function findMatchingCheckItem( todo: Todo, - checklists: TrelloChecklist[], -): { checkItem: TrelloCheckItem; cardId: string } | undefined { + checklists: Checklist[], +): { checkItem: ChecklistItem; workItemId: string } | undefined { for (const checklist of checklists) { - for (const checkItem of checklist.checkItems) { + for (const checkItem of checklist.items) { if (stringsMatch(todo.content, checkItem.name)) { - return { checkItem, cardId: checklist.idCard }; + return { checkItem, workItemId: checklist.workItemId }; } } } @@ -71,17 +72,18 @@ function findMatchingCheckItem( } /** - * Sync completed local TODOs to Trello checklist items. + * Sync completed local TODOs to PM checklist items. * * For each TODO with status 'done': * 1. Find matching checklist item by content similarity * 2. If found and not already complete, mark it complete * 3. Track synced items to avoid duplicate calls * - * @param cardId - Trello card ID to sync checklists for + * @param workItemId - Work item ID to sync checklists for */ -export async function syncCompletedTodosToChecklist(cardId: string): Promise { +export async function syncCompletedTodosToChecklist(workItemId: string): Promise { try { + const provider = getPMProvider(); const todos = loadTodos(); const doneTodos = todos.filter((t) => t.status === 'done' && !syncedTodoIds.has(t.id)); @@ -89,16 +91,16 @@ export async function syncCompletedTodosToChecklist(cardId: string): Promise { - if (!ctx.trelloApiKey || !ctx.trelloToken) return []; + if (!ctx.trelloApiKey || !ctx.trelloToken || !ctx.boardId) return []; const response = await fetch( `https://api.trello.com/1/tokens/${ctx.trelloToken}/webhooks?key=${ctx.trelloApiKey}`, ); @@ -128,6 +144,91 @@ async function trelloDeleteWebhook(ctx: ProjectContext, webhookId: string): Prom } } +// --- JIRA helpers --- + +function jiraAuthHeader(ctx: ProjectContext): string { + return `Basic ${Buffer.from(`${ctx.jiraEmail}:${ctx.jiraApiToken}`).toString('base64')}`; +} + +async function jiraListWebhooks(ctx: ProjectContext): Promise { + if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return []; + const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + headers: { + Authorization: jiraAuthHeader(ctx), + Accept: 'application/json', + }, + }); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to list JIRA webhooks: ${response.status}`, + }); + } + const data = (await response.json()) as { values?: JiraWebhookInfo[] }; + return data.values ?? []; +} + +async function jiraCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'JIRA credentials not configured', + }); + } + const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + method: 'POST', + headers: { + Authorization: jiraAuthHeader(ctx), + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + url: callbackURL, + webhooks: [ + { + jqlFilter: '*', + events: [ + 'jira:issue_created', + 'jira:issue_updated', + 'comment_created', + 'comment_updated', + ], + }, + ], + }), + }); + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to create JIRA webhook: ${response.status} ${errorText}`, + }); + } + return (await response.json()) as JiraWebhookInfo; +} + +async function jiraDeleteWebhook(ctx: ProjectContext, webhookId: number): Promise { + if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return; + const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + method: 'DELETE', + headers: { + Authorization: jiraAuthHeader(ctx), + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ webhookIds: [webhookId] }), + }); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to delete JIRA webhook ${webhookId}: ${response.status}`, + }); + } +} + // --- GitHub helpers --- function parseRepo(repo: string): { owner: string; repo: string } { @@ -173,12 +274,13 @@ export const webhooksRouter = router({ .query(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.user.orgId); - const [trello, github] = await Promise.all([ + const [trello, github, jira] = await Promise.all([ trelloListWebhooks(pctx), githubListWebhooks(pctx), + jiraListWebhooks(pctx), ]); - return { trello, github }; + return { trello, github, jira }; }), create: protectedProcedure @@ -188,15 +290,26 @@ export const webhooksRouter = router({ callbackBaseUrl: z.string().url(), trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), + jiraOnly: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.user.orgId); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); - const results: { trello?: TrelloWebhook | string; github?: GitHubWebhook | string } = {}; + const results: { + trello?: TrelloWebhook | string; + github?: GitHubWebhook | string; + jira?: JiraWebhookInfo | string; + } = {}; - // Trello webhook - if (!input.githubOnly && pctx.trelloApiKey && pctx.trelloToken) { + // Trello webhook (skip for JIRA-only projects) + if ( + !input.githubOnly && + !input.jiraOnly && + pctx.trelloApiKey && + pctx.trelloToken && + pctx.boardId + ) { const trelloCallbackUrl = `${baseUrl}/webhook/trello`; const existing = await trelloListWebhooks(pctx); const duplicate = existing.find((w) => w.callbackURL === trelloCallbackUrl); @@ -208,8 +321,27 @@ export const webhooksRouter = router({ } } + // JIRA webhook (skip for Trello-only projects) + if ( + !input.trelloOnly && + !input.githubOnly && + pctx.jiraEmail && + pctx.jiraApiToken && + pctx.jiraBaseUrl + ) { + const jiraCallbackUrl = `${baseUrl}/webhook/jira`; + const existing = await jiraListWebhooks(pctx); + const duplicate = existing.find((w) => w.url === jiraCallbackUrl); + + if (duplicate) { + results.jira = `Already exists: ${duplicate.id}`; + } else { + results.jira = await jiraCreateWebhook(pctx, jiraCallbackUrl); + } + } + // GitHub webhook - if (!input.trelloOnly && pctx.githubToken) { + if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { const githubCallbackUrl = `${baseUrl}/webhook/github`; const existing = await githubListWebhooks(pctx); const duplicate = existing.find((w) => w.config.url === githubCallbackUrl); @@ -231,15 +363,20 @@ export const webhooksRouter = router({ callbackBaseUrl: z.string().url(), trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), + jiraOnly: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.user.orgId); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); - const deleted: { trello: string[]; github: number[] } = { trello: [], github: [] }; + const deleted: { trello: string[]; github: number[]; jira: number[] } = { + trello: [], + github: [], + jira: [], + }; // Trello - if (!input.githubOnly && pctx.trelloApiKey && pctx.trelloToken) { + if (!input.githubOnly && !input.jiraOnly && pctx.trelloApiKey && pctx.trelloToken) { const trelloCallbackUrl = `${baseUrl}/webhook/trello`; const existing = await trelloListWebhooks(pctx); const matching = existing.filter((w) => w.callbackURL === trelloCallbackUrl); @@ -249,8 +386,19 @@ export const webhooksRouter = router({ } } + // JIRA + if (!input.trelloOnly && !input.githubOnly && pctx.jiraEmail && pctx.jiraApiToken) { + const jiraCallbackUrl = `${baseUrl}/webhook/jira`; + const existing = await jiraListWebhooks(pctx); + const matching = existing.filter((w) => w.url === jiraCallbackUrl); + for (const w of matching) { + await jiraDeleteWebhook(pctx, w.id); + deleted.jira.push(w.id); + } + } + // GitHub - if (!input.trelloOnly && pctx.githubToken) { + if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { const githubCallbackUrl = `${baseUrl}/webhook/github`; const existing = await githubListWebhooks(pctx); const matching = existing.filter((w) => w.config.url === githubCallbackUrl); diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 81e190c3..1fdc93de 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -13,7 +13,7 @@ import { createRun, storeRunLogs, } from '../db/repositories/runsRepository.js'; -import { readCard } from '../gadgets/trello/core/readCard.js'; +import { readWorkItem } from '../gadgets/pm/core/readWorkItem.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; import { loadCascadeEnv, unloadCascadeEnv } from '../utils/cascadeEnv.js'; import { cleanupLogDirectory, cleanupLogFile, createFileLogger } from '../utils/fileLogger.js'; @@ -30,67 +30,67 @@ import type { AgentBackend, AgentBackendInput, ContextInjection, ToolManifest } function getToolManifests(): ToolManifest[] { return [ { - name: 'ReadTrelloCard', + name: 'ReadWorkItem', description: - 'Read a Trello card with title, description, comments, checklists, and attachments.', - cliCommand: 'cascade-tools trello read-card', + 'Read a work item (card/issue) with title, description, comments, checklists, and attachments.', + cliCommand: 'cascade-tools pm read-work-item', parameters: { - cardId: { type: 'string', required: true }, + workItemId: { type: 'string', required: true }, includeComments: { type: 'boolean', default: true }, }, }, { - name: 'PostTrelloComment', - description: 'Post a comment to a Trello card.', - cliCommand: 'cascade-tools trello post-comment', + name: 'PostComment', + description: 'Post a comment to a work item (card/issue).', + cliCommand: 'cascade-tools pm post-comment', parameters: { - cardId: { type: 'string', required: true }, + workItemId: { type: 'string', required: true }, text: { type: 'string', required: true }, }, }, { - name: 'UpdateTrelloCard', - description: 'Update a Trello card title, description, or labels.', - cliCommand: 'cascade-tools trello update-card', + name: 'UpdateWorkItem', + description: 'Update a work item title, description, or labels.', + cliCommand: 'cascade-tools pm update-work-item', parameters: { - cardId: { type: 'string', required: true }, + workItemId: { type: 'string', required: true }, title: { type: 'string' }, description: { type: 'string' }, }, }, { - name: 'CreateTrelloCard', - description: 'Create a new Trello card.', - cliCommand: 'cascade-tools trello create-card', + name: 'CreateWorkItem', + description: 'Create a new work item (card/issue).', + cliCommand: 'cascade-tools pm create-work-item', parameters: { - listId: { type: 'string', required: true }, + containerId: { type: 'string', required: true }, title: { type: 'string', required: true }, }, }, { - name: 'ListTrelloCards', - description: 'List all cards in a Trello list.', - cliCommand: 'cascade-tools trello list-cards', - parameters: { listId: { type: 'string', required: true } }, + name: 'ListWorkItems', + description: 'List all work items in a container.', + cliCommand: 'cascade-tools pm list-work-items', + parameters: { containerId: { type: 'string', required: true } }, }, { - name: 'AddChecklistToCard', - description: 'Add a checklist with items to a Trello card.', - cliCommand: 'cascade-tools trello add-checklist', + name: 'AddChecklist', + description: 'Add a checklist with items to a work item.', + cliCommand: 'cascade-tools pm add-checklist', parameters: { - cardId: { type: 'string', required: true }, + workItemId: { type: 'string', required: true }, name: { type: 'string', required: true }, items: { type: 'array', required: true }, }, }, { name: 'UpdateChecklistItem', - description: 'Update a checklist item state on a Trello card.', - cliCommand: 'cascade-tools trello update-checklist-item', + description: 'Update a checklist item state on a work item.', + cliCommand: 'cascade-tools pm update-checklist-item', parameters: { - cardId: { type: 'string', required: true }, + workItemId: { type: 'string', required: true }, checkItemId: { type: 'string', required: true }, - state: { type: 'string', enum: ['complete', 'incomplete'] }, + complete: { type: 'boolean' }, }, }, { @@ -216,13 +216,13 @@ async function fetchContextInjections( const cardId = input.cardId; if (cardId && !input.logDir) { - log.info('Fetching card data for context injection', { cardId }); - const cardData = await readCard(cardId, true); + log.info('Fetching work item data for context injection', { cardId }); + const cardData = await readWorkItem(cardId, true); injections.push({ - toolName: 'ReadTrelloCard', - params: { cardId, includeComments: true }, + toolName: 'ReadWorkItem', + params: { workItemId: cardId, includeComments: true }, result: cardData, - description: 'Pre-fetched Trello card data', + description: 'Pre-fetched work item data', }); } @@ -262,13 +262,19 @@ async function buildBackendInput( ): Promise> { const { project, config, cardId } = input; + const pmType = project.pm?.type ?? 'trello'; const promptContext: PromptContext = { cardId, - cardUrl: cardId ? `https://trello.com/c/${cardId}` : undefined, + cardUrl: cardId + ? pmType === 'jira' && project.jira + ? `${project.jira.baseUrl}/browse/${cardId}` + : `https://trello.com/c/${cardId}` + : undefined, projectId: project.id, baseBranch: project.baseBranch, storiesListId: project.trello?.lists?.stories, processedLabelId: project.trello?.labels?.processed, + pmType, }; const { systemPrompt, model, maxIterations } = await resolveModelConfig({ @@ -292,7 +298,7 @@ async function buildBackendInput( config, repoDir, systemPrompt, - taskPrompt: `Analyze and process the Trello card with ID: ${cardId || 'unknown'}. The card data has been pre-loaded.`, + taskPrompt: `Analyze and process the work item with ID: ${cardId || 'unknown'}. The work item data has been pre-loaded.`, cliToolsDir, availableTools: getToolManifests(), contextInjections, @@ -506,7 +512,7 @@ export async function executeWithBackend( logFn.call(logger, message, context); }, agentType, - taskDescription: cardId ? `Trello card ${cardId}` : 'Unknown task', + taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', progressModel: input.config.defaults.progressModel, intervalMinutes: input.config.defaults.progressIntervalMinutes, customModels: CUSTOM_MODELS as ModelSpec[], diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index 9b5d21f2..c8635551 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -17,7 +17,7 @@ import { formatGitHubProgressComment, formatStatusMessage } from '../config/stat import { getSessionState } from '../gadgets/sessionState.js'; import { loadTodos } from '../gadgets/todo/storage.js'; import { githubClient } from '../github/client.js'; -import { trelloClient } from '../trello/client.js'; +import { getPMProviderOrNull } from '../pm/index.js'; import { type ProgressContext, callProgressModel } from './progressModel.js'; import type { LogWriter, ProgressReporter } from './types.js'; @@ -137,15 +137,18 @@ export class ProgressMonitor implements ProgressReporter { } private async postProgress(summary: string): Promise { - // Post to Trello + // Post to PM provider (Trello/JIRA) if (this.config.trello) { try { - await trelloClient.addComment(this.config.trello.cardId, summary); - this.config.logWriter('INFO', 'Posted progress update to Trello', { - cardId: this.config.trello.cardId, - }); + const provider = getPMProviderOrNull(); + if (provider) { + await provider.addComment(this.config.trello.cardId, summary); + this.config.logWriter('INFO', 'Posted progress update to work item', { + cardId: this.config.trello.cardId, + }); + } } catch (err) { - this.config.logWriter('WARN', 'Failed to post progress to Trello', { + this.config.logWriter('WARN', 'Failed to post progress to work item', { error: String(err), }); } diff --git a/src/cli/dashboard/defaults/set.ts b/src/cli/dashboard/defaults/set.ts index 9832accf..13ea8503 100644 --- a/src/cli/dashboard/defaults/set.ts +++ b/src/cli/dashboard/defaults/set.ts @@ -8,9 +8,7 @@ export default class DefaultsSet extends DashboardCommand { ...DashboardCommand.baseFlags, model: Flags.string({ description: 'Default model' }), 'max-iterations': Flags.integer({ description: 'Max iterations per agent run' }), - 'fresh-machine-timeout': Flags.integer({ description: 'Fresh machine timeout (ms)' }), 'watchdog-timeout': Flags.integer({ description: 'Watchdog timeout (ms)' }), - 'post-job-grace': Flags.integer({ description: 'Post-job grace period (ms)' }), 'card-budget': Flags.string({ description: 'Per-card budget in USD' }), 'agent-backend': Flags.string({ description: 'Default agent backend' }), 'progress-model': Flags.string({ description: 'Model for progress updates' }), @@ -24,9 +22,7 @@ export default class DefaultsSet extends DashboardCommand { await this.client.defaults.upsert.mutate({ model: flags.model, maxIterations: flags['max-iterations'], - freshMachineTimeoutMs: flags['fresh-machine-timeout'], watchdogTimeoutMs: flags['watchdog-timeout'], - postJobGracePeriodMs: flags['post-job-grace'], cardBudgetUsd: flags['card-budget'], agentBackend: flags['agent-backend'], progressModel: flags['progress-model'], diff --git a/src/cli/dashboard/defaults/show.ts b/src/cli/dashboard/defaults/show.ts index 54d9cc2b..5442ff40 100644 --- a/src/cli/dashboard/defaults/show.ts +++ b/src/cli/dashboard/defaults/show.ts @@ -26,9 +26,7 @@ export default class DefaultsShow extends DashboardCommand { this.outputDetail(defaults as unknown as Record, { model: { label: 'Model' }, maxIterations: { label: 'Max Iterations' }, - freshMachineTimeoutMs: { label: 'Fresh Machine Timeout' }, watchdogTimeoutMs: { label: 'Watchdog Timeout' }, - postJobGracePeriodMs: { label: 'Post-Job Grace' }, cardBudgetUsd: { label: 'Card Budget' }, agentBackend: { label: 'Agent Backend' }, progressModel: { label: 'Progress Model' }, diff --git a/src/config/configCache.ts b/src/config/configCache.ts index 05bd253d..33ae96c3 100644 --- a/src/config/configCache.ts +++ b/src/config/configCache.ts @@ -11,6 +11,7 @@ class ConfigCache { private configEntry: CacheEntry | null = null; private projectByBoardId = new Map>(); private projectByRepo = new Map>(); + private projectByJiraKey = new Map>(); private projectSecrets = new Map>>(); private orgIdByProject = new Map>(); private ttlMs: number; @@ -53,6 +54,15 @@ class ConfigCache { this.projectByRepo.set(repo, this.makeEntry(project)); } + getProjectByJiraKey(projectKey: string): ProjectConfig | undefined | null { + const entry = this.projectByJiraKey.get(projectKey); + return this.isValid(entry) ? entry.data : null; + } + + setProjectByJiraKey(projectKey: string, project: ProjectConfig | undefined): void { + this.projectByJiraKey.set(projectKey, this.makeEntry(project)); + } + getOrgIdForProject(projectId: string): string | null { const entry = this.orgIdByProject.get(projectId); return this.isValid(entry) ? entry.data : null; @@ -75,6 +85,7 @@ class ConfigCache { this.configEntry = null; this.projectByBoardId.clear(); this.projectByRepo.clear(); + this.projectByJiraKey.clear(); this.projectSecrets.clear(); this.orgIdByProject.clear(); } diff --git a/src/config/provider.ts b/src/config/provider.ts index 2d4a48e8..cee65b17 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -1,6 +1,7 @@ import { findProjectByBoardIdFromDb, findProjectByIdFromDb, + findProjectByJiraProjectKeyFromDb, findProjectByRepoFromDb, loadConfigFromDb, } from '../db/repositories/configRepository.js'; @@ -39,6 +40,17 @@ export async function findProjectByRepo(repo: string): Promise { + const cached = configCache.getProjectByJiraKey(projectKey); + if (cached !== null) return cached; + + const project = await findProjectByJiraProjectKeyFromDb(projectKey); + configCache.setProjectByJiraKey(projectKey, project); + return project; +} + export async function findProjectById(id: string): Promise { // No cache for by-id lookups (less frequent, PK is fast) return findProjectByIdFromDb(id); diff --git a/src/config/schema.ts b/src/config/schema.ts index 92d4bbce..0efe1142 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -6,6 +6,18 @@ const AgentBackendConfigSchema = z.object({ subscriptionCostZero: z.boolean().default(false), }); +const JiraConfigSchema = z.object({ + projectKey: z.string().min(1), + baseUrl: z.string().url(), + statuses: z.record(z.string()), // CASCADE status names → JIRA status IDs/names + issueTypes: z.record(z.string()).optional(), + customFields: z + .object({ + cost: z.string().optional(), + }) + .optional(), +}); + export const ProjectConfigSchema = z.object({ id: z.string().min(1), orgId: z.string().min(1), @@ -14,16 +26,26 @@ export const ProjectConfigSchema = z.object({ baseBranch: z.string().default('main'), branchPrefix: z.string().default('feature/'), - trello: z.object({ - boardId: z.string().min(1), - lists: z.record(z.string()), - labels: z.record(z.string()), - customFields: z - .object({ - cost: z.string().optional(), - }) - .optional(), - }), + pm: z + .object({ + type: z.enum(['trello', 'jira']).default('trello'), + }) + .default({ type: 'trello' }), + + trello: z + .object({ + boardId: z.string().min(1), + lists: z.record(z.string()), + labels: z.record(z.string()), + customFields: z + .object({ + cost: z.string().optional(), + }) + .optional(), + }) + .optional(), + + jira: JiraConfigSchema.optional(), prompts: z.record(z.string()).optional(), model: z.string().optional(), @@ -39,17 +61,11 @@ export const CascadeConfigSchema = z.object({ agentModels: z.record(z.string()).default({}), maxIterations: z.number().int().positive().default(50), agentIterations: z.record(z.number().int().positive()).default({}), - freshMachineTimeoutMs: z - .number() - .int() - .positive() - .default(5 * 60 * 1000), // 5 min - exit if no work received after boot watchdogTimeoutMs: z .number() .int() .positive() .default(30 * 60 * 1000), // 30 min max job duration - postJobGracePeriodMs: z.number().int().nonnegative().default(5000), // 5 sec grace before exit cardBudgetUsd: z.number().positive().default(5), agentBackend: z.string().default('llmist'), progressModel: z.string().default('openrouter:google/gemini-2.5-flash-lite'), diff --git a/src/db/migrations/0007_remove_flyio_columns.sql b/src/db/migrations/0007_remove_flyio_columns.sql new file mode 100644 index 00000000..40c7043b --- /dev/null +++ b/src/db/migrations/0007_remove_flyio_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE cascade_defaults DROP COLUMN IF EXISTS fresh_machine_timeout_ms; +ALTER TABLE cascade_defaults DROP COLUMN IF EXISTS post_job_grace_period_ms; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 900de8dd..1eee21ee 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1741000000000, "tag": "0006_users_and_sessions", "breakpoints": false + }, + { + "idx": 6, + "version": "7", + "when": 1742000000000, + "tag": "0007_remove_flyio_columns", + "breakpoints": false } ] } diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index 3b55680e..c2837844 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -11,12 +11,18 @@ interface TrelloIntegrationConfig { customFields?: { cost?: string }; } +interface JiraIntegrationConfig { + projectKey: string; + baseUrl: string; + statuses: Record; + issueTypes?: Record; + customFields?: { cost?: string }; +} + interface DefaultsRow { model: string | null; maxIterations: number | null; - freshMachineTimeoutMs: number | null; watchdogTimeoutMs: number | null; - postJobGracePeriodMs: number | null; cardBudgetUsd: string | null; agentBackend: string | null; progressModel: string | null; @@ -59,9 +65,7 @@ function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentC agentModels: orUndefined(models), maxIterations: row?.maxIterations ?? undefined, agentIterations: orUndefined(iterations), - freshMachineTimeoutMs: row?.freshMachineTimeoutMs ?? undefined, watchdogTimeoutMs: row?.watchdogTimeoutMs ?? undefined, - postJobGracePeriodMs: row?.postJobGracePeriodMs ?? undefined, cardBudgetUsd: row?.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, agentBackend: row?.agentBackend ?? undefined, progressModel: row?.progressModel ?? undefined, @@ -77,9 +81,13 @@ function mapProjectRow( row: ProjectRow, projectAgentConfigs: AgentConfigRow[], trelloConfig?: TrelloIntegrationConfig, + jiraConfig?: JiraIntegrationConfig, ): Record { const { models, prompts, backends } = buildAgentMaps(projectAgentConfigs); + // Derive PM type from integration config + const pmType = jiraConfig ? 'jira' : 'trello'; + const project: Record = { id: row.id, orgId: row.orgId, @@ -87,20 +95,32 @@ function mapProjectRow( repo: row.repo, baseBranch: row.baseBranch ?? 'main', branchPrefix: row.branchPrefix ?? 'feature/', - trello: trelloConfig - ? { - boardId: trelloConfig.boardId, - lists: trelloConfig.lists, - labels: trelloConfig.labels, - customFields: trelloConfig.customFields, - } - : { boardId: '', lists: {}, labels: {} }, + pm: { type: pmType }, prompts: orUndefined(prompts), model: row.model ?? undefined, agentModels: orUndefined(models), cardBudgetUsd: row.cardBudgetUsd ? Number(row.cardBudgetUsd) : undefined, }; + if (trelloConfig) { + project.trello = { + boardId: trelloConfig.boardId, + lists: trelloConfig.lists, + labels: trelloConfig.labels, + customFields: trelloConfig.customFields, + }; + } + + if (jiraConfig) { + project.jira = { + projectKey: jiraConfig.projectKey, + baseUrl: jiraConfig.baseUrl, + statuses: jiraConfig.statuses, + issueTypes: jiraConfig.issueTypes, + customFields: jiraConfig.customFields, + }; + } + if (row.agentBackend) { project.agentBackend = { default: row.agentBackend, @@ -171,7 +191,10 @@ export async function loadConfigFromDb(): Promise { const trelloConfig = integrations.find((i) => i.type === 'trello')?.config as | TrelloIntegrationConfig | undefined; - return mapProjectRow(row, projectAgentConfigsMap.get(row.id) ?? [], trelloConfig); + const jiraConfig = integrations.find((i) => i.type === 'jira')?.config as + | JiraIntegrationConfig + | undefined; + return mapProjectRow(row, projectAgentConfigsMap.get(row.id) ?? [], trelloConfig, jiraConfig); }), }; @@ -204,10 +227,13 @@ async function findProjectFromDb(whereClause: SQL): Promise i.type === 'trello')?.config as | TrelloIntegrationConfig | undefined; + const jiraConfig = integrations.find((i) => i.type === 'jira')?.config as + | JiraIntegrationConfig + | undefined; const rawConfig = { defaults: mapDefaultsRow(defaultsRow, [...globalAcs, ...orgAcs]), - projects: [mapProjectRow(row, projectAcs, trelloConfig)], + projects: [mapProjectRow(row, projectAcs, trelloConfig, jiraConfig)], }; const validated = validateConfig(rawConfig); return validated.projects[0]; @@ -230,3 +256,15 @@ export function findProjectByRepoFromDb(repo: string): Promise { return findProjectFromDb(eq(projects.id, id)); } + +export function findProjectByJiraProjectKeyFromDb( + projectKey: string, +): Promise { + return findProjectFromDb( + sql`${projects.id} IN ( + SELECT ${projectIntegrations.projectId} FROM ${projectIntegrations} + WHERE ${projectIntegrations.type} = 'jira' + AND ${projectIntegrations.config}->>'projectKey' = ${projectKey} + )`, + ); +} diff --git a/src/db/repositories/settingsRepository.ts b/src/db/repositories/settingsRepository.ts index 9b870d25..206aeaf4 100644 --- a/src/db/repositories/settingsRepository.ts +++ b/src/db/repositories/settingsRepository.ts @@ -38,9 +38,7 @@ export async function upsertCascadeDefaults( data: { model?: string | null; maxIterations?: number | null; - freshMachineTimeoutMs?: number | null; watchdogTimeoutMs?: number | null; - postJobGracePeriodMs?: number | null; cardBudgetUsd?: string | null; agentBackend?: string | null; progressModel?: string | null; diff --git a/src/db/schema/defaults.ts b/src/db/schema/defaults.ts index 0b0ad0d4..81827eb4 100644 --- a/src/db/schema/defaults.ts +++ b/src/db/schema/defaults.ts @@ -7,9 +7,7 @@ export const cascadeDefaults = pgTable('cascade_defaults', { .references(() => organizations.id, { onDelete: 'cascade' }), model: text('model'), maxIterations: integer('max_iterations'), - freshMachineTimeoutMs: integer('fresh_machine_timeout_ms'), watchdogTimeoutMs: integer('watchdog_timeout_ms'), - postJobGracePeriodMs: integer('post_job_grace_period_ms'), cardBudgetUsd: numeric('card_budget_usd', { precision: 10, scale: 2 }), agentBackend: text('agent_backend'), progressModel: text('progress_model'), diff --git a/src/gadgets/pm/AddChecklist.ts b/src/gadgets/pm/AddChecklist.ts new file mode 100644 index 00000000..b1eb94c7 --- /dev/null +++ b/src/gadgets/pm/AddChecklist.ts @@ -0,0 +1,38 @@ +import { Gadget, z } from 'llmist'; +import { addChecklist } from './core/addChecklist.js'; + +export class AddChecklist extends Gadget({ + name: 'AddChecklist', + description: + 'Add a checklist with items to a work item. Use this to create interactive checklists for acceptance criteria or implementation steps.', + timeoutMs: 30000, + schema: z.object({ + workItemId: z.string().describe('The work item ID (Trello card ID or JIRA issue key)'), + 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: { + workItemId: '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 work item', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return addChecklist({ + workItemId: params.workItemId, + checklistName: params.checklistName, + items: params.items, + }); + } +} diff --git a/src/gadgets/pm/CreateWorkItem.ts b/src/gadgets/pm/CreateWorkItem.ts new file mode 100644 index 00000000..ae34a452 --- /dev/null +++ b/src/gadgets/pm/CreateWorkItem.ts @@ -0,0 +1,37 @@ +import { Gadget, z } from 'llmist'; +import { createWorkItem } from './core/createWorkItem.js'; + +export class CreateWorkItem extends Gadget({ + name: 'CreateWorkItem', + description: + 'Create a new work item (card/issue). Use this to create user story cards or break down work into smaller tasks.', + timeoutMs: 30000, + schema: z.object({ + containerId: z.string().describe('Container ID — Trello list ID or JIRA project key'), + title: z.string().max(200).describe('Work item title'), + description: z + .string() + .optional() + .describe( + 'Description (markdown supported). Include acceptance criteria and technical notes.', + ), + }), + examples: [ + { + params: { + containerId: 'abc123', + title: 'Add email validation to signup form', + description: '## Acceptance Criteria\n\n- [ ] Email format is validated on blur', + }, + comment: 'Create a new work item', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return createWorkItem({ + containerId: params.containerId, + title: params.title, + description: params.description, + }); + } +} diff --git a/src/gadgets/pm/ListWorkItems.ts b/src/gadgets/pm/ListWorkItems.ts new file mode 100644 index 00000000..090bdb92 --- /dev/null +++ b/src/gadgets/pm/ListWorkItems.ts @@ -0,0 +1,22 @@ +import { Gadget, z } from 'llmist'; +import { listWorkItems } from './core/listWorkItems.js'; + +export class ListWorkItems extends Gadget({ + name: 'ListWorkItems', + description: + 'List all work items in a container (Trello list or JIRA project). Use this to see items you created or to find items to update.', + timeoutMs: 30000, + schema: z.object({ + containerId: z.string().describe('Container ID — Trello list ID or JIRA project key'), + }), + examples: [ + { + params: { containerId: 'abc123' }, + comment: 'List all work items to find ones to update', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return listWorkItems(params.containerId); + } +} diff --git a/src/gadgets/pm/PostComment.ts b/src/gadgets/pm/PostComment.ts new file mode 100644 index 00000000..793ab470 --- /dev/null +++ b/src/gadgets/pm/PostComment.ts @@ -0,0 +1,26 @@ +import { Gadget, z } from 'llmist'; +import { postComment } from './core/postComment.js'; + +export class PostComment extends Gadget({ + name: 'PostComment', + description: + 'Post a comment to a work item (card/issue). Use this to communicate with the user, ask questions, or provide status updates.', + timeoutMs: 30000, + schema: z.object({ + workItemId: z.string().describe('The work item ID (Trello card ID or JIRA issue key)'), + text: z.string().describe('The comment text to post (supports markdown)'), + }), + examples: [ + { + params: { + workItemId: 'abc123', + text: '**Brief Ready for Review**\n\nI have analyzed the codebase and updated the description.', + }, + comment: 'Post a status update to the work item', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return postComment(params.workItemId, params.text); + } +} diff --git a/src/gadgets/pm/ReadWorkItem.ts b/src/gadgets/pm/ReadWorkItem.ts new file mode 100644 index 00000000..97137466 --- /dev/null +++ b/src/gadgets/pm/ReadWorkItem.ts @@ -0,0 +1,30 @@ +import { Gadget, z } from 'llmist'; +import { readWorkItem } from './core/readWorkItem.js'; + +export class ReadWorkItem extends Gadget({ + name: 'ReadWorkItem', + description: + 'Read a work item (card/issue) to retrieve its title, description, comments, checklists, and attachments. Use this to understand the current state before making changes.', + timeoutMs: 30000, + schema: z.object({ + workItemId: z.string().describe('The work item ID (Trello card ID or JIRA issue key)'), + includeComments: z + .boolean() + .optional() + .default(true) + .describe('Whether to include comments in the response'), + }), + examples: [ + { + params: { workItemId: 'abc123', includeComments: true }, + comment: 'Read the work item with its comments to understand context', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return readWorkItem(params.workItemId, params.includeComments); + } +} + +/** @deprecated Use readWorkItem from './core/readWorkItem.js' instead */ +export { readWorkItem as formatWorkItemData } from './core/readWorkItem.js'; diff --git a/src/gadgets/pm/UpdateChecklistItem.ts b/src/gadgets/pm/UpdateChecklistItem.ts new file mode 100644 index 00000000..98fc7779 --- /dev/null +++ b/src/gadgets/pm/UpdateChecklistItem.ts @@ -0,0 +1,28 @@ +import { Gadget, z } from 'llmist'; +import { updateChecklistItem } from './core/updateChecklistItem.js'; + +export class PMUpdateChecklistItem extends Gadget({ + name: 'UpdateChecklistItem', + description: + 'Update a checklist item state on a work item. Use this to mark items as complete or incomplete.', + timeoutMs: 15000, + schema: z.object({ + workItemId: z.string().describe('The work item ID (Trello card ID or JIRA issue key)'), + checkItemId: z.string().describe('The checklist item ID to update'), + complete: z.boolean().describe('Whether the item is complete'), + }), + examples: [ + { + params: { + workItemId: 'abc123', + checkItemId: 'item456', + complete: true, + }, + comment: 'Mark an item as complete', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return updateChecklistItem(params.workItemId, params.checkItemId, params.complete); + } +} diff --git a/src/gadgets/pm/UpdateWorkItem.ts b/src/gadgets/pm/UpdateWorkItem.ts new file mode 100644 index 00000000..4c27b875 --- /dev/null +++ b/src/gadgets/pm/UpdateWorkItem.ts @@ -0,0 +1,40 @@ +import { Gadget, z } from 'llmist'; +import { updateWorkItem } from './core/updateWorkItem.js'; + +export class UpdateWorkItem extends Gadget({ + name: 'UpdateWorkItem', + description: + 'Update a work item title and/or description. Use this to save your analysis, brief, or plan.', + timeoutMs: 30000, + schema: z.object({ + workItemId: z.string().describe('The work item ID (Trello card ID or JIRA issue key)'), + title: z + .string() + .max(200) + .optional() + .describe('New title (max 200 chars). Should be action-oriented.'), + description: z + .string() + .optional() + .describe('New description (markdown supported). Use this to save the full brief or plan.'), + addLabelIds: z.array(z.string()).optional().describe('Label IDs/names to add to the work item'), + }), + examples: [ + { + params: { + workItemId: 'abc123', + description: '## Context\n\nBackground info...\n\n## Requirements\n\n- Item 1\n- Item 2', + }, + comment: 'Update the description with a structured brief', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return updateWorkItem({ + workItemId: params.workItemId, + title: params.title, + description: params.description, + addLabelIds: params.addLabelIds, + }); + } +} diff --git a/src/gadgets/pm/core/addChecklist.ts b/src/gadgets/pm/core/addChecklist.ts new file mode 100644 index 00000000..3c0e8d9f --- /dev/null +++ b/src/gadgets/pm/core/addChecklist.ts @@ -0,0 +1,23 @@ +import { getPMProvider } from '../../../pm/index.js'; + +export interface AddChecklistParams { + workItemId: string; + checklistName: string; + items: string[]; +} + +export async function addChecklist(params: AddChecklistParams): Promise { + try { + const provider = getPMProvider(); + const checklist = await provider.createChecklist(params.workItemId, params.checklistName); + + for (const item of params.items) { + await provider.addChecklistItem(checklist.id, item); + } + + return `Checklist "${params.checklistName}" created with ${params.items.length} items on work item ${params.workItemId}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error adding checklist: ${message}`; + } +} diff --git a/src/gadgets/pm/core/createWorkItem.ts b/src/gadgets/pm/core/createWorkItem.ts new file mode 100644 index 00000000..6c43abda --- /dev/null +++ b/src/gadgets/pm/core/createWorkItem.ts @@ -0,0 +1,22 @@ +import { getPMProvider } from '../../../pm/index.js'; + +export interface CreateWorkItemParams { + containerId: string; + title: string; + description?: string; +} + +export async function createWorkItem(params: CreateWorkItemParams): Promise { + try { + const item = await getPMProvider().createWorkItem({ + containerId: params.containerId, + title: params.title, + description: params.description, + }); + + return `Work item created successfully: "${item.title}" - ${item.url}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error creating work item: ${message}`; + } +} diff --git a/src/gadgets/pm/core/listWorkItems.ts b/src/gadgets/pm/core/listWorkItems.ts new file mode 100644 index 00000000..66e073fd --- /dev/null +++ b/src/gadgets/pm/core/listWorkItems.ts @@ -0,0 +1,27 @@ +import { getPMProvider } from '../../../pm/index.js'; + +export async function listWorkItems(containerId: string): Promise { + try { + const items = await getPMProvider().listWorkItems(containerId); + + if (items.length === 0) { + return 'No work items found.'; + } + + let result = `# Work Items (${items.length})\n\n`; + for (const item of items) { + result += `## ${item.title}\n`; + result += `- **ID:** ${item.id}\n`; + result += `- **URL:** ${item.url}\n`; + if (item.description) { + result += `- **Description:** ${item.description.slice(0, 100)}${item.description.length > 100 ? '...' : ''}\n`; + } + result += '\n'; + } + + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error listing work items: ${message}`; + } +} diff --git a/src/gadgets/pm/core/postComment.ts b/src/gadgets/pm/core/postComment.ts new file mode 100644 index 00000000..a7348461 --- /dev/null +++ b/src/gadgets/pm/core/postComment.ts @@ -0,0 +1,11 @@ +import { getPMProvider } from '../../../pm/index.js'; + +export async function postComment(workItemId: string, text: string): Promise { + try { + await getPMProvider().addComment(workItemId, 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/pm/core/readWorkItem.ts b/src/gadgets/pm/core/readWorkItem.ts new file mode 100644 index 00000000..2ddb8f46 --- /dev/null +++ b/src/gadgets/pm/core/readWorkItem.ts @@ -0,0 +1,61 @@ +import { getPMProvider } from '../../../pm/index.js'; + +export async function readWorkItem(workItemId: string, includeComments = true): Promise { + try { + const provider = getPMProvider(); + const [item, checklists, attachments] = await Promise.all([ + provider.getWorkItem(workItemId), + provider.getChecklists(workItemId), + provider.getAttachments(workItemId), + ]); + + let result = `# ${item.title}\n\n**URL:** ${item.url}\n\n## Description\n\n${item.description || '(No description)'}\n\n`; + + if (item.labels.length > 0) { + result += `## Labels\n\n${item.labels.map((l) => `- ${l.name}${l.color ? ` (${l.color})` : ''}`).join('\n')}\n\n`; + } + + if (checklists.length > 0) { + result += '## Checklists\n\n'; + for (const checklist of checklists) { + result += `### ${checklist.name} [checklistId: ${checklist.id}]\n\n`; + for (const item of checklist.items) { + const checkbox = item.complete ? '[x]' : '[ ]'; + result += `- ${checkbox} ${item.name} [checkItemId: ${item.id}]\n`; + } + result += '\n'; + } + } + + if (attachments.length > 0) { + 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'; + } + result += '\n'; + } + + if (includeComments) { + const comments = await provider.getWorkItemComments(workItemId); + if (comments.length === 0) { + result += '## Comments\n\n(No comments)\n\n'; + } else { + result += `## Comments (${comments.length})\n\n`; + for (const comment of comments.slice().reverse()) { + const date = new Date(comment.date).toISOString(); + result += `### ${comment.author.name} (${date})\n\n`; + result += `${comment.text}\n\n`; + } + } + } + + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error reading work item: ${message}`; + } +} diff --git a/src/gadgets/pm/core/updateChecklistItem.ts b/src/gadgets/pm/core/updateChecklistItem.ts new file mode 100644 index 00000000..3016fb05 --- /dev/null +++ b/src/gadgets/pm/core/updateChecklistItem.ts @@ -0,0 +1,17 @@ +import { getPMProvider } from '../../../pm/index.js'; + +export async function updateChecklistItem( + workItemId: string, + checkItemId: string, + complete: boolean, +): Promise { + try { + await getPMProvider().updateChecklistItem(workItemId, checkItemId, complete); + + const action = complete ? 'marked complete' : 'marked incomplete'; + return `Checklist item ${checkItemId} ${action} on work item ${workItemId}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error updating checklist item: ${message}`; + } +} diff --git a/src/gadgets/pm/core/updateWorkItem.ts b/src/gadgets/pm/core/updateWorkItem.ts new file mode 100644 index 00000000..af486fd8 --- /dev/null +++ b/src/gadgets/pm/core/updateWorkItem.ts @@ -0,0 +1,41 @@ +import { getPMProvider } from '../../../pm/index.js'; + +export interface UpdateWorkItemParams { + workItemId: string; + title?: string; + description?: string; + addLabelIds?: string[]; +} + +export async function updateWorkItem(params: UpdateWorkItemParams): Promise { + if (!params.title && !params.description && !params.addLabelIds?.length) { + return 'Nothing to update - provide title, description, or labels'; + } + + try { + const provider = getPMProvider(); + + if (params.title || params.description) { + await provider.updateWorkItem(params.workItemId, { + title: params.title, + description: params.description, + }); + } + + if (params.addLabelIds?.length) { + for (const labelId of params.addLabelIds) { + await provider.addLabel(params.workItemId, 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 `Work item updated: ${updated.join(', ')}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error updating work item: ${message}`; + } +} diff --git a/src/gadgets/pm/index.ts b/src/gadgets/pm/index.ts new file mode 100644 index 00000000..470fb296 --- /dev/null +++ b/src/gadgets/pm/index.ts @@ -0,0 +1,7 @@ +export { ReadWorkItem, formatWorkItemData } from './ReadWorkItem.js'; +export { PostComment } from './PostComment.js'; +export { UpdateWorkItem } from './UpdateWorkItem.js'; +export { CreateWorkItem } from './CreateWorkItem.js'; +export { ListWorkItems } from './ListWorkItems.js'; +export { AddChecklist } from './AddChecklist.js'; +export { PMUpdateChecklistItem } from './UpdateChecklistItem.js'; diff --git a/src/index.ts b/src/index.ts index 82f247f9..24d9e3d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,11 @@ import { createServer } from './server.js'; import { createTriggerRegistry, processGitHubWebhook, + processJiraWebhook, registerBuiltInTriggers, } from './triggers/index.js'; import { processTrelloWebhook } from './triggers/trello/webhook-handler.js'; -import { logger, setLogLevel, startFreshMachineTimer } from './utils/index.js'; +import { logger, setLogLevel } from './utils/index.js'; async function main(): Promise { // Load environment config @@ -34,14 +35,11 @@ async function main(): Promise { onGitHubWebhook: async (payload, eventType) => { await processGitHubWebhook(payload, eventType, triggerRegistry); }, + onJiraWebhook: async (payload) => { + await processJiraWebhook(payload, triggerRegistry); + }, }); - // Start fresh machine timer (for Fly.io cost management) - // Exits if no work received within timeout - if (process.env.FLY_APP_NAME) { - startFreshMachineTimer(config.defaults.freshMachineTimeoutMs); - } - // Start server const server = serve({ fetch: app.fetch, diff --git a/src/jira/client.ts b/src/jira/client.ts new file mode 100644 index 00000000..cedca18e --- /dev/null +++ b/src/jira/client.ts @@ -0,0 +1,162 @@ +/** + * JIRA client using jira.js Version3Client. + * + * Same AsyncLocalStorage pattern as the Trello client — credentials + * are scoped per-request via withJiraCredentials(). + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { Version3Client } from 'jira.js'; +import { logger } from '../utils/logging.js'; +import type { JiraCredentials } from './types.js'; + +const jiraCredentialStore = new AsyncLocalStorage(); + +export function withJiraCredentials(creds: JiraCredentials, fn: () => Promise): Promise { + return jiraCredentialStore.run(creds, fn); +} + +export function getJiraCredentials(): JiraCredentials { + const scoped = jiraCredentialStore.getStore(); + if (!scoped) { + throw new Error( + 'No JIRA credentials in scope. Wrap the call with withJiraCredentials() or ensure per-project JIRA_EMAIL/JIRA_API_TOKEN/JIRA_BASE_URL are set in the database.', + ); + } + return scoped; +} + +function getClient(): Version3Client { + const creds = getJiraCredentials(); + return new Version3Client({ + host: creds.baseUrl, + authentication: { + basic: { + email: creds.email, + apiToken: creds.apiToken, + }, + }, + }); +} + +export const jiraClient = { + async getIssue(issueKey: string) { + logger.debug('Fetching JIRA issue', { issueKey }); + return getClient().issues.getIssue({ + issueIdOrKey: issueKey, + fields: [ + 'summary', + 'description', + 'status', + 'labels', + 'issuetype', + 'subtasks', + 'attachment', + 'comment', + ], + }); + }, + + async getIssueComments(issueKey: string) { + logger.debug('Fetching JIRA issue comments', { issueKey }); + const result = await getClient().issueComments.getComments({ + issueIdOrKey: issueKey, + orderBy: '-created', + }); + return result.comments ?? []; + }, + + async updateIssue(issueKey: string, updates: { summary?: string; description?: unknown }) { + logger.debug('Updating JIRA issue', { issueKey }); + const fields: Record = {}; + if (updates.summary) fields.summary = updates.summary; + if (updates.description) fields.description = updates.description; + await getClient().issues.editIssue({ + issueIdOrKey: issueKey, + fields, + }); + }, + + async addComment(issueKey: string, body: unknown) { + logger.debug('Adding JIRA comment', { issueKey }); + await getClient().issueComments.addComment({ + issueIdOrKey: issueKey, + comment: body as any, + }); + }, + + async createIssue(fields: Record) { + logger.debug('Creating JIRA issue', { project: (fields.project as any)?.key }); + return getClient().issues.createIssue({ fields: fields as any }); + }, + + async transitionIssue(issueKey: string, transitionId: string) { + logger.debug('Transitioning JIRA issue', { issueKey, transitionId }); + await getClient().issues.doTransition({ + issueIdOrKey: issueKey, + transition: { id: transitionId }, + }); + }, + + async getTransitions(issueKey: string) { + logger.debug('Fetching JIRA transitions', { issueKey }); + const result = await getClient().issues.getTransitions({ issueIdOrKey: issueKey }); + return result.transitions ?? []; + }, + + async updateLabels(issueKey: string, labels: string[]) { + logger.debug('Updating JIRA issue labels', { issueKey, labels }); + await getClient().issues.editIssue({ + issueIdOrKey: issueKey, + fields: { labels }, + }); + }, + + async getIssueLabels(issueKey: string): Promise { + const issue = await getClient().issues.getIssue({ + issueIdOrKey: issueKey, + fields: ['labels'], + }); + return (issue.fields?.labels as string[]) ?? []; + }, + + async searchIssues(jql: string, fields: string[] = ['summary', 'status', 'labels']) { + logger.debug('Searching JIRA issues', { jql }); + const result = await getClient().issueSearch.searchForIssuesUsingJql({ + jql, + fields, + }); + return result.issues ?? []; + }, + + async getCustomFieldValue(issueKey: string, fieldId: string): Promise { + const issue = await getClient().issues.getIssue({ + issueIdOrKey: issueKey, + fields: [fieldId], + }); + return issue.fields?.[fieldId]; + }, + + async updateCustomField(issueKey: string, fieldId: string, value: unknown) { + await getClient().issues.editIssue({ + issueIdOrKey: issueKey, + fields: { [fieldId]: value }, + }); + }, + + async getMyself() { + logger.debug('Fetching authenticated JIRA user'); + return getClient().myself.getCurrentUser(); + }, + + async addAttachmentFile(issueKey: string, buffer: Buffer, filename: string) { + logger.debug('Adding JIRA attachment', { issueKey, filename }); + await getClient().issueAttachments.addAttachment({ + issueIdOrKey: issueKey, + attachment: { + filename, + file: buffer, + }, + }); + }, +}; diff --git a/src/jira/types.ts b/src/jira/types.ts new file mode 100644 index 00000000..1595db80 --- /dev/null +++ b/src/jira/types.ts @@ -0,0 +1,5 @@ +export interface JiraCredentials { + email: string; + apiToken: string; + baseUrl: string; +} diff --git a/src/pm/context.ts b/src/pm/context.ts new file mode 100644 index 00000000..ba08502c --- /dev/null +++ b/src/pm/context.ts @@ -0,0 +1,30 @@ +/** + * AsyncLocalStorage-based scoping for the active PMProvider. + * + * Same pattern as withTrelloCredentials() — webhook handlers call + * withPMProvider(provider, fn) to make the provider available to + * all downstream code via getPMProvider(). + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { PMProvider } from './types.js'; + +const pmProviderStore = new AsyncLocalStorage(); + +export function withPMProvider(provider: PMProvider, fn: () => Promise): Promise { + return pmProviderStore.run(provider, fn); +} + +export function getPMProvider(): PMProvider { + const provider = pmProviderStore.getStore(); + if (!provider) { + throw new Error( + 'No PMProvider in scope. Wrap the call with withPMProvider() or ensure the webhook handler has established a PM context.', + ); + } + return provider; +} + +export function getPMProviderOrNull(): PMProvider | null { + return pmProviderStore.getStore() ?? null; +} diff --git a/src/pm/factory.ts b/src/pm/factory.ts new file mode 100644 index 00000000..6a2cc45a --- /dev/null +++ b/src/pm/factory.ts @@ -0,0 +1,25 @@ +/** + * 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 new file mode 100644 index 00000000..ba83dd6c --- /dev/null +++ b/src/pm/index.ts @@ -0,0 +1,18 @@ +export type { + PMProvider, + PMType, + WorkItem, + WorkItemComment, + WorkItemLabel, + Checklist, + ChecklistItem, + Attachment, + CreateWorkItemConfig, +} 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'; diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts new file mode 100644 index 00000000..4ff54386 --- /dev/null +++ b/src/pm/jira/adapter.ts @@ -0,0 +1,262 @@ +/** + * JiraPMProvider — wraps the jiraClient to implement the PMProvider interface. + * + * Assumes jiraClient credentials are already in scope via withJiraCredentials(). + */ + +import { jiraClient } from '../../jira/client.js'; +import { logger } from '../../utils/logging.js'; +import type { + Attachment, + Checklist, + ChecklistItem, + CreateWorkItemConfig, + PMProvider, + WorkItem, + WorkItemComment, + WorkItemLabel, +} from '../types.js'; +import { adfToPlainText, markdownToAdf } from './adf.js'; + +interface JiraConfig { + projectKey: string; + baseUrl: string; + statuses: Record; + issueTypes?: Record; + customFields?: { cost?: string }; +} + +export class JiraPMProvider implements PMProvider { + readonly type = 'jira' as const; + + constructor(private config: JiraConfig) {} + + async getWorkItem(id: string): Promise { + const issue = await jiraClient.getIssue(id); + const fields = issue.fields ?? {}; + return { + id: issue.key ?? id, + title: (fields.summary as string) ?? '', + description: adfToPlainText(fields.description), + url: this.getWorkItemUrl(issue.key ?? id), + status: (fields.status as { name?: string })?.name, + labels: ((fields.labels as string[]) ?? []).map( + (l): WorkItemLabel => ({ + id: l, + name: l, + }), + ), + }; + } + + async getWorkItemComments(id: string): Promise { + const comments = await jiraClient.getIssueComments(id); + return comments.map((c: any) => ({ + id: c.id ?? '', + date: c.created ?? '', + text: adfToPlainText(c.body), + author: { + id: c.author?.accountId ?? '', + name: c.author?.displayName ?? '', + username: c.author?.emailAddress ?? '', + }, + })); + } + + async updateWorkItem( + id: string, + updates: { title?: string; description?: string }, + ): Promise { + await jiraClient.updateIssue(id, { + summary: updates.title, + description: updates.description ? markdownToAdf(updates.description) : undefined, + }); + } + + async addComment(id: string, text: string): Promise { + const adfBody = markdownToAdf(text); + await jiraClient.addComment(id, adfBody); + } + + async createWorkItem(config: CreateWorkItemConfig): Promise { + const issueType = this.config.issueTypes?.default ?? 'Task'; + const result = await jiraClient.createIssue({ + project: { key: config.containerId || this.config.projectKey }, + summary: config.title, + description: config.description ? markdownToAdf(config.description) : undefined, + issuetype: { name: issueType }, + ...(config.labels?.length ? { labels: config.labels } : {}), + }); + const key = result.key ?? ''; + return { + id: key, + title: config.title, + description: config.description ?? '', + url: this.getWorkItemUrl(key), + labels: [], + }; + } + + async listWorkItems(containerId: string): Promise { + // containerId is the JIRA project key + const jql = `project = "${containerId}" ORDER BY created DESC`; + const issues = await jiraClient.searchIssues(jql); + return issues.map((issue: any) => ({ + id: issue.key ?? '', + title: issue.fields?.summary ?? '', + description: '', + url: this.getWorkItemUrl(issue.key ?? ''), + status: issue.fields?.status?.name, + labels: ((issue.fields?.labels as string[]) ?? []).map( + (l: string): WorkItemLabel => ({ id: l, name: l }), + ), + })); + } + + async moveWorkItem(id: string, destination: string): Promise { + // destination is a JIRA status name — find the transition ID + const transitions = await jiraClient.getTransitions(id); + const transition = transitions.find( + (t: any) => + t.name?.toLowerCase() === destination.toLowerCase() || + t.to?.name?.toLowerCase() === destination.toLowerCase() || + t.id === destination, + ); + if (!transition) { + logger.warn('No JIRA transition found for destination', { + issueKey: id, + destination, + available: transitions.map((t: any) => `${t.id}:${t.name}`), + }); + return; + } + await jiraClient.transitionIssue(id, transition.id ?? ''); + } + + async addLabel(id: string, labelName: string): Promise { + const currentLabels = await jiraClient.getIssueLabels(id); + if (!currentLabels.includes(labelName)) { + await jiraClient.updateLabels(id, [...currentLabels, labelName]); + } + } + + async removeLabel(id: string, labelName: string): Promise { + const currentLabels = await jiraClient.getIssueLabels(id); + const newLabels = currentLabels.filter((l) => l !== labelName); + if (newLabels.length !== currentLabels.length) { + await jiraClient.updateLabels(id, newLabels); + } + } + + async getChecklists(workItemId: string): Promise { + // JIRA doesn't have native checklists — map subtasks + const issue = await jiraClient.getIssue(workItemId); + const subtasks = (issue.fields as any)?.subtasks ?? []; + if (subtasks.length === 0) return []; + + const items: ChecklistItem[] = subtasks.map((st: any) => ({ + id: st.key ?? st.id ?? '', + name: st.fields?.summary ?? '', + complete: st.fields?.status?.name === 'Done', + })); + + return [ + { + id: `subtasks-${workItemId}`, + name: 'Subtasks', + workItemId, + items, + }, + ]; + } + + async createChecklist(workItemId: string, name: string): Promise { + // In JIRA, "create checklist" = create a parent concept. + // Items will be subtasks created via addChecklistItem. + return { + id: `checklist-${workItemId}-${Date.now()}`, + name, + workItemId, + items: [], + }; + } + + async addChecklistItem(_checklistId: string, name: string, _checked = false): Promise { + // Extract parent issue key from checklistId format: "checklist-PROJ-123-timestamp" + // or "subtasks-PROJ-123" + const match = _checklistId.match(/(?:checklist|subtasks)-(.+?)(?:-\d+)?$/); + const parentKey = match?.[1]; + if (!parentKey) { + logger.warn('Cannot extract parent issue from checklist ID', { checklistId: _checklistId }); + return; + } + + const issueType = this.config.issueTypes?.subtask ?? 'Sub-task'; + await jiraClient.createIssue({ + project: { key: this.config.projectKey }, + parent: { key: parentKey }, + summary: name, + issuetype: { name: issueType }, + }); + } + + async updateChecklistItem( + _workItemId: string, + checkItemId: string, + complete: boolean, + ): Promise { + // checkItemId is a JIRA issue key (subtask) + const targetStatus = complete ? 'Done' : 'To Do'; + await this.moveWorkItem(checkItemId, targetStatus); + } + + async getAttachments(workItemId: string): Promise { + const issue = await jiraClient.getIssue(workItemId); + const attachments = (issue.fields as any)?.attachment ?? []; + return attachments.map((a: any) => ({ + id: a.id ?? '', + name: a.filename ?? '', + url: a.content ?? '', + mimeType: a.mimeType ?? '', + bytes: a.size ?? 0, + date: a.created ?? '', + })); + } + + async addAttachment(_workItemId: string, url: string, name: string): Promise { + // JIRA only supports file uploads for attachments, not URL links. + // Add as a comment with the link instead. + await this.addComment(_workItemId, `Attachment: [${name}](${url})`); + } + + async addAttachmentFile( + workItemId: string, + buffer: Buffer, + name: string, + _mimeType: string, + ): Promise { + await jiraClient.addAttachmentFile(workItemId, buffer, name); + } + + async getCustomFieldNumber(workItemId: string, fieldId: string): Promise { + const value = await jiraClient.getCustomFieldValue(workItemId, fieldId); + return typeof value === 'number' ? value : Number.parseFloat(String(value ?? '0')); + } + + async updateCustomFieldNumber(workItemId: string, fieldId: string, value: number): Promise { + await jiraClient.updateCustomField(workItemId, fieldId, value); + } + + getWorkItemUrl(id: string): string { + return `${this.config.baseUrl}/browse/${id}`; + } + + async getAuthenticatedUser(): Promise<{ id: string; name: string; username: string }> { + const user = await jiraClient.getMyself(); + return { + id: user.accountId ?? '', + name: user.displayName ?? '', + username: user.emailAddress ?? '', + }; + } +} diff --git a/src/pm/jira/adf.ts b/src/pm/jira/adf.ts new file mode 100644 index 00000000..8f8107a4 --- /dev/null +++ b/src/pm/jira/adf.ts @@ -0,0 +1,194 @@ +/** + * Atlassian Document Format (ADF) helpers. + * + * JIRA Cloud's REST API v3 uses ADF for rich text fields. + * These helpers convert between markdown and ADF. + */ + +/** + * Convert a simple markdown string to ADF document. + * Handles paragraphs, headings, bullet lists, bold, inline code, and code blocks. + */ +export function markdownToAdf(markdown: string): unknown { + const lines = markdown.split('\n'); + const content: unknown[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Code block + if (line.startsWith('```')) { + const lang = line.slice(3).trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + content.push({ + type: 'codeBlock', + attrs: lang ? { language: lang } : {}, + content: [{ type: 'text', text: codeLines.join('\n') }], + }); + continue; + } + + // Heading + const headingMatch = line.match(/^(#{1,6})\s+(.+)/); + if (headingMatch) { + content.push({ + type: 'heading', + attrs: { level: headingMatch[1].length }, + content: inlineToAdf(headingMatch[2]), + }); + i++; + continue; + } + + // Bullet list + if (line.match(/^[-*]\s+/)) { + const items: unknown[] = []; + while (i < lines.length && lines[i].match(/^[-*]\s+/)) { + const itemText = lines[i].replace(/^[-*]\s+/, ''); + items.push({ + type: 'listItem', + content: [{ type: 'paragraph', content: inlineToAdf(itemText) }], + }); + i++; + } + content.push({ type: 'bulletList', content: items }); + continue; + } + + // Empty line + if (line.trim() === '') { + i++; + continue; + } + + // Regular paragraph + content.push({ + type: 'paragraph', + content: inlineToAdf(line), + }); + i++; + } + + return { + type: 'doc', + version: 1, + content: content.length > 0 ? content : [{ type: 'paragraph', content: [] }], + }; +} + +/** + * Convert inline markdown to ADF inline nodes. + */ +function inlineToAdf(text: string): unknown[] { + const nodes: unknown[] = []; + // Simple approach: handle **bold**, `code`, and plain text + const regex = /(\*\*(.+?)\*\*|`([^`]+)`)/g; + let lastIndex = 0; + let match: RegExpExecArray | null = regex.exec(text); + + while (match !== null) { + // Add plain text before this match + if (match.index > lastIndex) { + nodes.push({ type: 'text', text: text.slice(lastIndex, match.index) }); + } + + if (match[2]) { + // Bold + nodes.push({ type: 'text', text: match[2], marks: [{ type: 'strong' }] }); + } else if (match[3]) { + // Inline code + nodes.push({ type: 'text', text: match[3], marks: [{ type: 'code' }] }); + } + + lastIndex = match.index + match[0].length; + match = regex.exec(text); + } + + // Add remaining plain text + if (lastIndex < text.length) { + nodes.push({ type: 'text', text: text.slice(lastIndex) }); + } + + // Fallback for empty + if (nodes.length === 0 && text) { + nodes.push({ type: 'text', text }); + } + + return nodes; +} + +/** + * Convert ADF document to plain text. + * Used when reading JIRA issue descriptions/comments. + */ +export function adfToPlainText(adf: unknown): string { + if (!adf || typeof adf !== 'object') return ''; + + const doc = adf as { type?: string; content?: unknown[]; text?: string }; + + if (doc.type === 'text') { + return doc.text ?? ''; + } + + if (!doc.content || !Array.isArray(doc.content)) { + return doc.text ?? ''; + } + + const parts: string[] = []; + for (const node of doc.content) { + const n = node as { + type?: string; + content?: unknown[]; + text?: string; + attrs?: Record; + }; + + switch (n.type) { + case 'paragraph': + parts.push(adfToPlainText(n)); + parts.push(''); + break; + case 'heading': { + const level = (n.attrs?.level as number) ?? 1; + parts.push(`${'#'.repeat(level)} ${adfToPlainText(n)}`); + parts.push(''); + break; + } + case 'bulletList': + if (n.content) { + for (const item of n.content) { + parts.push(`- ${adfToPlainText(item)}`); + } + } + parts.push(''); + break; + case 'listItem': + parts.push(adfToPlainText(n)); + break; + case 'codeBlock': + parts.push('```'); + parts.push(adfToPlainText(n)); + parts.push('```'); + parts.push(''); + break; + case 'text': + parts.push(n.text ?? ''); + break; + default: + parts.push(adfToPlainText(n)); + break; + } + } + + return parts + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/src/pm/lifecycle.ts b/src/pm/lifecycle.ts new file mode 100644 index 00000000..2a10aa58 --- /dev/null +++ b/src/pm/lifecycle.ts @@ -0,0 +1,166 @@ +/** + * PMLifecycleManager — extracts the label/move/comment lifecycle from webhook + * handlers into a reusable, PM-agnostic manager. + * + * Both Trello and JIRA webhook handlers call this instead of directly + * manipulating labels, statuses, and comments. + */ + +import type { ProjectConfig } from '../types/index.js'; +import { safeOperation, silentOperation } from '../utils/safeOperation.js'; +import type { PMProvider } from './types.js'; + +/** + * Normalized PM config — resolved from either project.trello or project.jira config. + */ +export interface ProjectPMConfig { + labels: { + processing?: string; + processed?: string; + error?: string; + readyToProcess?: string; + }; + statuses: { + inProgress?: string; + inReview?: string; + done?: string; + merged?: string; + }; +} + +/** + * Resolve PM-specific config (labels, statuses) from project configuration. + */ +export function resolveProjectPMConfig(project: ProjectConfig): ProjectPMConfig { + if (project.pm?.type === 'jira' && project.jira) { + // JIRA uses label strings (not IDs) and status names from config + return { + labels: { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + 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, + }, + }; +} + +export class PMLifecycleManager { + constructor( + private provider: PMProvider, + private pmConfig: ProjectPMConfig, + ) {} + + async prepareForAgent(workItemId: string, agentType: string): Promise { + await this.safeAddLabel(workItemId, this.pmConfig.labels.processing); + await this.safeRemoveLabel(workItemId, this.pmConfig.labels.readyToProcess); + await this.safeRemoveLabel(workItemId, this.pmConfig.labels.processed); + + if (agentType === 'implementation') { + await this.safeMove(workItemId, this.pmConfig.statuses.inProgress); + } + } + + async handleSuccess(workItemId: string, agentType: string, prUrl?: string): Promise { + await this.safeAddLabel(workItemId, this.pmConfig.labels.processed); + + if (agentType === 'implementation') { + await this.safeMove(workItemId, this.pmConfig.statuses.inReview); + if (prUrl) { + await this.safeAddComment(workItemId, `PR created: ${prUrl}`); + } + } + } + + async handleFailure(workItemId: string, error?: string): Promise { + await this.safeAddLabel(workItemId, this.pmConfig.labels.error); + if (error) { + await this.safeAddComment(workItemId, `❌ Agent failed: ${error}`); + } + } + + async handleBudgetExceeded( + workItemId: string, + currentCost: number, + budget: number, + ): Promise { + await this.safeRemoveLabel(workItemId, this.pmConfig.labels.processing); + await this.safeAddLabel(workItemId, this.pmConfig.labels.error); + await this.safeAddComment( + workItemId, + `⛔ Budget exceeded: cost $${currentCost.toFixed(2)} >= limit $${budget.toFixed(2)}. Agent not started.`, + ); + } + + async handleBudgetWarning( + workItemId: string, + currentCost: number, + budget: number, + ): Promise { + await this.safeAddLabel(workItemId, this.pmConfig.labels.error); + await this.safeAddComment( + workItemId, + `⚠️ Budget limit reached: cost $${currentCost.toFixed(2)} >= limit $${budget.toFixed(2)}. Further agent runs will be blocked.`, + ); + } + + async cleanupProcessing(workItemId: string): Promise { + await this.safeRemoveLabel(workItemId, this.pmConfig.labels.processing); + } + + async handleError(workItemId: string, error: string): Promise { + await this.safeAddLabel(workItemId, this.pmConfig.labels.error); + await this.safeAddComment(workItemId, `❌ Error: ${error}`); + } + + // --- Helpers --- + + private async safeAddLabel(workItemId: string, label?: string): Promise { + if (!label) return; + await safeOperation(() => this.provider.addLabel(workItemId, label), { + action: 'add label', + label, + }); + } + + private async safeRemoveLabel(workItemId: string, label?: string): Promise { + if (!label) return; + await silentOperation(() => this.provider.removeLabel(workItemId, label)); + } + + private async safeMove(workItemId: string, destination?: string): Promise { + if (!destination) return; + await safeOperation(() => this.provider.moveWorkItem(workItemId, destination), { + action: 'move work item', + destination, + }); + } + + private async safeAddComment(workItemId: string, text: string): Promise { + await safeOperation(() => this.provider.addComment(workItemId, text), { + action: 'add comment', + }); + } +} diff --git a/src/pm/trello/adapter.ts b/src/pm/trello/adapter.ts new file mode 100644 index 00000000..fb36832c --- /dev/null +++ b/src/pm/trello/adapter.ts @@ -0,0 +1,208 @@ +/** + * TrelloPMProvider — wraps the existing trelloClient singleton + * to implement the PMProvider interface. + * + * Assumes trelloClient credentials are already in scope via + * withTrelloCredentials() — this adapter simply delegates. + */ + +import { trelloClient } from '../../trello/client.js'; +import type { + Attachment, + Checklist, + ChecklistItem, + CreateWorkItemConfig, + PMProvider, + WorkItem, + WorkItemComment, + WorkItemLabel, +} from '../types.js'; + +export class TrelloPMProvider implements PMProvider { + readonly type = 'trello' as const; + + async getWorkItem(id: string): Promise { + const card = await trelloClient.getCard(id); + return { + id: card.id, + title: card.name, + description: card.desc, + url: card.url, + labels: card.labels.map( + (l): WorkItemLabel => ({ + id: l.id, + name: l.name, + color: l.color, + }), + ), + }; + } + + async getWorkItemComments(id: string): Promise { + const comments = await trelloClient.getCardComments(id); + return comments.map((c) => ({ + id: c.id, + date: c.date, + text: c.data.text, + author: { + id: c.memberCreator.id, + name: c.memberCreator.fullName, + username: c.memberCreator.username, + }, + })); + } + + async updateWorkItem( + id: string, + updates: { title?: string; description?: string }, + ): Promise { + await trelloClient.updateCard(id, { + name: updates.title, + desc: updates.description, + }); + } + + async addComment(id: string, text: string): Promise { + await trelloClient.addComment(id, text); + } + + async createWorkItem(config: CreateWorkItemConfig): Promise { + const card = await trelloClient.createCard(config.containerId, { + name: config.title, + desc: config.description, + idLabels: config.labels, + }); + return { + id: card.id, + title: card.name, + description: card.desc, + url: card.url, + labels: card.labels.map( + (l): WorkItemLabel => ({ + id: l.id, + name: l.name, + color: l.color, + }), + ), + }; + } + + async listWorkItems(containerId: string): Promise { + const cards = await trelloClient.getListCards(containerId); + return cards.map((card) => ({ + id: card.id, + title: card.name, + description: card.desc, + url: card.url, + labels: card.labels.map( + (l): WorkItemLabel => ({ + id: l.id, + name: l.name, + color: l.color, + }), + ), + })); + } + + async moveWorkItem(id: string, destination: string): Promise { + await trelloClient.moveCardToList(id, destination); + } + + async addLabel(id: string, labelId: string): Promise { + await trelloClient.addLabelToCard(id, labelId); + } + + async removeLabel(id: string, labelId: string): Promise { + await trelloClient.removeLabelFromCard(id, labelId); + } + + async getChecklists(workItemId: string): Promise { + const checklists = await trelloClient.getCardChecklists(workItemId); + return checklists.map((cl) => ({ + id: cl.id, + name: cl.name, + workItemId: cl.idCard, + items: cl.checkItems.map( + (item): ChecklistItem => ({ + id: item.id, + name: item.name, + complete: item.state === 'complete', + }), + ), + })); + } + + async createChecklist(workItemId: string, name: string): Promise { + const cl = await trelloClient.createChecklist(workItemId, name); + return { + id: cl.id, + name: cl.name, + workItemId: cl.idCard, + items: [], + }; + } + + async addChecklistItem(checklistId: string, name: string, checked = false): Promise { + await trelloClient.addChecklistItem(checklistId, name, checked); + } + + async updateChecklistItem( + workItemId: string, + checkItemId: string, + complete: boolean, + ): Promise { + await trelloClient.updateChecklistItem( + workItemId, + checkItemId, + complete ? 'complete' : 'incomplete', + ); + } + + async getAttachments(workItemId: string): Promise { + const attachments = await trelloClient.getCardAttachments(workItemId); + return attachments.map((a) => ({ + id: a.id, + name: a.name, + url: a.url, + mimeType: a.mimeType, + bytes: a.bytes, + date: a.date, + })); + } + + async addAttachment(workItemId: string, url: string, name: string): Promise { + await trelloClient.addAttachment(workItemId, url, name); + } + + async addAttachmentFile( + workItemId: string, + buffer: Buffer, + name: string, + mimeType: string, + ): Promise { + await trelloClient.addAttachmentFile(workItemId, buffer, name, mimeType); + } + + async getCustomFieldNumber(workItemId: string, fieldId: string): Promise { + const items = await trelloClient.getCardCustomFieldItems(workItemId); + const item = items.find((i) => i.idCustomField === fieldId); + return Number.parseFloat(item?.value?.number ?? '0'); + } + + async updateCustomFieldNumber(workItemId: string, fieldId: string, value: number): Promise { + await trelloClient.updateCardCustomFieldNumber(workItemId, fieldId, value); + } + + getWorkItemUrl(id: string): string { + return `https://trello.com/c/${id}`; + } + + async getAuthenticatedUser(): Promise<{ id: string; name: string; username: string }> { + const me = await trelloClient.getMe(); + return { + id: me.id, + name: me.fullName, + username: me.username, + }; + } +} diff --git a/src/pm/types.ts b/src/pm/types.ts new file mode 100644 index 00000000..64509e96 --- /dev/null +++ b/src/pm/types.ts @@ -0,0 +1,100 @@ +/** + * PM Provider abstraction — defines the interface that Trello, JIRA, and + * future project-management integrations must implement. + */ + +export type PMType = 'trello' | 'jira'; + +export interface WorkItem { + id: string; + title: string; + description: string; + url: string; + status?: string; + labels: WorkItemLabel[]; +} + +export interface WorkItemLabel { + id: string; + name: string; + color?: string; +} + +export interface WorkItemComment { + id: string; + date: string; + text: string; + author: { + id: string; + name: string; + username: string; + }; +} + +export interface Checklist { + id: string; + name: string; + workItemId: string; + items: ChecklistItem[]; +} + +export interface ChecklistItem { + id: string; + name: string; + complete: boolean; +} + +export interface Attachment { + id: string; + name: string; + url: string; + mimeType: string; + bytes: number; + date: string; +} + +export interface CreateWorkItemConfig { + containerId: string; // Trello listId or JIRA projectKey + title: string; + description?: string; + labels?: string[]; +} + +export interface PMProvider { + readonly type: PMType; + + // Core CRUD + getWorkItem(id: string): Promise; + getWorkItemComments(id: string): Promise; + updateWorkItem(id: string, updates: { title?: string; description?: string }): Promise; + addComment(id: string, text: string): Promise; + createWorkItem(config: CreateWorkItemConfig): Promise; + listWorkItems(containerId: string): Promise; + + // Lifecycle + moveWorkItem(id: string, destination: string): Promise; + addLabel(id: string, labelIdOrName: string): Promise; + removeLabel(id: string, labelIdOrName: string): Promise; + + // Checklists + getChecklists(workItemId: string): Promise; + createChecklist(workItemId: string, name: string): Promise; + addChecklistItem(checklistId: string, name: string, checked?: boolean): Promise; + updateChecklistItem(workItemId: string, checkItemId: string, complete: boolean): Promise; + + // Attachments & custom fields + getAttachments(workItemId: string): Promise; + addAttachment(workItemId: string, url: string, name: string): Promise; + addAttachmentFile( + workItemId: string, + buffer: Buffer, + name: string, + mimeType: string, + ): Promise; + getCustomFieldNumber(workItemId: string, fieldId: string): Promise; + updateCustomFieldNumber(workItemId: string, fieldId: string, value: number): Promise; + + // Utility + getWorkItemUrl(id: string): string; + getAuthenticatedUser(): Promise<{ id: string; name: string; username: string }>; +} diff --git a/src/router/config.ts b/src/router/config.ts index aa713cba..0c779bc6 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -5,11 +5,16 @@ import type { CascadeConfig } from '../types/index.js'; export interface RouterProjectConfig { id: string; repo: string; // owner/repo format - trello: { + pmType: 'trello' | 'jira'; + trello?: { boardId: string; lists: Record; labels: Record; }; + jira?: { + projectKey: string; + baseUrl: string; + }; } export interface RouterConfig { @@ -37,11 +42,20 @@ export async function loadProjectConfig(): Promise<{ projects: RouterProjectConf projects: config.projects.map((p) => ({ id: p.id, repo: p.repo, - trello: { - boardId: p.trello.boardId, - lists: p.trello.lists, - labels: p.trello.labels, - }, + 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, + }, + }), })), }; console.log(`[Router] Loaded config with ${projectConfig.projects.length} projects`); diff --git a/src/router/index.ts b/src/router/index.ts index 7f3dba08..4e593e2c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -22,6 +22,7 @@ function isCardInTriggerList( data: Record | undefined, project: RouterProjectConfig, ): boolean { + if (!project.trello) return false; const triggerLists = [ project.trello.lists.briefing, project.trello.lists.planning, @@ -57,6 +58,7 @@ function isReadyToProcessLabelAdded( project: RouterProjectConfig, ): boolean { if (actionType !== 'addLabelToCard' || !data?.label) return false; + if (!project.trello) return false; const label = data.label as Record; const labelId = label.id as string; @@ -74,7 +76,7 @@ function isAgentLogAttachmentUploaded( project: RouterProjectConfig, ): boolean { if (actionType !== 'addAttachmentToCard' || !data?.attachment) return false; - if (!project.trello.lists.debug) return false; + if (!project.trello?.lists.debug) return false; const attachment = data.attachment as Record; const name = attachment.name as string | undefined; @@ -110,7 +112,7 @@ function parseTrelloWebhook(payload: unknown): TrelloWebhookResult { const actionType = action.type as string; const data = action.data as Record | undefined; - const project = getProjectConfig().projects.find((proj) => proj.trello.boardId === boardId); + const project = getProjectConfig().projects.find((proj) => proj.trello?.boardId === boardId); if (!project) { return { shouldProcess: false }; } @@ -258,6 +260,68 @@ app.post('/github/webhook', async (c) => { return c.text('OK', 200); }); +// JIRA webhook verification +app.get('/jira/webhook', (c) => { + return c.text('OK', 200); +}); + +// JIRA webhook handler +app.post('/jira/webhook', async (c) => { + let payload: unknown; + try { + payload = await c.req.json(); + } catch { + return c.text('Bad Request', 400); + } + + const p = payload as Record; + const webhookEvent = (p.webhookEvent as string) || ''; + const issue = p.issue as Record | undefined; + const issueKey = (issue?.key as string) || ''; + const fields = issue?.fields as Record | undefined; + const projectField = fields?.project as Record | undefined; + const jiraProjectKey = (projectField?.key as string) || ''; + + // Match JIRA project key to a configured project + const project = jiraProjectKey + ? getProjectConfig().projects.find((proj) => proj.jira?.projectKey === jiraProjectKey) + : undefined; + + // Process issue transitions and comment events + const processableEvents = [ + 'jira:issue_updated', + 'jira:issue_created', + 'comment_created', + 'comment_updated', + ]; + const shouldProcess = project && processableEvents.some((e) => webhookEvent.startsWith(e)); + + if (shouldProcess && project) { + console.log('[Router] Queueing JIRA job:', { webhookEvent, issueKey, projectId: project.id }); + + const job: CascadeJob = { + type: 'jira', + source: 'jira', + payload, + projectId: project.id, + issueKey, + webhookEvent, + receivedAt: new Date().toISOString(), + }; + + try { + const jobId = await addJob(job); + console.log('[Router] JIRA job queued:', { jobId, webhookEvent }); + } catch (err) { + console.error('[Router] Failed to queue JIRA job:', err); + } + } else { + console.log(`[Router] Ignoring JIRA: ${webhookEvent}`); + } + + return c.text('OK', 200); +}); + // Graceful shutdown async function shutdown(signal: string): Promise { console.log(`[Router] Received ${signal}, shutting down...`); diff --git a/src/router/queue.ts b/src/router/queue.ts index 223235ff..e7ea3fb6 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -33,7 +33,17 @@ export interface GitHubJob { receivedAt: string; } -export type CascadeJob = TrelloJob | GitHubJob; +export interface JiraJob { + type: 'jira'; + source: 'jira'; + payload: unknown; + projectId: string; + issueKey: string; + webhookEvent: string; + receivedAt: string; +} + +export type CascadeJob = TrelloJob | GitHubJob | JiraJob; // Create the job queue export const jobQueue = new Queue('cascade-jobs', { diff --git a/src/server.ts b/src/server.ts index 502abc7e..84950fbb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,7 @@ export interface ServerDependencies { config: CascadeConfig; onTrelloWebhook: (payload: unknown) => Promise; onGitHubWebhook: (payload: unknown, eventType: string) => Promise; + onJiraWebhook: (payload: unknown) => Promise; } export function createServer(deps: ServerDependencies): Hono { @@ -68,7 +69,6 @@ export function createServer(deps: ServerDependencies): Hono { // Trello webhook - POST for events app.post('/trello/webhook', async (c) => { - // Check capacity synchronously - return 503 if at capacity so Fly.io routes to another machine if (isCurrentlyProcessing() && !canAcceptWebhook()) { logger.warn('Machine at capacity, returning 503'); return c.text('Service Unavailable', 503); @@ -101,7 +101,6 @@ export function createServer(deps: ServerDependencies): Hono { }); app.post('/github/webhook', async (c) => { - // Check capacity synchronously - return 503 if at capacity so Fly.io routes to another machine if (isCurrentlyProcessing() && !canAcceptWebhook()) { logger.warn('Machine at capacity, returning 503'); return c.text('Service Unavailable', 503); @@ -157,6 +156,42 @@ export function createServer(deps: ServerDependencies): Hono { } }); + // JIRA webhook - GET/HEAD for verification + app.get('/jira/webhook', (c) => { + return c.text('OK', 200); + }); + + // JIRA webhook - POST for events + app.post('/jira/webhook', async (c) => { + if (isCurrentlyProcessing() && !canAcceptWebhook()) { + logger.warn('Machine at capacity, returning 503'); + return c.text('Service Unavailable', 503); + } + + try { + const payload = await c.req.json(); + logger.info('Received JIRA webhook', { + event: (payload as Record)?.webhookEvent, + issueKey: ((payload as Record)?.issue as Record)?.key, + }); + + // Process asynchronously - respond immediately + setImmediate(() => { + deps.onJiraWebhook(payload).catch((err) => { + logger.error('Error processing JIRA webhook', { + error: String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + }); + }); + + return c.text('OK', 200); + } catch (err) { + logger.error('Failed to parse JIRA webhook', { error: String(err) }); + return c.text('Bad Request', 400); + } + }); + // ========================================================================= // Static file serving (production — built frontend) // ========================================================================= diff --git a/src/triggers/github/utils.ts b/src/triggers/github/utils.ts index 0924208c..d8f56c1b 100644 --- a/src/triggers/github/utils.ts +++ b/src/triggers/github/utils.ts @@ -1,9 +1,13 @@ import { getAuthenticatedUser } from '../../github/client.js'; +import type { ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; // Trello card URL pattern: https://trello.com/c/SHORT_ID/optional-slug const TRELLO_CARD_URL_REGEX = /https:\/\/trello\.com\/c\/([a-zA-Z0-9]+)/; +// JIRA issue key pattern: PROJECT-123 +const JIRA_ISSUE_KEY_REGEX = /\b([A-Z][A-Z0-9]+-\d+)\b/; + /** * Extract Trello card short ID from text (e.g., PR body). * Returns the short ID (e.g., "abc123" from "https://trello.com/c/abc123/card-name") @@ -60,3 +64,44 @@ export function requireTrelloCardId( } return extractTrelloCardId(prBody); } + +/** + * Extract a JIRA issue key (e.g., "PROJ-123") from text. + */ +export function extractJiraIssueKey(text: string | null): string | null { + if (!text) return null; + const match = text.match(JIRA_ISSUE_KEY_REGEX); + return match ? match[1] : null; +} + +/** + * Extract work item ID from text based on the project's PM type. + * For Trello projects, looks for Trello card URLs. + * For JIRA projects, looks for JIRA issue keys. + */ +export function extractWorkItemId(text: string | null, project: ProjectConfig): string | null { + if (!text) return null; + if (project.pm?.type === 'jira') { + return extractJiraIssueKey(text); + } + return extractTrelloCardId(text); +} + +/** + * Validate PR body has a work item reference and extract the ID. + * Works for both Trello (card URL) and JIRA (issue key) projects. + */ +export function requireWorkItemId( + prBody: string | null, + project: ProjectConfig, + context: { prNumber: number; triggerName: string }, +): string | null { + const id = extractWorkItemId(prBody, project); + if (!id) { + logger.info(`PR does not have work item reference, skipping ${context.triggerName}`, { + prNumber: context.prNumber, + pmType: project.pm?.type ?? 'trello', + }); + } + return id; +} diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 2e1d4e95..8ae0e9e4 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -7,16 +7,20 @@ import { } from '../../config/provider.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { githubClient, withGitHubToken } from '../../github/client.js'; -import { trelloClient, withTrelloCredentials } from '../../trello/client.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 { - cancelFreshMachineTimer, dequeueWebhook, enqueueWebhook, getQueueLength, isCurrentlyProcessing, logger, - scheduleShutdownAfterJob, setProcessing, startWatchdog, } from '../../utils/index.js'; @@ -34,99 +38,47 @@ async function executeGitHubAgent( project: ProjectConfig, config: CascadeConfig, ): Promise { - // Resolve per-project credentials up front — all Trello/GitHub API calls - // in this function (budget checks, labels, comments, agent run) require scoped credentials. - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); + const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY').catch(() => ''); + const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN').catch(() => ''); const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); - // Inject LLM API keys into process.env for llmist backend const restoreLlmEnv = await injectLlmApiKeys(project.id); try { + const pmProvider = createPMProvider(project); await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withGitHubToken(githubToken, () => executeGitHubAgentWithCreds(result, project, config)), + withPMProvider(pmProvider, () => + withGitHubToken(githubToken, () => executeGitHubAgentWithCreds(result, project, config)), + ), ); } finally { restoreLlmEnv(); } } -async function checkGitHubPreFlightBudget( - cardId: string, - project: ProjectConfig, - config: CascadeConfig, -): Promise<{ blocked: boolean; remainingBudgetUsd?: number }> { - const budgetCheck = await checkBudgetExceeded(cardId, project, config); - if (budgetCheck?.exceeded) { - logger.warn('Card budget exceeded, GitHub agent not started', { - cardId, - currentCost: budgetCheck.currentCost, - budget: budgetCheck.budget, - }); - await safeOperation(() => trelloClient.addLabelToCard(cardId, project.trello.labels.error), { - action: 'add error label', - }); - await safeOperation( - () => - trelloClient.addComment( - cardId, - `⛔ Budget exceeded: card cost $${budgetCheck.currentCost.toFixed(2)} >= limit $${budgetCheck.budget.toFixed(2)}. Agent not started.`, - ), - { action: 'add budget comment' }, - ); - return { blocked: true }; - } - return { blocked: false, remainingBudgetUsd: budgetCheck?.remaining }; -} - -async function handleGitHubPostFlightBudget( - cardId: string, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - const postBudgetCheck = await checkBudgetExceeded(cardId, project, config); - if (postBudgetCheck?.exceeded) { - await safeOperation(() => trelloClient.addLabelToCard(cardId, project.trello.labels.error), { - action: 'add error label', - }); - await safeOperation( - () => - trelloClient.addComment( - cardId, - `⚠️ Budget limit reached: card cost $${postBudgetCheck.currentCost.toFixed(2)} >= limit $${postBudgetCheck.budget.toFixed(2)}. Further agent runs will be blocked.`, - ), - { action: 'add budget warning comment' }, - ); - } -} - -async function tryGitHubAutoDebug( - agentResult: { runId?: string }, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - if (!agentResult.runId) return; - const debugTarget = await shouldTriggerDebug(agentResult.runId); - if (debugTarget) { - triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => - logger.error('Auto-debug failed', { error: String(err) }), - ); - } -} - async function executeGitHubAgentWithCreds( result: TriggerResult, project: ProjectConfig, config: CascadeConfig, ): Promise { - const { cardId } = result; + const cardId = result.cardId ?? result.workItemId; + const pmProvider = createPMProvider(project); + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); let remainingBudgetUsd: number | undefined; if (cardId) { - const budget = await checkGitHubPreFlightBudget(cardId, project, config); - if (budget.blocked) return; - remainingBudgetUsd = budget.remainingBudgetUsd; + const budgetCheck = await checkBudgetExceeded(cardId, project, config); + if (budgetCheck?.exceeded) { + logger.warn('Budget exceeded, GitHub agent not started', { + cardId, + currentCost: budgetCheck.currentCost, + budget: budgetCheck.budget, + }); + await lifecycle.handleBudgetExceeded(cardId, budgetCheck.currentCost, budgetCheck.budget); + return; + } + remainingBudgetUsd = budgetCheck?.remaining; } const agentResult = await runAgent(result.agentType, { @@ -138,21 +90,20 @@ async function executeGitHubAgentWithCreds( if (cardId) { await handleAgentResultArtifacts(cardId, result.agentType, agentResult, project); - await handleGitHubPostFlightBudget(cardId, project, config); + + const postBudgetCheck = await checkBudgetExceeded(cardId, project, config); + if (postBudgetCheck?.exceeded) { + await lifecycle.handleBudgetWarning( + cardId, + postBudgetCheck.currentCost, + postBudgetCheck.budget, + ); + } } // Move to in-review if implementation completed successfully if (cardId && result.agentType === 'implementation' && agentResult.success) { - await safeOperation(() => trelloClient.moveCardToList(cardId, project.trello.lists.inReview), { - action: 'move card to in-review', - cardId, - }); - if (agentResult.prUrl) { - await safeOperation( - () => trelloClient.addComment(cardId, `PR created: ${agentResult.prUrl}`), - { action: 'add PR comment', cardId }, - ); - } + await lifecycle.handleSuccess(cardId, result.agentType, agentResult.prUrl); } if (!agentResult.success && result.prNumber) { @@ -170,6 +121,20 @@ async function executeGitHubAgentWithCreds( await tryGitHubAutoDebug(agentResult, project, config); } +async function tryGitHubAutoDebug( + agentResult: { runId?: string }, + project: ProjectConfig, + config: CascadeConfig, +): Promise { + if (!agentResult.runId) return; + const debugTarget = await shouldTriggerDebug(agentResult.runId); + if (debugTarget) { + triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => + logger.error('Auto-debug failed', { error: String(err) }), + ); + } +} + async function updateInitialCommentWithError( result: TriggerResult, agentResult: { success: boolean; error?: string }, @@ -221,20 +186,14 @@ async function runGitHubAgentJob( githubToken: string, registry: TriggerRegistry, ): Promise { - // Use agent-scoped GITHUB_TOKEN when available, so acknowledgments - // and error messages appear from the agent's identity (not the repo owner). const agentGitHubToken = result.agentType ? await getAgentCredential(project.id, result.agentType, 'GITHUB_TOKEN') : null; const prCommentToken = agentGitHubToken || githubToken; await withGitHubToken(prCommentToken, () => postAcknowledgmentComment(result)); - cancelFreshMachineTimer(); setProcessing(true); - - if (process.env.FLY_APP_NAME) { - startWatchdog(config.defaults.watchdogTimeoutMs); - } + startWatchdog(config.defaults.watchdogTimeoutMs); try { await executeGitHubAgent(result, project, config); @@ -245,11 +204,11 @@ async function runGitHubAgentJob( ); } finally { setProcessing(false); - processNextQueuedGitHubWebhook(config, registry); + processNextQueuedGitHubWebhook(registry); } } -function processNextQueuedGitHubWebhook(config: CascadeConfig, registry: TriggerRegistry): void { +function processNextQueuedGitHubWebhook(registry: TriggerRegistry): void { const next = dequeueWebhook(); if (next) { const eventType = next.eventType || 'pull_request_review_comment'; @@ -262,8 +221,6 @@ function processNextQueuedGitHubWebhook(config: CascadeConfig, registry: Trigger logger.error('Failed to process queued GitHub webhook', { error: String(err) }); }); }); - } else if (process.env.FLY_APP_NAME) { - scheduleShutdownAfterJob(config.defaults.postJobGracePeriodMs); } } @@ -274,7 +231,6 @@ export async function processGitHubWebhook( ): Promise { logger.info('Processing GitHub webhook', { eventType }); - // Extract repo from payload const p = payload as Record; const repository = p.repository as Record | undefined; const repoFullName = repository?.full_name as string | undefined; @@ -306,15 +262,15 @@ export async function processGitHubWebhook( return; } - // Resolve credentials early — trigger handlers (dispatch) may call GitHub/Trello APIs - // (e.g. PRMergedTrigger, PRReadyToMergeTrigger perform actions directly in handle()) - const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); - const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); + // Resolve credentials early — trigger handlers may call GitHub/Trello APIs + const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY').catch(() => ''); + const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN').catch(() => ''); const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); + const pmProvider = createPMProvider(project); const ctx: TriggerContext = { project, source: 'github', payload }; const result = await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withGitHubToken(githubToken, () => registry.dispatch(ctx)), + withPMProvider(pmProvider, () => withGitHubToken(githubToken, () => registry.dispatch(ctx))), ); if (!result) { @@ -327,12 +283,9 @@ export async function processGitHubWebhook( prNumber: result.prNumber, }); - // Only run agent if agentType is specified - // Some triggers (like PRReadyToMergeTrigger) perform actions directly without needing an agent if (result.agentType) { await runGitHubAgentJob(result, project, config, githubToken, registry); } else { - // No agent needed, trigger already performed its action logger.info('Trigger completed without agent', { prNumber: result.prNumber }); } } diff --git a/src/triggers/index.ts b/src/triggers/index.ts index dd880c5c..ee526d95 100644 --- a/src/triggers/index.ts +++ b/src/triggers/index.ts @@ -5,6 +5,8 @@ import { PRCommentMentionTrigger } from './github/pr-comment-mention.js'; import { PRMergedTrigger } from './github/pr-merged.js'; import { PRReadyToMergeTrigger } from './github/pr-ready-to-merge.js'; import { PRReviewSubmittedTrigger } from './github/pr-review-submitted.js'; +import { JiraCommentMentionTrigger } from './jira/comment-mention.js'; +import { JiraIssueTransitionedTrigger } from './jira/issue-transitioned.js'; import type { TriggerRegistry } from './registry.js'; import { CardMovedToBriefingTrigger, @@ -24,6 +26,7 @@ export type { export { isTrelloWebhookPayload } from './types.js'; export { processTrelloWebhook } from './trello/webhook-handler.js'; export { processGitHubWebhook } from './github/webhook-handler.js'; +export { processJiraWebhook } from './jira/webhook-handler.js'; export function registerBuiltInTriggers(registry: TriggerRegistry): void { // Trello: Comment @mention trigger (runs respond-to-planning-comment when bot is @mentioned) @@ -38,6 +41,13 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void { // Trello: Label triggers registry.register(new ReadyToProcessLabelTrigger()); + // JIRA: Comment @mention trigger (runs respond-to-planning-comment when bot is @mentioned) + // Must be registered before issue transition trigger so it gets first crack at comment events + registry.register(new JiraCommentMentionTrigger()); + + // JIRA: Issue transitioned trigger (runs briefing/planning/implementation based on status) + registry.register(new JiraIssueTransitionedTrigger()); + // GitHub: PR opened trigger (initial review on new PRs) // DISABLED: Triggers respond-to-review which has file editing gadgets - needs review // registry.register(new PROpenedTrigger()); diff --git a/src/triggers/jira/comment-mention.ts b/src/triggers/jira/comment-mention.ts new file mode 100644 index 00000000..241cef38 --- /dev/null +++ b/src/triggers/jira/comment-mention.ts @@ -0,0 +1,148 @@ +/** + * JIRA comment @mention trigger. + * + * Fires when someone @mentions the CASCADE bot user in a JIRA issue comment. + * Runs the respond-to-planning-comment agent. + */ + +import { jiraClient } from '../../jira/client.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; + +interface JiraWebhookPayload { + webhookEvent: string; + issue?: { + key: string; + fields?: { + project?: { key?: string }; + status?: { name?: string }; + }; + }; + comment?: { + body?: unknown; + author?: { displayName?: string; accountId?: string }; + }; +} + +// Cache authenticated user info to avoid repeated API calls +let cachedUserInfo: { accountId: string; displayName: string } | null = null; + +async function getAuthenticatedUserInfo(): Promise<{ accountId: string; displayName: string }> { + if (cachedUserInfo) { + return cachedUserInfo; + } + const me = await jiraClient.getMyself(); + cachedUserInfo = { + accountId: me.accountId ?? '', + displayName: me.displayName ?? '', + }; + logger.info('Cached authenticated JIRA user info', { + accountId: cachedUserInfo.accountId, + displayName: cachedUserInfo.displayName, + }); + return cachedUserInfo; +} + +/** + * Extract plain text from an ADF body (simple recursive extraction). + */ +function extractTextFromAdf(body: unknown): string { + if (!body || typeof body !== 'object') return ''; + const node = body as Record; + + if (node.type === 'text' && typeof node.text === 'string') { + return node.text; + } + + if (node.type === 'mention' && typeof node.attrs === 'object') { + const attrs = node.attrs as Record; + return `@${attrs.text ?? attrs.id ?? ''}`; + } + + if (Array.isArray(node.content)) { + return (node.content as unknown[]).map(extractTextFromAdf).join(''); + } + + return ''; +} + +/** + * Check if ADF body contains an @mention for the given account ID. + * JIRA ADF represents mentions as nodes with type=mention and attrs.id=accountId. + */ +function hasMentionInAdf(body: unknown, accountId: string): boolean { + if (!body || typeof body !== 'object') return false; + const node = body as Record; + + if (node.type === 'mention' && typeof node.attrs === 'object') { + const attrs = node.attrs as Record; + return attrs.id === accountId; + } + + if (Array.isArray(node.content)) { + return (node.content as unknown[]).some((child) => hasMentionInAdf(child, accountId)); + } + + return false; +} + +export class JiraCommentMentionTrigger implements TriggerHandler { + name = 'jira-comment-mention'; + description = + 'Triggers respond-to-planning-comment agent when someone @mentions the bot in a JIRA comment'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'jira') return false; + + const payload = ctx.payload as JiraWebhookPayload; + return payload.webhookEvent === 'comment_created' || payload.webhookEvent === 'comment_updated'; + } + + async handle(ctx: TriggerContext): Promise { + const payload = ctx.payload as JiraWebhookPayload; + const issueKey = payload.issue?.key; + const commentBody = payload.comment?.body; + const commentAuthor = payload.comment?.author; + + if (!issueKey || !commentBody) { + return null; + } + + // Resolve our JIRA identity + const userInfo = await getAuthenticatedUserInfo(); + + // Check for @mention in ADF body + if (!hasMentionInAdf(commentBody, userInfo.accountId)) { + return null; + } + + // Skip self-authored comments to prevent infinite loops + if (commentAuthor?.accountId === userInfo.accountId) { + logger.debug('Skipping self-authored JIRA comment to prevent infinite loop', { + issueKey, + accountId: userInfo.accountId, + }); + return null; + } + + const commentText = extractTextFromAdf(commentBody); + const authorName = commentAuthor?.displayName || 'unknown'; + + logger.info('JIRA comment @mention detected, triggering agent', { + issueKey, + commentAuthor: authorName, + botAccountId: userInfo.accountId, + }); + + return { + agentType: 'respond-to-planning-comment', + agentInput: { + cardId: issueKey, + triggerCommentText: commentText, + triggerCommentAuthor: authorName, + }, + workItemId: issueKey, + cardId: issueKey, + }; + } +} diff --git a/src/triggers/jira/issue-transitioned.ts b/src/triggers/jira/issue-transitioned.ts new file mode 100644 index 00000000..b4e2ce3c --- /dev/null +++ b/src/triggers/jira/issue-transitioned.ts @@ -0,0 +1,114 @@ +/** + * JIRA issue-transitioned trigger. + * + * Fires when a JIRA issue transitions to a configured status that maps to + * a CASCADE agent type (briefing, planning, implementation). + */ + +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; + +interface JiraWebhookPayload { + webhookEvent: string; + issue?: { + key: string; + fields?: { + project?: { key?: string }; + status?: { name?: string }; + summary?: string; + }; + }; + changelog?: { + items?: Array<{ + field?: string; + fromString?: string; + toString?: string; + }>; + }; +} + +/** + * Maps a JIRA status name to the CASCADE agent type based on project config. + * + * project.jira.statuses maps CASCADE status names to JIRA status names, e.g.: + * { briefing: "Briefing", planning: "Planning", todo: "To Do" } + * + * We invert this mapping: if the issue transitioned to "Briefing", we fire + * the briefing agent. + */ +const STATUS_TO_AGENT: Record = { + briefing: 'briefing', + planning: 'planning', + todo: 'implementation', +}; + +export class JiraIssueTransitionedTrigger implements TriggerHandler { + name = 'jira-issue-transitioned'; + description = 'Triggers agent when a JIRA issue transitions to a configured status'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'jira') return false; + + const payload = ctx.payload as JiraWebhookPayload; + if (!payload.webhookEvent?.startsWith('jira:issue_updated')) return false; + + // Must have a status change in changelog + const statusChange = payload.changelog?.items?.find((item) => item.field === 'status'); + return !!statusChange; + } + + async handle(ctx: TriggerContext): Promise { + const payload = ctx.payload as JiraWebhookPayload; + const issueKey = payload.issue?.key; + const statusChange = payload.changelog?.items?.find((item) => item.field === 'status'); + + if (!issueKey || !statusChange) { + return null; + } + + const newStatus = statusChange.toString; + if (!newStatus) { + return null; + } + + const jiraConfig = ctx.project.jira; + if (!jiraConfig?.statuses) { + logger.debug('No JIRA status configuration, skipping issue transition trigger', { + projectId: ctx.project.id, + }); + return null; + } + + // Find which CASCADE status key maps to this JIRA status + let agentType: string | undefined; + for (const [cascadeStatus, jiraStatus] of Object.entries(jiraConfig.statuses)) { + if (jiraStatus.toLowerCase() === newStatus.toLowerCase()) { + agentType = STATUS_TO_AGENT[cascadeStatus]; + break; + } + } + + if (!agentType) { + logger.debug('JIRA status transition does not map to any agent', { + issueKey, + newStatus, + configuredStatuses: jiraConfig.statuses, + }); + return null; + } + + logger.info('JIRA issue transitioned to agent-triggering status', { + issueKey, + fromStatus: statusChange.fromString, + toStatus: newStatus, + agentType, + }); + + return { + agentType, + agentInput: { cardId: issueKey }, + workItemId: issueKey, + cardId: issueKey, + }; + } +} diff --git a/src/triggers/jira/webhook-handler.ts b/src/triggers/jira/webhook-handler.ts new file mode 100644 index 00000000..e8ce4e51 --- /dev/null +++ b/src/triggers/jira/webhook-handler.ts @@ -0,0 +1,279 @@ +/** + * JIRA webhook handler. + * + * Processes JIRA webhooks: validates payload, extracts project key, + * finds project via findProjectByJiraProjectKey(), resolves creds, + * and dispatches to the trigger registry. + */ + +import { runAgent } from '../../agents/registry.js'; +import { + findProjectByJiraProjectKey, + getAgentCredential, + getProjectSecret, + loadConfig, +} from '../../config/provider.js'; +import { withGitHubToken } from '../../github/client.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 { + dequeueWebhook, + enqueueWebhook, + getQueueLength, + isCurrentlyProcessing, + logger, + setProcessing, + startWatchdog, +} from '../../utils/index.js'; +import { injectLlmApiKeys } from '../../utils/llmEnv.js'; +import type { TriggerRegistry } from '../registry.js'; +import { handleAgentResultArtifacts } from '../shared/agent-result-handler.js'; +import { checkBudgetExceeded } from '../shared/budget.js'; +import { triggerDebugAnalysis } from '../shared/debug-runner.js'; +import { shouldTriggerDebug } from '../shared/debug-trigger.js'; +import type { TriggerResult } from '../types.js'; + +interface JiraWebhookPayload { + webhookEvent: string; + issue?: { + key: string; + fields?: { + project?: { key?: string }; + status?: { name?: string }; + summary?: string; + comment?: { comments?: unknown[] }; + }; + }; + comment?: { + 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 getProjectSecret(project.id, 'JIRA_EMAIL'); + const jiraApiToken = await getProjectSecret(project.id, 'JIRA_API_TOKEN'); + const jiraBaseUrl = + project.jira?.baseUrl ?? (await getProjectSecret(project.id, 'JIRA_BASE_URL')); + const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); + + const agentGitHubToken = await getAgentCredential(project.id, result.agentType, 'GITHUB_TOKEN'); + const effectiveGithubToken = agentGitHubToken || githubToken; + + const restoreLlmEnv = await injectLlmApiKeys(project.id); + + try { + const pmProvider = createPMProvider(project); + await withJiraCredentials( + { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, + () => + withPMProvider(pmProvider, () => + withGitHubToken(effectiveGithubToken, () => + executeJiraAgentWithCreds(result, project, config), + ), + ), + ); + } finally { + restoreLlmEnv(); + } +} + +async function executeJiraAgentWithCreds( + result: TriggerResult, + project: ProjectConfig, + config: CascadeConfig, +): Promise { + const workItemId = result.workItemId ?? result.cardId; + const pmProvider = createPMProvider(project); + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); + + let remainingBudgetUsd: number | undefined; + if (workItemId) { + const budgetCheck = await checkBudgetExceeded(workItemId, project, config); + if (budgetCheck?.exceeded) { + logger.warn('Budget exceeded, JIRA agent not started', { + workItemId, + currentCost: budgetCheck.currentCost, + budget: budgetCheck.budget, + }); + await lifecycle.handleBudgetExceeded(workItemId, budgetCheck.currentCost, budgetCheck.budget); + return; + } + remainingBudgetUsd = budgetCheck?.remaining; + } + + if (workItemId) { + await lifecycle.prepareForAgent(workItemId, result.agentType); + } + + const agentResult = await runAgent(result.agentType, { + ...result.agentInput, + cardId: workItemId, + remainingBudgetUsd, + project, + config, + }); + + if (workItemId) { + await handleAgentResultArtifacts(workItemId, result.agentType, agentResult, project); + + const postBudgetCheck = await checkBudgetExceeded(workItemId, project, config); + if (postBudgetCheck?.exceeded) { + await lifecycle.handleBudgetWarning( + workItemId, + postBudgetCheck.currentCost, + postBudgetCheck.budget, + ); + } + + await lifecycle.cleanupProcessing(workItemId); + + if (agentResult.success) { + await lifecycle.handleSuccess(workItemId, result.agentType, agentResult.prUrl); + } else { + await lifecycle.handleFailure(workItemId, agentResult.error); + } + } + + logger.info('JIRA agent completed', { + agentType: result.agentType, + success: agentResult.success, + runId: agentResult.runId, + }); + + // Auto-debug + if (agentResult.runId) { + const debugTarget = await shouldTriggerDebug(agentResult.runId); + if (debugTarget) { + triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => + logger.error('Auto-debug failed', { error: String(err) }), + ); + } + } +} + +function processNextQueuedJiraWebhook(registry: TriggerRegistry): void { + const next = dequeueWebhook(); + if (next) { + logger.info('Processing queued JIRA webhook', { queueLength: getQueueLength() }); + setImmediate(() => { + processJiraWebhook(next.payload, registry).catch((err) => { + logger.error('Failed to process queued JIRA webhook', { error: String(err) }); + }); + }); + } +} + +export async function processJiraWebhook( + payload: unknown, + registry: TriggerRegistry, +): 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); + 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 config = await loadConfig(); + + const project = await findProjectByJiraProjectKey(projectKey); + if (!project) { + logger.warn('No project configured for JIRA project key', { projectKey }); + return; + } + + // Establish JIRA credential + PM provider scope + const jiraEmail = await getProjectSecret(project.id, 'JIRA_EMAIL'); + const jiraApiToken = await getProjectSecret(project.id, 'JIRA_API_TOKEN'); + const jiraBaseUrl = + project.jira?.baseUrl ?? (await getProjectSecret(project.id, 'JIRA_BASE_URL')); + const pmProvider = createPMProvider(project); + + await withJiraCredentials( + { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, + () => + withPMProvider(pmProvider, async () => { + 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 }); + return; + } + + 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); + } + }), + ); +} diff --git a/src/triggers/shared/agent-result-handler.ts b/src/triggers/shared/agent-result-handler.ts index 7f985e55..777bca14 100644 --- a/src/triggers/shared/agent-result-handler.ts +++ b/src/triggers/shared/agent-result-handler.ts @@ -1,14 +1,25 @@ -import { trelloClient } from '../../trello/client.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'; /** - * Update cost custom field on the Trello card. - * Shared between GitHub and Trello webhook handlers. + * 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. * * Logs are now stored in the database (agent_run_logs table) instead of - * being uploaded as ZIP attachments to Trello cards. + * being uploaded as attachments. */ export async function handleAgentResultArtifacts( cardId: string, @@ -17,17 +28,16 @@ export async function handleAgentResultArtifacts( project: ProjectConfig, ): Promise { // Update cost custom field (accumulate with existing) - const costFieldId = project.trello?.customFields?.cost; + const costFieldId = getCostFieldId(project); if (costFieldId && agentResult.cost !== undefined && agentResult.cost > 0) { const sessionCost = agentResult.cost; await safeOperation( async () => { - const items = await trelloClient.getCardCustomFieldItems(cardId); - const currentItem = items.find((i) => i.idCustomField === costFieldId); - const currentCost = Number.parseFloat(currentItem?.value?.number ?? '0'); + const provider = getPMProvider(); + const currentCost = await provider.getCustomFieldNumber(cardId, costFieldId); const newTotal = Math.round((currentCost + sessionCost) * 10000) / 10000; - await trelloClient.updateCardCustomFieldNumber(cardId, costFieldId, newTotal); - logger.info('Updated card cost', { + await provider.updateCustomFieldNumber(cardId, costFieldId, newTotal); + logger.info('Updated work item cost', { cardId, sessionCost, totalCost: newTotal, diff --git a/src/triggers/shared/budget.ts b/src/triggers/shared/budget.ts index 14ce642f..2aad72c3 100644 --- a/src/triggers/shared/budget.ts +++ b/src/triggers/shared/budget.ts @@ -1,4 +1,4 @@ -import { trelloClient } from '../../trello/client.js'; +import { getPMProvider } from '../../pm/index.js'; import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; export interface BudgetCheckResult { @@ -8,31 +8,40 @@ 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). */ export function resolveCardBudget(project: ProjectConfig, config: CascadeConfig): number | null { - const costFieldId = project.trello?.customFields?.cost; + const costFieldId = getCostFieldId(project); if (!costFieldId) return null; return project.cardBudgetUsd ?? config.defaults.cardBudgetUsd; } /** - * Read the accumulated cost from a card's custom field. + * Read the accumulated cost from a work item's custom field. * Returns 0 if no value set yet. */ export async function getCardAccumulatedCost( cardId: string, project: ProjectConfig, ): Promise { - const costFieldId = project.trello?.customFields?.cost; + const costFieldId = getCostFieldId(project); if (!costFieldId) return 0; - const items = await trelloClient.getCardCustomFieldItems(cardId); - const currentItem = items.find((i) => i.idCustomField === costFieldId); - return Number.parseFloat(currentItem?.value?.number ?? '0'); + const provider = getPMProvider(); + return provider.getCustomFieldNumber(cardId, costFieldId); } /** diff --git a/src/triggers/shared/debug-runner.ts b/src/triggers/shared/debug-runner.ts index a138b425..575456ef 100644 --- a/src/triggers/shared/debug-runner.ts +++ b/src/triggers/shared/debug-runner.ts @@ -8,7 +8,7 @@ import { getRunLogs, storeDebugAnalysis, } from '../../db/repositories/runsRepository.js'; -import { trelloClient } from '../../trello/client.js'; +import { getPMProvider } from '../../pm/index.js'; import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { cleanupTempDir } from '../../utils/repo.js'; @@ -127,7 +127,15 @@ export async function triggerDebugAnalysis( logDir = await extractLogsToTempDir(analyzedRunId); const originalCardName = cardId ? `Card ${cardId}` : 'Unknown card'; - const originalCardUrl = cardId ? `https://trello.com/c/${cardId}` : ''; + let originalCardUrl = ''; + if (cardId) { + try { + const provider = getPMProvider(); + originalCardUrl = provider.getWorkItemUrl(cardId); + } catch { + originalCardUrl = `https://trello.com/c/${cardId}`; + } + } const agentResult: AgentResult = await runAgent('debug', { logDir, @@ -152,14 +160,15 @@ export async function triggerDebugAnalysis( severity: run.status === 'timed_out' ? 'timeout' : 'failure', }); - // Post summary comment on original Trello card + // Post summary comment on original work item if (cardId && parsed.summary) { try { + const provider = getPMProvider(); const rootCauseText = parsed.rootCause ? `**Root Cause:** ${parsed.rootCause.slice(0, 200)}\n\n` : ''; const comment = `🔍 **Debug Analysis** (run: ${analyzedRunId.slice(0, 8)})\n\n${parsed.summary}\n\n${rootCauseText}_Full analysis stored in database._`; - await trelloClient.addComment(cardId, comment); + await provider.addComment(cardId, comment); } catch (err) { logger.warn('Failed to post debug summary comment', { cardId, diff --git a/src/triggers/trello/card-moved.ts b/src/triggers/trello/card-moved.ts index ca4ac124..926204a9 100644 --- a/src/triggers/trello/card-moved.ts +++ b/src/triggers/trello/card-moved.ts @@ -27,7 +27,7 @@ function createCardMovedTrigger(config: CardMovedConfig): TriggerHandler { if (!isTrelloWebhookPayload(ctx.payload)) return false; const payload = ctx.payload; - const targetListId = ctx.project.trello.lists[config.listKey]; + const targetListId = ctx.project.trello?.lists[config.listKey]; // Card moved into the target list const isMove = diff --git a/src/triggers/trello/comment-mention.ts b/src/triggers/trello/comment-mention.ts index e86d29f5..18c5f234 100644 --- a/src/triggers/trello/comment-mention.ts +++ b/src/triggers/trello/comment-mention.ts @@ -66,7 +66,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 = ctx.project.trello?.lists.planning; if (!planningListId) { logger.debug('Planning list not configured, skipping comment mention trigger', { projectId: ctx.project.id, diff --git a/src/triggers/trello/label-added.ts b/src/triggers/trello/label-added.ts index 0b743ecb..8a490330 100644 --- a/src/triggers/trello/label-added.ts +++ b/src/triggers/trello/label-added.ts @@ -17,7 +17,7 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { if (!isTrelloWebhookPayload(ctx.payload)) return false; const payload = ctx.payload; - const readyLabelId = ctx.project.trello.labels.readyToProcess; + const readyLabelId = ctx.project.trello?.labels.readyToProcess; return ( payload.action.type === 'addLabelToCard' && payload.action.data.label?.id === readyLabelId @@ -39,7 +39,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 = ctx.project.trello?.lists ?? {}; let agentType: string; if (currentListId === lists.briefing) { diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 351a08ab..0d15b25d 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -6,7 +6,13 @@ import { loadConfig, } from '../../config/provider.js'; import { withGitHubToken } from '../../github/client.js'; -import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; +import { + PMLifecycleManager, + createPMProvider, + resolveProjectPMConfig, + withPMProvider, +} from '../../pm/index.js'; +import { withTrelloCredentials } from '../../trello/client.js'; import type { AgentResult, CascadeConfig, @@ -14,7 +20,6 @@ import type { TriggerContext, } from '../../types/index.js'; import { - cancelFreshMachineTimer, clearCardActive, dequeueWebhook, enqueueWebhook, @@ -22,13 +27,11 @@ import { isCardActive, isCurrentlyProcessing, logger, - scheduleShutdownAfterJob, setCardActive, setProcessing, startWatchdog, } from '../../utils/index.js'; import { injectLlmApiKeys } from '../../utils/llmEnv.js'; -import { safeOperation, silentOperation } from '../../utils/safeOperation.js'; import type { TriggerRegistry } from '../registry.js'; import { handleAgentResultArtifacts } from '../shared/agent-result-handler.js'; import { checkBudgetExceeded } from '../shared/budget.js'; @@ -37,67 +40,6 @@ import { shouldTriggerDebug } from '../shared/debug-trigger.js'; import type { TrelloWebhookPayload, TriggerResult } from '../types.js'; import { isTrelloWebhookPayload } from '../types.js'; -// ============================================================================ -// Safe Card Operations -// ============================================================================ - -async function safeAddLabel(cardId: string, labelId: string | undefined): Promise { - if (!labelId) return; - await safeOperation(() => trelloClient.addLabelToCard(cardId, labelId), { - action: 'add label', - labelId, - }); -} - -async function safeRemoveLabel(cardId: string, labelId: string | undefined): Promise { - if (!labelId) return; - await silentOperation(() => trelloClient.removeLabelFromCard(cardId, labelId)); -} - -async function safeAddComment(cardId: string, text: string): Promise { - await safeOperation(() => trelloClient.addComment(cardId, text), { action: 'add comment' }); -} - -async function safeMoveCard(cardId: string, listId: string | undefined): Promise { - if (!listId) return; - await safeOperation(() => trelloClient.moveCardToList(cardId, listId), { - action: 'move card', - listId, - }); -} - -// ============================================================================ -// Agent Result Handlers -// ============================================================================ - -async function handleAgentSuccess( - cardId: string, - project: ProjectConfig, - result: TriggerResult, - agentResult: AgentResult, -): Promise { - await safeAddLabel(cardId, project.trello.labels.processed); - - // Move to in-review if implementation completed successfully - if (result.agentType === 'implementation') { - await safeMoveCard(cardId, project.trello.lists.inReview); - if (agentResult.prUrl) { - await safeAddComment(cardId, `PR created: ${agentResult.prUrl}`); - } - } -} - -async function handleAgentFailure( - cardId: string, - project: ProjectConfig, - agentResult: AgentResult, -): Promise { - await safeAddLabel(cardId, project.trello.labels.error); - if (agentResult.error) { - await safeAddComment(cardId, `❌ Agent failed: ${agentResult.error}`); - } -} - // ============================================================================ // Agent Execution // ============================================================================ @@ -107,112 +49,55 @@ async function executeAgent( project: ProjectConfig, config: CascadeConfig, ): Promise { - // Resolve per-project credentials up front — all Trello/GitHub API calls - // in this function (labels, comments, card moves, budget checks, agent run) - // require scoped credentials. const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); const githubToken = await getProjectSecret(project.id, 'GITHUB_TOKEN'); - // Check for agent-scoped credential overrides const agentGitHubToken = await getAgentCredential(project.id, result.agentType, 'GITHUB_TOKEN'); const effectiveGithubToken = agentGitHubToken || githubToken; - // Inject LLM API keys into process.env for llmist backend const restoreLlmEnv = await injectLlmApiKeys(project.id); try { + const pmProvider = createPMProvider(project); await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withGitHubToken(effectiveGithubToken, () => executeAgentWithCreds(result, project, config)), + withPMProvider(pmProvider, () => + withGitHubToken(effectiveGithubToken, () => executeAgentWithCreds(result, project, config)), + ), ); } finally { restoreLlmEnv(); } } -async function checkPreFlightBudget( - cardId: string, - project: ProjectConfig, - config: CascadeConfig, -): Promise<{ blocked: boolean; remainingBudgetUsd?: number }> { - const budgetCheck = await checkBudgetExceeded(cardId, project, config); - if (budgetCheck?.exceeded) { - logger.warn('Card budget exceeded, agent not started', { - cardId, - currentCost: budgetCheck.currentCost, - budget: budgetCheck.budget, - }); - await safeRemoveLabel(cardId, project.trello.labels.processing); - await safeAddLabel(cardId, project.trello.labels.error); - await safeAddComment( - cardId, - `⛔ Budget exceeded: card cost $${budgetCheck.currentCost.toFixed(2)} >= limit $${budgetCheck.budget.toFixed(2)}. Agent not started.`, - ); - return { blocked: true }; - } - return { blocked: false, remainingBudgetUsd: budgetCheck?.remaining }; -} - -async function prepareCardForAgent( - cardId: string, - project: ProjectConfig, - agentType: string, -): Promise { - setCardActive(cardId); - await safeAddLabel(cardId, project.trello.labels.processing); - await safeRemoveLabel(cardId, project.trello.labels.readyToProcess); - await safeRemoveLabel(cardId, project.trello.labels.processed); - - if (agentType === 'implementation') { - await safeMoveCard(cardId, project.trello.lists.inProgress); - } -} - -async function checkPostFlightBudget( - cardId: string, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - const postBudgetCheck = await checkBudgetExceeded(cardId, project, config); - if (postBudgetCheck?.exceeded) { - await safeAddLabel(cardId, project.trello.labels.error); - await safeAddComment( - cardId, - `⚠️ Budget limit reached: card cost $${postBudgetCheck.currentCost.toFixed(2)} >= limit $${postBudgetCheck.budget.toFixed(2)}. Further agent runs will be blocked.`, - ); - } -} - -async function tryAutoDebug( - agentResult: AgentResult, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - if (!agentResult.runId) return; - const debugTarget = await shouldTriggerDebug(agentResult.runId); - if (debugTarget) { - triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => - logger.error('Auto-debug failed', { error: String(err) }), - ); - } -} - async function executeAgentWithCreds( result: TriggerResult, project: ProjectConfig, config: CascadeConfig, ): Promise { - const { cardId } = result; + const cardId = result.cardId ?? result.workItemId; + const pmProvider = createPMProvider(project); + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); let remainingBudgetUsd: number | undefined; if (cardId) { - const budget = await checkPreFlightBudget(cardId, project, config); - if (budget.blocked) return; - remainingBudgetUsd = budget.remainingBudgetUsd; + const budgetCheck = await checkBudgetExceeded(cardId, project, config); + if (budgetCheck?.exceeded) { + logger.warn('Budget exceeded, agent not started', { + cardId, + currentCost: budgetCheck.currentCost, + budget: budgetCheck.budget, + }); + await lifecycle.handleBudgetExceeded(cardId, budgetCheck.currentCost, budgetCheck.budget); + return; + } + remainingBudgetUsd = budgetCheck?.remaining; } if (cardId) { - await prepareCardForAgent(cardId, project, result.agentType); + setCardActive(cardId); + await lifecycle.prepareForAgent(cardId, result.agentType); } const agentResult = await runAgent(result.agentType, { @@ -224,13 +109,22 @@ async function executeAgentWithCreds( if (cardId) { await handleAgentResultArtifacts(cardId, result.agentType, agentResult, project); - await checkPostFlightBudget(cardId, project, config); - await safeRemoveLabel(cardId, project.trello.labels.processing); + + const postBudgetCheck = await checkBudgetExceeded(cardId, project, config); + if (postBudgetCheck?.exceeded) { + await lifecycle.handleBudgetWarning( + cardId, + postBudgetCheck.currentCost, + postBudgetCheck.budget, + ); + } + + await lifecycle.cleanupProcessing(cardId); if (agentResult.success) { - await handleAgentSuccess(cardId, project, result, agentResult); + await lifecycle.handleSuccess(cardId, result.agentType, agentResult.prUrl); } else { - await handleAgentFailure(cardId, project, agentResult); + await lifecycle.handleFailure(cardId, agentResult.error); } } @@ -243,11 +137,25 @@ async function executeAgentWithCreds( await tryAutoDebug(agentResult, project, config); } +async function tryAutoDebug( + agentResult: AgentResult, + project: ProjectConfig, + config: CascadeConfig, +): Promise { + if (!agentResult.runId) return; + const debugTarget = await shouldTriggerDebug(agentResult.runId); + if (debugTarget) { + triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.cardId).catch((err) => + logger.error('Auto-debug failed', { error: String(err) }), + ); + } +} + // ============================================================================ // Webhook Processing // ============================================================================ -function processNextQueuedWebhook(config: CascadeConfig, registry: TriggerRegistry): void { +function processNextQueuedWebhook(registry: TriggerRegistry): void { const next = dequeueWebhook(); if (next) { logger.info('Processing queued webhook', { queueLength: getQueueLength() }); @@ -256,8 +164,6 @@ function processNextQueuedWebhook(config: CascadeConfig, registry: TriggerRegist logger.error('Failed to process queued webhook', { error: String(err) }); }); }); - } else if (process.env.FLY_APP_NAME) { - scheduleShutdownAfterJob(config.defaults.postJobGracePeriodMs); } } @@ -302,46 +208,47 @@ export async function processTrelloWebhook( return; } - // Establish Trello credential scope for all downstream operations - // (trigger dispatch, label/comment updates, agent execution) + // Establish Trello credential + PM provider scope for all downstream operations const trelloApiKey = await getProjectSecret(project.id, 'TRELLO_API_KEY'); const trelloToken = await getProjectSecret(project.id, 'TRELLO_TOKEN'); + const pmProvider = createPMProvider(project); + + await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => + withPMProvider(pmProvider, async () => { + const ctx: TriggerContext = { project, source: 'trello', payload }; + const result = await registry.dispatch(ctx); + if (!result) { + logger.info('No trigger matched for webhook', { actionType }); + return; + } - await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, async () => { - const ctx: TriggerContext = { project, source: 'trello', payload }; - const result = await registry.dispatch(ctx); - if (!result) { - logger.info('No trigger matched for webhook', { actionType }); - return; - } - - if (result.cardId && isCardActive(result.cardId)) { - logger.info('Card already being processed, skipping', { cardId: result.cardId }); - return; - } - - logger.info('Trigger matched', { agentType: result.agentType, cardId: result.cardId }); - cancelFreshMachineTimer(); - setProcessing(true); + const cardId = result.cardId ?? result.workItemId; + if (cardId && isCardActive(cardId)) { + logger.info('Card already being processed, skipping', { cardId }); + return; + } - if (process.env.FLY_APP_NAME) { + logger.info('Trigger matched', { agentType: result.agentType, cardId }); + setProcessing(true); startWatchdog(config.defaults.watchdogTimeoutMs); - } - try { - await executeAgent(result, project, config); - } catch (err) { - logger.error('Failed to process webhook', { error: String(err) }); - if (result.cardId) { - await safeAddLabel(result.cardId, project.trello.labels.error); - await safeAddComment(result.cardId, `❌ Error: ${String(err)}`); + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); + + try { + 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); + processNextQueuedWebhook(registry); } - } finally { - if (result?.cardId) { - clearCardActive(result.cardId); - } - setProcessing(false); - processNextQueuedWebhook(config, registry); - } - }); + }), + ); } diff --git a/src/types/index.ts b/src/types/index.ts index 178b1f76..bd5b5dfd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,7 +46,7 @@ export interface AgentResult { runId?: string; } -export type TriggerSource = 'trello' | 'github' | 'manual'; +export type TriggerSource = 'trello' | 'github' | 'jira' | 'manual'; export interface TriggerContext { project: ProjectConfig; @@ -57,7 +57,10 @@ export interface TriggerContext { export interface TriggerResult { agentType: string; agentInput: AgentInput; + /** @deprecated Use workItemId instead */ cardId?: string; + /** Alias for cardId — preferred name for PM-agnostic code */ + workItemId?: string; prNumber?: number; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 18f3df3a..2406a3f7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,14 +1,11 @@ export { logger, setLogLevel, getLogLevel } from './logging.js'; export { - startFreshMachineTimer, - cancelFreshMachineTimer, setProcessing, isCurrentlyProcessing, startWatchdog, clearWatchdog, setWatchdogCleanup, clearWatchdogCleanup, - scheduleShutdownAfterJob, } from './lifecycle.js'; export { createTempDir, cloneRepo, cleanupTempDir, runCommand, getWorkspaceDir } from './repo.js'; export { diff --git a/src/utils/lifecycle.ts b/src/utils/lifecycle.ts index d0005902..256e6b39 100644 --- a/src/utils/lifecycle.ts +++ b/src/utils/lifecycle.ts @@ -1,33 +1,9 @@ import { logger } from './logging.js'; -let freshMachineTimer: ReturnType | null = null; -let shutdownTimer: ReturnType | null = null; let watchdogTimer: ReturnType | null = null; let isProcessing = false; let watchdogCleanup: (() => Promise) | null = null; -// Fresh machine timer - exits if no work received after boot -export function startFreshMachineTimer(timeoutMs: number): void { - if (freshMachineTimer) { - clearTimeout(freshMachineTimer); - } - - logger.info('Fresh machine timer started', { timeoutMs }); - - freshMachineTimer = setTimeout(() => { - logger.info('No work received, shutting down fresh machine'); - process.exit(0); - }, timeoutMs); -} - -export function cancelFreshMachineTimer(): void { - if (freshMachineTimer) { - clearTimeout(freshMachineTimer); - freshMachineTimer = null; - logger.debug('Fresh machine timer cancelled (work received)'); - } -} - export function setProcessing(processing: boolean): void { isProcessing = processing; } @@ -78,19 +54,3 @@ export function clearWatchdog(): void { watchdogTimer = null; } } - -// Schedule shutdown shortly after job completion -export function scheduleShutdownAfterJob(gracePeriodMs = 5000): void { - clearWatchdog(); - - if (shutdownTimer) { - clearTimeout(shutdownTimer); - } - - logger.info('Job complete, scheduling shutdown', { gracePeriodMs }); - - shutdownTimer = setTimeout(() => { - logger.info('Grace period complete, shutting down'); - process.exit(0); - }, gracePeriodMs); -} diff --git a/tests/unit/agents/registry.test.ts b/tests/unit/agents/registry.test.ts index d2c61be7..69414e41 100644 --- a/tests/unit/agents/registry.test.ts +++ b/tests/unit/agents/registry.test.ts @@ -59,9 +59,7 @@ function makeInput(): AgentInput & { project: ProjectConfig; config: CascadeConf agentModels: {}, maxIterations: 50, agentIterations: {}, - freshMachineTimeoutMs: 300000, watchdogTimeoutMs: 1800000, - postJobGracePeriodMs: 5000, cardBudgetUsd: 5, agentBackend: 'llmist', progressModel: 'openrouter:google/gemini-2.5-flash-lite', diff --git a/tests/unit/api/routers/defaults.test.ts b/tests/unit/api/routers/defaults.test.ts index a29c94ba..893c516a 100644 --- a/tests/unit/api/routers/defaults.test.ts +++ b/tests/unit/api/routers/defaults.test.ts @@ -68,9 +68,7 @@ describe('defaultsRouter', () => { await caller.upsert({ model: 'claude-sonnet-4-5-20250929', maxIterations: 30, - freshMachineTimeoutMs: 600000, watchdogTimeoutMs: 300000, - postJobGracePeriodMs: 30000, cardBudgetUsd: '5.00', agentBackend: 'claude-code', progressModel: 'claude-haiku-3-20240307', @@ -80,9 +78,7 @@ describe('defaultsRouter', () => { expect(mockUpsertCascadeDefaults).toHaveBeenCalledWith('org-1', { model: 'claude-sonnet-4-5-20250929', maxIterations: 30, - freshMachineTimeoutMs: 600000, watchdogTimeoutMs: 300000, - postJobGracePeriodMs: 30000, cardBudgetUsd: '5.00', agentBackend: 'claude-code', progressModel: 'claude-haiku-3-20240307', diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 7db22229..e6257ef4 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock all external dependencies -vi.mock('../../../src/gadgets/trello/core/readCard.js', () => ({ - readCard: vi.fn(), +vi.mock('../../../src/gadgets/pm/core/readWorkItem.js', () => ({ + readWorkItem: vi.fn(), })); vi.mock('../../../src/agents/shared/repository.js', () => ({ @@ -66,7 +66,7 @@ import { executeWithBackend } from '../../../src/backends/adapter.js'; import { createProgressMonitor } from '../../../src/backends/progress.js'; import type { AgentBackend } from '../../../src/backends/types.js'; import { getProjectSecrets } from '../../../src/config/provider.js'; -import { readCard } from '../../../src/gadgets/trello/core/readCard.js'; +import { readWorkItem } from '../../../src/gadgets/pm/core/readWorkItem.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; import { loadCascadeEnv, unloadCascadeEnv } from '../../../src/utils/cascadeEnv.js'; import { @@ -78,7 +78,7 @@ import { clearWatchdogCleanup, setWatchdogCleanup } from '../../../src/utils/lif import { logger } from '../../../src/utils/logging.js'; import { cleanupTempDir } from '../../../src/utils/repo.js'; -const mockReadCard = vi.mocked(readCard); +const mockReadWorkItem = vi.mocked(readWorkItem); const mockSetupRepository = vi.mocked(setupRepository); const mockResolveModelConfig = vi.mocked(resolveModelConfig); const mockCreateFileLogger = vi.mocked(createFileLogger); @@ -110,9 +110,7 @@ function makeConfig(): CascadeConfig { agentModels: {}, maxIterations: 50, agentIterations: {}, - freshMachineTimeoutMs: 300000, watchdogTimeoutMs: 1800000, - postJobGracePeriodMs: 5000, cardBudgetUsd: 5, agentBackend: 'llmist', progressModel: 'openrouter:google/gemini-2.5-flash-lite', @@ -164,7 +162,7 @@ function setupMocks() { model: 'test-model', maxIterations: 50, } as never); - mockReadCard.mockResolvedValue('Card data'); + mockReadWorkItem.mockResolvedValue('Card data'); mockCreateProgressMonitor.mockReturnValue(null); mockGetProjectSecrets.mockResolvedValue({}); return mockLoggerInstance; @@ -291,7 +289,7 @@ describe('executeWithBackend', () => { await executeWithBackend(backend, 'implementation', input); - expect(mockReadCard).toHaveBeenCalledWith('card123', true); + expect(mockReadWorkItem).toHaveBeenCalledWith('card123', true); }); it('skips context injection when logDir present', async () => { @@ -301,7 +299,7 @@ describe('executeWithBackend', () => { await executeWithBackend(backend, 'implementation', input); - expect(mockReadCard).not.toHaveBeenCalled(); + expect(mockReadWorkItem).not.toHaveBeenCalled(); }); it('marks implementation agent as failed when no PR was created', async () => { diff --git a/tests/unit/backends/resolution.test.ts b/tests/unit/backends/resolution.test.ts index 134ec90e..62f7a7da 100644 --- a/tests/unit/backends/resolution.test.ts +++ b/tests/unit/backends/resolution.test.ts @@ -21,9 +21,7 @@ function makeConfig(overrides?: Partial): CascadeConf agentModels: {}, maxIterations: 50, agentIterations: {}, - freshMachineTimeoutMs: 300000, watchdogTimeoutMs: 1800000, - postJobGracePeriodMs: 5000, cardBudgetUsd: 5, agentBackend: 'llmist', progressModel: 'openrouter:google/gemini-2.5-flash-lite', diff --git a/tests/unit/config/projects.test.ts b/tests/unit/config/projects.test.ts index d0b02aed..44b5693d 100644 --- a/tests/unit/config/projects.test.ts +++ b/tests/unit/config/projects.test.ts @@ -71,9 +71,7 @@ describe('config provider', () => { agentModels: {}, maxIterations: 50, agentIterations: {}, - freshMachineTimeoutMs: 300000, watchdogTimeoutMs: 1800000, - postJobGracePeriodMs: 5000, cardBudgetUsd: 5, agentBackend: 'llmist', progressModel: 'test-model', diff --git a/tests/unit/db/repositories/configRepository.test.ts b/tests/unit/db/repositories/configRepository.test.ts index ea61cd54..b42c9cb2 100644 --- a/tests/unit/db/repositories/configRepository.test.ts +++ b/tests/unit/db/repositories/configRepository.test.ts @@ -20,9 +20,7 @@ const defaultsRow = { orgId: 'default', model: 'test-model', maxIterations: 50, - freshMachineTimeoutMs: 300000, watchdogTimeoutMs: 1800000, - postJobGracePeriodMs: 5000, cardBudgetUsd: '5.00', agentBackend: 'llmist', progressModel: 'progress-model', diff --git a/tests/unit/triggers/budget.test.ts b/tests/unit/triggers/budget.test.ts index 2861793a..94b526a8 100644 --- a/tests/unit/triggers/budget.test.ts +++ b/tests/unit/triggers/budget.test.ts @@ -32,9 +32,7 @@ const baseConfig: CascadeConfig = { agentModels: {}, maxIterations: 50, agentIterations: {}, - freshMachineTimeoutMs: 300000, watchdogTimeoutMs: 1800000, - postJobGracePeriodMs: 5000, cardBudgetUsd: 5, agentBackend: 'llmist', progressModel: 'openrouter:google/gemini-2.5-flash-lite', diff --git a/tests/unit/utils/lifecycle.test.ts b/tests/unit/utils/lifecycle.test.ts index f3347c0f..da3f7d7c 100644 --- a/tests/unit/utils/lifecycle.test.ts +++ b/tests/unit/utils/lifecycle.test.ts @@ -9,14 +9,11 @@ vi.mock('../../../src/utils/logging.js', () => ({ })); import { - cancelFreshMachineTimer, clearWatchdog, clearWatchdogCleanup, isCurrentlyProcessing, - scheduleShutdownAfterJob, setProcessing, setWatchdogCleanup, - startFreshMachineTimer, startWatchdog, } from '../../../src/utils/lifecycle.js'; @@ -29,7 +26,6 @@ describe('lifecycle', () => { afterEach(() => { // Clean up all timers - cancelFreshMachineTimer(); clearWatchdog(); clearWatchdogCleanup(); setProcessing(false); @@ -54,44 +50,6 @@ describe('lifecycle', () => { }); }); - describe('fresh machine timer', () => { - it('exits after timeout when no work received', () => { - startFreshMachineTimer(5000); - - vi.advanceTimersByTime(5000); - - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('does not exit before timeout', () => { - startFreshMachineTimer(5000); - - vi.advanceTimersByTime(4999); - - expect(process.exit).not.toHaveBeenCalled(); - }); - - it('can be cancelled', () => { - startFreshMachineTimer(5000); - cancelFreshMachineTimer(); - - vi.advanceTimersByTime(10000); - - expect(process.exit).not.toHaveBeenCalled(); - }); - - it('replaces existing timer when started again', () => { - startFreshMachineTimer(5000); - startFreshMachineTimer(10000); - - vi.advanceTimersByTime(5000); - expect(process.exit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(5000); - expect(process.exit).toHaveBeenCalledWith(0); - }); - }); - describe('watchdog', () => { it('force exits after timeout', () => { startWatchdog(30000); @@ -122,38 +80,6 @@ describe('lifecycle', () => { }); }); - describe('shutdown after job', () => { - it('exits after grace period', () => { - scheduleShutdownAfterJob(5000); - - vi.advanceTimersByTime(5000); - - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('clears watchdog when scheduling shutdown', () => { - startWatchdog(30000); - scheduleShutdownAfterJob(5000); - - vi.advanceTimersByTime(30000); - - // Should exit at 5000 (shutdown), not at 30000 (watchdog) - expect(process.exit).toHaveBeenCalledTimes(1); - expect(process.exit).toHaveBeenCalledWith(0); - }); - - it('replaces existing shutdown timer', () => { - scheduleShutdownAfterJob(5000); - scheduleShutdownAfterJob(10000); - - vi.advanceTimersByTime(5000); - expect(process.exit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(5000); - expect(process.exit).toHaveBeenCalledWith(0); - }); - }); - describe('watchdog cleanup', () => { it('can set and clear watchdog cleanup', () => { const cleanup = vi.fn().mockResolvedValue(undefined); diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 64e503cf..deb77dee 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -82,9 +82,16 @@ function fromKVPairs(pairs: KVPair[]): Record { return result; } -export function IntegrationForm({ projectId }: { projectId: string }) { +type IntegrationType = 'trello' | 'jira'; + +function TrelloForm({ + projectId, + initialConfig, +}: { + projectId: string; + initialConfig?: Record; +}) { const queryClient = useQueryClient(); - const integrationsQuery = useQuery(trpc.projects.integrations.list.queryOptions({ projectId })); const [boardId, setBoardId] = useState(''); const [lists, setLists] = useState([]); @@ -92,18 +99,14 @@ export function IntegrationForm({ projectId }: { projectId: string }) { const [costField, setCostField] = useState(''); useEffect(() => { - if (integrationsQuery.data) { - const trello = integrationsQuery.data.find((i) => i.type === 'trello'); - if (trello) { - const config = trello.config as Record; - setBoardId((config.boardId as string) ?? ''); - setLists(toKVPairs(config.lists as Record)); - setLabels(toKVPairs(config.labels as Record)); - const cf = config.customFields as Record | undefined; - setCostField(cf?.cost ?? ''); - } + if (initialConfig) { + setBoardId((initialConfig.boardId as string) ?? ''); + setLists(toKVPairs(initialConfig.lists as Record)); + setLabels(toKVPairs(initialConfig.labels as Record)); + const cf = initialConfig.customFields as Record | undefined; + setCostField(cf?.cost ?? ''); } - }, [integrationsQuery.data]); + }, [initialConfig]); const upsertMutation = useMutation({ mutationFn: () => @@ -124,14 +127,8 @@ export function IntegrationForm({ projectId }: { projectId: string }) { }, }); - if (integrationsQuery.isLoading) { - return
Loading integrations...
; - } - return ( -
-

Trello Integration

- +
); } + +function JiraForm({ + projectId, + initialConfig, +}: { + projectId: string; + initialConfig?: Record; +}) { + const queryClient = useQueryClient(); + + const [jiraProjectKey, setJiraProjectKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [statuses, setStatuses] = useState([]); + const [issueTypes, setIssueTypes] = useState([]); + const [costField, setCostField] = useState(''); + + useEffect(() => { + if (initialConfig) { + setJiraProjectKey((initialConfig.projectKey as string) ?? ''); + setBaseUrl((initialConfig.baseUrl as string) ?? ''); + setStatuses(toKVPairs(initialConfig.statuses as Record)); + setIssueTypes(toKVPairs(initialConfig.issueTypes as Record)); + const cf = initialConfig.customFields as Record | undefined; + setCostField(cf?.cost ?? ''); + } + }, [initialConfig]); + + const upsertMutation = useMutation({ + mutationFn: () => + trpcClient.projects.integrations.upsert.mutate({ + projectId, + type: 'jira', + config: { + projectKey: jiraProjectKey, + baseUrl, + statuses: fromKVPairs(statuses), + ...(issueTypes.length > 0 ? { issueTypes: fromKVPairs(issueTypes) } : {}), + ...(costField ? { customFields: { cost: costField } } : {}), + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
+
+ + setJiraProjectKey(e.target.value)} + placeholder="e.g., PROJ" + /> +
+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://your-instance.atlassian.net" + /> +
+ + +

+ Map CASCADE statuses (briefing, planning, todo, inProgress, inReview, done, merged) to JIRA + status names. +

+ + + +
+ + setCostField(e.target.value)} + placeholder="e.g., customfield_10042" + /> +
+ +
+ + {upsertMutation.isSuccess && Saved} + {upsertMutation.isError && ( + {upsertMutation.error.message} + )} +
+
+ ); +} + +export function IntegrationForm({ projectId }: { projectId: string }) { + const integrationsQuery = useQuery(trpc.projects.integrations.list.queryOptions({ projectId })); + + // Determine active integration type from existing data + const trelloConfig = integrationsQuery.data?.find((i) => i.type === 'trello'); + const jiraConfig = integrationsQuery.data?.find((i) => i.type === 'jira'); + + const defaultTab: IntegrationType = jiraConfig && !trelloConfig ? 'jira' : 'trello'; + const [activeTab, setActiveTab] = useState(defaultTab); + + // Sync default tab when data loads + useEffect(() => { + if (jiraConfig && !trelloConfig) { + setActiveTab('jira'); + } + }, [jiraConfig, trelloConfig]); + + if (integrationsQuery.isLoading) { + return
Loading integrations...
; + } + + return ( +
+
+ + +
+ + {activeTab === 'trello' && ( + } + /> + )} + + {activeTab === 'jira' && ( + } + /> + )} +
+ ); +} diff --git a/web/src/components/settings/defaults-form.tsx b/web/src/components/settings/defaults-form.tsx index 03af6196..3951ced8 100644 --- a/web/src/components/settings/defaults-form.tsx +++ b/web/src/components/settings/defaults-form.tsx @@ -17,9 +17,7 @@ export function DefaultsForm() { const [model, setModel] = useState(''); const [maxIterations, setMaxIterations] = useState(''); - const [freshMachineTimeoutMs, setFreshMachineTimeoutMs] = useState(''); const [watchdogTimeoutMs, setWatchdogTimeoutMs] = useState(''); - const [postJobGracePeriodMs, setPostJobGracePeriodMs] = useState(''); const [cardBudgetUsd, setCardBudgetUsd] = useState(''); const [agentBackend, setAgentBackend] = useState(''); const [progressModel, setProgressModel] = useState(''); @@ -30,9 +28,7 @@ export function DefaultsForm() { const d = defaultsQuery.data; setModel(d.model ?? ''); setMaxIterations(d.maxIterations?.toString() ?? ''); - setFreshMachineTimeoutMs(d.freshMachineTimeoutMs?.toString() ?? ''); setWatchdogTimeoutMs(d.watchdogTimeoutMs?.toString() ?? ''); - setPostJobGracePeriodMs(d.postJobGracePeriodMs?.toString() ?? ''); setCardBudgetUsd(d.cardBudgetUsd ?? ''); setAgentBackend(d.agentBackend ?? ''); setProgressModel(d.progressModel ?? ''); @@ -45,9 +41,7 @@ export function DefaultsForm() { trpcClient.defaults.upsert.mutate({ model: model || null, maxIterations: maxIterations ? Number(maxIterations) : null, - freshMachineTimeoutMs: freshMachineTimeoutMs ? Number(freshMachineTimeoutMs) : null, watchdogTimeoutMs: watchdogTimeoutMs ? Number(watchdogTimeoutMs) : null, - postJobGracePeriodMs: postJobGracePeriodMs ? Number(postJobGracePeriodMs) : null, cardBudgetUsd: cardBudgetUsd || null, agentBackend: agentBackend || null, progressModel: progressModel || null, @@ -91,16 +85,7 @@ export function DefaultsForm() { />
-
-
- - setFreshMachineTimeoutMs(e.target.value)} - /> -
+
setWatchdogTimeoutMs(e.target.value)} />
-
- - setPostJobGracePeriodMs(e.target.value)} - /> -
-
-
Date: Mon, 16 Feb 2026 15:20:18 +0000 Subject: [PATCH 2/3] refactor: migrate CLI commands from Trello-specific to PM-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename src/cli/trello/ to src/cli/pm/ and update all 7 CLI commands to use PM-agnostic gadgets from src/gadgets/pm/core/ instead of Trello-specific ones: - read-card → read-work-item (uses readWorkItem) - post-comment → post-comment (uses postComment) - update-card → update-work-item (uses updateWorkItem) - create-card → create-work-item (uses createWorkItem) - list-cards → list-work-items (uses listWorkItems) - add-checklist → add-checklist (uses addChecklist) - update-checklist-item → update-checklist-item (uses updateChecklistItem) Also: - Add JIRA credential scoping and PM provider context to CredentialScopedCommand - Update Claude Code backend guidance text for PM-agnostic terminology - Update adapter CLI command strings from cascade-tools trello to cascade-tools pm - Update CLAUDE.md CLI binary documentation - Update claude-code backend tests for new tool names Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- src/backends/claude-code/index.ts | 2 +- src/cli/base.ts | 21 +++++++++++++ src/cli/{trello => pm}/add-checklist.ts | 8 ++--- src/cli/pm/create-work-item.ts | 26 ++++++++++++++++ src/cli/pm/list-work-items.ts | 17 +++++++++++ src/cli/{trello => pm}/post-comment.ts | 8 ++--- src/cli/pm/read-work-item.ts | 26 ++++++++++++++++ .../{trello => pm}/update-checklist-item.ts | 10 +++---- src/cli/pm/update-work-item.ts | 30 +++++++++++++++++++ src/cli/trello/create-card.ts | 26 ---------------- src/cli/trello/list-cards.ts | 17 ----------- src/cli/trello/read-card.ts | 26 ---------------- src/cli/trello/update-card.ts | 30 ------------------- tests/unit/backends/claude-code.test.ts | 28 ++++++++--------- 15 files changed, 149 insertions(+), 128 deletions(-) rename src/cli/{trello => pm}/add-checklist.ts (79%) create mode 100644 src/cli/pm/create-work-item.ts create mode 100644 src/cli/pm/list-work-items.ts rename src/cli/{trello => pm}/post-comment.ts (63%) create mode 100644 src/cli/pm/read-work-item.ts rename src/cli/{trello => pm}/update-checklist-item.ts (75%) create mode 100644 src/cli/pm/update-work-item.ts delete mode 100644 src/cli/trello/create-card.ts delete mode 100644 src/cli/trello/list-cards.ts delete mode 100644 src/cli/trello/read-card.ts delete mode 100644 src/cli/trello/update-card.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3081391c..5be41f2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -340,7 +340,7 @@ src/cli/dashboard/ └── webhooks/ # 3 commands ``` -The `cascade` binary is separate from `cascade-tools` (which is for agents). The `cascade-tools` binary uses a custom oclif config in `bin/cascade-tools.js` to discover only agent tool commands (`dist/cli/trello/`, `dist/cli/github/`, `dist/cli/session/`), while `cascade` discovers only dashboard commands (`dist/cli/dashboard/`). +The `cascade` binary is separate from `cascade-tools` (which is for agents). The `cascade-tools` binary uses a custom oclif config in `bin/cascade-tools.js` to discover only agent tool commands (`dist/cli/pm/`, `dist/cli/github/`, `dist/cli/session/`), while `cascade` discovers only dashboard commands (`dist/cli/dashboard/`). ## Adding New Triggers diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index a4d8521f..8d62b5b9 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -28,7 +28,7 @@ export function buildToolGuidance(tools: ToolManifest[]): string { guidance += 'Use the Bash tool to invoke these CASCADE-specific commands.\n'; guidance += 'All commands output JSON. Parse the output to extract results.\n\n'; guidance += - '**CRITICAL**: You MUST use these cascade-tools commands for all Trello, GitHub, and session operations. ' + + '**CRITICAL**: You MUST use these cascade-tools commands for all PM (Trello/JIRA), GitHub, and session operations. ' + 'Do NOT use `gh` CLI or other tools directly — cascade-tools handle authentication, push, and ' + 'state tracking that raw CLI tools do not. For example, `cascade-tools github create-pr` pushes ' + 'the branch AND creates the PR atomically, while `gh pr create` does NOT push and will fail.\n\n'; diff --git a/src/cli/base.ts b/src/cli/base.ts index 53c38e14..e84c91f2 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -1,6 +1,9 @@ import { Command } from '@oclif/core'; import { withGitHubToken } from '../github/client.js'; +import { withJiraCredentials } from '../jira/client.js'; +import { createPMProvider, withPMProvider } from '../pm/index.js'; import { withTrelloCredentials } from '../trello/client.js'; +import type { ProjectConfig } from '../types/index.js'; export abstract class CredentialScopedCommand extends Command { /** Subclasses implement this instead of run() */ @@ -10,6 +13,9 @@ export abstract class CredentialScopedCommand extends Command { const githubToken = process.env.GITHUB_TOKEN; const trelloApiKey = process.env.TRELLO_API_KEY; const trelloToken = process.env.TRELLO_TOKEN; + const jiraEmail = process.env.JIRA_EMAIL; + const jiraApiToken = process.env.JIRA_API_TOKEN; + const jiraBaseUrl = process.env.JIRA_BASE_URL; let fn: () => Promise = () => this.execute(); @@ -21,6 +27,21 @@ export abstract class CredentialScopedCommand extends Command { const prev = fn; fn = () => withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, prev); } + if (jiraEmail && jiraApiToken && jiraBaseUrl) { + const prev = fn; + fn = () => + withJiraCredentials( + { email: jiraEmail, apiToken: jiraApiToken, baseUrl: jiraBaseUrl }, + prev, + ); + } + + // Establish PM provider scope — infer type from available credentials + const pmType = jiraEmail && jiraApiToken && jiraBaseUrl ? 'jira' : 'trello'; + const pmProject = { pm: { type: pmType } } as ProjectConfig; + const pmProvider = createPMProvider(pmProject); + const prev = fn; + fn = () => withPMProvider(pmProvider, prev); await fn(); } diff --git a/src/cli/trello/add-checklist.ts b/src/cli/pm/add-checklist.ts similarity index 79% rename from src/cli/trello/add-checklist.ts rename to src/cli/pm/add-checklist.ts index 01341c6f..2dbfe743 100644 --- a/src/cli/trello/add-checklist.ts +++ b/src/cli/pm/add-checklist.ts @@ -1,12 +1,12 @@ import { Args, Flags } from '@oclif/core'; -import { addChecklist } from '../../gadgets/trello/core/addChecklist.js'; +import { addChecklist } from '../../gadgets/pm/core/addChecklist.js'; import { CredentialScopedCommand } from '../base.js'; export default class AddChecklist extends CredentialScopedCommand { - static override description = 'Add a checklist with items to a Trello card.'; + static override description = 'Add a checklist with items to a work item.'; static override args = { - cardId: Args.string({ description: 'The Trello card ID', required: true }), + workItemId: Args.string({ description: 'The work item ID', required: true }), }; static override flags = { @@ -21,7 +21,7 @@ export default class AddChecklist extends CredentialScopedCommand { async execute(): Promise { const { args, flags } = await this.parse(AddChecklist); const result = await addChecklist({ - cardId: args.cardId, + workItemId: args.workItemId, checklistName: flags.name, items: flags.items, }); diff --git a/src/cli/pm/create-work-item.ts b/src/cli/pm/create-work-item.ts new file mode 100644 index 00000000..d2390911 --- /dev/null +++ b/src/cli/pm/create-work-item.ts @@ -0,0 +1,26 @@ +import { Args, Flags } from '@oclif/core'; +import { createWorkItem } from '../../gadgets/pm/core/createWorkItem.js'; +import { CredentialScopedCommand } from '../base.js'; + +export default class CreateWorkItem extends CredentialScopedCommand { + static override description = 'Create a new work item in a container (list/project).'; + + static override args = { + containerId: Args.string({ description: 'The container ID (list or project)', required: true }), + }; + + static override flags = { + title: Flags.string({ description: 'Work item title', required: true }), + description: Flags.string({ description: 'Work item description (markdown supported)' }), + }; + + async execute(): Promise { + const { args, flags } = await this.parse(CreateWorkItem); + const result = await createWorkItem({ + containerId: args.containerId, + title: flags.title, + description: flags.description, + }); + this.log(JSON.stringify({ success: true, data: result })); + } +} diff --git a/src/cli/pm/list-work-items.ts b/src/cli/pm/list-work-items.ts new file mode 100644 index 00000000..65b7ce40 --- /dev/null +++ b/src/cli/pm/list-work-items.ts @@ -0,0 +1,17 @@ +import { Args } from '@oclif/core'; +import { listWorkItems } from '../../gadgets/pm/core/listWorkItems.js'; +import { CredentialScopedCommand } from '../base.js'; + +export default class ListWorkItems extends CredentialScopedCommand { + static override description = 'List all work items in a container (list/project).'; + + static override args = { + containerId: Args.string({ description: 'The container ID (list or project)', required: true }), + }; + + async execute(): Promise { + const { args } = await this.parse(ListWorkItems); + const result = await listWorkItems(args.containerId); + this.log(JSON.stringify({ success: true, data: result })); + } +} diff --git a/src/cli/trello/post-comment.ts b/src/cli/pm/post-comment.ts similarity index 63% rename from src/cli/trello/post-comment.ts rename to src/cli/pm/post-comment.ts index baff89a0..757e9a4f 100644 --- a/src/cli/trello/post-comment.ts +++ b/src/cli/pm/post-comment.ts @@ -1,12 +1,12 @@ import { Args, Flags } from '@oclif/core'; -import { postComment } from '../../gadgets/trello/core/postComment.js'; +import { postComment } from '../../gadgets/pm/core/postComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class PostComment extends CredentialScopedCommand { - static override description = 'Post a comment to a Trello card.'; + static override description = 'Post a comment to a work item.'; static override args = { - cardId: Args.string({ description: 'The Trello card ID', required: true }), + workItemId: Args.string({ description: 'The work item ID', required: true }), }; static override flags = { @@ -15,7 +15,7 @@ export default class PostComment extends CredentialScopedCommand { async execute(): Promise { const { args, flags } = await this.parse(PostComment); - const result = await postComment(args.cardId, flags.text); + const result = await postComment(args.workItemId, flags.text); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/read-work-item.ts b/src/cli/pm/read-work-item.ts new file mode 100644 index 00000000..47503ec1 --- /dev/null +++ b/src/cli/pm/read-work-item.ts @@ -0,0 +1,26 @@ +import { Args, Flags } from '@oclif/core'; +import { readWorkItem } from '../../gadgets/pm/core/readWorkItem.js'; +import { CredentialScopedCommand } from '../base.js'; + +export default class ReadWorkItem extends CredentialScopedCommand { + static override description = + 'Read a work item with its title, description, comments, checklists, and attachments.'; + + static override args = { + workItemId: Args.string({ description: 'The work item ID', required: true }), + }; + + static override flags = { + 'include-comments': Flags.boolean({ + description: 'Include comments in the response', + default: true, + allowNo: true, + }), + }; + + async execute(): Promise { + const { args, flags } = await this.parse(ReadWorkItem); + const result = await readWorkItem(args.workItemId, flags['include-comments']); + this.log(JSON.stringify({ success: true, data: result })); + } +} diff --git a/src/cli/trello/update-checklist-item.ts b/src/cli/pm/update-checklist-item.ts similarity index 75% rename from src/cli/trello/update-checklist-item.ts rename to src/cli/pm/update-checklist-item.ts index 3a792b77..d49901cb 100644 --- a/src/cli/trello/update-checklist-item.ts +++ b/src/cli/pm/update-checklist-item.ts @@ -1,12 +1,12 @@ import { Args, Flags } from '@oclif/core'; -import { updateChecklistItem } from '../../gadgets/trello/core/updateChecklistItem.js'; +import { updateChecklistItem } from '../../gadgets/pm/core/updateChecklistItem.js'; import { CredentialScopedCommand } from '../base.js'; export default class UpdateChecklistItem extends CredentialScopedCommand { - static override description = 'Update a checklist item state on a Trello card.'; + static override description = 'Update a checklist item state on a work item.'; static override args = { - cardId: Args.string({ description: 'The Trello card ID', required: true }), + workItemId: Args.string({ description: 'The work item ID', required: true }), }; static override flags = { @@ -21,9 +21,9 @@ export default class UpdateChecklistItem extends CredentialScopedCommand { async execute(): Promise { const { args, flags } = await this.parse(UpdateChecklistItem); const result = await updateChecklistItem( - args.cardId, + args.workItemId, flags['check-item-id'], - flags.state as 'complete' | 'incomplete', + flags.state === 'complete', ); this.log(JSON.stringify({ success: true, data: result })); } diff --git a/src/cli/pm/update-work-item.ts b/src/cli/pm/update-work-item.ts new file mode 100644 index 00000000..4bfc5547 --- /dev/null +++ b/src/cli/pm/update-work-item.ts @@ -0,0 +1,30 @@ +import { Args, Flags } from '@oclif/core'; +import { updateWorkItem } from '../../gadgets/pm/core/updateWorkItem.js'; +import { CredentialScopedCommand } from '../base.js'; + +export default class UpdateWorkItem extends CredentialScopedCommand { + static override description = 'Update a work item title, description, or labels.'; + + static override args = { + workItemId: Args.string({ description: 'The work item ID', required: true }), + }; + + static override flags = { + title: Flags.string({ description: 'New title' }), + description: Flags.string({ description: 'New description (markdown supported)' }), + 'add-label-ids': Flags.string({ + description: 'Comma-separated label IDs to add', + }), + }; + + async execute(): Promise { + const { args, flags } = await this.parse(UpdateWorkItem); + const result = await updateWorkItem({ + workItemId: args.workItemId, + title: flags.title, + description: flags.description, + addLabelIds: flags['add-label-ids']?.split(','), + }); + this.log(JSON.stringify({ success: true, data: result })); + } +} diff --git a/src/cli/trello/create-card.ts b/src/cli/trello/create-card.ts deleted file mode 100644 index 69f6317e..00000000 --- a/src/cli/trello/create-card.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Args, Flags } from '@oclif/core'; -import { createCard } from '../../gadgets/trello/core/createCard.js'; -import { CredentialScopedCommand } from '../base.js'; - -export default class CreateCard extends CredentialScopedCommand { - static override description = 'Create a new Trello card in a specific list.'; - - static override args = { - listId: Args.string({ description: 'The Trello list ID', required: true }), - }; - - static override flags = { - title: Flags.string({ description: 'Card title', required: true }), - description: Flags.string({ description: 'Card description (markdown supported)' }), - }; - - async execute(): Promise { - const { args, flags } = await this.parse(CreateCard); - const result = await createCard({ - listId: args.listId, - title: flags.title, - description: flags.description, - }); - this.log(JSON.stringify({ success: true, data: result })); - } -} diff --git a/src/cli/trello/list-cards.ts b/src/cli/trello/list-cards.ts deleted file mode 100644 index f2591d91..00000000 --- a/src/cli/trello/list-cards.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Args } from '@oclif/core'; -import { listCards } from '../../gadgets/trello/core/listCards.js'; -import { CredentialScopedCommand } from '../base.js'; - -export default class ListCards extends CredentialScopedCommand { - static override description = 'List all cards on a Trello list.'; - - static override args = { - listId: Args.string({ description: 'The Trello list ID', required: true }), - }; - - async execute(): Promise { - const { args } = await this.parse(ListCards); - const result = await listCards(args.listId); - this.log(JSON.stringify({ success: true, data: result })); - } -} diff --git a/src/cli/trello/read-card.ts b/src/cli/trello/read-card.ts deleted file mode 100644 index f6c661a9..00000000 --- a/src/cli/trello/read-card.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Args, Flags } from '@oclif/core'; -import { readCard } from '../../gadgets/trello/core/readCard.js'; -import { CredentialScopedCommand } from '../base.js'; - -export default class ReadCard extends CredentialScopedCommand { - static override description = - 'Read a Trello card with its title, description, comments, checklists, and attachments.'; - - static override args = { - cardId: Args.string({ description: 'The Trello card ID', required: true }), - }; - - static override flags = { - 'include-comments': Flags.boolean({ - description: 'Include card comments in the response', - default: true, - allowNo: true, - }), - }; - - async execute(): Promise { - const { args, flags } = await this.parse(ReadCard); - const result = await readCard(args.cardId, flags['include-comments']); - this.log(JSON.stringify({ success: true, data: result })); - } -} diff --git a/src/cli/trello/update-card.ts b/src/cli/trello/update-card.ts deleted file mode 100644 index e2596519..00000000 --- a/src/cli/trello/update-card.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Args, Flags } from '@oclif/core'; -import { updateCard } from '../../gadgets/trello/core/updateCard.js'; -import { CredentialScopedCommand } from '../base.js'; - -export default class UpdateCard extends CredentialScopedCommand { - static override description = 'Update a Trello card title, description, or labels.'; - - static override args = { - cardId: Args.string({ description: 'The Trello card ID', required: true }), - }; - - static override flags = { - title: Flags.string({ description: 'New card title' }), - description: Flags.string({ description: 'New card description (markdown supported)' }), - 'add-label-ids': Flags.string({ - description: 'Comma-separated label IDs to add', - }), - }; - - async execute(): Promise { - const { args, flags } = await this.parse(UpdateCard); - const result = await updateCard({ - cardId: args.cardId, - title: flags.title, - description: flags.description, - addLabelIds: flags['add-label-ids']?.split(','), - }); - this.log(JSON.stringify({ success: true, data: result })); - } -} diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index 3fb98c61..d9e048b7 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -30,11 +30,11 @@ function unsetEnv(key: string) { const sampleTools: ToolManifest[] = [ { - name: 'ReadTrelloCard', - description: 'Read a Trello card.', - cliCommand: 'cascade-tools trello read-card', + name: 'ReadWorkItem', + description: 'Read a work item.', + cliCommand: 'cascade-tools pm read-work-item', parameters: { - cardId: { type: 'string', required: true }, + workItemId: { type: 'string', required: true }, includeComments: { type: 'boolean' }, }, }, @@ -83,9 +83,9 @@ describe('buildToolGuidance', () => { it('generates markdown reference for tools', () => { const guidance = buildToolGuidance(sampleTools); expect(guidance).toContain('## CASCADE Tools'); - expect(guidance).toContain('### ReadTrelloCard'); - expect(guidance).toContain('cascade-tools trello read-card'); - expect(guidance).toContain('--cardId '); + expect(guidance).toContain('### ReadWorkItem'); + expect(guidance).toContain('cascade-tools pm read-work-item'); + expect(guidance).toContain('--workItemId '); expect(guidance).toContain('[--includeComments ]'); expect(guidance).toContain('### Finish'); expect(guidance).toContain('--comment '); @@ -94,8 +94,8 @@ describe('buildToolGuidance', () => { it('marks required params without brackets', () => { const guidance = buildToolGuidance(sampleTools); // Required param has no brackets - expect(guidance).toContain(' --cardId '); - expect(guidance).not.toContain('[--cardId'); + expect(guidance).toContain(' --workItemId '); + expect(guidance).not.toContain('[--workItemId'); }); it('marks optional params with brackets', () => { @@ -112,15 +112,15 @@ describe('buildTaskPrompt', () => { it('appends context injections', () => { const prompt = buildTaskPrompt('Do the thing.', [ { - toolName: 'ReadTrelloCard', - params: { cardId: 'abc' }, + toolName: 'ReadWorkItem', + params: { workItemId: 'abc' }, result: '{"title":"My card"}', - description: 'Pre-fetched Trello card data', + description: 'Pre-fetched work item data', }, ]); expect(prompt).toContain('## Pre-loaded Context'); - expect(prompt).toContain('### Pre-fetched Trello card data (ReadTrelloCard)'); - expect(prompt).toContain('"cardId":"abc"'); + expect(prompt).toContain('### Pre-fetched work item data (ReadWorkItem)'); + expect(prompt).toContain('"workItemId":"abc"'); expect(prompt).toContain('{"title":"My card"}'); }); }); From 1db54304572e4981e90d753e7d08183c453a4631 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Feb 2026 15:24:21 +0000 Subject: [PATCH 3/3] fix: update remaining tests for PM-agnostic provider migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update test mocks from direct Trello client calls to PM provider interface: - prompts.test.ts: ReadTrelloCard/CreateTrelloCard → ReadWorkItem/CreateWorkItem - fetchImplementationSteps.test.ts: mock getPMProvider instead of trelloClient - progress.test.ts: mock getPMProviderOrNull instead of trelloClient.addComment - budget.test.ts: mock getPMProvider.getCustomFieldNumber instead of trelloClient - debug-runner.test.ts: mock getPMProvider.addComment instead of trelloClient Co-Authored-By: Claude Opus 4.6 --- .../agents/fetchImplementationSteps.test.ts | 66 +++++++++---------- tests/unit/agents/prompts.test.ts | 6 +- tests/unit/backends/progress.test.ts | 32 +++++---- tests/unit/triggers/budget.test.ts | 30 +++------ tests/unit/triggers/debug-runner.test.ts | 15 +++-- 5 files changed, 70 insertions(+), 79 deletions(-) diff --git a/tests/unit/agents/fetchImplementationSteps.test.ts b/tests/unit/agents/fetchImplementationSteps.test.ts index 2d95edb5..257ce332 100644 --- a/tests/unit/agents/fetchImplementationSteps.test.ts +++ b/tests/unit/agents/fetchImplementationSteps.test.ts @@ -1,31 +1,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/trello/client.js', () => ({ - trelloClient: { - getCardChecklists: vi.fn(), - }, +vi.mock('../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(), })); import { fetchImplementationSteps } from '../../../src/agents/base.js'; -import { trelloClient } from '../../../src/trello/client.js'; +import { getPMProvider } from '../../../src/pm/index.js'; -const mockGetCardChecklists = vi.mocked(trelloClient.getCardChecklists); +const mockPMProvider = { + getChecklists: vi.fn(), +}; describe('fetchImplementationSteps', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as any); }); it('extracts incomplete items from Implementation Steps checklist', async () => { - mockGetCardChecklists.mockResolvedValue([ + mockPMProvider.getChecklists.mockResolvedValue([ { id: 'cl1', name: '📋 Implementation Steps', - idCard: 'card1', - checkItems: [ - { id: 'ci1', name: 'Add helper function', state: 'incomplete' }, - { id: 'ci2', name: 'Update prompt template', state: 'incomplete' }, - { id: 'ci3', name: 'Write tests', state: 'incomplete' }, + items: [ + { id: 'ci1', name: 'Add helper function', complete: false }, + { id: 'ci2', name: 'Update prompt template', complete: false }, + { id: 'ci3', name: 'Write tests', complete: false }, ], }, ]); @@ -33,18 +33,17 @@ describe('fetchImplementationSteps', () => { const result = await fetchImplementationSteps('card1'); expect(result).toEqual(['Add helper function', 'Update prompt template', 'Write tests']); - expect(mockGetCardChecklists).toHaveBeenCalledWith('card1'); + expect(mockPMProvider.getChecklists).toHaveBeenCalledWith('card1'); }); it('filters out already-complete items', async () => { - mockGetCardChecklists.mockResolvedValue([ + mockPMProvider.getChecklists.mockResolvedValue([ { id: 'cl1', name: '📋 Implementation Steps', - idCard: 'card1', - checkItems: [ - { id: 'ci1', name: 'Already done step', state: 'complete' }, - { id: 'ci2', name: 'Remaining step', state: 'incomplete' }, + items: [ + { id: 'ci1', name: 'Already done step', complete: true }, + { id: 'ci2', name: 'Remaining step', complete: false }, ], }, ]); @@ -55,14 +54,13 @@ describe('fetchImplementationSteps', () => { }); it('returns undefined when all items are complete', async () => { - mockGetCardChecklists.mockResolvedValue([ + mockPMProvider.getChecklists.mockResolvedValue([ { id: 'cl1', name: '📋 Implementation Steps', - idCard: 'card1', - checkItems: [ - { id: 'ci1', name: 'Done step 1', state: 'complete' }, - { id: 'ci2', name: 'Done step 2', state: 'complete' }, + items: [ + { id: 'ci1', name: 'Done step 1', complete: true }, + { id: 'ci2', name: 'Done step 2', complete: true }, ], }, ]); @@ -73,12 +71,11 @@ describe('fetchImplementationSteps', () => { }); it('returns undefined when no Implementation Steps checklist exists', async () => { - mockGetCardChecklists.mockResolvedValue([ + mockPMProvider.getChecklists.mockResolvedValue([ { id: 'cl1', name: '✅ Acceptance Criteria', - idCard: 'card1', - checkItems: [{ id: 'ci1', name: 'Some criterion', state: 'incomplete' }], + items: [{ id: 'ci1', name: 'Some criterion', complete: false }], }, ]); @@ -88,12 +85,11 @@ describe('fetchImplementationSteps', () => { }); it('returns undefined when checklist has no items', async () => { - mockGetCardChecklists.mockResolvedValue([ + mockPMProvider.getChecklists.mockResolvedValue([ { id: 'cl1', name: '📋 Implementation Steps', - idCard: 'card1', - checkItems: [], + items: [], }, ]); @@ -103,7 +99,7 @@ describe('fetchImplementationSteps', () => { }); it('returns undefined when card has no checklists', async () => { - mockGetCardChecklists.mockResolvedValue([]); + mockPMProvider.getChecklists.mockResolvedValue([]); const result = await fetchImplementationSteps('card1'); @@ -111,7 +107,7 @@ describe('fetchImplementationSteps', () => { }); it('returns undefined when API call fails', async () => { - mockGetCardChecklists.mockRejectedValue(new Error('API error')); + mockPMProvider.getChecklists.mockRejectedValue(new Error('API error')); const result = await fetchImplementationSteps('card1'); @@ -119,18 +115,16 @@ describe('fetchImplementationSteps', () => { }); it('matches checklist by substring (handles emoji prefix)', async () => { - mockGetCardChecklists.mockResolvedValue([ + mockPMProvider.getChecklists.mockResolvedValue([ { id: 'cl1', name: 'Some other checklist', - idCard: 'card1', - checkItems: [{ id: 'ci1', name: 'Ignored', state: 'incomplete' }], + items: [{ id: 'ci1', name: 'Ignored', complete: false }], }, { id: 'cl2', name: '📋 Implementation Steps (Phase 1)', - idCard: 'card1', - checkItems: [{ id: 'ci2', name: 'Phase 1 step', state: 'incomplete' }], + items: [{ id: 'ci2', name: 'Phase 1 step', complete: false }], }, ]); diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 72a3c9ab..195156b9 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -43,14 +43,14 @@ describe('getSystemPrompt', () => { describe('system prompts content', () => { it('briefing prompt includes key instructions', () => { const prompt = getSystemPrompt('briefing'); - expect(prompt).toContain('ReadTrelloCard'); - expect(prompt).toContain('CreateTrelloCard'); + expect(prompt).toContain('ReadWorkItem'); + expect(prompt).toContain('CreateWorkItem'); expect(prompt).toContain('INVEST'); }); it('planning prompt includes key instructions', () => { const prompt = getSystemPrompt('planning'); - expect(prompt).toContain('ReadTrelloCard'); + expect(prompt).toContain('ReadWorkItem'); expect(prompt).toContain('step-by-step'); }); diff --git a/tests/unit/backends/progress.test.ts b/tests/unit/backends/progress.test.ts index 3e765932..62778a28 100644 --- a/tests/unit/backends/progress.test.ts +++ b/tests/unit/backends/progress.test.ts @@ -1,9 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/trello/client.js', () => ({ - trelloClient: { - addComment: vi.fn(), - }, +vi.mock('../../../src/pm/index.js', () => ({ + getPMProviderOrNull: vi.fn(), })); vi.mock('../../../src/github/client.js', () => ({ @@ -46,9 +44,10 @@ import { import { getSessionState } from '../../../src/gadgets/sessionState.js'; import { loadTodos } from '../../../src/gadgets/todo/storage.js'; import { githubClient } from '../../../src/github/client.js'; -import { trelloClient } from '../../../src/trello/client.js'; +import { getPMProviderOrNull } from '../../../src/pm/index.js'; -const mockTrello = vi.mocked(trelloClient); +const mockGetPMProvider = vi.mocked(getPMProviderOrNull); +const mockPMProvider = { addComment: vi.fn() }; const mockGithub = vi.mocked(githubClient); const mockGetStatusConfig = vi.mocked(getStatusUpdateConfig); const mockFormatStatus = vi.mocked(formatStatusMessage); @@ -62,6 +61,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); mockLoadTodos.mockReturnValue([]); + mockGetPMProvider.mockReturnValue(null); }); afterEach(() => { @@ -185,15 +185,16 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); + mockGetPMProvider.mockReturnValue(mockPMProvider as any); mockCallProgressModel.mockResolvedValue('**Progress**: All good'); - mockTrello.addComment.mockResolvedValue(undefined as never); + mockPMProvider.addComment.mockResolvedValue(undefined as never); monitor.start(); await vi.advanceTimersByTimeAsync(5 * 60 * 1000); monitor.stop(); expect(mockCallProgressModel).toHaveBeenCalled(); - expect(mockTrello.addComment).toHaveBeenCalledWith('card1', '**Progress**: All good'); + expect(mockPMProvider.addComment).toHaveBeenCalledWith('card1', '**Progress**: All good'); }); it('falls back to template when progress model fails', async () => { @@ -208,16 +209,17 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); + mockGetPMProvider.mockReturnValue(mockPMProvider as any); mockCallProgressModel.mockRejectedValue(new Error('Model error')); mockFormatStatus.mockReturnValue('Fallback progress'); - mockTrello.addComment.mockResolvedValue(undefined as never); + mockPMProvider.addComment.mockResolvedValue(undefined as never); monitor.start(); await vi.advanceTimersByTimeAsync(5 * 60 * 1000); monitor.stop(); expect(mockFormatStatus).toHaveBeenCalled(); - expect(mockTrello.addComment).toHaveBeenCalledWith('card1', 'Fallback progress'); + expect(mockPMProvider.addComment).toHaveBeenCalledWith('card1', 'Fallback progress'); }); it('syncs checklist for implementation agents', async () => { @@ -231,8 +233,9 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); + mockGetPMProvider.mockReturnValue(mockPMProvider as any); mockCallProgressModel.mockResolvedValue('Progress'); - mockTrello.addComment.mockResolvedValue(undefined as never); + mockPMProvider.addComment.mockResolvedValue(undefined as never); mockSyncChecklist.mockResolvedValue(); monitor.start(); @@ -253,8 +256,9 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); + mockGetPMProvider.mockReturnValue(mockPMProvider as any); mockCallProgressModel.mockResolvedValue('Progress'); - mockTrello.addComment.mockResolvedValue(undefined as never); + mockPMProvider.addComment.mockResolvedValue(undefined as never); monitor.start(); await vi.advanceTimersByTimeAsync(5 * 60 * 1000); @@ -333,8 +337,9 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); + mockGetPMProvider.mockReturnValue(mockPMProvider as any); mockCallProgressModel.mockResolvedValue('Progress'); - mockTrello.addComment.mockRejectedValue(new Error('API error')); + mockPMProvider.addComment.mockRejectedValue(new Error('API error')); monitor.start(); await vi.advanceTimersByTimeAsync(5 * 60 * 1000); @@ -348,6 +353,7 @@ describe('ProgressMonitor — tick behavior', () => { }); it('prevents concurrent ticks', async () => { + mockGetPMProvider.mockReturnValue(mockPMProvider as any); const monitor = new ProgressMonitor({ agentType: 'implementation', taskDescription: 'Test task', diff --git a/tests/unit/triggers/budget.test.ts b/tests/unit/triggers/budget.test.ts index 94b526a8..1d1383f4 100644 --- a/tests/unit/triggers/budget.test.ts +++ b/tests/unit/triggers/budget.test.ts @@ -1,16 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/trello/client.js', () => ({ - trelloClient: { - getCardCustomFieldItems: vi.fn(), - }, +vi.mock('../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(), })); -import { trelloClient } from '../../../src/trello/client.js'; +import { getPMProvider } from '../../../src/pm/index.js'; import { checkBudgetExceeded, resolveCardBudget } from '../../../src/triggers/shared/budget.js'; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; -const mockGetCustomFields = vi.mocked(trelloClient.getCardCustomFieldItems); +const mockPMProvider = { getCustomFieldNumber: vi.fn() }; +vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as any); const baseProject: ProjectConfig = { id: 'test', @@ -80,11 +79,10 @@ describe('checkBudgetExceeded', () => { }; const result = await checkBudgetExceeded('card1', project, baseConfig); expect(result).toBeNull(); - expect(mockGetCustomFields).not.toHaveBeenCalled(); }); it('returns not exceeded with full budget when no cost value yet', async () => { - mockGetCustomFields.mockResolvedValue([]); + mockPMProvider.getCustomFieldNumber.mockResolvedValue(0); const result = await checkBudgetExceeded('card1', baseProject, baseConfig); expect(result).toEqual({ exceeded: false, @@ -95,9 +93,7 @@ describe('checkBudgetExceeded', () => { }); it('returns not exceeded when under budget', async () => { - mockGetCustomFields.mockResolvedValue([ - { idCustomField: 'cf-cost-123', value: { number: '1.25' } }, - ]); + mockPMProvider.getCustomFieldNumber.mockResolvedValue(1.25); const result = await checkBudgetExceeded('card1', baseProject, baseConfig); expect(result).toEqual({ exceeded: false, @@ -108,9 +104,7 @@ describe('checkBudgetExceeded', () => { }); it('returns exceeded when cost equals budget', async () => { - mockGetCustomFields.mockResolvedValue([ - { idCustomField: 'cf-cost-123', value: { number: '5.00' } }, - ]); + mockPMProvider.getCustomFieldNumber.mockResolvedValue(5); const result = await checkBudgetExceeded('card1', baseProject, baseConfig); expect(result).toEqual({ exceeded: true, @@ -121,9 +115,7 @@ describe('checkBudgetExceeded', () => { }); it('returns exceeded when over budget', async () => { - mockGetCustomFields.mockResolvedValue([ - { idCustomField: 'cf-cost-123', value: { number: '6.00' } }, - ]); + mockPMProvider.getCustomFieldNumber.mockResolvedValue(6); const result = await checkBudgetExceeded('card1', baseProject, baseConfig); expect(result).toEqual({ exceeded: true, @@ -135,9 +127,7 @@ describe('checkBudgetExceeded', () => { it('uses project budget override', async () => { const project = { ...baseProject, cardBudgetUsd: 10.0 }; - mockGetCustomFields.mockResolvedValue([ - { idCustomField: 'cf-cost-123', value: { number: '5.00' } }, - ]); + mockPMProvider.getCustomFieldNumber.mockResolvedValue(5); const result = await checkBudgetExceeded('card1', project, baseConfig); expect(result).toEqual({ exceeded: false, diff --git a/tests/unit/triggers/debug-runner.test.ts b/tests/unit/triggers/debug-runner.test.ts index 625a37df..9425c1a7 100644 --- a/tests/unit/triggers/debug-runner.test.ts +++ b/tests/unit/triggers/debug-runner.test.ts @@ -12,10 +12,8 @@ vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ storeDebugAnalysis: vi.fn(), })); -vi.mock('../../../src/trello/client.js', () => ({ - trelloClient: { - addComment: vi.fn(), - }, +vi.mock('../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(), })); vi.mock('../../../src/utils/logging.js', () => ({ @@ -37,8 +35,10 @@ import { getRunLogs, storeDebugAnalysis, } from '../../../src/db/repositories/runsRepository.js'; -import { trelloClient } from '../../../src/trello/client.js'; +import { getPMProvider } from '../../../src/pm/index.js'; import { triggerDebugAnalysis } from '../../../src/triggers/shared/debug-runner.js'; + +const mockPMProvider = { addComment: vi.fn() }; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; const mockProject = { @@ -59,6 +59,7 @@ const mockConfig = {} as CascadeConfig; describe('triggerDebugAnalysis', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as any); }); it('returns early when run is not found', async () => { @@ -118,7 +119,7 @@ describe('triggerDebugAnalysis', () => { ); // Should post Trello comment - expect(trelloClient.addComment).toHaveBeenCalledWith( + expect(mockPMProvider.addComment).toHaveBeenCalledWith( 'card-1', expect.stringContaining('Debug Analysis'), ); @@ -171,7 +172,7 @@ describe('triggerDebugAnalysis', () => { await triggerDebugAnalysis('run-1', mockProject, mockConfig); - expect(trelloClient.addComment).not.toHaveBeenCalled(); + expect(mockPMProvider.addComment).not.toHaveBeenCalled(); }); it('writes LLM call files to temp dir', async () => {