From c6b6a20a03b479a00750ea5da223357dcc29b926 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 18 May 2026 10:05:57 +0000 Subject: [PATCH] feat(jira): route custom mapped statuses through workflow-definition resolvers --- src/triggers/jira/label-added.ts | 11 +- src/triggers/jira/status-changed.ts | 7 +- src/triggers/shared/pm-label.ts | 11 ++ src/triggers/shared/pm-status.ts | 11 ++ tests/unit/triggers/jira-label-added.test.ts | 159 ++++++++++++++- .../unit/triggers/jira-status-changed.test.ts | 183 ++++++++++++++++++ tests/unit/triggers/shared/pm-label.test.ts | 73 ++++++- tests/unit/triggers/shared/pm-status.test.ts | 56 ++++++ 8 files changed, 503 insertions(+), 8 deletions(-) diff --git a/src/triggers/jira/label-added.ts b/src/triggers/jira/label-added.ts index 66a6b8eff..2e73c3cc0 100644 --- a/src/triggers/jira/label-added.ts +++ b/src/triggers/jira/label-added.ts @@ -14,7 +14,10 @@ import { getJiraConfig } from '../../pm/config.js'; import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; -import { buildPMLabelDispatchResult, resolvePMLabelAgentByStatusName } from '../shared/pm-label.js'; +import { + buildPMLabelDispatchResult, + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions, +} from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { JiraWebhookPayload } from './types.js'; @@ -85,12 +88,12 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return null; } - const agentType = resolvePMLabelAgentByStatusName({ + const resolved = await resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ statusName: currentStatus, configuredStatuses: jiraConfig.statuses, }); - if (!agentType) { + if (!resolved) { logger.debug('JIRA issue status does not map to any agent', { issueKey, currentStatus, @@ -98,6 +101,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { }); return null; } + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; // Check per-agent ready-to-process toggle via new DB-driven system if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:label-added', this.name))) { @@ -107,6 +111,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { logger.info('JIRA "Ready to Process" label added, triggering agent', { issueKey, currentStatus, + cascadeStatus: matchedCascadeStatus, agentType, }); diff --git a/src/triggers/jira/status-changed.ts b/src/triggers/jira/status-changed.ts index 79e995fa6..41f3154c1 100644 --- a/src/triggers/jira/status-changed.ts +++ b/src/triggers/jira/status-changed.ts @@ -15,7 +15,7 @@ import { logger } from '../../utils/logging.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; import { buildPMStatusDispatchResult, - resolvePMStatusAgentByName, + resolvePMStatusAgentByNameFromWorkflowDefinitions, shouldFirePMStatusEvent, } from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; @@ -83,7 +83,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { return null; } - const resolved = resolvePMStatusAgentByName({ + const resolved = await resolvePMStatusAgentByNameFromWorkflowDefinitions({ statusName: newStatus, configuredStatuses: jiraConfig.statuses, }); @@ -95,7 +95,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { }); return null; } - const { agentType } = resolved; + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; const { enabled, parameters } = await checkTriggerEnabledWithParams( ctx.project.id, @@ -133,6 +133,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { eventKind: isCreate ? 'create' : 'move', ...(isCreate ? {} : { fromStatus: statusChange?.fromString }), toStatus: newStatus, + cascadeStatus: matchedCascadeStatus, agentType, }); diff --git a/src/triggers/shared/pm-label.ts b/src/triggers/shared/pm-label.ts index 9e7573907..cf03ff221 100644 --- a/src/triggers/shared/pm-label.ts +++ b/src/triggers/shared/pm-label.ts @@ -4,6 +4,7 @@ import { resolvePMStatusAgentById, resolvePMStatusAgentByIdFromWorkflowDefinitions, resolvePMStatusAgentByName, + resolvePMStatusAgentByNameFromWorkflowDefinitions, } from './pm-status.js'; import { buildPMDispatchResult } from './result-builders.js'; @@ -47,6 +48,16 @@ export function resolvePMLabelAgentByStatusIdFromWorkflowDefinitions(args: { }); } +export function resolvePMLabelAgentByStatusNameFromWorkflowDefinitions(args: { + statusName: string; + configuredStatuses: Record; +}): Promise<{ agentType: string; cascadeStatus: string } | undefined> { + return resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: args.statusName, + configuredStatuses: args.configuredStatuses, + }); +} + export function buildPMLabelDispatchResult(args: { agentType: string; workItemId: string; diff --git a/src/triggers/shared/pm-status.ts b/src/triggers/shared/pm-status.ts index e77b5dc3e..07e56bef5 100644 --- a/src/triggers/shared/pm-status.ts +++ b/src/triggers/shared/pm-status.ts @@ -94,6 +94,17 @@ export function resolvePMStatusAgentByIdFromWorkflowDefinitions(args: { }); } +export function resolvePMStatusAgentByNameFromWorkflowDefinitions(args: { + statusName: string; + configuredStatuses: Record; +}): Promise { + return resolvePMStatusAgentFromWorkflowDefinitions({ + incomingStatus: args.statusName, + configuredStatuses: args.configuredStatuses, + matcher: caseInsensitiveStatusMatcher, + }); +} + export function buildPMStatusCoalesceKey(projectId: string, workItemId: string): string { return `${projectId}:${workItemId}`; } diff --git a/tests/unit/triggers/jira-label-added.test.ts b/tests/unit/triggers/jira-label-added.test.ts index 36fd3f041..2a9b44310 100644 --- a/tests/unit/triggers/jira-label-added.test.ts +++ b/tests/unit/triggers/jira-label-added.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mockAcknowledgmentsModule, mockConfigProvider, @@ -9,6 +9,15 @@ import { mockTriggerCheckModule, } from '../../helpers/sharedMocks.js'; +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); @@ -99,6 +108,11 @@ function buildCtx(overrides: { } describe('JiraReadyToProcessLabelTrigger', () => { + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockReset(); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + }); + describe('matches()', () => { it('matches when cascade-ready label is added', () => { expect(trigger.matches(buildCtx({}))).toBe(true); @@ -298,4 +312,147 @@ describe('JiraReadyToProcessLabelTrigger', () => { expect(result).toBeNull(); }); }); + + describe('custom workflow status mapping', () => { + const customProject = { + ...baseProject, + jira: { + ...baseJiraConfig, + statuses: { + ...baseJiraConfig.statuses, + prd: 'PRD Review', + ux: 'UX Mocks', + }, + }, + } as TriggerContext['project']; + + it('dispatches a custom agent when the issue is in a custom status', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'PRD Review' }), + ); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('prd'); + expect(result?.workItemId).toBe('TEST-42'); + expect(result?.workItemUrl).toBe('https://test.atlassian.net/browse/TEST-42'); + expect(result?.workItemTitle).toBe('Test issue'); + expect(result?.agentInput.triggerEvent).toBe('pm:label-added'); + }); + + it('matches custom status names case-insensitively', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'prd review' }), + ); + expect(result?.agentType).toBe('prd'); + }); + + it('returns null when a custom status has no dispatch agent configured', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'ux') { + return { + id: 2, + key: 'ux', + label: 'UX', + agentType: null, + sortOrder: 2000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'UX Mocks' }), + ); + expect(result).toBeNull(); + }); + + it('returns null when a custom status is missing from workflow definitions', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'PRD Review' }), + ); + expect(result).toBeNull(); + }); + + it('checks trigger enablement for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + await trigger.handle(buildCtx({ project: customProject, statusName: 'PRD Review' })); + + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'test-project', + 'prd', + 'pm:label-added', + 'jira-ready-to-process-label-added', + ); + }); + + it('returns null when trigger is disabled for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + vi.mocked(checkTriggerEnabled).mockResolvedValueOnce(false); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'PRD Review' }), + ); + expect(result).toBeNull(); + }); + }); }); diff --git a/tests/unit/triggers/jira-status-changed.test.ts b/tests/unit/triggers/jira-status-changed.test.ts index 12a39197a..d6c20266b 100644 --- a/tests/unit/triggers/jira-status-changed.test.ts +++ b/tests/unit/triggers/jira-status-changed.test.ts @@ -5,6 +5,15 @@ import { mockTriggerCheckModule, } from '../../helpers/sharedMocks.js'; +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); @@ -90,6 +99,7 @@ describe('JiraStatusChangedTrigger', () => { beforeEach(() => { vi.resetAllMocks(); mockTriggerConfig(true); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); trigger = new JiraStatusChangedTrigger(); }); @@ -367,4 +377,177 @@ describe('JiraStatusChangedTrigger', () => { expect(result).not.toHaveProperty('coalesceRole'); }); }); + + describe('custom workflow status mapping', () => { + const customProject = { + id: 'test-project', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + jira: { + projectKey: 'PROJ', + baseUrl: 'https://myorg.atlassian.net', + statuses: { + backlog: 'Backlog', + splitting: 'Splitting', + planning: 'Planning', + todo: 'To Do', + done: 'Done', + prd: 'PRD Review', + ux: 'UX Mocks', + }, + }, + } as TriggerContext['project']; + + function buildCustomCtx(statusName: string): TriggerContext { + return { + project: customProject, + source: 'jira', + payload: { + webhookEvent: 'jira:issue_updated', + issue: { + key: 'PROJ-42', + fields: { summary: 'Test Issue' }, + }, + changelog: { + items: [{ field: 'status', fromString: 'Backlog', toString: statusName }], + }, + }, + }; + } + + it('dispatches a custom agent when a custom status is configured and matches case-insensitively', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle(buildCustomCtx('prd review')); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('prd'); + 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('returns null when a custom status has no dispatch agent configured', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'ux') { + return { + id: 2, + key: 'ux', + label: 'UX', + agentType: null, + sortOrder: 2000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle(buildCustomCtx('UX Mocks')); + expect(result).toBeNull(); + }); + + it('returns null when a custom status is missing from the workflow definitions table', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + + const result = await trigger.handle(buildCustomCtx('PRD Review')); + expect(result).toBeNull(); + }); + + it('calls checkTriggerEnabledWithParams with the custom agent type', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + await trigger.handle(buildCustomCtx('PRD Review')); + + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( + 'test-project', + 'prd', + 'pm:status-changed', + 'jira-status-changed', + ); + }); + + it('returns null when the trigger is disabled for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + mockTriggerConfig(false); + + const result = await trigger.handle(buildCustomCtx('PRD Review')); + expect(result).toBeNull(); + }); + + it('dispatches a custom agent on create when onCreate is enabled', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + mockTriggerConfig(true, { onCreate: true, onMove: true }); + + const ctx: TriggerContext = { + project: customProject, + source: 'jira', + payload: { + webhookEvent: 'jira:issue_created', + issue: { + key: 'PROJ-42', + fields: { summary: 'Test Issue', status: { name: 'PRD Review' } }, + }, + }, + }; + + const result = await trigger.handle(ctx); + expect(result?.agentType).toBe('prd'); + }); + }); }); diff --git a/tests/unit/triggers/shared/pm-label.test.ts b/tests/unit/triggers/shared/pm-label.test.ts index 2b80fbcf7..140ac29a1 100644 --- a/tests/unit/triggers/shared/pm-label.test.ts +++ b/tests/unit/triggers/shared/pm-label.test.ts @@ -1,12 +1,29 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + import { buildPMLabelDispatchResult, resolvePMLabelAgentByList, resolvePMLabelAgentByStatusId, + resolvePMLabelAgentByStatusIdFromWorkflowDefinitions, resolvePMLabelAgentByStatusName, + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions, } from '../../../../src/triggers/shared/pm-label.js'; describe('PM label helpers', () => { + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockReset(); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + }); + it('resolves Trello current lists to agent types', () => { const lists = { splitting: 'list-splitting', @@ -42,6 +59,60 @@ describe('PM label helpers', () => { ).toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); }); + it('resolves JIRA status names through workflow definitions case-insensitively', async () => { + await expect( + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ + statusName: 'to do', + configuredStatuses: { + todo: 'To Do', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('resolves custom JIRA status names through workflow definitions', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + await expect( + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ + statusName: 'PRD Review', + configuredStatuses: { + prd: 'PRD Review', + }, + }), + ).resolves.toEqual({ agentType: 'prd', cascadeStatus: 'prd' }); + }); + + it('returns undefined when JIRA workflow status has no dispatch agent', async () => { + await expect( + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ + statusName: 'Done', + configuredStatuses: { + done: 'Done', + }, + }), + ).resolves.toBeUndefined(); + }); + + it('resolves Linear state IDs through workflow definitions', async () => { + await expect( + resolvePMLabelAgentByStatusIdFromWorkflowDefinitions({ + statusId: 'state-todo', + configuredStatuses: { + todo: 'state-todo', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + it('builds canonical label-added dispatch results', () => { expect( buildPMLabelDispatchResult({ diff --git a/tests/unit/triggers/shared/pm-status.test.ts b/tests/unit/triggers/shared/pm-status.test.ts index ca05c80e5..e834369d5 100644 --- a/tests/unit/triggers/shared/pm-status.test.ts +++ b/tests/unit/triggers/shared/pm-status.test.ts @@ -15,6 +15,7 @@ import { resolvePMStatusAgentById, resolvePMStatusAgentByIdFromWorkflowDefinitions, resolvePMStatusAgentByName, + resolvePMStatusAgentByNameFromWorkflowDefinitions, shouldFirePMStatusEvent, } from '../../../../src/triggers/shared/pm-status.js'; @@ -91,6 +92,61 @@ describe('PM status helpers', () => { ).resolves.toBeUndefined(); }); + it('resolves custom workflow status names to custom agents case-insensitively', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'prd review', + configuredStatuses: { + prd: 'PRD Review', + }, + }), + ).resolves.toEqual({ agentType: 'prd', cascadeStatus: 'prd' }); + }); + + it('resolves built-in status names through workflow definitions case-insensitively', async () => { + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'to do', + configuredStatuses: { + todo: 'To Do', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('ignores workflow statuses with no dispatch agent when resolving by name', async () => { + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'Done', + configuredStatuses: { + done: 'Done', + }, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when name does not match any configured status', async () => { + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'Unknown', + configuredStatuses: { + todo: 'To Do', + planning: 'Planning', + }, + }), + ).resolves.toBeUndefined(); + }); + it('applies shared onCreate/onMove trigger parameter semantics', () => { expect(shouldFirePMStatusEvent(true, { onCreate: true })).toBe(true); expect(shouldFirePMStatusEvent(true, {})).toBe(false);