diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 4901dd76..f1c361a2 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -72,6 +72,13 @@ jobs: cascade-migrator:dev \ ./node_modules/.bin/drizzle-kit migrate + - name: Run trigger config migration (dev) + run: | + docker run --rm \ + -e DATABASE_URL="${{ secrets.DEV_DATABASE_URL }}" \ + cascade-migrator:dev \ + npx tsx tools/migrate-triggers.ts + - name: Pull and restart cascade-router-dev run: | cd /opt/services diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ff064608..e1f1c608 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -65,6 +65,13 @@ jobs: cascade-migrator:latest \ ./node_modules/.bin/drizzle-kit migrate + - name: Run trigger config migration + run: | + docker run --rm \ + -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ + cascade-migrator:latest \ + npx tsx tools/migrate-triggers.ts + - name: Pull latest worker image run: docker pull ${{ env.WORKER_IMAGE }}:latest diff --git a/CLAUDE.md b/CLAUDE.md index 04459c88..0d744bdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,120 +210,90 @@ cascade projects integration-credential-set --category sms --role p **Outbound SMS**: Agents use the `SendSms` gadget. SMS credentials are scoped automatically during agent execution (mirrors email integration). -### Review Agent Trigger Modes +### Agent Trigger Configuration + +Triggers define which events activate which agents. Configuration is stored in the `agent_trigger_configs` table and managed via the unified `trigger-set` command. -The review agent supports three independent trigger modes via the `reviewTrigger` config in the SCM integration triggers. **All modes default to `false`** — existing behavior is preserved via a legacy fallback. +#### Trigger Format -| Mode | Description | -|------|-------------| -| `ownPrsOnly` | Trigger review when CI passes on PRs authored by the **implementer** persona | -| `externalPrs` | Trigger review when CI passes on PRs authored by **anyone** (including external contributors) | -| `onReviewRequested` | Trigger review when a CASCADE persona is **explicitly requested** as reviewer | +Triggers use a category-prefixed event format: `{category}:{event-name}` +- PM triggers: `pm:card-moved`, `pm:issue-transitioned`, `pm:label-added` +- SCM triggers: `scm:check-suite-success`, `scm:check-suite-failure`, `scm:pr-review-submitted` +- Email triggers: `email:received` +- SMS triggers: `sms:received` -#### Setting via CLI +#### CLI Commands ```bash -# Enable review for implementer PRs only (most common) -cascade projects review-trigger-set --own-prs-only +# Discover available triggers for an agent +cascade projects trigger-discover --agent review +cascade projects trigger-discover --agent implementation -# Enable review for external contributor PRs -cascade projects review-trigger-set --external-prs +# List configured triggers for a project +cascade projects trigger-list +cascade projects trigger-list --agent review -# Enable both CI-triggered modes -cascade projects review-trigger-set --own-prs-only --external-prs +# Configure a trigger (unified command) +cascade projects trigger-set --agent review --event scm:check-suite-success --enable +cascade projects trigger-set --agent review --event scm:check-suite-success --disable +cascade projects trigger-set --agent review --event scm:check-suite-success --params '{"authorMode":"own"}' -# Enable review when explicitly requested -cascade projects review-trigger-set --on-review-requested +# Enable implementation trigger for card moved to Todo +cascade projects trigger-set --agent implementation --event pm:card-moved --enable -# Disable a mode -cascade projects review-trigger-set --no-own-prs-only +# Disable splitting trigger for JIRA issue transitions +cascade projects trigger-set --agent splitting --event pm:issue-transitioned --disable ``` #### Setting via Dashboard -In the **Agent Configs** tab, the `review` agent section shows three toggles under the SCM integration: -- **Own PRs Only** — CI-triggered review for implementer-authored PRs -- **External PRs** — CI-triggered review for all other PR authors -- **On Review Requested** — review triggered when a persona is explicitly requested - -#### Direct JSON Config +In the **Agent Configs** tab, each agent shows toggles for its supported triggers. Triggers with parameters (like `authorMode` for review) show additional input fields when enabled. -```bash -cascade projects integration-set \ - --category scm --provider github --config '{}' \ - --triggers '{"reviewTrigger":{"ownPrsOnly":true,"externalPrs":false,"onReviewRequested":true}}' -``` - -#### Backward Compatibility - -When `reviewTrigger` is absent, the system falls back to legacy booleans: -- `checkSuiteSuccess` → `ownPrsOnly` (default `true` for existing projects) -- `reviewRequested` → `onReviewRequested` (default `false`) -- `externalPrs` always `false` in legacy mode (no legacy equivalent) - -### PM Agent Trigger Modes +#### Trigger Migration -Splitting, planning, and implementation agents each have independent toggles for their PM triggers. **All modes default to `true`** for backward compatibility. +When merging to `dev` or `main`, legacy trigger configs from `project_integrations.triggers` are automatically migrated to the new `agent_trigger_configs` table. The migration is idempotent and preserves existing configurations. -#### Trello card-moved triggers - -| Flag | Description | -|------|-------------| -| `cardMovedToSplitting` | Trigger splitting agent when a card is moved to the Splitting list | -| `cardMovedToPlanning` | Trigger planning agent when a card is moved to the Planning list | -| `cardMovedToTodo` | Trigger implementation agent when a card is moved to the Todo list | +### Review Agent Trigger Modes -#### JIRA issue-transitioned triggers (per-agent) +The review agent supports multiple trigger events: -The `issueTransitioned` field supports both a legacy boolean (applies to all agents) and a nested per-agent object: +| Event | Description | +|-------|-------------| +| `scm:check-suite-success` | Trigger review when CI passes (use `authorMode` parameter: `own` or `external`) | +| `scm:review-requested` | Trigger review when a CASCADE persona is explicitly requested as reviewer | +| `scm:pr-opened` | Trigger review when a PR is opened | -| Agent | Field | Description | -|-------|-------|-------------| -| splitting | `issueTransitioned.splitting` | Trigger splitting when issue transitions to Splitting status | -| planning | `issueTransitioned.planning` | Trigger planning when issue transitions to Planning status | -| implementation | `issueTransitioned.implementation` | Trigger implementation when issue transitions to Todo status | +```bash +# Enable review for implementer PRs only (most common) +cascade projects trigger-set --agent review --event scm:check-suite-success --enable --params '{"authorMode":"own"}' -#### Setting via CLI +# Enable review for external contributor PRs +cascade projects trigger-set --agent review --event scm:check-suite-success --enable --params '{"authorMode":"external"}' -```bash -# Disable Trello card-moved trigger for splitting agent -cascade projects pm-trigger-set --no-card-moved-to-splitting - -# Disable JIRA issue-transitioned for implementation agent only -cascade projects pm-trigger-set --no-issue-transitioned-implementation - -# Enable JIRA triggers for splitting and planning, disable for implementation -cascade projects pm-trigger-set \ - --issue-transitioned-splitting \ - --issue-transitioned-planning \ - --no-issue-transitioned-implementation - -# Disable all Trello card-moved triggers -cascade projects pm-trigger-set \ - --no-card-moved-to-splitting \ - --no-card-moved-to-planning \ - --no-card-moved-to-todo +# Enable review when explicitly requested +cascade projects trigger-set --agent review --event scm:review-requested --enable ``` -#### Setting via Dashboard +### PM Agent Trigger Modes -In the **Agent Configs** tab, the splitting, planning, and implementation agent sections each show: -- **Card moved to [list]** — Trello card-moved toggle (Trello projects only) -- **Issue Transitioned** — JIRA per-agent transition toggle (JIRA projects only) -- **Ready to Process label** — label-based trigger toggle +Splitting, planning, and implementation agents each support PM triggers: -#### Direct JSON Config +| Event | Providers | Description | +|-------|-----------|-------------| +| `pm:card-moved` | Trello | Trigger when card moved to agent's target list | +| `pm:issue-transitioned` | JIRA | Trigger when issue transitions to agent's target status | +| `pm:label-added` | All | Trigger when Ready to Process label is added | ```bash -# Disable JIRA issue-transitioned for implementation only -cascade projects integration-set \ - --category pm --provider jira --config '{"projectKey":"PROJ","statuses":{...}}' \ - --triggers '{"issueTransitioned":{"splitting":true,"planning":true,"implementation":false}}' -``` +# Enable card-moved trigger for implementation +cascade projects trigger-set --agent implementation --event pm:card-moved --enable -#### Backward Compatibility +# Disable JIRA issue-transitioned for planning +cascade projects trigger-set --agent planning --event pm:issue-transitioned --disable -The legacy `issueTransitioned: true/false` boolean is still supported — it applies to all agents uniformly. +# Enable label-added trigger for splitting +cascade projects trigger-set --agent splitting --event pm:label-added --enable +``` ## Claude Code Backend @@ -483,6 +453,9 @@ cascade projects integrations cascade projects integration-set --category pm --provider trello --config '{"boardId":"..."}' cascade projects integration-credential-set --category scm --role implementer_token --credential-id 5 cascade projects integration-credential-rm --category scm --role implementer_token +cascade projects trigger-discover --agent +cascade projects trigger-list [--agent ] +cascade projects trigger-set --agent --event [--enable|--disable] [--params JSON] # Credentials cascade credentials list @@ -527,7 +500,7 @@ src/cli/dashboard/ ├── logout.ts ├── whoami.ts ├── runs/ # 6 commands -├── projects/ # 8 commands +├── projects/ # 13 commands ├── credentials/ # 4 commands ├── defaults/ # 2 commands ├── org/ # 2 commands diff --git a/package.json b/package.json index ed5aed1f..89c7cb7a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "credentials:encrypt": "node --env-file=.env --import tsx tools/migrate-credentials-encrypt.ts", "credentials:decrypt": "node --env-file=.env --import tsx tools/migrate-credentials-decrypt.ts", "credentials:rotate-key": "node --env-file=.env --import tsx tools/rotate-credential-key.ts", - "tool:test-email": "npx tsx tools/test-email-integration.ts" + "tool:test-email": "npx tsx tools/test-email-integration.ts", + "tool:migrate-triggers": "node --env-file=.env --import tsx tools/migrate-triggers.ts" }, "keywords": [ "trello", diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index 7bb84483..62bd733b 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -36,6 +36,8 @@ triggers: label: Target List options: [todo] defaultValue: todo + # contextPipeline can be specified per-trigger to override strategies.contextPipeline + # Currently using strategy default: [directoryListing, contextFiles, squint, workItem] - event: pm:issue-transitioned label: Issue Transitioned description: Trigger when issue transitions to Todo status diff --git a/src/agents/definitions/profiles.ts b/src/agents/definitions/profiles.ts index 90c1eeeb..0b2c0c44 100644 --- a/src/agents/definitions/profiles.ts +++ b/src/agents/definitions/profiles.ts @@ -21,7 +21,12 @@ import { import { buildGadgetsForAgent } from '../shared/gadgets.js'; import type { FetchContextParams, PreExecuteParams } from './contextSteps.js'; import { resolveAgentDefinition } from './loader.js'; -import type { AgentCapabilities, AgentDefinition } from './schema.js'; +import type { + AgentCapabilities, + AgentDefinition, + ContextStepName, + SupportedTrigger, +} from './schema.js'; import { CONTEXT_STEP_REGISTRY, PRE_EXECUTE_REGISTRY } from './strategies.js'; // Re-export for backward compatibility @@ -81,6 +86,32 @@ function getAllCapabilities(caps: AgentCapabilities): Capability[] { return [...caps.required, ...caps.optional]; } +/** + * Resolve the context pipeline for a given trigger event. + * Uses the trigger-specific pipeline if defined, otherwise falls back to the default. + * + * @param triggers - Array of supported triggers from the agent definition + * @param defaultPipeline - Default pipeline from strategies.contextPipeline + * @param triggerEvent - Optional trigger event (e.g., 'pm:card-moved', 'scm:check-suite-success') + * @returns The context pipeline to use + */ +function resolveContextPipeline( + triggers: SupportedTrigger[], + defaultPipeline: ContextStepName[], + triggerEvent?: string, +): ContextStepName[] { + if (!triggerEvent) { + return defaultPipeline; + } + + const trigger = triggers.find((t) => t.event === triggerEvent); + if (trigger?.contextPipeline && trigger.contextPipeline.length > 0) { + return trigger.contextPipeline; + } + + return defaultPipeline; +} + // ============================================================================ // Profile Builder (Capability-driven) // ============================================================================ @@ -97,8 +128,11 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag // Get gadget options from strategies const gadgetOptions = def.strategies.gadgetOptions; - // Get context pipeline from strategies - const contextPipeline = def.strategies.contextPipeline; + // Get default context pipeline from strategies + const defaultContextPipeline = def.strategies.contextPipeline; + + // Get triggers for dynamic context pipeline resolution + const triggers = def.triggers ?? []; // Get task prompt template from prompts (required by schema) const taskPromptTemplate = def.prompts.taskPrompt; @@ -121,6 +155,14 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag ...(def.backend.blockGitPush !== undefined && { blockGitPush: def.backend.blockGitPush }), ...(def.backend.requiresPR && { requiresPR: true }), fetchContext: async (params) => { + // Resolve context pipeline: use trigger-specific pipeline if available, + // otherwise fall back to the default from strategies.contextPipeline + const contextPipeline = resolveContextPipeline( + triggers, + defaultContextPipeline, + params.input.triggerType, + ); + const injections: ContextInjection[] = []; for (const step of contextPipeline) { const stepFn = resolveRegistry(CONTEXT_STEP_REGISTRY, step, 'contextPipeline step'); diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml index 194dfe48..cfe198fb 100644 --- a/src/agents/definitions/respond-to-ci.yaml +++ b/src/agents/definitions/respond-to-ci.yaml @@ -30,6 +30,8 @@ triggers: description: Trigger when CI checks fail defaultEnabled: true providers: [github] + # contextPipeline can be specified per-trigger to override strategies.contextPipeline + # Currently using strategy default: [prContext, directoryListing, contextFiles, squint, workItem] strategies: contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index 50cbdea1..e8698ea5 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -36,6 +36,8 @@ triggers: description: Filter PRs by author type options: [own, external, all] defaultValue: own + # contextPipeline can be specified per-trigger to override strategies.contextPipeline + # Currently using strategy default: [prContext, contextFiles, squint] - event: scm:review-requested label: On Review Requested description: Trigger review when a CASCADE persona is explicitly requested as reviewer diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 8c7ce5b0..684ef18b 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -67,6 +67,23 @@ export const TriggerParameterSchema = z { message: 'Parameter with defaultValue cannot be required' }, ); +// ============================================================================ +// Context Step Names (used by triggers and strategies) +// ============================================================================ + +export const CONTEXT_STEP_NAMES = [ + 'directoryListing', + 'contextFiles', + 'squint', + 'workItem', + 'prContext', + 'prConversation', + 'prefetchedEmails', +] as const; + +/** Context step name schema for use in triggers */ +const ContextStepNameSchema = z.enum(CONTEXT_STEP_NAMES); + // ============================================================================ // Supported Trigger Schema // ============================================================================ @@ -94,6 +111,13 @@ export const SupportedTriggerSchema = z.object({ parameters: z.array(TriggerParameterSchema).default([]), /** Provider filter - only applies to these providers (e.g., ['trello']) */ providers: z.array(KnownProviderSchema).optional(), + /** + * Optional custom context pipeline for this trigger. + * When specified, overrides the agent's default strategies.contextPipeline. + * Useful when different triggers require different context (e.g., PM triggers + * need workItem, SCM triggers need prContext). + */ + contextPipeline: z.array(ContextStepNameSchema).optional(), }); // ============================================================================ @@ -170,16 +194,6 @@ const GadgetOptionsSchema = z }) .optional(); -export const CONTEXT_STEP_NAMES = [ - 'directoryListing', - 'contextFiles', - 'squint', - 'workItem', - 'prContext', - 'prConversation', - 'prefetchedEmails', -] as const; - export const COMPACTION_NAMES = ['implementation', 'default'] as const; /** @@ -284,6 +298,9 @@ export type TriggerParameter = z.infer; /** Supported trigger definition */ export type SupportedTrigger = z.infer; +/** Context step name */ +export type ContextStepName = (typeof CONTEXT_STEP_NAMES)[number]; + /** Integration requirements (explicit required/optional) */ export type IntegrationRequirements = z.infer; diff --git a/src/api/routers/_shared/triggerTypes.ts b/src/api/routers/_shared/triggerTypes.ts new file mode 100644 index 00000000..2e06588e --- /dev/null +++ b/src/api/routers/_shared/triggerTypes.ts @@ -0,0 +1,100 @@ +/** + * Shared types for the trigger configuration API. + * These types are used by both the backend (tRPC router) and frontend (dashboard). + */ + +// ============================================================================ +// Parameter Types +// ============================================================================ + +/** + * Supported parameter value types for triggers. + */ +export type TriggerParameterValue = string | boolean | number; + +/** + * Parameter definition for a trigger. + * Defines the schema for a configurable parameter. + */ +export interface TriggerParameterDef { + name: string; + type: 'string' | 'email' | 'boolean' | 'select' | 'number'; + label: string; + description: string | null; + required: boolean; + defaultValue: TriggerParameterValue | null; + options: string[] | null; +} + +// ============================================================================ +// Resolved Trigger Types +// ============================================================================ + +/** + * A resolved trigger with merged definition and config data. + * Returned by getProjectTriggersView for dashboard rendering. + */ +export interface ResolvedTrigger { + /** Event identifier (e.g., "pm:card-moved", "scm:check-suite-success") */ + event: string; + /** Human-readable label */ + label: string; + /** Optional description */ + description: string | null; + /** Provider restrictions (e.g., ["trello"], ["github"]) */ + providers: string[] | null; + /** Whether this trigger is currently enabled */ + enabled: boolean; + /** Current parameter values (merged from definition defaults + config overrides) */ + parameters: Record; + /** Parameter definitions for UI rendering */ + parameterDefs: TriggerParameterDef[]; + /** Whether this trigger has been customized from defaults */ + isCustomized: boolean; +} + +/** + * Agent with its resolved triggers. + */ +export interface AgentTriggersView { + agentType: string; + triggers: ResolvedTrigger[]; +} + +/** + * Active integration providers for a project. + */ +export interface ProjectIntegrationsMap { + pm: string | null; + scm: string | null; + email: string | null; + sms: string | null; +} + +/** + * Complete triggers view for a project. + * Response type for getProjectTriggersView. + */ +export interface ProjectTriggersView { + agents: AgentTriggersView[]; + integrations: ProjectIntegrationsMap; +} + +// ============================================================================ +// Category Labels +// ============================================================================ + +/** + * Human-readable labels for trigger categories. + */ +export const TRIGGER_CATEGORY_LABELS: Record = { + pm: 'Project Management', + scm: 'Source Control', + email: 'Email', + sms: 'SMS', +} as const; + +/** + * Valid trigger categories. + */ +export type TriggerCategory = 'pm' | 'scm' | 'email' | 'sms'; diff --git a/src/api/routers/agentTriggerConfigs.ts b/src/api/routers/agentTriggerConfigs.ts index d1dea8fc..152f3183 100644 --- a/src/api/routers/agentTriggerConfigs.ts +++ b/src/api/routers/agentTriggerConfigs.ts @@ -1,5 +1,12 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { getKnownAgentTypes, loadAgentDefinition } from '../../agents/definitions/loader.js'; +import type { + AgentDefinition, + SupportedTrigger, + TriggerParameter, +} from '../../agents/definitions/schema.js'; +import { listAgentDefinitions } from '../../db/repositories/agentDefinitionsRepository.js'; import { deleteTriggerConfig, getTriggerConfig, @@ -9,8 +16,42 @@ import { updateTriggerConfig, upsertTriggerConfig, } from '../../db/repositories/agentTriggerConfigsRepository.js'; +import { listProjectIntegrations } from '../../db/repositories/settingsRepository.js'; +import { logger } from '../../utils/logging.js'; import { protectedProcedure, router } from '../trpc.js'; import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; +import type { + ProjectTriggersView, + ResolvedTrigger, + TriggerParameterDef, + TriggerParameterValue, +} from './_shared/triggerTypes.js'; + +// ============================================================================ +// Input Schemas +// ============================================================================ + +/** + * Trigger event format: {category}:{event-name} + * Categories: pm, scm, email, sms + * Event name: lowercase letters, numbers, and hyphens + */ +const TriggerEventSchema = z + .string() + .regex( + /^(pm|scm|email|sms):[a-z][a-z0-9-]*$/, + 'Event must be in format {category}:{event-name} (e.g., pm:card-moved, scm:check-suite-success)', + ); + +/** + * Trigger parameters: flat key-value map with primitive values only. + * Nested objects are not supported. + */ +const TriggerParametersSchema = z.record(z.union([z.string(), z.boolean(), z.number()])).optional(); + +// ============================================================================ +// Router +// ============================================================================ export const agentTriggerConfigsRouter = router({ /** @@ -41,7 +82,7 @@ export const agentTriggerConfigsRouter = router({ z.object({ projectId: z.string(), agentType: z.string(), - triggerEvent: z.string(), + triggerEvent: TriggerEventSchema, }), ) .query(async ({ ctx, input }) => { @@ -57,9 +98,9 @@ export const agentTriggerConfigsRouter = router({ z.object({ projectId: z.string(), agentType: z.string(), - triggerEvent: z.string(), + triggerEvent: TriggerEventSchema, enabled: z.boolean().optional(), - parameters: z.record(z.unknown()).optional(), + parameters: TriggerParametersSchema, }), ) .mutation(async ({ ctx, input }) => { @@ -81,7 +122,7 @@ export const agentTriggerConfigsRouter = router({ z.object({ id: z.number(), enabled: z.boolean().optional(), - parameters: z.record(z.unknown()).optional(), + parameters: TriggerParametersSchema, }), ) .mutation(async ({ ctx, input }) => { @@ -129,9 +170,9 @@ export const agentTriggerConfigsRouter = router({ configs: z.array( z.object({ agentType: z.string(), - triggerEvent: z.string(), + triggerEvent: TriggerEventSchema, enabled: z.boolean().optional(), - parameters: z.record(z.unknown()).optional(), + parameters: TriggerParametersSchema, }), ), }), @@ -152,4 +193,138 @@ export const agentTriggerConfigsRouter = router({ ); return results; }), + + /** + * Get a composite view of all triggers for a project. + * Combines agent definitions with project-specific trigger configs. + * This is optimized for the dashboard to fetch all trigger data in a single call. + */ + getProjectTriggersView: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }): Promise => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + + // Fetch DB definitions and configs in parallel + const [dbDefinitions, configs, integrations] = await Promise.all([ + listAgentDefinitions().catch((err) => { + logger.warn('Failed to fetch agent definitions from DB', { error: err }); + return []; + }), + getTriggerConfigsByProject(input.projectId), + listProjectIntegrations(input.projectId), + ]); + + // Build a combined list of definitions (DB + YAML) + const yamlTypes = getKnownAgentTypes(); + const definitions: Array<{ agentType: string; definition: AgentDefinition }> = []; + const seen = new Set(); + + // Start with DB definitions (they override YAML) + for (const row of dbDefinitions) { + definitions.push({ agentType: row.agentType, definition: row.definition }); + seen.add(row.agentType); + } + + // Fill in YAML-only types not in DB + for (const agentType of yamlTypes) { + if (!seen.has(agentType)) { + try { + definitions.push({ agentType, definition: loadAgentDefinition(agentType) }); + } catch (err) { + logger.warn('Failed to load agent definition from YAML', { agentType, error: err }); + } + } + } + + // Build a map of configs by agent type and event for O(1) lookup + const configMap = new Map>(); + for (const config of configs) { + if (!configMap.has(config.agentType)) { + configMap.set(config.agentType, new Map()); + } + configMap.get(config.agentType)?.set(config.triggerEvent, config); + } + + // Helper to merge parameter values with definitions + function mergeParameters( + paramDefs: TriggerParameter[], + configParams?: Record, + ): Record { + const result: Record = {}; + for (const def of paramDefs) { + // Use configured value if available, otherwise use default + const value = + configParams?.[def.name] !== undefined ? configParams[def.name] : def.defaultValue; + // Only include valid primitive values + if ( + typeof value === 'string' || + typeof value === 'boolean' || + typeof value === 'number' + ) { + result[def.name] = value; + } + } + return result; + } + + // Helper to map parameter definitions + function mapParameterDef(p: TriggerParameter): TriggerParameterDef { + return { + name: p.name, + type: p.type as TriggerParameterDef['type'], + label: p.label, + description: p.description ?? null, + required: p.required, + defaultValue: p.defaultValue ?? null, + options: p.options ?? null, + }; + } + + // Build the agents array with merged trigger data + const agents = definitions.map((def) => { + const agentConfigs = configMap.get(def.agentType); + const triggers: ResolvedTrigger[] = (def.definition.triggers ?? []).map( + (trigger: SupportedTrigger) => { + const config = agentConfigs?.get(trigger.event); + return { + event: trigger.event, + label: trigger.label, + description: trigger.description ?? null, + providers: trigger.providers ?? null, + enabled: config?.enabled ?? trigger.defaultEnabled, + parameters: mergeParameters( + trigger.parameters ?? [], + config?.parameters as Record | undefined, + ), + parameterDefs: (trigger.parameters ?? []).map(mapParameterDef), + isCustomized: config !== undefined, + }; + }, + ); + + return { + agentType: def.agentType, + triggers, + }; + }); + + // Build integrations map with single pass + const integrationsMap = { + pm: null as string | null, + scm: null as string | null, + email: null as string | null, + sms: null as string | null, + }; + for (const integration of integrations) { + const category = integration.category as keyof typeof integrationsMap; + if (category in integrationsMap) { + integrationsMap[category] = integration.provider as string; + } + } + + return { + agents, + integrations: integrationsMap, + }; + }), }); diff --git a/src/cli/dashboard/projects/pm-trigger-set.ts b/src/cli/dashboard/projects/pm-trigger-set.ts deleted file mode 100644 index f525f271..00000000 --- a/src/cli/dashboard/projects/pm-trigger-set.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { Args, Flags } from '@oclif/core'; -import { DashboardCommand } from '../_shared/base.js'; - -/** - * CLI command for configuring PM trigger modes per agent type. - * - * Usage: - * cascade projects pm-trigger-set [--card-moved-to-splitting] [--issue-transitioned-splitting] ... - * - * At least one flag must be provided. Pass `--no-` to disable a mode. - * Uses the `projects.integrations.updateTriggers` tRPC endpoint, updating the - * PM integration triggers config for the project. - * - * Trello flags update the top-level boolean keys (cardMovedToSplitting, etc.). - * JIRA flags update the nested `issueTransitioned` object per agent type. - */ -export default class ProjectsPmTriggerSet extends DashboardCommand { - static override description = - 'Configure PM trigger modes per agent type (card-moved for Trello, issue-transitioned for JIRA).'; - - static override aliases = ['projects:pm-trigger-set']; - - static override args = { - id: Args.string({ description: 'Project ID', required: true }), - }; - - static override flags = { - ...DashboardCommand.baseFlags, - // Trello card-moved triggers - 'card-moved-to-splitting': Flags.boolean({ - description: 'Enable splitting agent when a card is moved to the Splitting list (Trello).', - allowNo: true, - default: undefined, - }), - 'card-moved-to-planning': Flags.boolean({ - description: 'Enable planning agent when a card is moved to the Planning list (Trello).', - allowNo: true, - default: undefined, - }), - 'card-moved-to-todo': Flags.boolean({ - description: 'Enable implementation agent when a card is moved to the Todo list (Trello).', - allowNo: true, - default: undefined, - }), - // JIRA issue-transitioned triggers (per-agent) - 'issue-transitioned-splitting': Flags.boolean({ - description: - 'Enable splitting agent when a JIRA issue transitions to the configured Splitting status.', - allowNo: true, - default: undefined, - }), - 'issue-transitioned-planning': Flags.boolean({ - description: - 'Enable planning agent when a JIRA issue transitions to the configured Planning status.', - allowNo: true, - default: undefined, - }), - 'issue-transitioned-implementation': Flags.boolean({ - description: - 'Enable implementation agent when a JIRA issue transitions to the configured Todo status.', - allowNo: true, - default: undefined, - }), - }; - - /** Build the triggers patch object from parsed flag values. */ - private buildTriggers(parsedFlags: { - cardMovedToSplitting: boolean | undefined; - cardMovedToPlanning: boolean | undefined; - cardMovedToTodo: boolean | undefined; - issueTransitionedSplitting: boolean | undefined; - issueTransitionedPlanning: boolean | undefined; - issueTransitionedImplementation: boolean | undefined; - }): Record> { - const { - cardMovedToSplitting, - cardMovedToPlanning, - cardMovedToTodo, - issueTransitionedSplitting, - issueTransitionedPlanning, - issueTransitionedImplementation, - } = parsedFlags; - - const triggers: Record> = {}; - - if (cardMovedToSplitting !== undefined) triggers.cardMovedToSplitting = cardMovedToSplitting; - if (cardMovedToPlanning !== undefined) triggers.cardMovedToPlanning = cardMovedToPlanning; - if (cardMovedToTodo !== undefined) triggers.cardMovedToTodo = cardMovedToTodo; - - const issueTransitioned: Record = {}; - if (issueTransitionedSplitting !== undefined) - issueTransitioned.splitting = issueTransitionedSplitting; - if (issueTransitionedPlanning !== undefined) - issueTransitioned.planning = issueTransitionedPlanning; - if (issueTransitionedImplementation !== undefined) - issueTransitioned.implementation = issueTransitionedImplementation; - - if (Object.keys(issueTransitioned).length > 0) { - triggers.issueTransitioned = issueTransitioned; - } - - return triggers; - } - - /** Format a human-readable summary of changed triggers. */ - private formatOutput( - projectId: string, - parsedFlags: { - cardMovedToSplitting: boolean | undefined; - cardMovedToPlanning: boolean | undefined; - cardMovedToTodo: boolean | undefined; - issueTransitionedSplitting: boolean | undefined; - issueTransitionedPlanning: boolean | undefined; - issueTransitionedImplementation: boolean | undefined; - }, - ): string { - const { - cardMovedToSplitting, - cardMovedToPlanning, - cardMovedToTodo, - issueTransitionedSplitting, - issueTransitionedPlanning, - issueTransitionedImplementation, - } = parsedFlags; - - const lines: string[] = [`PM trigger modes updated for project: ${projectId}`]; - if (cardMovedToSplitting !== undefined) - lines.push(` cardMovedToSplitting: ${cardMovedToSplitting}`); - if (cardMovedToPlanning !== undefined) - lines.push(` cardMovedToPlanning: ${cardMovedToPlanning}`); - if (cardMovedToTodo !== undefined) lines.push(` cardMovedToTodo: ${cardMovedToTodo}`); - if (issueTransitionedSplitting !== undefined) - lines.push(` issueTransitioned.splitting: ${issueTransitionedSplitting}`); - if (issueTransitionedPlanning !== undefined) - lines.push(` issueTransitioned.planning: ${issueTransitionedPlanning}`); - if (issueTransitionedImplementation !== undefined) - lines.push(` issueTransitioned.implementation: ${issueTransitionedImplementation}`); - return lines.join('\n'); - } - - async run(): Promise { - const { args, flags } = await this.parse(ProjectsPmTriggerSet); - - const cardMovedToSplitting = flags['card-moved-to-splitting']; - const cardMovedToPlanning = flags['card-moved-to-planning']; - const cardMovedToTodo = flags['card-moved-to-todo']; - const issueTransitionedSplitting = flags['issue-transitioned-splitting']; - const issueTransitionedPlanning = flags['issue-transitioned-planning']; - const issueTransitionedImplementation = flags['issue-transitioned-implementation']; - - const hasAnyFlag = - cardMovedToSplitting !== undefined || - cardMovedToPlanning !== undefined || - cardMovedToTodo !== undefined || - issueTransitionedSplitting !== undefined || - issueTransitionedPlanning !== undefined || - issueTransitionedImplementation !== undefined; - - if (!hasAnyFlag) { - this.error( - 'At least one flag must be provided: ' + - '--card-moved-to-splitting, --card-moved-to-planning, --card-moved-to-todo, ' + - '--issue-transitioned-splitting, --issue-transitioned-planning, --issue-transitioned-implementation ' + - '(use --no- to disable).', - ); - } - - const parsedFlags = { - cardMovedToSplitting, - cardMovedToPlanning, - cardMovedToTodo, - issueTransitionedSplitting, - issueTransitionedPlanning, - issueTransitionedImplementation, - }; - - const triggers = this.buildTriggers(parsedFlags); - - try { - await this.client.projects.integrations.updateTriggers.mutate({ - projectId: args.id, - category: 'pm', - triggers, - }); - - if (flags.json) { - this.outputJson({ ok: true, triggers }); - return; - } - - this.log(this.formatOutput(args.id, parsedFlags)); - } catch (err) { - this.handleError(err); - } - } -} diff --git a/src/cli/dashboard/projects/review-trigger-set.ts b/src/cli/dashboard/projects/review-trigger-set.ts deleted file mode 100644 index 47faa2ec..00000000 --- a/src/cli/dashboard/projects/review-trigger-set.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Args, Flags } from '@oclif/core'; -import { DashboardCommand } from '../_shared/base.js'; - -/** - * CLI command for configuring the review agent's trigger modes. - * - * Usage: - * cascade projects review-trigger-set [--own-prs-only] [--external-prs] [--on-review-requested] - * - * At least one flag must be provided. Pass `--no-` to disable a mode. - * Uses the `projects.integrations.updateTriggers` tRPC endpoint, updating the - * `reviewTrigger` nested object in the project's SCM integration triggers. - */ -export default class ProjectsReviewTriggerSet extends DashboardCommand { - static override description = - 'Configure review trigger modes for a project (which PRs the review agent should review).'; - - static override aliases = ['projects:review-trigger-set']; - - static override args = { - id: Args.string({ description: 'Project ID', required: true }), - }; - - static override flags = { - ...DashboardCommand.baseFlags, - 'own-prs-only': Flags.boolean({ - description: - 'Enable review agent for PRs authored by the implementer persona (after CI passes).', - allowNo: true, - default: undefined, - }), - 'external-prs': Flags.boolean({ - description: - 'Enable review agent for PRs authored by anyone outside the CASCADE personas (after CI passes).', - allowNo: true, - default: undefined, - }), - 'on-review-requested': Flags.boolean({ - description: - 'Enable review agent when a CASCADE persona is explicitly requested as reviewer.', - allowNo: true, - default: undefined, - }), - 'pr-opened': Flags.boolean({ - description: - 'Enable respond-to-review on newly opened PRs (filtered by own-prs-only / external-prs).', - allowNo: true, - default: undefined, - }), - }; - - async run(): Promise { - const { args, flags } = await this.parse(ProjectsReviewTriggerSet); - - const ownPrsOnly = flags['own-prs-only']; - const externalPrs = flags['external-prs']; - const onReviewRequested = flags['on-review-requested']; - const prOpened = flags['pr-opened']; - - if ( - ownPrsOnly === undefined && - externalPrs === undefined && - onReviewRequested === undefined && - prOpened === undefined - ) { - this.error( - 'At least one flag must be provided: --own-prs-only, --external-prs, --on-review-requested, --pr-opened (use --no- to disable).', - ); - } - - // Build the nested reviewTrigger object with only the provided flags - const reviewTrigger: Record = {}; - if (ownPrsOnly !== undefined) reviewTrigger.ownPrsOnly = ownPrsOnly; - if (externalPrs !== undefined) reviewTrigger.externalPrs = externalPrs; - if (onReviewRequested !== undefined) reviewTrigger.onReviewRequested = onReviewRequested; - - // Build the top-level triggers payload - const triggers: Record> = {}; - if (Object.keys(reviewTrigger).length > 0) triggers.reviewTrigger = reviewTrigger; - if (prOpened !== undefined) triggers.prOpened = prOpened; - - try { - await this.client.projects.integrations.updateTriggers.mutate({ - projectId: args.id, - category: 'scm', - triggers, - }); - - if (flags.json) { - this.outputJson({ ok: true, ...triggers }); - return; - } - - const lines: string[] = [`Review trigger modes updated for project: ${args.id}`]; - if (ownPrsOnly !== undefined) lines.push(` ownPrsOnly: ${ownPrsOnly}`); - if (externalPrs !== undefined) lines.push(` externalPrs: ${externalPrs}`); - if (onReviewRequested !== undefined) lines.push(` onReviewRequested: ${onReviewRequested}`); - if (prOpened !== undefined) lines.push(` prOpened: ${prOpened}`); - this.log(lines.join('\n')); - } catch (err) { - this.handleError(err); - } - } -} diff --git a/src/cli/dashboard/projects/trigger-discover.ts b/src/cli/dashboard/projects/trigger-discover.ts new file mode 100644 index 00000000..d1878dc3 --- /dev/null +++ b/src/cli/dashboard/projects/trigger-discover.ts @@ -0,0 +1,95 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +/** + * CLI command for discovering available triggers for an agent type. + * + * Usage: + * cascade projects trigger-discover --agent implementation + * cascade projects trigger-discover --agent review + * cascade projects trigger-discover --agent planning --json + * + * This command reads trigger definitions from the agent's YAML/DB definition + * and displays them in a human-readable format or as JSON. + */ +export default class ProjectsTriggerDiscover extends DashboardCommand { + static override description = 'Discover available triggers for an agent type.'; + + static override aliases = ['projects:trigger-discover']; + + static override flags = { + ...DashboardCommand.baseFlags, + agent: Flags.string({ + description: 'Agent type (e.g., implementation, review, splitting, planning)', + required: true, + char: 'a', + }), + }; + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: CLI command with conditional output formatting + async run(): Promise { + const { flags } = await this.parse(ProjectsTriggerDiscover); + + try { + const result = await this.client.agentDefinitions.get.query({ + agentType: flags.agent, + }); + + if (!result) { + this.error(`Unknown agent type: ${flags.agent}`); + } + + const triggers = result.definition.triggers ?? []; + + if (flags.json) { + this.outputJson(triggers); + return; + } + + if (triggers.length === 0) { + this.log(`\nNo triggers defined for "${flags.agent}" agent.\n`); + return; + } + + this.log(`\nAvailable triggers for "${flags.agent}" agent:\n`); + + for (const trigger of triggers) { + this.log(` ${trigger.event}`); + this.log(` Label: ${trigger.label}`); + this.log(` Default: ${trigger.defaultEnabled ? 'enabled' : 'disabled'}`); + + if (trigger.description) { + this.log(` Description: ${trigger.description}`); + } + + if (trigger.providers && trigger.providers.length > 0) { + this.log(` Providers: ${trigger.providers.join(', ')}`); + } + + if (trigger.parameters && trigger.parameters.length > 0) { + this.log(' Parameters:'); + for (const param of trigger.parameters) { + const required = param.required ? ' (required)' : ''; + const defaultVal = + param.defaultValue !== undefined ? ` [default: ${param.defaultValue}]` : ''; + const options = param.options ? ` (options: ${param.options.join(', ')})` : ''; + this.log(` - ${param.name}: ${param.type}${required}${defaultVal}${options}`); + if (param.description) { + this.log(` ${param.description}`); + } + } + } + + this.log(''); + } + + this.log('Usage example:'); + this.log( + ` cascade projects trigger-set --agent ${flags.agent} --event ${triggers[0]?.event ?? 'pm:example'} --enable`, + ); + this.log(''); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/trigger-list.ts b/src/cli/dashboard/projects/trigger-list.ts new file mode 100644 index 00000000..48214eb1 --- /dev/null +++ b/src/cli/dashboard/projects/trigger-list.ts @@ -0,0 +1,74 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +/** + * CLI command for listing configured triggers for a project. + * + * Usage: + * cascade projects trigger-list + * cascade projects trigger-list --agent implementation + * + * Lists all trigger configurations from the agent_trigger_configs table. + */ +export default class ProjectsTriggerList extends DashboardCommand { + static override description = 'List configured triggers for a project.'; + + static override aliases = ['projects:trigger-list']; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + agent: Flags.string({ + description: 'Filter by agent type (e.g., implementation, review)', + char: 'a', + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsTriggerList); + + try { + const configs = flags.agent + ? await this.client.agentTriggerConfigs.listByProjectAndAgent.query({ + projectId: args.id, + agentType: flags.agent, + }) + : await this.client.agentTriggerConfigs.listByProject.query({ + projectId: args.id, + }); + + if (flags.json) { + this.outputJson(configs); + return; + } + + if (configs.length === 0) { + this.log('No trigger configurations found.'); + if (!flags.agent) { + this.log('Triggers are using default settings from agent definitions.'); + } + return; + } + + this.outputTable( + configs.map((c) => ({ + agent: c.agentType, + event: c.triggerEvent, + enabled: c.enabled ? 'yes' : 'no', + parameters: Object.keys(c.parameters).length > 0 ? JSON.stringify(c.parameters) : '-', + })), + [ + { key: 'agent', header: 'Agent' }, + { key: 'event', header: 'Event' }, + { key: 'enabled', header: 'Enabled' }, + { key: 'parameters', header: 'Parameters' }, + ], + ); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/trigger-set.ts b/src/cli/dashboard/projects/trigger-set.ts new file mode 100644 index 00000000..32e67d22 --- /dev/null +++ b/src/cli/dashboard/projects/trigger-set.ts @@ -0,0 +1,159 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +/** + * CLI command for configuring agent triggers. + * + * Usage: + * cascade projects trigger-set --agent implementation --event pm:card-moved --enable + * cascade projects trigger-set --agent review --event scm:check-suite-success --disable + * cascade projects trigger-set --agent review --event scm:check-suite-success --params '{"authorMode":"own"}' + * + * This is the unified command that replaces the older pm-trigger-set and review-trigger-set commands. + * Uses the `agentTriggerConfigs.upsert` tRPC endpoint to store trigger configs in the new table. + */ +export default class ProjectsTriggerSet extends DashboardCommand { + static override description = + 'Configure a trigger for a specific agent (unified command for all trigger types).'; + + static override aliases = ['projects:trigger-set']; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + agent: Flags.string({ + description: 'Agent type (e.g., implementation, review, splitting, planning)', + required: true, + char: 'a', + }), + event: Flags.string({ + description: 'Trigger event (e.g., pm:card-moved, scm:check-suite-success)', + required: true, + char: 'e', + }), + enable: Flags.boolean({ + description: 'Enable this trigger', + exclusive: ['disable'], + }), + disable: Flags.boolean({ + description: 'Disable this trigger', + exclusive: ['enable'], + }), + params: Flags.string({ + description: 'Trigger parameters as JSON (e.g., \'{"authorMode":"own"}\')', + char: 'p', + }), + strict: Flags.boolean({ + description: 'Error on unknown events instead of warning', + default: false, + }), + }; + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: CLI command with multiple validation paths + async run(): Promise { + const { args, flags } = await this.parse(ProjectsTriggerSet); + + const agent = flags.agent; + const event = flags.event; + const enable = flags.enable; + const disable = flags.disable; + const paramsJson = flags.params; + const strict = flags.strict; + + // Validate at least one option is provided + if (enable === undefined && disable === undefined && paramsJson === undefined) { + this.error('At least one of --enable, --disable, or --params must be provided.'); + } + + // Validate event format early (before API call) + const eventPattern = /^(pm|scm|email|sms):[a-z][a-z0-9-]*$/; + if (!eventPattern.test(event)) { + this.error( + `Invalid event format: "${event}". Events must be in format {category}:{event-name} (e.g., pm:card-moved, scm:check-suite-success).`, + ); + } + + // Parse parameters JSON if provided + let parameters: Record | undefined; + if (paramsJson) { + try { + const parsed = JSON.parse(paramsJson); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + this.error('--params must be a JSON object'); + } + // Validate all values are primitives (string, number, boolean) + for (const [key, value] of Object.entries(parsed)) { + const valueType = typeof value; + if (valueType !== 'string' && valueType !== 'number' && valueType !== 'boolean') { + this.error(`Invalid parameter value for "${key}": must be string, number, or boolean`); + } + } + parameters = parsed as Record; + } catch (err) { + if (err instanceof Error && err.message.startsWith('Invalid parameter value')) { + throw err; + } + this.error(`Invalid JSON in --params: ${paramsJson}`); + } + } + + // Determine enabled state + let enabled: boolean | undefined; + if (enable) { + enabled = true; + } else if (disable) { + enabled = false; + } + + try { + // Validate event against known triggers for this agent type + const definition = await this.client.agentDefinitions.get.query({ + agentType: agent, + }); + + if (!definition) { + this.error(`Unknown agent type: ${agent}`); + } + + const validEvents = (definition.definition.triggers ?? []).map( + (t: { event: string }) => t.event, + ); + if (validEvents.length > 0 && !validEvents.includes(event)) { + const message = `Unknown event "${event}" for agent "${agent}".`; + const hint = `Valid events:\n ${validEvents.join('\n ')}\n\nRun "cascade projects trigger-discover --agent ${agent}" for details.`; + if (strict) { + this.error(`${message}\n\n${hint}`); + } + this.warn(message); + this.log(hint); + } + + const result = await this.client.agentTriggerConfigs.upsert.mutate({ + projectId: args.id, + agentType: agent, + triggerEvent: event, + enabled, + parameters, + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + const lines: string[] = [`Trigger config updated for project: ${args.id}`]; + lines.push(` Agent: ${agent}`); + lines.push(` Event: ${event}`); + lines.push(` Enabled: ${result.enabled}`); + if (Object.keys(result.parameters).length > 0) { + lines.push(` Parameters: ${JSON.stringify(result.parameters)}`); + } + this.log(lines.join('\n')); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/triggers/config-resolver.ts b/src/triggers/config-resolver.ts index 9479fcdd..50ec72e6 100644 --- a/src/triggers/config-resolver.ts +++ b/src/triggers/config-resolver.ts @@ -1,6 +1,39 @@ /** - * Trigger configuration resolver. - * Merges agent definition defaults with project-specific overrides from the database. + * Trigger Configuration Resolver + * + * This module resolves trigger configurations by merging multiple sources: + * + * 1. **Definition defaults** - From YAML agent definitions (e.g., `implementation.yaml`) + * - Each agent declares supported triggers in `triggers[]` + * - Each trigger has `defaultEnabled` and default `parameters` + * + * 2. **Project-level overrides** - From `agent_trigger_configs` table + * - Per-project, per-agent, per-trigger customization + * - Can override `enabled` and `parameters` + * + * 3. **Legacy fallback** - From `project_integrations.triggers` JSONB + * - For backward compatibility during migration + * - Uses `LEGACY_TRIGGER_KEY_MAP` for key translation + * + * ## Resolution Order + * + * 1. If config exists in `agent_trigger_configs` → use it + * 2. Else → use definition default + * + * ## Usage + * + * Trigger handlers should use: + * - `isTriggerEnabled(projectId, agentType, event)` - Check if trigger should fire + * - `getTriggerParameters(projectId, agentType, event)` - Get merged parameters + * + * Dashboard should use: + * - `resolveTriggerConfigs(projectId, agentType)` - Get all triggers with their configs + * + * ## Note on contextPipeline + * + * The `contextPipeline` field in trigger definitions is read-only from YAML. + * It cannot be overridden per-project via the database. If different triggers + * need different context, declare `contextPipeline` per-trigger in the YAML. */ import { resolveAgentDefinition } from '../agents/definitions/index.js'; @@ -186,71 +219,3 @@ function mergeTriggerConfig( isCustomized: !!override, }; } - -// ============================================================================ -// Legacy Fallback Support -// ============================================================================ - -/** - * Check if a trigger is enabled using the legacy project_integrations.triggers config. - * This is a fallback for projects that haven't migrated to the new system. - * - * @param legacyTriggers - The triggers JSONB from project_integrations - * @param triggerEvent - The new-style event name (e.g., 'pm:card-moved') - * @param legacyKey - The legacy trigger config key (e.g., 'cardMovedToTodo') - * @param defaultValue - Default value if not found - */ -export function resolveLegacyTriggerEnabled( - legacyTriggers: Record | null | undefined, - _triggerEvent: string, - legacyKey: string, - defaultValue: boolean, -): boolean { - if (!legacyTriggers) { - return defaultValue; - } - - const value = legacyTriggers[legacyKey]; - if (typeof value === 'boolean') { - return value; - } - - return defaultValue; -} - -/** - * Map from new trigger event names to legacy trigger config keys. - * Used for backward compatibility during migration. - */ -export const LEGACY_TRIGGER_KEY_MAP: Record = { - // PM triggers - 'pm:card-moved': 'cardMovedToTodo', // varies by agent - 'pm:issue-transitioned': 'issueTransitioned', - 'pm:label-added': 'readyToProcessLabel', - 'pm:comment-mention': 'commentMention', - // SCM triggers - 'scm:check-suite-success': 'checkSuiteSuccess', - 'scm:check-suite-failure': 'checkSuiteFailure', - 'scm:pr-review-submitted': 'prReviewSubmitted', - 'scm:pr-comment-mention': 'prCommentMention', - 'scm:review-requested': 'reviewRequested', - 'scm:pr-opened': 'prOpened', - // Email triggers - 'email:received': 'emailReceived', -}; - -/** - * Get the legacy trigger key for an agent-specific card-moved trigger. - */ -export function getLegacyCardMovedKey(agentType: string): string { - switch (agentType) { - case 'splitting': - return 'cardMovedToSplitting'; - case 'planning': - return 'cardMovedToPlanning'; - case 'implementation': - return 'cardMovedToTodo'; - default: - return 'cardMovedToTodo'; - } -} diff --git a/tests/unit/api/routers/_shared/triggerTypes.test.ts b/tests/unit/api/routers/_shared/triggerTypes.test.ts new file mode 100644 index 00000000..ebfc9afe --- /dev/null +++ b/tests/unit/api/routers/_shared/triggerTypes.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { + type ProjectTriggersView, + type ResolvedTrigger, + TRIGGER_CATEGORY_LABELS, + type TriggerCategory, + type TriggerParameterDef, + type TriggerParameterValue, +} from '../../../../../src/api/routers/_shared/triggerTypes.js'; + +describe('triggerTypes', () => { + describe('TRIGGER_CATEGORY_LABELS', () => { + it('has labels for all categories', () => { + expect(TRIGGER_CATEGORY_LABELS.pm).toBe('Project Management'); + expect(TRIGGER_CATEGORY_LABELS.scm).toBe('Source Control'); + expect(TRIGGER_CATEGORY_LABELS.email).toBe('Email'); + expect(TRIGGER_CATEGORY_LABELS.sms).toBe('SMS'); + }); + + it('has exactly 4 categories', () => { + expect(Object.keys(TRIGGER_CATEGORY_LABELS)).toHaveLength(4); + }); + }); + + describe('type exports', () => { + it('TriggerParameterValue supports expected types', () => { + const stringVal: TriggerParameterValue = 'test'; + const boolVal: TriggerParameterValue = true; + const numVal: TriggerParameterValue = 42; + + expect(typeof stringVal).toBe('string'); + expect(typeof boolVal).toBe('boolean'); + expect(typeof numVal).toBe('number'); + }); + + it('TriggerParameterDef has required fields', () => { + const paramDef: TriggerParameterDef = { + name: 'testParam', + type: 'string', + label: 'Test Parameter', + description: null, + required: false, + defaultValue: null, + options: null, + }; + + expect(paramDef.name).toBe('testParam'); + expect(paramDef.type).toBe('string'); + expect(paramDef.label).toBe('Test Parameter'); + }); + + it('ResolvedTrigger has required fields', () => { + const trigger: ResolvedTrigger = { + event: 'pm:card-moved', + label: 'Card Moved', + description: null, + providers: ['trello'], + enabled: true, + parameters: {}, + parameterDefs: [], + isCustomized: false, + }; + + expect(trigger.event).toBe('pm:card-moved'); + expect(trigger.enabled).toBe(true); + expect(trigger.isCustomized).toBe(false); + }); + + it('TriggerCategory is a valid union type', () => { + const pm: TriggerCategory = 'pm'; + const scm: TriggerCategory = 'scm'; + const email: TriggerCategory = 'email'; + const sms: TriggerCategory = 'sms'; + + expect([pm, scm, email, sms]).toEqual(['pm', 'scm', 'email', 'sms']); + }); + + it('ProjectTriggersView has agents and integrations', () => { + const view: ProjectTriggersView = { + agents: [ + { + agentType: 'implementation', + triggers: [], + }, + ], + integrations: { + pm: 'trello', + scm: 'github', + email: null, + sms: null, + }, + }; + + expect(view.agents).toHaveLength(1); + expect(view.integrations.pm).toBe('trello'); + expect(view.integrations.scm).toBe('github'); + }); + }); +}); diff --git a/tests/unit/web/triggerAgentMapping.test.ts b/tests/unit/web/triggerAgentMapping.test.ts index f964604d..59062e23 100644 --- a/tests/unit/web/triggerAgentMapping.test.ts +++ b/tests/unit/web/triggerAgentMapping.test.ts @@ -2,99 +2,13 @@ import { describe, expect, it } from 'vitest'; import { AGENT_LABELS, ALL_AGENT_TYPES, + CATEGORY_LABELS, EMAIL_TRIGGER_AGENTS, - getTriggersForAgent, + LIFECYCLE_TRIGGERS, + getTriggerValue, + setTriggerValue, } from '../../../web/src/lib/trigger-agent-mapping.js'; -describe('getTriggersForAgent', () => { - it('returns all triggers when no opts given (backward compatibility)', () => { - const triggers = getTriggersForAgent('review'); - expect(triggers).toHaveLength(4); - expect(triggers.map((t) => t.key)).toEqual([ - 'reviewTrigger.ownPrsOnly', - 'reviewTrigger.externalPrs', - 'reviewTrigger.onReviewRequested', - 'prOpened', - ]); - }); - - it('returns empty array for review agent with category: pm', () => { - const triggers = getTriggersForAgent('review', { category: 'pm' }); - expect(triggers).toHaveLength(0); - }); - - it('returns 4 review triggers for review agent with category: scm', () => { - const triggers = getTriggersForAgent('review', { category: 'scm' }); - expect(triggers).toHaveLength(4); - for (const t of triggers) { - expect(t.category).toBe('scm'); - } - }); - - it('returns PM-only triggers for splitting with category: pm and pmProvider: trello', () => { - const triggers = getTriggersForAgent('splitting', { category: 'pm', pmProvider: 'trello' }); - expect(triggers.length).toBeGreaterThan(0); - for (const t of triggers) { - expect(t.category).toBe('pm'); - // Should not include JIRA-only triggers - if (t.pmProvider) { - expect(t.pmProvider).toBe('trello'); - } - } - const keys = triggers.map((t) => t.key); - expect(keys).toContain('cardMovedToSplitting'); - expect(keys).toContain('readyToProcessLabel.splitting'); - expect(keys).not.toContain('issueTransitioned.splitting'); - }); - - it('returns empty array for splitting with category: scm', () => { - const triggers = getTriggersForAgent('splitting', { category: 'scm' }); - expect(triggers).toHaveLength(0); - }); - - it('filters by pmProvider without category', () => { - const jiraTriggers = getTriggersForAgent('splitting', { pmProvider: 'jira' }); - const trelloTriggers = getTriggersForAgent('splitting', { pmProvider: 'trello' }); - // JIRA provider should exclude cardMovedToSplitting (trello-only) - expect(jiraTriggers.map((t) => t.key)).not.toContain('cardMovedToSplitting'); - // Trello provider should exclude issueTransitioned.splitting (jira-only) - expect(trelloTriggers.map((t) => t.key)).not.toContain('issueTransitioned.splitting'); - }); - - it('returns empty array for unknown agent type', () => { - const triggers = getTriggersForAgent('unknown-agent', { category: 'pm' }); - expect(triggers).toHaveLength(0); - }); -}); - -describe('getTriggersForAgent — review trigger dot-notation keys and defaults', () => { - it('returns dot-notation keys for review SCM triggers', () => { - const triggerDefs = getTriggersForAgent('review', { category: 'scm' }); - - // Verify that the trigger definitions have the expected dot-notation keys - expect(triggerDefs.map((t) => t.key)).toEqual([ - 'reviewTrigger.ownPrsOnly', - 'reviewTrigger.externalPrs', - 'reviewTrigger.onReviewRequested', - 'prOpened', - ]); - - // Verify each trigger has the correct category - for (const t of triggerDefs) { - expect(t.category).toBe('scm'); - } - }); - - it('returns correct defaultValues for review triggers', () => { - const triggers = getTriggersForAgent('review', { category: 'scm' }); - const defaults = Object.fromEntries(triggers.map((t) => [t.key, t.defaultValue])); - expect(defaults['reviewTrigger.ownPrsOnly']).toBe(false); - expect(defaults['reviewTrigger.externalPrs']).toBe(false); - expect(defaults['reviewTrigger.onReviewRequested']).toBe(false); - expect(defaults.prOpened).toBe(false); - }); -}); - describe('ALL_AGENT_TYPES', () => { it('includes email-joke', () => { expect(ALL_AGENT_TYPES).toContain('email-joke'); @@ -155,16 +69,113 @@ describe('EMAIL_TRIGGER_AGENTS', () => { }); }); -describe('getTriggersForAgent — email-joke', () => { - it('returns empty array for email-joke (triggers are handled by a custom widget, not toggles)', () => { - expect(getTriggersForAgent('email-joke')).toHaveLength(0); +describe('CATEGORY_LABELS', () => { + it('exports category labels from shared types', () => { + expect(CATEGORY_LABELS.pm).toBe('Project Management'); + expect(CATEGORY_LABELS.scm).toBe('Source Control'); + }); +}); + +describe('LIFECYCLE_TRIGGERS', () => { + it('contains prReadyToMerge and prMerged triggers', () => { + const keys = LIFECYCLE_TRIGGERS.map((t) => t.key); + expect(keys).toContain('prReadyToMerge'); + expect(keys).toContain('prMerged'); + }); + + it('all triggers have required fields', () => { + for (const trigger of LIFECYCLE_TRIGGERS) { + expect(trigger.key).toBeDefined(); + expect(trigger.label).toBeDefined(); + expect(trigger.description).toBeDefined(); + expect(typeof trigger.defaultValue).toBe('boolean'); + expect(trigger.category).toBe('scm'); + } + }); +}); + +describe('getTriggerValue', () => { + it('returns default value when key is not present', () => { + expect(getTriggerValue({}, 'cardMovedToSplitting', true)).toBe(true); + expect(getTriggerValue({}, 'cardMovedToSplitting', false)).toBe(false); }); - it('returns empty array for email-joke with category: pm', () => { - expect(getTriggersForAgent('email-joke', { category: 'pm' })).toHaveLength(0); + it('returns boolean value for simple key', () => { + expect(getTriggerValue({ cardMovedToSplitting: true }, 'cardMovedToSplitting', false)).toBe( + true, + ); + expect(getTriggerValue({ cardMovedToSplitting: false }, 'cardMovedToSplitting', true)).toBe( + false, + ); }); - it('returns empty array for email-joke with category: scm', () => { - expect(getTriggersForAgent('email-joke', { category: 'scm' })).toHaveLength(0); + it('handles nested keys (dot notation)', () => { + const triggers = { + readyToProcessLabel: { + splitting: true, + planning: false, + }, + }; + expect(getTriggerValue(triggers, 'readyToProcessLabel.splitting', false)).toBe(true); + expect(getTriggerValue(triggers, 'readyToProcessLabel.planning', true)).toBe(false); + }); + + it('handles legacy boolean for nested keys', () => { + const triggers = { readyToProcessLabel: true }; + expect(getTriggerValue(triggers, 'readyToProcessLabel.splitting', false)).toBe(true); + expect(getTriggerValue(triggers, 'readyToProcessLabel.planning', false)).toBe(true); + + const triggersFalse = { readyToProcessLabel: false }; + expect(getTriggerValue(triggersFalse, 'readyToProcessLabel.splitting', true)).toBe(false); + }); + + it('returns default when nested key is missing from object', () => { + const triggers = { + readyToProcessLabel: { splitting: true }, + }; + expect(getTriggerValue(triggers, 'readyToProcessLabel.implementation', true)).toBe(true); + }); +}); + +describe('setTriggerValue', () => { + it('sets simple key', () => { + const result = setTriggerValue({}, 'cardMovedToSplitting', true); + expect(result.cardMovedToSplitting).toBe(true); + }); + + it('preserves existing keys', () => { + const result = setTriggerValue({ existingKey: 'value' }, 'newKey', true); + expect(result.existingKey).toBe('value'); + expect(result.newKey).toBe(true); + }); + + it('sets nested key creating object structure', () => { + const result = setTriggerValue({}, 'readyToProcessLabel.splitting', true); + expect(result.readyToProcessLabel).toEqual({ splitting: true }); + }); + + it('expands legacy boolean to object when setting nested key', () => { + const result = setTriggerValue( + { readyToProcessLabel: true }, + 'readyToProcessLabel.splitting', + false, + ); + expect(result.readyToProcessLabel).toEqual({ + splitting: false, + planning: true, + implementation: true, + }); + }); + + it('merges into existing nested object', () => { + const triggers = { + readyToProcessLabel: { splitting: true, planning: false }, + }; + const result = setTriggerValue(triggers, 'readyToProcessLabel.implementation', true); + expect(result.readyToProcessLabel).toEqual({ + splitting: true, + planning: false, + implementation: true, + }); }); }); diff --git a/tools/migrate-triggers.ts b/tools/migrate-triggers.ts new file mode 100644 index 00000000..947963dd --- /dev/null +++ b/tools/migrate-triggers.ts @@ -0,0 +1,344 @@ +#!/usr/bin/env tsx +/** + * Migrate existing trigger configs from project_integrations.triggers JSONB + * to the new agent_trigger_configs table. + * + * This script reads the legacy triggers from project_integrations and creates + * corresponding rows in agent_trigger_configs using the new event format. + * + * Usage: + * npx tsx tools/migrate-triggers.ts [--dry-run] + * + * The script will: + * 1. Scan all project_integrations for non-empty triggers + * 2. Map legacy keys to new event format + * 3. Upsert into agent_trigger_configs (skip if row exists) + * 4. Log: projects migrated, configs created, skipped + */ + +import { and, eq, sql } from 'drizzle-orm'; +import { closeDb, getDb } from '../src/db/client.js'; +import { agentTriggerConfigs, projectIntegrations } from '../src/db/schema/index.js'; + +// ============================================================================ +// Legacy Key Mappings +// ============================================================================ + +interface TriggerMapping { + agentType: string; + event: string; + parameters?: Record; +} + +// PM triggers (Trello card-moved) +const PM_CARD_MOVED_MAPPINGS: Record = { + cardMovedToSplitting: { agentType: 'splitting', event: 'pm:card-moved' }, + cardMovedToPlanning: { agentType: 'planning', event: 'pm:card-moved' }, + cardMovedToTodo: { agentType: 'implementation', event: 'pm:card-moved' }, +}; + +// PM triggers (JIRA issue-transitioned, nested under issueTransitioned object) +const PM_ISSUE_TRANSITIONED_AGENTS = ['splitting', 'planning', 'implementation'] as const; + +// PM triggers (label-added, nested under readyToProcessLabel object) +const PM_LABEL_ADDED_AGENTS = ['splitting', 'planning', 'implementation'] as const; + +// SCM triggers (GitHub) +const SCM_SIMPLE_MAPPINGS: Record = { + checkSuiteFailure: { agentType: 'respond-to-ci', event: 'scm:check-suite-failure' }, + prReviewSubmitted: { agentType: 'respond-to-review', event: 'scm:pr-review-submitted' }, + prCommentMention: { agentType: 'respond-to-pr-comment', event: 'scm:pr-comment-mention' }, + prOpened: { agentType: 'review', event: 'scm:pr-opened' }, +}; + +// SCM triggers (review trigger nested under reviewTrigger object) +const REVIEW_TRIGGER_MAPPINGS: Record = { + ownPrsOnly: { + agentType: 'review', + event: 'scm:check-suite-success', + parameters: { authorMode: 'own' }, + }, + externalPrs: { + agentType: 'review', + event: 'scm:check-suite-success', + parameters: { authorMode: 'external' }, + }, + onReviewRequested: { agentType: 'review', event: 'scm:review-requested' }, +}; + +// ============================================================================ +// Migration Logic +// ============================================================================ + +interface MigrationStats { + integrationsMigrated: number; + configsCreated: number; + configsSkipped: number; + configsUpdated: number; +} + +interface LegacyTriggers { + // PM (Trello) + cardMovedToSplitting?: boolean; + cardMovedToPlanning?: boolean; + cardMovedToTodo?: boolean; + // PM (JIRA) - can be boolean (applies to all) or object + issueTransitioned?: + | boolean + | { splitting?: boolean; planning?: boolean; implementation?: boolean }; + // PM (label-added) - can be boolean or object + readyToProcessLabel?: + | boolean + | { splitting?: boolean; planning?: boolean; implementation?: boolean }; + // PM (comment mention) + commentMention?: boolean; + // SCM (GitHub) + checkSuiteFailure?: boolean; + prReviewSubmitted?: boolean; + prCommentMention?: boolean; + prOpened?: boolean; + // SCM (GitHub review trigger) - nested object + reviewTrigger?: { + ownPrsOnly?: boolean; + externalPrs?: boolean; + onReviewRequested?: boolean; + }; + // Lifecycle (kept in legacy table, not migrated) + prReadyToMerge?: boolean; + prMerged?: boolean; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: migration script with straightforward nested logic +async function migrateIntegration( + db: ReturnType, + integration: { id: number; projectId: string; category: string; triggers: unknown }, + dryRun: boolean, + stats: MigrationStats, +): Promise { + const triggers = integration.triggers as LegacyTriggers; + if (!triggers || typeof triggers !== 'object') { + return; + } + + const projectId = integration.projectId; + const configsToCreate: Array<{ + agentType: string; + event: string; + enabled: boolean; + parameters: Record; + }> = []; + + // Process PM triggers + if (integration.category === 'pm') { + // Card-moved triggers (Trello) + for (const [key, mapping] of Object.entries(PM_CARD_MOVED_MAPPINGS)) { + const value = triggers[key as keyof LegacyTriggers]; + if (typeof value === 'boolean') { + configsToCreate.push({ + agentType: mapping.agentType, + event: mapping.event, + enabled: value, + parameters: {}, + }); + } + } + + // Issue-transitioned triggers (JIRA) + const issueTransitioned = triggers.issueTransitioned; + if (issueTransitioned !== undefined) { + if (typeof issueTransitioned === 'boolean') { + // Boolean applies to all agents + for (const agentType of PM_ISSUE_TRANSITIONED_AGENTS) { + configsToCreate.push({ + agentType, + event: 'pm:issue-transitioned', + enabled: issueTransitioned, + parameters: {}, + }); + } + } else if (typeof issueTransitioned === 'object') { + // Per-agent settings + for (const agentType of PM_ISSUE_TRANSITIONED_AGENTS) { + const value = issueTransitioned[agentType]; + if (typeof value === 'boolean') { + configsToCreate.push({ + agentType, + event: 'pm:issue-transitioned', + enabled: value, + parameters: {}, + }); + } + } + } + } + + // Label-added triggers + const readyToProcessLabel = triggers.readyToProcessLabel; + if (readyToProcessLabel !== undefined) { + if (typeof readyToProcessLabel === 'boolean') { + // Boolean applies to all agents + for (const agentType of PM_LABEL_ADDED_AGENTS) { + configsToCreate.push({ + agentType, + event: 'pm:label-added', + enabled: readyToProcessLabel, + parameters: {}, + }); + } + } else if (typeof readyToProcessLabel === 'object') { + // Per-agent settings + for (const agentType of PM_LABEL_ADDED_AGENTS) { + const value = readyToProcessLabel[agentType]; + if (typeof value === 'boolean') { + configsToCreate.push({ + agentType, + event: 'pm:label-added', + enabled: value, + parameters: {}, + }); + } + } + } + } + + // Comment mention (affects planning and respond-to-planning-comment) + if (typeof triggers.commentMention === 'boolean') { + configsToCreate.push({ + agentType: 'planning', + event: 'pm:comment-mention', + enabled: triggers.commentMention, + parameters: {}, + }); + configsToCreate.push({ + agentType: 'respond-to-planning-comment', + event: 'pm:comment-mention', + enabled: triggers.commentMention, + parameters: {}, + }); + } + } + + // Process SCM triggers + if (integration.category === 'scm') { + // Simple SCM triggers + for (const [key, mapping] of Object.entries(SCM_SIMPLE_MAPPINGS)) { + const value = triggers[key as keyof LegacyTriggers]; + if (typeof value === 'boolean') { + configsToCreate.push({ + agentType: mapping.agentType, + event: mapping.event, + enabled: value, + parameters: mapping.parameters ?? {}, + }); + } + } + + // Review trigger (nested) + const reviewTrigger = triggers.reviewTrigger; + if (reviewTrigger && typeof reviewTrigger === 'object') { + for (const [key, mapping] of Object.entries(REVIEW_TRIGGER_MAPPINGS)) { + const value = reviewTrigger[key as keyof typeof reviewTrigger]; + if (typeof value === 'boolean') { + configsToCreate.push({ + agentType: mapping.agentType, + event: mapping.event, + enabled: value, + parameters: mapping.parameters ?? {}, + }); + } + } + } + } + + // Upsert each config + for (const config of configsToCreate) { + // Check if config already exists + const [existing] = await db + .select({ id: agentTriggerConfigs.id }) + .from(agentTriggerConfigs) + .where( + and( + eq(agentTriggerConfigs.projectId, projectId), + eq(agentTriggerConfigs.agentType, config.agentType), + eq(agentTriggerConfigs.triggerEvent, config.event), + ), + ); + + if (existing) { + stats.configsSkipped++; + console.log(` [skip] ${projectId}/${config.agentType}/${config.event}: already exists`); + continue; + } + + if (dryRun) { + stats.configsCreated++; + console.log( + ` [would create] ${projectId}/${config.agentType}/${config.event}: enabled=${config.enabled}`, + ); + } else { + await db.insert(agentTriggerConfigs).values({ + projectId, + agentType: config.agentType, + triggerEvent: config.event, + enabled: config.enabled, + parameters: config.parameters, + }); + stats.configsCreated++; + console.log( + ` [created] ${projectId}/${config.agentType}/${config.event}: enabled=${config.enabled}`, + ); + } + } + + if (configsToCreate.length > 0) { + stats.integrationsMigrated++; + } +} + +async function main() { + const dryRun = process.argv.includes('--dry-run'); + + console.log(`\nTrigger Config Migration${dryRun ? ' (DRY RUN)' : ''}`); + console.log('='.repeat(50)); + + const db = getDb(); + + // Get all integrations with non-empty triggers + const integrations = await db + .select({ + id: projectIntegrations.id, + projectId: projectIntegrations.projectId, + category: projectIntegrations.category, + triggers: projectIntegrations.triggers, + }) + .from(projectIntegrations) + .where(sql`${projectIntegrations.triggers} != '{}'::jsonb`); + + console.log(`\nFound ${integrations.length} integrations with trigger configs\n`); + + const stats: MigrationStats = { + integrationsMigrated: 0, + configsCreated: 0, + configsSkipped: 0, + configsUpdated: 0, + }; + + for (const integration of integrations) { + console.log(`Processing: ${integration.projectId} (${integration.category})`); + await migrateIntegration(db, integration, dryRun, stats); + } + + console.log(`\n${'='.repeat(50)}`); + console.log(`${dryRun ? '[DRY RUN] ' : ''}Migration Summary:`); + console.log(` Integrations processed: ${stats.integrationsMigrated}`); + console.log(` Configs created: ${stats.configsCreated}`); + console.log(` Configs skipped (already exist): ${stats.configsSkipped}`); + console.log(''); + + await closeDb(); +} + +main().catch((err) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 9e573830..1c1046c5 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -1,4 +1,8 @@ import { ModelField } from '@/components/settings/model-field.js'; +import { + DefinitionTriggerToggles, + type ResolvedTrigger, +} from '@/components/shared/definition-trigger-toggles.js'; import { TriggerToggles } from '@/components/shared/trigger-toggles.js'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; import { Input } from '@/components/ui/input.js'; @@ -12,18 +16,18 @@ import { } from '@/components/ui/select.js'; import { AGENT_LABELS, - ALL_AGENT_TYPES, + CATEGORY_LABELS, EMAIL_TRIGGER_AGENTS, type KnownAgentType, LIFECYCLE_TRIGGERS, - SHARED_PM_TRIGGERS, - getTriggersForAgent, + type TriggerParameterValue, } from '@/lib/trigger-agent-mapping.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { EmailJokeConfig } from './email-wizard.js'; interface AgentConfig { @@ -46,107 +50,73 @@ function AgentConfigBadge({ config }: { config: AgentConfig | null }) { return {parts.join(' · ')}; } -/** - * Extract only the trigger keys relevant to the given trigger definitions - * from the full triggers record. Handles nested dot-notation keys. - */ -function extractRelevantTriggers( - triggerDefs: ReturnType, - allTriggers: Record, -): Record { - const relevant: Record = {}; - for (const t of triggerDefs) { - const parts = t.key.split('.'); - if (parts.length > 1) { - const [parent, child] = parts; - if (!(parent in relevant)) { - relevant[parent] = {}; - } - const parentObj = - typeof allTriggers[parent] === 'object' && allTriggers[parent] !== null - ? allTriggers[parent] - : {}; - (relevant[parent] as Record)[child] = - ((parentObj as Record)[child] as boolean) ?? t.defaultValue; - } else { - relevant[t.key] = allTriggers[t.key] ?? t.defaultValue; - } - } - return relevant; -} +// ============================================================================ +// Definition-Based Agent Section (New) +// ============================================================================ -function AgentSection({ - agentType, - projectId, - config, - pmTriggers, - scmTriggers, - pmProvider, - onEditConfig, - onDeleteConfig, - onSaveTriggers, -}: { +interface DefinitionAgentSectionProps { agentType: string; - /** Only required for agents in EMAIL_TRIGGER_AGENTS */ - projectId?: string; + projectId: string; config: AgentConfig | null; - pmTriggers: Record; - scmTriggers: Record; - pmProvider: string; + triggers: ResolvedTrigger[]; + integrations: { + pm: string | null; + scm: string | null; + email: string | null; + sms: string | null; + }; onEditConfig: (config: AgentConfig | null, agentType: string) => void; onDeleteConfig: (id: number) => void; - onSaveTriggers: ( - category: 'pm' | 'scm', - triggers: Record, + onTriggerToggle: (agentType: string, event: string, enabled: boolean) => void; + onTriggerParamChange: ( agentType: string, + event: string, + parameters: Record, + currentEnabled: boolean, ) => void; -}) { +} + +function DefinitionAgentSection({ + agentType, + projectId, + config, + triggers, + integrations, + onEditConfig, + onDeleteConfig, + onTriggerToggle, + onTriggerParamChange, +}: DefinitionAgentSectionProps) { const [expanded, setExpanded] = useState(false); - const [localPmTriggers, setLocalPmTriggers] = useState>(pmTriggers); - const [localScmTriggers, setLocalScmTriggers] = useState>(scmTriggers); - const [pmSaving, setPmSaving] = useState(false); - const [scmSaving, setScmSaving] = useState(false); - const [pmSaved, setPmSaved] = useState(false); - const [scmSaved, setScmSaved] = useState(false); - - const agentPmTriggers = getTriggersForAgent(agentType, { pmProvider, category: 'pm' }); - const agentScmTriggers = getTriggersForAgent(agentType, { category: 'scm' }); const hasEmailTriggers = EMAIL_TRIGGER_AGENTS.has(agentType as KnownAgentType); - const hasTriggers = agentPmTriggers.length > 0 || agentScmTriggers.length > 0 || hasEmailTriggers; - - // Sync local state when props change (e.g., after another agent section saves shared triggers) - useEffect(() => { - setLocalPmTriggers(pmTriggers); - }, [pmTriggers]); - - useEffect(() => { - setLocalScmTriggers(scmTriggers); - }, [scmTriggers]); - - const handleSavePm = async () => { - setPmSaving(true); - try { - const relevant = extractRelevantTriggers(agentPmTriggers, localPmTriggers); - await onSaveTriggers('pm', relevant, agentType); - setPmSaved(true); - setTimeout(() => setPmSaved(false), 2000); - } finally { - setPmSaving(false); + // Group triggers by category and filter by active integrations + const triggersByCategory = useMemo(() => { + const groups: Record = { pm: [], scm: [], email: [], sms: [] }; + + for (const trigger of triggers) { + // Extract category from event (e.g., "pm:card-moved" -> "pm") + const [category] = trigger.event.split(':'); + if (category in groups) { + // Filter by provider if the trigger has provider restrictions + if (trigger.providers && trigger.providers.length > 0) { + const activeProvider = integrations[category as keyof typeof integrations]; + const matchesProvider = trigger.providers.some((p) => p === activeProvider); + if (!matchesProvider) continue; + } + groups[category].push(trigger); + } } - }; - const handleSaveScm = async () => { - setScmSaving(true); - try { - const relevant = extractRelevantTriggers(agentScmTriggers, localScmTriggers); - await onSaveTriggers('scm', relevant, agentType); - setScmSaved(true); - setTimeout(() => setScmSaved(false), 2000); - } finally { - setScmSaving(false); - } - }; + return groups; + }, [triggers, integrations]); + + const hasTriggers = + triggersByCategory.pm.length > 0 || + triggersByCategory.scm.length > 0 || + triggersByCategory.email.length > 0 || + triggersByCategory.sms.length > 0 || + hasEmailTriggers; return (
@@ -197,63 +167,35 @@ function AgentSection({ {/* Expanded content */} {expanded && (
- {/* PM Triggers */} - {agentPmTriggers.length > 0 && ( -
-

- Project Management Triggers -

- -
- - {pmSaved && Saved} -
-
- )} - - {/* SCM Triggers */} - {agentScmTriggers.length > 0 && ( -
-

- GitHub Triggers -

- -
- - {scmSaved && Saved} + {/* Render triggers by category */} + {(['pm', 'scm', 'email', 'sms'] as const).map((category) => { + const categoryTriggers = triggersByCategory[category]; + if (categoryTriggers.length === 0) return null; + + return ( +
+

+ {CATEGORY_LABELS[category] ?? category} Triggers +

+ onTriggerToggle(agentType, event, enabled)} + onParamChange={(event, params) => { + // Find the current trigger to get its enabled state + const currentTrigger = categoryTriggers.find((t) => t.event === event); + onTriggerParamChange(agentType, event, params, currentTrigger?.enabled ?? true); + }} + idPrefix={`${agentType}-${category}`} + />
-
- )} + ); + })} - {/* Email Triggers */} - {hasEmailTriggers && projectId && ( + {/* Email Triggers (custom widget) */} + {hasEmailTriggers && (

- Email Triggers + Email Configuration

@@ -270,10 +212,23 @@ function AgentSection({ ); } +// ============================================================================ +// Main Component +// ============================================================================ + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: manages multiple mutations + state for agent configs and trigger updates export function ProjectAgentConfigs({ projectId }: { projectId: string }) { const queryClient = useQueryClient(); + + // Agent configs query const configsQuery = useQuery(trpc.agentConfigs.list.queryOptions({ projectId })); + + // Definition-based triggers query + const triggersViewQuery = useQuery( + trpc.agentTriggerConfigs.getProjectTriggersView.queryOptions({ projectId }), + ); + + // Integrations query (for lifecycle triggers) const integrationsQuery = useQuery(trpc.projects.integrations.list.queryOptions({ projectId })); const [dialogOpen, setDialogOpen] = useState(false); @@ -285,11 +240,11 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { const [localLifecycleTriggers, setLocalLifecycleTriggers] = useState>({}); const [lifecycleSaving, setLifecycleSaving] = useState(false); const [lifecycleSaved, setLifecycleSaved] = useState(false); - const [localSharedPmTriggers, setLocalSharedPmTriggers] = useState>({}); - const [sharedPmSaving, setSharedPmSaving] = useState(false); - const [sharedPmSaved, setSharedPmSaved] = useState(false); const configsQueryKey = trpc.agentConfigs.list.queryOptions({ projectId }).queryKey; + const triggersViewQueryKey = trpc.agentTriggerConfigs.getProjectTriggersView.queryOptions({ + projectId, + }).queryKey; const integrationsQueryKey = trpc.projects.integrations.list.queryOptions({ projectId }).queryKey; function openCreate(defaultAgentType: string) { @@ -310,6 +265,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { setDialogOpen(true); } + // Agent config mutations (shared) const createMutation = useMutation({ mutationFn: () => trpcClient.agentConfigs.create.mutate({ @@ -345,6 +301,27 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { onSuccess: () => queryClient.invalidateQueries({ queryKey: configsQueryKey }), }); + // New trigger mutation (uses agentTriggerConfigs.upsert) + const upsertTriggerMutation = useMutation({ + mutationFn: (input: { + agentType: string; + triggerEvent: string; + enabled?: boolean; + parameters?: Record; + }) => + trpcClient.agentTriggerConfigs.upsert.mutate({ + projectId, + agentType: input.agentType, + triggerEvent: input.triggerEvent, + enabled: input.enabled, + parameters: input.parameters, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: triggersViewQueryKey }); + }, + }); + + // Lifecycle trigger mutation (uses legacy save mechanism) const updateTriggersMutation = useMutation({ mutationFn: ({ category, @@ -360,43 +337,47 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { }, }); - // Derive trigger values from query data (safe to compute even while loading — defaults to {}) + // Derive trigger values for lifecycle triggers const integrations = (integrationsQuery.data ?? []) as Array>; - const pmIntegration = integrations.find((i) => i.category === 'pm'); const scmIntegration = integrations.find((i) => i.category === 'scm'); const emptyTriggers = useMemo>(() => ({}), []); - const pmTriggers = (pmIntegration?.triggers as Record) ?? emptyTriggers; const scmTriggers = (scmIntegration?.triggers as Record) ?? emptyTriggers; - // Sync lifecycle and shared PM trigger state from query data (must be before early return) + // Sync lifecycle trigger state useEffect(() => { setLocalLifecycleTriggers(scmTriggers); }, [scmTriggers]); - useEffect(() => { - setLocalSharedPmTriggers(pmTriggers); - }, [pmTriggers]); + // Loading state + const isLoading = configsQuery.isLoading || triggersViewQuery.isLoading; - if (configsQuery.isLoading || integrationsQuery.isLoading) { + if (isLoading) { return
Loading agent configs...
; } const configs = (configsQuery.data ?? []) as AgentConfig[]; - const pmProvider = (pmIntegration?.provider as string) ?? 'trello'; - // Build a map of agentType → AgentConfig for quick lookup + // Build agent config map const configByAgent = new Map(); for (const c of configs) { configByAgent.set(c.agentType, c); } - // Filter shared PM triggers by current PM provider - const filteredSharedPmTriggers = SHARED_PM_TRIGGERS.filter( - (t) => !t.pmProvider || t.pmProvider === pmProvider, - ); - - const activeMutation = editing ? updateMutation : createMutation; + // Build triggers map from API + const triggersByAgent = new Map(); + const triggersViewIntegrations = triggersViewQuery.data?.integrations ?? { + pm: null, + scm: null, + email: null, + sms: null, + }; + if (triggersViewQuery.data) { + for (const agent of triggersViewQuery.data.agents) { + triggersByAgent.set(agent.agentType, agent.triggers as ResolvedTrigger[]); + } + } + // Handlers const handleEditConfig = (config: AgentConfig | null, type: string) => { if (config) { openEdit(config); @@ -405,12 +386,36 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { } }; - const handleSaveTriggers = async ( - category: 'pm' | 'scm', - triggers: Record, - _agentType: string, + const handleTriggerToggle = (agentType: string, event: string, enabled: boolean) => { + upsertTriggerMutation.mutate( + { agentType, triggerEvent: event, enabled }, + { + onError: (err) => { + toast.error('Failed to update trigger', { + description: err.message, + }); + }, + }, + ); + }; + + const handleTriggerParamChange = ( + agentType: string, + event: string, + parameters: Record, + currentEnabled: boolean, ) => { - await updateTriggersMutation.mutateAsync({ category, triggers }); + // Include the current enabled state to avoid overwriting it + upsertTriggerMutation.mutate( + { agentType, triggerEvent: event, enabled: currentEnabled, parameters }, + { + onError: (err) => { + toast.error('Failed to update trigger parameters', { + description: err.message, + }); + }, + }, + ); }; const handleSaveLifecycle = async () => { @@ -430,22 +435,10 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { } }; - const handleSaveSharedPm = async () => { - setSharedPmSaving(true); - try { - const changed: Record = {}; - for (const t of filteredSharedPmTriggers) { - if (t.key in localSharedPmTriggers) { - changed[t.key] = localSharedPmTriggers[t.key]; - } - } - await updateTriggersMutation.mutateAsync({ category: 'pm', triggers: changed }); - setSharedPmSaved(true); - setTimeout(() => setSharedPmSaved(false), 2000); - } finally { - setSharedPmSaving(false); - } - }; + const activeMutation = editing ? updateMutation : createMutation; + + // Get list of agent types to display + const agentTypes = Array.from(triggersByAgent.keys()); return (
@@ -455,69 +448,22 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { {/* Agent sections */}
- {ALL_AGENT_TYPES.map((type) => ( - ( + deleteMutation.mutate(id)} - onSaveTriggers={handleSaveTriggers} + onTriggerToggle={handleTriggerToggle} + onTriggerParamChange={handleTriggerParamChange} /> ))} - - {/* Custom agent types not in the known list */} - {configs - .filter((c) => !(ALL_AGENT_TYPES as readonly string[]).includes(c.agentType)) - .map((c) => ( - deleteMutation.mutate(id)} - onSaveTriggers={handleSaveTriggers} - /> - ))}
- {/* Shared PM triggers section */} - {filteredSharedPmTriggers.length > 0 && ( -
-
-

Shared PM Triggers

-

- These triggers affect multiple agent types and are controlled globally. -

-
- -
- - {sharedPmSaved && Saved} -
-
- )} - {/* Lifecycle triggers section */} {LIFECYCLE_TRIGGERS.length > 0 && (
diff --git a/web/src/components/shared/definition-trigger-toggles.tsx b/web/src/components/shared/definition-trigger-toggles.tsx new file mode 100644 index 00000000..0908259b --- /dev/null +++ b/web/src/components/shared/definition-trigger-toggles.tsx @@ -0,0 +1,94 @@ +import { Badge } from '@/components/ui/badge.js'; +import { TriggerParameterInput } from './trigger-parameter-input.js'; + +// Re-export the ResolvedTrigger type from shared location +export type { ResolvedTrigger } from '../../../../src/api/routers/_shared/triggerTypes.js'; + +import type { + ResolvedTrigger, + TriggerParameterValue, +} from '../../../../src/api/routers/_shared/triggerTypes.js'; + +interface Props { + triggers: ResolvedTrigger[]; + onToggle: (event: string, enabled: boolean) => void; + onParamChange: (event: string, parameters: Record) => void; + idPrefix?: string; + disabled?: boolean; +} + +/** + * Renders trigger toggles from agent definitions with support for parameters. + * Uses the definition-based trigger format from getProjectTriggersView. + */ +export function DefinitionTriggerToggles({ + triggers, + onToggle, + onParamChange, + idPrefix, + disabled, +}: Props) { + if (triggers.length === 0) return null; + + return ( +
+ {triggers.map((trigger) => { + const htmlId = `trigger-${idPrefix ? `${idPrefix}-` : ''}${trigger.event}`; + const hasParams = trigger.parameterDefs.length > 0; + + return ( +
+
+ onToggle(trigger.event, e.target.checked)} + disabled={disabled} + className="mt-0.5 h-4 w-4 rounded border-input" + aria-describedby={trigger.description ? `${htmlId}-desc` : undefined} + /> +
+
+ + {trigger.isCustomized && ( + + customized + + )} +
+ {trigger.description && ( +

+ {trigger.description} +

+ )} +
+
+ + {/* Render parameters inline when trigger is enabled and has params */} + {hasParams && trigger.enabled && ( +
+ {trigger.parameterDefs.map((param) => ( + { + onParamChange(trigger.event, { + ...trigger.parameters, + [param.name]: newValue, + }); + }} + disabled={disabled} + /> + ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/web/src/components/shared/trigger-parameter-input.tsx b/web/src/components/shared/trigger-parameter-input.tsx new file mode 100644 index 00000000..39ba5e30 --- /dev/null +++ b/web/src/components/shared/trigger-parameter-input.tsx @@ -0,0 +1,142 @@ +import { Input } from '@/components/ui/input.js'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select.js'; + +// Re-export types from shared location for convenience +export type { + TriggerParameterDef, + TriggerParameterValue, +} from '../../../../src/api/routers/_shared/triggerTypes.js'; + +import type { + TriggerParameterDef, + TriggerParameterValue, +} from '../../../../src/api/routers/_shared/triggerTypes.js'; + +interface Props { + parameter: TriggerParameterDef; + value: TriggerParameterValue | undefined; + onChange: (value: TriggerParameterValue) => void; + disabled?: boolean; +} + +/** + * Renders an input widget for a trigger parameter based on its type. + * Supports string, email, boolean, select, and number types. + */ +export function TriggerParameterInput({ parameter, value, onChange, disabled }: Props) { + // Helper to get the current value with fallback to default + const getCurrentValue = (fallback: T): T => { + if (value !== undefined) return value as T; + if (parameter.defaultValue !== undefined && parameter.defaultValue !== null) { + return parameter.defaultValue as T; + } + return fallback; + }; + + switch (parameter.type) { + case 'boolean': + return ( +
+ onChange(e.target.checked)} + disabled={disabled} + className="h-4 w-4 rounded border-input" + /> + + {parameter.description && ( + ({parameter.description}) + )} +
+ ); + + case 'select': + return ( +
+ + + {parameter.description && ( +

{parameter.description}

+ )} +
+ ); + + case 'number': + return ( +
+ + { + const num = e.target.value === '' ? 0 : Number(e.target.value); + onChange(num); + }} + placeholder={parameter.description ?? undefined} + disabled={disabled} + className="h-8 text-sm" + /> + {parameter.description && ( +

{parameter.description}

+ )} +
+ ); + + // 'email' and 'string' types (and any unknown type) render a text input + default: + return ( +
+ + onChange(e.target.value)} + placeholder={parameter.description ?? undefined} + disabled={disabled} + className="h-8 text-sm" + /> + {parameter.description && ( +

{parameter.description}

+ )} +
+ ); + } +} diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index a7dab14e..af78b479 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -1,8 +1,21 @@ /** - * Defines the mapping between agent types and their trigger toggles. - * Used to render trigger configuration in the Agent Configs tab. + * Trigger mapping utilities for the Agent Configs tab. + * Uses definition-based triggers from the API via agentTriggerConfigs.getProjectTriggersView. */ +// Re-export shared types for convenience +export { TRIGGER_CATEGORY_LABELS as CATEGORY_LABELS } from '../../../src/api/routers/_shared/triggerTypes.js'; +export type { + ResolvedTrigger, + TriggerParameterDef, + TriggerParameterValue, + ProjectTriggersView, +} from '../../../src/api/routers/_shared/triggerTypes.js'; + +// ============================================================================ +// Types +// ============================================================================ + export interface TriggerDef { /** Dot-notation path into the triggers config, e.g. "cardMovedToSplitting" or "readyToProcessLabel.splitting" */ key: string; @@ -39,194 +52,6 @@ export const LIFECYCLE_TRIGGERS: TriggerDef[] = [ }, ]; -/** - * Shared PM triggers that affect multiple agent types. - * Displayed once in a dedicated section rather than duplicated per-agent. - */ -export const SHARED_PM_TRIGGERS: TriggerDef[] = [ - { - key: 'commentMention', - label: 'Comment @mention', - description: - 'Trigger agent when the bot is @mentioned in a card/issue comment. Affects planning and respond-to-planning-comment agents.', - defaultValue: true, - category: 'pm', - }, -]; - -/** - * Map from agent type to the trigger toggles relevant to it. - */ -export const AGENT_TRIGGER_MAP: Record = { - splitting: [ - { - key: 'cardMovedToSplitting', - label: 'Card moved to Splitting', - description: 'Trigger splitting agent when a card is moved to the Splitting list.', - defaultValue: true, - pmProvider: 'trello', - category: 'pm', - }, - { - key: 'issueTransitioned.splitting', - label: 'Issue Transitioned', - description: - 'Trigger splitting agent when a JIRA issue transitions to the configured Splitting status.', - defaultValue: true, - pmProvider: 'jira', - category: 'pm', - }, - { - key: 'readyToProcessLabel.splitting', - label: 'Ready to Process label', - description: - 'Trigger splitting agent when the "Ready to Process" label is added to a card in the Splitting list.', - defaultValue: true, - category: 'pm', - }, - ], - planning: [ - { - key: 'cardMovedToPlanning', - label: 'Card moved to Planning', - description: 'Trigger planning agent when a card is moved to the Planning list.', - defaultValue: true, - pmProvider: 'trello', - category: 'pm', - }, - { - key: 'issueTransitioned.planning', - label: 'Issue Transitioned', - description: - 'Trigger planning agent when a JIRA issue transitions to the configured Planning status.', - defaultValue: true, - pmProvider: 'jira', - category: 'pm', - }, - { - key: 'readyToProcessLabel.planning', - label: 'Ready to Process label', - description: - 'Trigger planning agent when the "Ready to Process" label is added to a card in the Planning list.', - defaultValue: true, - category: 'pm', - }, - ], - implementation: [ - { - key: 'cardMovedToTodo', - label: 'Card moved to Todo', - description: 'Trigger implementation agent when a card is moved to the Todo list.', - defaultValue: true, - pmProvider: 'trello', - category: 'pm', - }, - { - key: 'issueTransitioned.implementation', - label: 'Issue Transitioned', - description: - 'Trigger implementation agent when a JIRA issue transitions to the configured Todo status.', - defaultValue: true, - pmProvider: 'jira', - category: 'pm', - }, - { - key: 'readyToProcessLabel.implementation', - label: 'Ready to Process label', - description: - 'Trigger implementation agent when the "Ready to Process" label is added to a card in the Todo list.', - defaultValue: true, - category: 'pm', - }, - ], - review: [ - { - key: 'reviewTrigger.ownPrsOnly', - label: 'Own PRs Only', - description: - 'Trigger review agent when CI passes on PRs authored by the implementer persona.', - defaultValue: false, - scmProvider: 'github', - category: 'scm', - }, - { - key: 'reviewTrigger.externalPrs', - label: 'External PRs', - description: - 'Trigger review agent when CI passes on PRs authored by anyone (not just the implementer).', - defaultValue: false, - scmProvider: 'github', - category: 'scm', - }, - { - key: 'reviewTrigger.onReviewRequested', - label: 'On Review Requested', - description: - 'Trigger review agent when a CASCADE persona is explicitly requested as reviewer.', - defaultValue: false, - scmProvider: 'github', - category: 'scm', - }, - { - key: 'prOpened', - label: 'PR Opened', - description: - 'Trigger review agent when a new PR is opened (without waiting for CI). Respects Own PRs / External PRs author modes.', - defaultValue: false, - scmProvider: 'github', - category: 'scm', - }, - ], - 'respond-to-review': [ - { - key: 'prReviewSubmitted', - label: 'PR Review Submitted', - description: 'Trigger respond-to-review when a review with changes requested is submitted.', - defaultValue: true, - scmProvider: 'github', - category: 'scm', - }, - ], - 'respond-to-ci': [ - { - key: 'checkSuiteFailure', - label: 'Check Suite Failure', - description: 'Trigger respond-to-ci agent when CI checks fail.', - defaultValue: true, - scmProvider: 'github', - category: 'scm', - }, - ], - 'respond-to-pr-comment': [ - { - key: 'prCommentMention', - label: 'PR Comment @mention', - description: - 'Trigger respond-to-pr-comment when the implementer bot is @mentioned in a PR comment.', - defaultValue: true, - scmProvider: 'github', - category: 'scm', - }, - ], - 'respond-to-planning-comment': [], - 'email-joke': [], -}; - -/** - * Get trigger definitions for a specific agent type, filtered by PM provider and/or category. - */ -export function getTriggersForAgent( - agentType: string, - opts?: { pmProvider?: string; category?: 'pm' | 'scm' }, -): TriggerDef[] { - const triggers = AGENT_TRIGGER_MAP[agentType] ?? []; - return triggers.filter((t) => { - if (opts?.category && t.category !== opts.category) return false; - if (t.pmProvider && opts?.pmProvider && t.pmProvider !== opts.pmProvider) return false; - return true; - }); -} - /** * Get the trigger value from a flat triggers record using dot-notation path. * e.g. "readyToProcessLabel.splitting" reads triggers.readyToProcessLabel.splitting @@ -320,48 +145,3 @@ export const AGENT_LABELS: Record = { /** Agent types that use email-based trigger configuration (custom widget, not toggle-based) */ export const EMAIL_TRIGGER_AGENTS = new Set(['email-joke']); - -// ============================================================================ -// Dynamic Trigger Resolution Helpers -// ============================================================================ - -/** - * Convert a definition-based trigger to the legacy TriggerDef format. - * This allows gradual migration from hardcoded AGENT_TRIGGER_MAP to definition-based triggers. - * - * @param trigger - Trigger from agent definition - * @returns TriggerDef compatible with existing dashboard components - */ -export function definitionTriggerToTriggerDef(trigger: { - event: string; - label: string; - description?: string; - defaultEnabled: boolean; - providers?: string[]; -}): TriggerDef { - // Map event category prefix to integration category - const [category] = trigger.event.split(':') as ['pm' | 'scm' | 'email' | 'sms']; - - return { - key: trigger.event, // Use event as key for new triggers - label: trigger.label, - description: trigger.description ?? '', - defaultValue: trigger.defaultEnabled, - category: category === 'email' || category === 'sms' ? 'pm' : category, // Map email/sms to pm for UI - pmProvider: trigger.providers?.find((p) => p === 'trello' || p === 'jira') as - | 'trello' - | 'jira' - | undefined, - scmProvider: trigger.providers?.find((p) => p === 'github') as 'github' | undefined, - }; -} - -/** - * Check if an agent has definition-based triggers. - * Used to determine whether to use dynamic or hardcoded triggers. - */ -export function hasDefinitionTriggers(agentType: string): boolean { - // For now, always return false to keep using hardcoded mapping - // This will be updated when the dashboard is ready to consume definition triggers - return false && agentType !== ''; // Placeholder - suppress unused warning -}