diff --git a/src/agents/definitions/backlog-manager.yaml b/src/agents/definitions/backlog-manager.yaml index 6b24a6a2..3664b010 100644 --- a/src/agents/definitions/backlog-manager.yaml +++ b/src/agents/definitions/backlog-manager.yaml @@ -36,6 +36,17 @@ triggers: backlog item to be pulled into TODO. Note: when enabled, this fires for both list moves — they cannot be independently toggled. defaultEnabled: false + parameters: + - name: onMove + type: boolean + label: Fire when moved into this status + description: Fire when an existing work item is moved into the target status + defaultValue: true + - name: onCreate + type: boolean + label: Fire when created in this status + description: Fire when a work item is created directly in the target status + defaultValue: false contextPipeline: [pipelineSnapshot] - event: internal:auto-chain label: Auto-chain after Splitting diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index 5b4a6a17..a6d1add6 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -35,6 +35,16 @@ triggers: label: Target Status options: [todo] defaultValue: todo + - name: onMove + type: boolean + label: Fire when moved into this status + description: Fire when an existing work item is moved into the target status + defaultValue: true + - name: onCreate + type: boolean + label: Fire when created in this status + description: Fire when a work item is created directly in the target status + defaultValue: false contextPipeline: [directoryListing, contextFiles, workItem, prepopulateTodos] - event: pm:label-added label: Ready to Process Label diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml index ad7c1d64..b8ef9c15 100644 --- a/src/agents/definitions/planning.yaml +++ b/src/agents/definitions/planning.yaml @@ -32,6 +32,16 @@ triggers: label: Target Status options: [planning] defaultValue: planning + - name: onMove + type: boolean + label: Fire when moved into this status + description: Fire when an existing work item is moved into the target status + defaultValue: true + - name: onCreate + type: boolean + label: Fire when created in this status + description: Fire when a work item is created directly in the target status + defaultValue: false contextPipeline: [directoryListing, contextFiles, workItem] - event: pm:label-added label: Ready to Process Label diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml index 31ce82a2..d6796f6b 100644 --- a/src/agents/definitions/splitting.yaml +++ b/src/agents/definitions/splitting.yaml @@ -33,6 +33,16 @@ triggers: label: Target Status options: [splitting] defaultValue: splitting + - name: onMove + type: boolean + label: Fire when moved into this status + description: Fire when an existing work item is moved into the target status + defaultValue: true + - name: onCreate + type: boolean + label: Fire when created in this status + description: Fire when a work item is created directly in the target status + defaultValue: false contextPipeline: [directoryListing, contextFiles, workItem] - event: pm:label-added label: Ready to Process Label diff --git a/src/db/migrations/0050_trello_status_changed_on_create_backfill.sql b/src/db/migrations/0050_trello_status_changed_on_create_backfill.sql new file mode 100644 index 00000000..a66589b2 --- /dev/null +++ b/src/db/migrations/0050_trello_status_changed_on_create_backfill.sql @@ -0,0 +1,24 @@ +-- 0050_trello_status_changed_on_create_backfill.sql +-- Backfill onCreate/onMove defaults for existing Trello projects so their +-- pm:status-changed triggers preserve the pre-feature behavior (fire on both +-- createCard and updateCard). YAML defaults are onCreate=false/onMove=true, +-- which would regress existing Trello users; this migration makes each Trello +-- project's intent explicit in the DB. +-- +-- Idempotent: re-running is a no-op because '||' lets the right-hand side +-- (the existing parameters) win on key overlap. + +BEGIN; + +UPDATE agent_trigger_configs atc +SET parameters = '{"onCreate": true, "onMove": true}'::jsonb || COALESCE(atc.parameters, '{}'::jsonb) +WHERE atc.trigger_event = 'pm:status-changed' + AND EXISTS ( + SELECT 1 + FROM project_integrations pi + WHERE pi.project_id = atc.project_id + AND pi.category = 'pm' + AND pi.provider = 'trello' + ); + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 8c4136fc..80b6d90f 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1784000000000, "tag": "0049_allow_linear_pm_provider", "breakpoints": false + }, + { + "idx": 50, + "version": "7", + "when": 1785000000000, + "tag": "0050_trello_status_changed_on_create_backfill", + "breakpoints": false } ] } diff --git a/src/triggers/jira/status-changed.ts b/src/triggers/jira/status-changed.ts index 6ea2ae00..a9813602 100644 --- a/src/triggers/jira/status-changed.ts +++ b/src/triggers/jira/status-changed.ts @@ -1,28 +1,57 @@ /** * JIRA status-changed trigger. * - * Fires when a JIRA issue transitions to a configured status that maps to - * a CASCADE agent type (splitting, planning, implementation). + * Fires when a JIRA issue either transitions into or is created in a configured + * status that maps to a CASCADE agent type. + * + * Two independent triggers, gated by params: + * onMove (default true) — fire on a jira:issue_updated event with a status changelog item + * onCreate (default false) — fire on a jira:issue_created event with a resolvable status */ import { getJiraConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; -import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; import { type JiraWebhookPayload, STATUS_TO_AGENT } from './types.js'; +function isCreateEvent(payload: JiraWebhookPayload): boolean { + return payload.webhookEvent === 'jira:issue_created'; +} + +function findStatusChange( + payload: JiraWebhookPayload, +): { fromString?: string; toString?: string } | undefined { + return payload.changelog?.items?.find((item) => item.field === 'status'); +} + /** * Resolve the new status name from a JIRA webhook payload. * Returns `undefined` when the status cannot be determined. */ function resolveNewStatus(payload: JiraWebhookPayload): string | undefined { - if (payload.webhookEvent === 'jira:issue_created') { - // For creation events, read status directly from issue fields + if (isCreateEvent(payload)) { return payload.issue?.fields?.status?.name; } - // For update events, status comes from the changelog - const statusChange = payload.changelog?.items?.find((item) => item.field === 'status'); - return statusChange?.toString; + return findStatusChange(payload)?.toString; +} + +function resolveAgentType( + newStatus: string, + configStatuses: Record, +): string | undefined { + const lower = newStatus.toLowerCase(); + for (const [cascadeStatus, jiraStatus] of Object.entries(configStatuses)) { + if (jiraStatus.toLowerCase() === lower) { + return STATUS_TO_AGENT[cascadeStatus]; + } + } + return undefined; +} + +function shouldFireOnEvent(isCreate: boolean, parameters: Record): boolean { + if (isCreate) return parameters.onCreate === true; + return parameters.onMove !== false; // default true } export class JiraStatusChangedTrigger implements TriggerHandler { @@ -34,16 +63,15 @@ export class JiraStatusChangedTrigger implements TriggerHandler { const payload = ctx.payload as JiraWebhookPayload; - // Issue created directly in a status - if (payload.webhookEvent === 'jira:issue_created') { - return true; + // Create path: require resolvable status so handle() has something to map + if (isCreateEvent(payload)) { + return typeof payload.issue?.fields?.status?.name === 'string'; } if (!payload.webhookEvent?.startsWith('jira:issue_updated')) return false; - // Must have a status change in changelog - const statusChange = payload.changelog?.items?.find((item) => item.field === 'status'); - return !!statusChange; + // Update path: must have a status change in the changelog + return !!findStatusChange(payload); } async handle(ctx: TriggerContext): Promise { @@ -67,15 +95,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { return null; } - // Find which CASCADE status key maps to this JIRA status - let agentType: string | undefined; - for (const [cascadeStatus, jiraStatus] of Object.entries(jiraConfig.statuses)) { - if (jiraStatus.toLowerCase() === newStatus.toLowerCase()) { - agentType = STATUS_TO_AGENT[cascadeStatus]; - break; - } - } - + const agentType = resolveAgentType(newStatus, jiraConfig.statuses); if (!agentType) { logger.debug('JIRA status transition does not map to any agent', { issueKey, @@ -85,18 +105,34 @@ export class JiraStatusChangedTrigger implements TriggerHandler { return null; } - // Check per-agent toggle for statusChanged via new DB-driven system - if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:status-changed', this.name))) { + const { enabled, parameters } = await checkTriggerEnabledWithParams( + ctx.project.id, + agentType, + 'pm:status-changed', + this.name, + ); + if (!enabled) return null; + + const isCreate = isCreateEvent(payload); + if (!shouldFireOnEvent(isCreate, parameters)) { + logger.debug('JIRA status-changed event gated by trigger params', { + issueKey, + agentType, + eventKind: isCreate ? 'create' : 'move', + parameters, + }); return null; } - logger.info('JIRA issue transitioned to agent-triggering status', { + const statusChange = findStatusChange(payload); + logger.info('JIRA issue entered agent-triggering status', { issueKey, + eventKind: isCreate ? 'create' : 'move', + ...(isCreate ? {} : { fromStatus: statusChange?.fromString }), toStatus: newStatus, agentType, }); - // Capture work item display data from the issue payload const workItemUrl = `${jiraConfig.baseUrl}/browse/${issueKey}`; const workItemTitle = payload.issue?.fields?.summary ?? undefined; diff --git a/src/triggers/linear/status-changed.ts b/src/triggers/linear/status-changed.ts index f6114274..495d01ae 100644 --- a/src/triggers/linear/status-changed.ts +++ b/src/triggers/linear/status-changed.ts @@ -1,21 +1,42 @@ /** * Linear status-changed trigger. * - * Fires when a Linear issue transitions to a configured state (by state ID) - * that maps to a CASCADE agent type (splitting, planning, implementation). + * Fires when a Linear issue either transitions into or is created in a + * configured state (by state ID) that maps to a CASCADE agent type. * - * Linear webhook structure for status changes: - * action: 'update', type: 'Issue' - * data.stateId: new state ID - * updatedFrom.stateId: previous state ID (only present when stateId changed) + * Two independent triggers, gated by params: + * onMove (default true) — fire when data.stateId changed on an update event + * onCreate (default false) — fire when an issue is created directly in a mapped state + * + * Linear webhook shapes: + * Update: action='update', type='Issue', data.stateId=new, updatedFrom.stateId=old + * Create: action='create', type='Issue', data.stateId=initial, no updatedFrom */ import { getLinearConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; -import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; import { type LinearWebhookTriggerPayload, STATUS_TO_AGENT } from './types.js'; +function resolveAgentType( + newStateId: string, + configStatuses: Record, +): { agentType: string; cascadeStatus: string } | undefined { + for (const [cascadeStatus, linearStateId] of Object.entries(configStatuses)) { + if (linearStateId === newStateId) { + const agentType = STATUS_TO_AGENT[cascadeStatus]; + if (agentType) return { agentType, cascadeStatus }; + } + } + return undefined; +} + +function shouldFireOnEvent(isCreate: boolean, parameters: Record): boolean { + if (isCreate) return parameters.onCreate === true; + return parameters.onMove !== false; // default true +} + export class LinearStatusChangedTrigger implements TriggerHandler { name = 'linear-status-changed'; description = 'Triggers agent when a Linear issue transitions to a configured state'; @@ -26,10 +47,13 @@ export class LinearStatusChangedTrigger implements TriggerHandler { const payload = ctx.payload as LinearWebhookTriggerPayload; if (payload.type !== 'Issue') return false; - // Issue created directly in a state (no updatedFrom on create events) - if (payload.action === 'create') return true; + // Create path: require data.stateId so handle() has something to map + if (payload.action === 'create') { + const data = payload.data as Record; + return typeof data.stateId === 'string'; + } - // Issue updated with a state change indicated by updatedFrom.stateId + // Update path: state change indicated by updatedFrom.stateId if (payload.action === 'update') { return typeof payload.updatedFrom?.stateId === 'string'; } @@ -60,18 +84,8 @@ export class LinearStatusChangedTrigger implements TriggerHandler { return null; } - // Find which CASCADE status key maps to this Linear state ID - let agentType: string | undefined; - let matchedCascadeStatus: string | undefined; - for (const [cascadeStatus, linearStateId] of Object.entries(linearConfig.statuses)) { - if (linearStateId === newStateId) { - agentType = STATUS_TO_AGENT[cascadeStatus]; - matchedCascadeStatus = cascadeStatus; - break; - } - } - - if (!agentType) { + const resolved = resolveAgentType(newStateId, linearConfig.statuses); + if (!resolved) { logger.debug('Linear state transition does not map to any agent', { issueIdentifier, newStateId, @@ -79,21 +93,36 @@ export class LinearStatusChangedTrigger implements TriggerHandler { }); return null; } + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; - // Check per-agent toggle for statusChanged via DB-driven system - if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:status-changed', this.name))) { + const { enabled, parameters } = await checkTriggerEnabledWithParams( + ctx.project.id, + agentType, + 'pm:status-changed', + this.name, + ); + if (!enabled) return null; + + const isCreate = payload.action === 'create'; + if (!shouldFireOnEvent(isCreate, parameters)) { + logger.debug('Linear status-changed event gated by trigger params', { + issueIdentifier, + agentType, + eventKind: isCreate ? 'create' : 'move', + parameters, + }); return null; } - logger.info('Linear issue transitioned to agent-triggering state', { + logger.info('Linear issue entered agent-triggering state', { issueIdentifier, - previousStateId: payload.updatedFrom?.stateId, + eventKind: isCreate ? 'create' : 'move', + previousStateId: isCreate ? undefined : payload.updatedFrom?.stateId, newStateId, cascadeStatus: matchedCascadeStatus, agentType, }); - // Use issueIdentifier (e.g. TEAM-123) as the workItemId, falling back to id const workItemId = issueIdentifier; const workItemUrl = issueUrl; const workItemTitle = issueTitle; diff --git a/src/triggers/trello/status-changed.ts b/src/triggers/trello/status-changed.ts index 9161db7c..bacddf87 100644 --- a/src/triggers/trello/status-changed.ts +++ b/src/triggers/trello/status-changed.ts @@ -1,12 +1,19 @@ import { getTrelloConfig } from '../../pm/config.js'; import { invalidateSnapshot } from '../../router/snapshot-manager.js'; import { logger } from '../../utils/logging.js'; -import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../types.js'; import { isTrelloWebhookPayload, type TrelloWebhookPayload } from './types.js'; // ============================================================================ // Status Changed Trigger Factory (Trello) +// +// Two independent toggles, gated by params resolved from the DB-driven config: +// onMove (default true) — fire when a card is moved into the target list +// onCreate (default false) — fire when a card is created directly in the target list +// +// Existing Trello projects are backfilled to { onCreate: true, onMove: true } via +// a data migration so behavior is preserved without relying on YAML defaults. // ============================================================================ interface StatusChangedConfig { @@ -18,6 +25,11 @@ interface StatusChangedConfig { invalidateSnapshotOnMove?: boolean; } +function shouldFireOnEvent(isCreate: boolean, parameters: Record): boolean { + if (isCreate) return parameters.onCreate === true; + return parameters.onMove !== false; // default true +} + function createStatusChangedTrigger(config: StatusChangedConfig): TriggerHandler { return { name: config.name, @@ -31,13 +43,11 @@ function createStatusChangedTrigger(config: StatusChangedConfig): TriggerHandler const payload = ctx.payload; const targetListId = trelloConfig?.lists[config.listKey]; - // Card moved into the target list const isMove = payload.action.type === 'updateCard' && payload.action.data.listAfter?.id === targetListId && payload.action.data.listBefore?.id !== targetListId; - // Card created directly in the target list const isCreate = payload.action.type === 'createCard' && payload.action.data.list?.id === targetListId; @@ -45,19 +55,27 @@ function createStatusChangedTrigger(config: StatusChangedConfig): TriggerHandler }, async handle(ctx: TriggerContext): Promise { - // Check trigger config via new DB-driven system - if ( - !(await checkTriggerEnabled( - ctx.project.id, - config.agentType, - 'pm:status-changed', - config.name, - )) - ) { + const { enabled, parameters } = await checkTriggerEnabledWithParams( + ctx.project.id, + config.agentType, + 'pm:status-changed', + config.name, + ); + if (!enabled) { return null; } const payload = ctx.payload as TrelloWebhookPayload; + const isCreate = payload.action.type === 'createCard'; + if (!shouldFireOnEvent(isCreate, parameters)) { + logger.debug('Trello status-changed event gated by trigger params', { + trigger: config.name, + eventKind: isCreate ? 'create' : 'move', + parameters, + }); + return null; + } + const cardId = payload.action.data.card?.id; if (!cardId) { @@ -65,7 +83,6 @@ function createStatusChangedTrigger(config: StatusChangedConfig): TriggerHandler return null; } - // Capture work item display data from the webhook payload const cardShortLink = payload.action.data.card?.shortLink; const cardName = payload.action.data.card?.name; const workItemUrl = cardShortLink ? `https://trello.com/c/${cardShortLink}` : undefined; diff --git a/tests/integration/db/trelloStatusChangedBackfill.test.ts b/tests/integration/db/trelloStatusChangedBackfill.test.ts new file mode 100644 index 00000000..1bc7d60e --- /dev/null +++ b/tests/integration/db/trelloStatusChangedBackfill.test.ts @@ -0,0 +1,169 @@ +/** + * Integration test for migration 0050: Trello pm:status-changed onCreate/onMove + * backfill. + * + * The migration is idempotent and runs at test bootstrap; this test seeds rows + * that look like pre-migration state, runs the migration SQL again, and + * verifies Trello rows are backfilled while non-Trello rows are untouched. + */ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { sql } from 'drizzle-orm'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getDb } from '../../../src/db/client.js'; +import { agentTriggerConfigs } from '../../../src/db/schema/index.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedIntegration, seedOrg, seedProject, seedTriggerConfig } from '../helpers/seed.js'; + +const MIGRATION_PATH = fileURLToPath( + new URL( + '../../../src/db/migrations/0050_trello_status_changed_on_create_backfill.sql', + import.meta.url, + ), +); + +async function runMigrationSql(): Promise { + const migrationText = await readFile(MIGRATION_PATH, 'utf-8'); + // Strip transaction boundaries; drizzle's raw sql tag runs inside its own conn + const body = migrationText + .split('\n') + .filter((line) => !/^\s*(BEGIN|COMMIT)\s*;?\s*$/i.test(line)) + .join('\n'); + await getDb().execute(sql.raw(body)); +} + +async function getParameters(projectId: string): Promise> { + const rows = await getDb() + .select() + .from(agentTriggerConfigs) + .where(sql`${agentTriggerConfigs.projectId} = ${projectId}`); + return (rows[0]?.parameters ?? {}) as Record; +} + +describe('migration 0050 — Trello pm:status-changed onCreate/onMove backfill', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg(); + }); + + it('backfills onCreate=true and onMove=true for a Trello project', async () => { + await seedProject({ id: 'trello-proj' }); + await seedIntegration({ projectId: 'trello-proj', category: 'pm', provider: 'trello' }); + await seedTriggerConfig({ + projectId: 'trello-proj', + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + parameters: {}, + }); + + await runMigrationSql(); + + const params = await getParameters('trello-proj'); + expect(params.onCreate).toBe(true); + expect(params.onMove).toBe(true); + }); + + it('preserves pre-existing keys when backfilling Trello', async () => { + await seedProject({ id: 'trello-proj' }); + await seedIntegration({ projectId: 'trello-proj', category: 'pm', provider: 'trello' }); + await seedTriggerConfig({ + projectId: 'trello-proj', + agentType: 'splitting', + triggerEvent: 'pm:status-changed', + parameters: { targetStatus: 'splitting' }, + }); + + await runMigrationSql(); + + const params = await getParameters('trello-proj'); + expect(params).toEqual({ + targetStatus: 'splitting', + onCreate: true, + onMove: true, + }); + }); + + it('does NOT modify user-set keys on Trello projects (onCreate=false stays false)', async () => { + await seedProject({ id: 'trello-proj' }); + await seedIntegration({ projectId: 'trello-proj', category: 'pm', provider: 'trello' }); + await seedTriggerConfig({ + projectId: 'trello-proj', + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + parameters: { onCreate: false }, + }); + + await runMigrationSql(); + + const params = await getParameters('trello-proj'); + expect(params.onCreate).toBe(false); + expect(params.onMove).toBe(true); + }); + + it('does NOT touch Linear projects', async () => { + await seedProject({ id: 'linear-proj' }); + await seedIntegration({ projectId: 'linear-proj', category: 'pm', provider: 'linear' }); + await seedTriggerConfig({ + projectId: 'linear-proj', + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + parameters: {}, + }); + + await runMigrationSql(); + + const params = await getParameters('linear-proj'); + expect(params).toEqual({}); + }); + + it('does NOT touch JIRA projects', async () => { + await seedProject({ id: 'jira-proj' }); + await seedIntegration({ projectId: 'jira-proj', category: 'pm', provider: 'jira' }); + await seedTriggerConfig({ + projectId: 'jira-proj', + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + parameters: {}, + }); + + await runMigrationSql(); + + const params = await getParameters('jira-proj'); + expect(params).toEqual({}); + }); + + it('does NOT modify non pm:status-changed rows for Trello projects', async () => { + await seedProject({ id: 'trello-proj' }); + await seedIntegration({ projectId: 'trello-proj', category: 'pm', provider: 'trello' }); + await seedTriggerConfig({ + projectId: 'trello-proj', + agentType: 'implementation', + triggerEvent: 'pm:label-added', + parameters: {}, + }); + + await runMigrationSql(); + + const params = await getParameters('trello-proj'); + expect(params).toEqual({}); + }); + + it('is idempotent: re-running leaves a backfilled Trello row unchanged', async () => { + await seedProject({ id: 'trello-proj' }); + await seedIntegration({ projectId: 'trello-proj', category: 'pm', provider: 'trello' }); + await seedTriggerConfig({ + projectId: 'trello-proj', + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + parameters: {}, + }); + + await runMigrationSql(); + const first = await getParameters('trello-proj'); + + await runMigrationSql(); + const second = await getParameters('trello-proj'); + + expect(second).toEqual(first); + }); +}); diff --git a/tests/unit/triggers/jira-status-changed.test.ts b/tests/unit/triggers/jira-status-changed.test.ts index db979f91..a3cfff0e 100644 --- a/tests/unit/triggers/jira-status-changed.test.ts +++ b/tests/unit/triggers/jira-status-changed.test.ts @@ -11,7 +11,7 @@ vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModu vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); import { JiraStatusChangedTrigger } from '../../../src/triggers/jira/status-changed.js'; -import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; +import { checkTriggerEnabledWithParams } from '../../../src/triggers/shared/trigger-check.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; const mockProject = { @@ -51,26 +51,15 @@ function buildCtx( source: overrides.source ?? 'jira', payload: { webhookEvent: overrides.webhookEvent ?? 'jira:issue_updated', - issue: - overrides.issueKey !== undefined - ? { - key: overrides.issueKey, - fields: { - summary: 'Test Issue', - ...(overrides.issueStatusName !== undefined - ? { status: { name: overrides.issueStatusName } } - : {}), - }, - } - : { - key: 'PROJ-42', - fields: { - summary: 'Test Issue', - ...(overrides.issueStatusName !== undefined - ? { status: { name: overrides.issueStatusName } } - : {}), - }, - }, + issue: { + key: overrides.issueKey ?? 'PROJ-42', + fields: { + summary: 'Test Issue', + ...(overrides.issueStatusName !== undefined + ? { status: { name: overrides.issueStatusName } } + : {}), + }, + }, changelog: { items: overrides.statusChangeItems ?? [ { field: 'status', fromString: 'Backlog', toString: 'Splitting' }, @@ -80,12 +69,20 @@ function buildCtx( }; } +/** Configure what checkTriggerEnabledWithParams returns for the next call(s). */ +function mockTriggerConfig( + enabled: boolean, + parameters: Record = { onCreate: false, onMove: true }, +) { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ enabled, parameters }); +} + describe('JiraStatusChangedTrigger', () => { let trigger: JiraStatusChangedTrigger; beforeEach(() => { vi.resetAllMocks(); - vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + mockTriggerConfig(true); trigger = new JiraStatusChangedTrigger(); }); @@ -102,18 +99,25 @@ describe('JiraStatusChangedTrigger', () => { expect(trigger.matches(buildCtx({ webhookEvent: 'jira:issue_deleted' }))).toBe(false); }); - it('matches jira:issue_created events (issue created directly in a status)', () => { - expect(trigger.matches(buildCtx({ webhookEvent: 'jira:issue_created' }))).toBe(true); + it('matches jira:issue_created events when fields.status.name is present', () => { + expect( + trigger.matches(buildCtx({ webhookEvent: 'jira:issue_created', issueStatusName: 'To Do' })), + ).toBe(true); }); - it('does not match when no status change in changelog', () => { + it('does not match jira:issue_created events without a status field', () => { + // issueStatusName omitted → no fields.status.name + expect(trigger.matches(buildCtx({ webhookEvent: 'jira:issue_created' }))).toBe(false); + }); + + it('does not match update events with no status change in changelog', () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'assignee', fromString: 'Alice', toString: 'Bob' }], }); expect(trigger.matches(ctx)).toBe(false); }); - it('does not match when changelog items is empty', () => { + it('does not match update events with empty changelog items', () => { const ctx = buildCtx({ statusChangeItems: [] }); expect(trigger.matches(ctx)).toBe(false); }); @@ -125,7 +129,7 @@ describe('JiraStatusChangedTrigger', () => { }); }); - describe('handle', () => { + describe('handle — move events (update)', () => { it('returns implementation agent for "To Do" transition', async () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'Planning', toString: 'To Do' }], @@ -146,40 +150,28 @@ describe('JiraStatusChangedTrigger', () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], }); - - const result = await trigger.handle(ctx); - - expect(result?.agentType).toBe('splitting'); + expect((await trigger.handle(ctx))?.agentType).toBe('splitting'); }); it('returns planning agent for "Planning" transition', async () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'Splitting', toString: 'Planning' }], }); - - const result = await trigger.handle(ctx); - - expect(result?.agentType).toBe('planning'); + expect((await trigger.handle(ctx))?.agentType).toBe('planning'); }); it('is case insensitive when matching status names', async () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'splitting' }], }); - - const result = await trigger.handle(ctx); - - expect(result?.agentType).toBe('splitting'); + expect((await trigger.handle(ctx))?.agentType).toBe('splitting'); }); it('returns backlog-manager agent for Backlog transition', async () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'Done', toString: 'Backlog' }], }); - const result = await trigger.handle(ctx); - - expect(result).not.toBeNull(); expect(result?.agentType).toBe('backlog-manager'); expect(result?.workItemId).toBe('PROJ-42'); }); @@ -188,157 +180,160 @@ describe('JiraStatusChangedTrigger', () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'To Do', toString: 'Done' }], }); - - const result = await trigger.handle(ctx); - - expect(result).toBeNull(); + expect(await trigger.handle(ctx)).toBeNull(); }); it('returns null when issue key is missing', async () => { const ctx = buildCtx({ issueKey: '' }); (ctx.payload as Record).issue = undefined; - - const result = await trigger.handle(ctx); - - expect(result).toBeNull(); - }); - - it('returns null when no status change item in changelog', async () => { - const ctx = buildCtx({ - statusChangeItems: [{ field: 'assignee' }], - }); - - const result = await trigger.handle(ctx); - - expect(result).toBeNull(); + expect(await trigger.handle(ctx)).toBeNull(); }); it('returns null when JIRA config is missing', async () => { const ctx = buildCtx({ noJiraConfig: true }); - - const result = await trigger.handle(ctx); - - expect(result).toBeNull(); + expect(await trigger.handle(ctx)).toBeNull(); }); it('returns null when status change has an empty toString value', async () => { const ctx = buildCtx({ - // Use an empty string for toString so that !newStatus is true statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: '' }], }); - - const result = await trigger.handle(ctx); - - expect(result).toBeNull(); + expect(await trigger.handle(ctx)).toBeNull(); }); - describe('creation events (jira:issue_created)', () => { - it('returns implementation agent when created in "To Do" status', async () => { - const ctx = buildCtx({ - webhookEvent: 'jira:issue_created', - issueStatusName: 'To Do', - }); - - const result = await trigger.handle(ctx); - - expect(result).not.toBeNull(); - expect(result?.agentType).toBe('implementation'); - expect(result?.workItemId).toBe('PROJ-42'); - expect(result?.workItemUrl).toBe('https://myorg.atlassian.net/browse/PROJ-42'); - expect(result?.workItemTitle).toBe('Test Issue'); - expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); + it('logs fromStatus on the update path', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], }); + await trigger.handle(ctx); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('JIRA'), + expect.objectContaining({ + fromStatus: 'Backlog', + toStatus: 'Splitting', + eventKind: 'move', + }), + ); + }); + }); - it('returns splitting agent when created in "Splitting" status', async () => { - const ctx = buildCtx({ - webhookEvent: 'jira:issue_created', - issueStatusName: 'Splitting', - }); - - const result = await trigger.handle(ctx); - - expect(result?.agentType).toBe('splitting'); + describe('handle — create events (jira:issue_created)', () => { + it('returns null when onCreate is false (default)', async () => { + // Default mock already sets onCreate: false + const ctx = buildCtx({ + webhookEvent: 'jira:issue_created', + issueStatusName: 'To Do', }); + expect(await trigger.handle(ctx)).toBeNull(); + }); - it('returns null when created in unmapped status', async () => { - const ctx = buildCtx({ - webhookEvent: 'jira:issue_created', - issueStatusName: 'Done', - }); - - const result = await trigger.handle(ctx); - - expect(result).toBeNull(); + it('returns implementation agent when onCreate is true and created in "To Do"', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + const ctx = buildCtx({ + webhookEvent: 'jira:issue_created', + issueStatusName: 'To Do', }); - it('returns null when issue has no status field on creation', async () => { - const ctx = buildCtx({ webhookEvent: 'jira:issue_created' }); - // No issueStatusName provided → fields.status is undefined - (ctx.payload as Record).issue = { - key: 'PROJ-42', - fields: { summary: 'Test Issue' }, - }; + const result = await trigger.handle(ctx); - const result = await trigger.handle(ctx); + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('PROJ-42'); + expect(result?.workItemUrl).toBe('https://myorg.atlassian.net/browse/PROJ-42'); + expect(result?.workItemTitle).toBe('Test Issue'); + expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); + }); - expect(result).toBeNull(); + it('returns splitting agent when onCreate is true and created in "Splitting"', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + const ctx = buildCtx({ + webhookEvent: 'jira:issue_created', + issueStatusName: 'Splitting', }); + expect((await trigger.handle(ctx))?.agentType).toBe('splitting'); }); - describe('per-agent statusChanged toggle (via checkTriggerEnabled)', () => { - it('fires when trigger is enabled for agent', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(true); - - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], - }); - - const result = await trigger.handle(ctx); - - expect(result?.agentType).toBe('splitting'); - expect(checkTriggerEnabled).toHaveBeenCalledWith( - 'test-project', - 'splitting', - 'pm:status-changed', - 'jira-status-changed', - ); + it('returns null when onCreate is true but status is unmapped', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + const ctx = buildCtx({ + webhookEvent: 'jira:issue_created', + issueStatusName: 'Done', }); + expect(await trigger.handle(ctx)).toBeNull(); + }); - it('returns null when trigger is disabled for splitting agent', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(false); - - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], - }); + it('does NOT log fromStatus on the create path', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + const ctx = buildCtx({ + webhookEvent: 'jira:issue_created', + issueStatusName: 'To Do', + }); + await trigger.handle(ctx); - const result = await trigger.handle(ctx); + const call = mockLogger.info.mock.calls.find( + (args) => typeof args[0] === 'string' && args[0].includes('JIRA'), + ); + expect(call).toBeTruthy(); + expect(call?.[1]).not.toHaveProperty('fromStatus'); + expect(call?.[1]).toMatchObject({ toStatus: 'To Do', eventKind: 'create' }); + }); + }); - expect(result).toBeNull(); + describe('handle — onMove gating', () => { + it('returns null when onMove is false and event is a move', async () => { + mockTriggerConfig(true, { onCreate: false, onMove: false }); + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], }); + expect(await trigger.handle(ctx)).toBeNull(); + }); - it('fires planning agent when trigger is enabled', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(true); - - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Splitting', toString: 'Planning' }], - }); + it('fires only for create when onMove is false and onCreate is true', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: false }); - const result = await trigger.handle(ctx); + const createCtx = buildCtx({ + webhookEvent: 'jira:issue_created', + issueStatusName: 'To Do', + }); + expect((await trigger.handle(createCtx))?.agentType).toBe('implementation'); - expect(result?.agentType).toBe('planning'); + mockTriggerConfig(true, { onCreate: true, onMove: false }); + const moveCtx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Planning', toString: 'To Do' }], }); + expect(await trigger.handle(moveCtx)).toBeNull(); + }); + }); - it('returns null when trigger is disabled for implementation agent', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + describe('per-agent statusChanged toggle', () => { + it('returns null when trigger is disabled for the resolved agent', async () => { + mockTriggerConfig(false); - const ctx = buildCtx({ - statusChangeItems: [{ field: 'status', fromString: 'Planning', toString: 'To Do' }], - }); + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], + }); - const result = await trigger.handle(ctx); + expect(await trigger.handle(ctx)).toBeNull(); + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( + 'test-project', + 'splitting', + 'pm:status-changed', + 'jira-status-changed', + ); + }); - expect(result).toBeNull(); + it('calls checkTriggerEnabledWithParams with correct args for implementation agent', async () => { + const ctx = buildCtx({ + statusChangeItems: [{ field: 'status', fromString: 'Planning', toString: 'To Do' }], }); + await trigger.handle(ctx); + + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( + 'test-project', + 'implementation', + 'pm:status-changed', + 'jira-status-changed', + ); }); }); }); diff --git a/tests/unit/triggers/linear-status-changed.test.ts b/tests/unit/triggers/linear-status-changed.test.ts index 216849ea..01f375f1 100644 --- a/tests/unit/triggers/linear-status-changed.test.ts +++ b/tests/unit/triggers/linear-status-changed.test.ts @@ -10,7 +10,7 @@ vi.mock('../../../src/pm/config.js', () => ({ })); import { LinearStatusChangedTrigger } from '../../../src/triggers/linear/status-changed.js'; -import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; +import { checkTriggerEnabledWithParams } from '../../../src/triggers/shared/trigger-check.js'; import type { TriggerContext } from '../../../src/types/index.js'; // --------------------------------------------------------------------------- @@ -84,6 +84,14 @@ function buildCtx( }; } +/** Configure what checkTriggerEnabledWithParams returns for the next call(s). */ +function mockTriggerConfig( + enabled: boolean, + parameters: Record = { onCreate: false, onMove: true }, +) { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ enabled, parameters }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -93,7 +101,8 @@ describe('LinearStatusChangedTrigger', () => { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + // Default: trigger enabled with YAML-default params (onCreate: false, onMove: true) + mockTriggerConfig(true); mockGetLinearConfig.mockReturnValue(baseLinearConfig); trigger = new LinearStatusChangedTrigger(); }); @@ -114,19 +123,29 @@ describe('LinearStatusChangedTrigger', () => { expect(trigger.matches(buildCtx({ action: 'remove' }))).toBe(false); }); - it('matches create/Issue events (issue created directly in a state)', () => { + it('matches create/Issue events when data.stateId is present', () => { expect(trigger.matches(buildCtx({ action: 'create', noUpdatedFrom: true }))).toBe(true); }); + it('does not match create events without data.stateId', () => { + const ctx = buildCtx({ action: 'create', noUpdatedFrom: true }); + (ctx.payload as Record).data = { + identifier: 'TEAM-1', + title: 'No state', + // no stateId + }; + expect(trigger.matches(ctx)).toBe(false); + }); + it('does not match non-Issue types', () => { expect(trigger.matches(buildCtx({ type: 'Comment' }))).toBe(false); }); - it('does not match when updatedFrom is missing', () => { + it('does not match update events when updatedFrom is missing', () => { expect(trigger.matches(buildCtx({ noUpdatedFrom: true }))).toBe(false); }); - it('does not match when updatedFrom.stateId is not a string', () => { + it('does not match update events when updatedFrom.stateId is not a string', () => { const ctx = buildCtx(); (ctx.payload as Record).updatedFrom = { stateId: 123 }; expect(trigger.matches(ctx)).toBe(false); @@ -138,10 +157,10 @@ describe('LinearStatusChangedTrigger', () => { }); // ========================================================================= - // handle + // handle — update path (default onMove: true) // ========================================================================= - describe('handle', () => { - it('returns implementation agent when new state maps to "todo"', async () => { + describe('handle — move events', () => { + it('returns implementation agent when moved to "todo"', async () => { const result = await trigger.handle(buildCtx({ newStateId: 'state-todo' })); expect(result).not.toBeNull(); @@ -153,37 +172,30 @@ describe('LinearStatusChangedTrigger', () => { expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); }); - it('returns splitting agent when new state maps to "splitting"', async () => { + it('returns splitting agent when moved to "splitting"', async () => { const result = await trigger.handle(buildCtx({ newStateId: 'state-splitting' })); - - expect(result).not.toBeNull(); expect(result?.agentType).toBe('splitting'); }); - it('returns planning agent when new state maps to "planning"', async () => { + it('returns planning agent when moved to "planning"', async () => { const result = await trigger.handle(buildCtx({ newStateId: 'state-planning' })); - - expect(result).not.toBeNull(); expect(result?.agentType).toBe('planning'); }); - it('returns backlog-manager agent when new state maps to "backlog"', async () => { + it('returns backlog-manager agent when moved to "backlog"', async () => { const result = await trigger.handle(buildCtx({ newStateId: 'state-backlog' })); - - expect(result).not.toBeNull(); expect(result?.agentType).toBe('backlog-manager'); }); - it('returns null when new state does not map to any agent', async () => { + it('returns null when moved to an unmapped state', async () => { const result = await trigger.handle(buildCtx({ newStateId: 'state-done' })); expect(result).toBeNull(); }); - it('returns null when newStateId is missing from data', async () => { + it('returns null when data.stateId is missing', async () => { const ctx = buildCtx(); (ctx.payload as Record).data = { identifier: 'TEAM-1', - // no stateId }; const result = await trigger.handle(ctx); expect(result).toBeNull(); @@ -191,16 +203,13 @@ describe('LinearStatusChangedTrigger', () => { it('returns null when issueIdentifier is missing', async () => { const ctx = buildCtx(); - (ctx.payload as Record).data = { - stateId: 'state-todo', - // no identifier or id - }; + (ctx.payload as Record).data = { stateId: 'state-todo' }; const result = await trigger.handle(ctx); expect(result).toBeNull(); }); it('returns null when linear config is missing statuses', async () => { - mockGetLinearConfig.mockReturnValue({ teamId: 'team-abc' }); // no statuses + mockGetLinearConfig.mockReturnValue({ teamId: 'team-abc' }); const result = await trigger.handle(buildCtx()); expect(result).toBeNull(); }); @@ -212,12 +221,12 @@ describe('LinearStatusChangedTrigger', () => { }); it('returns null when trigger is disabled for the resolved agent', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + mockTriggerConfig(false); const result = await trigger.handle(buildCtx({ newStateId: 'state-todo' })); expect(result).toBeNull(); - expect(checkTriggerEnabled).toHaveBeenCalledWith( + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( 'proj-linear', 'implementation', 'pm:status-changed', @@ -225,12 +234,10 @@ describe('LinearStatusChangedTrigger', () => { ); }); - it('calls checkTriggerEnabled with correct args for splitting agent', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(true); - + it('calls checkTriggerEnabledWithParams with correct args for splitting agent', async () => { await trigger.handle(buildCtx({ newStateId: 'state-splitting' })); - expect(checkTriggerEnabled).toHaveBeenCalledWith( + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( 'proj-linear', 'splitting', 'pm:status-changed', @@ -242,7 +249,6 @@ describe('LinearStatusChangedTrigger', () => { const result = await trigger.handle( buildCtx({ newStateId: 'state-todo', issueId: 'issue-uuid-123' }), ); - expect(result?.agentInput.linearIssueId).toBe('issue-uuid-123'); }); @@ -253,40 +259,95 @@ describe('LinearStatusChangedTrigger', () => { (data.data as Record).id = 'fallback-id'; const result = await trigger.handle(ctx); - expect(result?.workItemId).toBe('fallback-id'); }); + }); + + // ========================================================================= + // handle — create path + onCreate/onMove matrix + // ========================================================================= + describe('handle — create events', () => { + it('returns null when onCreate is false (default)', async () => { + // Default mock already sets onCreate: false + const result = await trigger.handle( + buildCtx({ action: 'create', newStateId: 'state-todo', noUpdatedFrom: true }), + ); + expect(result).toBeNull(); + }); + + it('returns implementation agent when onCreate is true and created in "todo"', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + + const result = await trigger.handle( + buildCtx({ action: 'create', newStateId: 'state-todo', noUpdatedFrom: true }), + ); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('TEAM-123'); + expect(result?.workItemTitle).toBe('Fix the bug'); + expect(result?.workItemUrl).toBe('https://linear.app/org/issue/TEAM-123'); + expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); + }); + + it('returns planning agent when onCreate is true and created in "planning"', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + + const result = await trigger.handle( + buildCtx({ action: 'create', newStateId: 'state-planning', noUpdatedFrom: true }), + ); + expect(result?.agentType).toBe('planning'); + }); + + it('returns null when onCreate is true but state is unmapped', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + + const result = await trigger.handle( + buildCtx({ action: 'create', newStateId: 'state-done', noUpdatedFrom: true }), + ); + expect(result).toBeNull(); + }); + }); + + // ========================================================================= + // handle — onMove gating + // ========================================================================= + describe('handle — onMove gating', () => { + it('returns null when onMove is false and event is a move', async () => { + mockTriggerConfig(true, { onCreate: false, onMove: false }); + + const result = await trigger.handle(buildCtx({ newStateId: 'state-todo' })); + expect(result).toBeNull(); + }); + + it('fires for move when onMove is true and onCreate is false (default)', async () => { + // Default already has onMove: true, onCreate: false + const result = await trigger.handle(buildCtx({ newStateId: 'state-todo' })); + expect(result?.agentType).toBe('implementation'); + }); + + it('does not fire for create when onMove is true but onCreate is false', async () => { + mockTriggerConfig(true, { onCreate: false, onMove: true }); + + const result = await trigger.handle( + buildCtx({ action: 'create', newStateId: 'state-todo', noUpdatedFrom: true }), + ); + expect(result).toBeNull(); + }); + + it('fires only for create when onMove is false and onCreate is true', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: false }); + + const createResult = await trigger.handle( + buildCtx({ action: 'create', newStateId: 'state-todo', noUpdatedFrom: true }), + ); + expect(createResult?.agentType).toBe('implementation'); + + // Reset mock since it's mockResolvedValueOnce-like behavior vs mockResolvedValue + mockTriggerConfig(true, { onCreate: true, onMove: false }); - describe('create events (issue created directly in a state)', () => { - it('returns implementation agent when created in "todo" state', async () => { - const result = await trigger.handle( - buildCtx({ action: 'create', newStateId: 'state-todo', noUpdatedFrom: true }), - ); - - expect(result).not.toBeNull(); - expect(result?.agentType).toBe('implementation'); - expect(result?.workItemId).toBe('TEAM-123'); - expect(result?.workItemTitle).toBe('Fix the bug'); - expect(result?.workItemUrl).toBe('https://linear.app/org/issue/TEAM-123'); - expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); - }); - - it('returns planning agent when created in "planning" state', async () => { - const result = await trigger.handle( - buildCtx({ action: 'create', newStateId: 'state-planning', noUpdatedFrom: true }), - ); - - expect(result).not.toBeNull(); - expect(result?.agentType).toBe('planning'); - }); - - it('returns null when created in unmapped state', async () => { - const result = await trigger.handle( - buildCtx({ action: 'create', newStateId: 'state-done', noUpdatedFrom: true }), - ); - - expect(result).toBeNull(); - }); + const moveResult = await trigger.handle(buildCtx({ newStateId: 'state-todo' })); + expect(moveResult).toBeNull(); }); }); }); diff --git a/tests/unit/triggers/merged-status-changed.test.ts b/tests/unit/triggers/merged-status-changed.test.ts index e7f6605e..6654ea0c 100644 --- a/tests/unit/triggers/merged-status-changed.test.ts +++ b/tests/unit/triggers/merged-status-changed.test.ts @@ -31,7 +31,7 @@ vi.mock('../../../src/router/snapshot-manager.js', () => ({ // Register PM integrations in the registry import '../../../src/pm/index.js'; -import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; +import { checkTriggerEnabledWithParams } from '../../../src/triggers/shared/trigger-check.js'; import { TrelloStatusChangedMergedTrigger } from '../../../src/triggers/trello/status-changed.js'; import type { TriggerContext } from '../../../src/triggers/types.js'; import { createMockProject, createTrelloActionPayload } from '../../helpers/factories.js'; @@ -202,7 +202,10 @@ describe('TrelloStatusChangedMergedTrigger', () => { }); it('returns null when trigger is disabled', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValueOnce(false); + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValueOnce({ + enabled: false, + parameters: {}, + }); const ctx: TriggerContext = { project: mockProject, @@ -224,7 +227,7 @@ describe('TrelloStatusChangedMergedTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(checkTriggerEnabled).toHaveBeenCalledWith( + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( mockProject.id, 'backlog-manager', 'pm:status-changed', @@ -283,7 +286,10 @@ describe('TrelloStatusChangedMergedTrigger', () => { it('does not invalidate snapshot when trigger is disabled (returns null before invalidation)', async () => { mockInvalidateSnapshot.mockClear(); - vi.mocked(checkTriggerEnabled).mockResolvedValueOnce(false); + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValueOnce({ + enabled: false, + parameters: {}, + }); const ctx: TriggerContext = { project: mockProject, diff --git a/tests/unit/triggers/status-changed.test.ts b/tests/unit/triggers/status-changed.test.ts index 5697ec27..406fbd18 100644 --- a/tests/unit/triggers/status-changed.test.ts +++ b/tests/unit/triggers/status-changed.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mockAcknowledgmentsModule, mockConfigProvider, @@ -25,7 +25,7 @@ vi.mock('../../../src/router/reactions.js', () => mockReactionsModule); // Register PM integrations in the registry import '../../../src/pm/index.js'; -import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; +import { checkTriggerEnabledWithParams } from '../../../src/triggers/shared/trigger-check.js'; import { TrelloStatusChangedSplittingTrigger, TrelloStatusChangedTodoTrigger, @@ -33,11 +33,24 @@ import { import type { TriggerContext } from '../../../src/triggers/types.js'; import { createMockProject, createTrelloActionPayload } from '../../helpers/factories.js'; +/** Default mock: enabled, onCreate=true onMove=true (matches Trello's backfilled state). */ +function mockTriggerConfig( + enabled: boolean, + parameters: Record = { onCreate: true, onMove: true }, +) { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ enabled, parameters }); +} + describe('TrelloStatusChangedSplittingTrigger', () => { const trigger = TrelloStatusChangedSplittingTrigger; const mockProject = createMockProject(); + beforeEach(() => { + // Default: trigger enabled with Trello's backfilled params (both toggles on) + mockTriggerConfig(true); + }); + it('matches when card moved to splitting list', () => { const ctx: TriggerContext = { project: mockProject, @@ -123,7 +136,7 @@ describe('TrelloStatusChangedSplittingTrigger', () => { }); it('should return null when trigger is disabled', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValueOnce(false); + mockTriggerConfig(false); const ctx: TriggerContext = { project: mockProject, @@ -145,7 +158,7 @@ describe('TrelloStatusChangedSplittingTrigger', () => { const result = await trigger.handle(ctx); expect(result).toBeNull(); - expect(checkTriggerEnabled).toHaveBeenCalledWith( + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( 'test', 'splitting', 'pm:status-changed', @@ -213,6 +226,10 @@ describe('TrelloStatusChangedTodoTrigger', () => { const mockProject = createMockProject(); + beforeEach(() => { + mockTriggerConfig(true); + }); + it('matches when card moved to todo list', () => { const ctx: TriggerContext = { project: mockProject, @@ -283,3 +300,113 @@ describe('TrelloStatusChangedTodoTrigger', () => { expect(result).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// onCreate / onMove matrix — exercises the factory's gating, not per-list +// --------------------------------------------------------------------------- + +describe('Trello status-changed onCreate/onMove matrix (splitting trigger)', () => { + const trigger = TrelloStatusChangedSplittingTrigger; + const mockProject = createMockProject(); + + function movePayload() { + return createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'updateCard', + date: '2024-01-01', + data: { + card: { id: 'card1', name: 'Test Card', idShort: 1, shortLink: 'abc' }, + listBefore: { id: 'other-list', name: 'Other' }, + listAfter: { id: 'splitting-list-id', name: 'Splitting' }, + }, + }, + }); + } + + function createPayload() { + return createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'createCard', + date: '2024-01-01', + data: { + card: { id: 'card1', name: 'Test Card', idShort: 1, shortLink: 'abc' }, + list: { id: 'splitting-list-id', name: 'Splitting' }, + }, + }, + }); + } + + it('fires on move when onMove=true and onCreate=true (backfilled default)', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + const ctx: TriggerContext = { project: mockProject, source: 'trello', payload: movePayload() }; + expect((await trigger.handle(ctx))?.agentType).toBe('splitting'); + }); + + it('fires on create when onMove=true and onCreate=true (backfilled default)', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + const ctx: TriggerContext = { + project: mockProject, + source: 'trello', + payload: createPayload(), + }; + expect((await trigger.handle(ctx))?.agentType).toBe('splitting'); + }); + + it('does NOT fire on create when onCreate=false', async () => { + mockTriggerConfig(true, { onCreate: false, onMove: true }); + const ctx: TriggerContext = { + project: mockProject, + source: 'trello', + payload: createPayload(), + }; + expect(await trigger.handle(ctx)).toBeNull(); + }); + + it('does NOT fire on move when onMove=false', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: false }); + const ctx: TriggerContext = { project: mockProject, source: 'trello', payload: movePayload() }; + expect(await trigger.handle(ctx)).toBeNull(); + }); + + it('fires only on create when onCreate=true and onMove=false', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: false }); + + const createCtx: TriggerContext = { + project: mockProject, + source: 'trello', + payload: createPayload(), + }; + expect((await trigger.handle(createCtx))?.agentType).toBe('splitting'); + + mockTriggerConfig(true, { onCreate: true, onMove: false }); + const moveCtx: TriggerContext = { + project: mockProject, + source: 'trello', + payload: movePayload(), + }; + expect(await trigger.handle(moveCtx)).toBeNull(); + }); + + it('fires only on move when onCreate=false and onMove=true (YAML default for new projects)', async () => { + mockTriggerConfig(true, { onCreate: false, onMove: true }); + + const moveCtx: TriggerContext = { + project: mockProject, + source: 'trello', + payload: movePayload(), + }; + expect((await trigger.handle(moveCtx))?.agentType).toBe('splitting'); + + mockTriggerConfig(true, { onCreate: false, onMove: true }); + const createCtx: TriggerContext = { + project: mockProject, + source: 'trello', + payload: createPayload(), + }; + expect(await trigger.handle(createCtx)).toBeNull(); + }); +});