Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/triggers/jira/label-added.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -85,19 +88,20 @@ 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,
configuredStatuses: jiraConfig.statuses,
});
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))) {
Expand All @@ -107,6 +111,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler {
logger.info('JIRA "Ready to Process" label added, triggering agent', {
issueKey,
currentStatus,
cascadeStatus: matchedCascadeStatus,
agentType,
});

Expand Down
7 changes: 4 additions & 3 deletions src/triggers/jira/status-changed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,7 +83,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler {
return null;
}

const resolved = resolvePMStatusAgentByName({
const resolved = await resolvePMStatusAgentByNameFromWorkflowDefinitions({
statusName: newStatus,
configuredStatuses: jiraConfig.statuses,
});
Expand All @@ -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,
Expand Down Expand Up @@ -133,6 +133,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler {
eventKind: isCreate ? 'create' : 'move',
...(isCreate ? {} : { fromStatus: statusChange?.fromString }),
toStatus: newStatus,
cascadeStatus: matchedCascadeStatus,
agentType,
});

Expand Down
11 changes: 11 additions & 0 deletions src/triggers/shared/pm-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
resolvePMStatusAgentById,
resolvePMStatusAgentByIdFromWorkflowDefinitions,
resolvePMStatusAgentByName,
resolvePMStatusAgentByNameFromWorkflowDefinitions,
} from './pm-status.js';
import { buildPMDispatchResult } from './result-builders.js';

Expand Down Expand Up @@ -47,6 +48,16 @@ export function resolvePMLabelAgentByStatusIdFromWorkflowDefinitions(args: {
});
}

export function resolvePMLabelAgentByStatusNameFromWorkflowDefinitions(args: {
statusName: string;
configuredStatuses: Record<string, string>;
}): Promise<{ agentType: string; cascadeStatus: string } | undefined> {
return resolvePMStatusAgentByNameFromWorkflowDefinitions({
statusName: args.statusName,
configuredStatuses: args.configuredStatuses,
});
}

export function buildPMLabelDispatchResult(args: {
agentType: string;
workItemId: string;
Expand Down
11 changes: 11 additions & 0 deletions src/triggers/shared/pm-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ export function resolvePMStatusAgentByIdFromWorkflowDefinitions(args: {
});
}

export function resolvePMStatusAgentByNameFromWorkflowDefinitions(args: {
statusName: string;
configuredStatuses: Record<string, string>;
}): Promise<ResolvedPMStatusAgent | undefined> {
return resolvePMStatusAgentFromWorkflowDefinitions({
incomingStatus: args.statusName,
configuredStatuses: args.configuredStatuses,
matcher: caseInsensitiveStatusMatcher,
});
}

export function buildPMStatusCoalesceKey(projectId: string, workItemId: string): string {
return `${projectId}:${workItemId}`;
}
Expand Down
159 changes: 158 additions & 1 deletion tests/unit/triggers/jira-label-added.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
mockAcknowledgmentsModule,
mockConfigProvider,
Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading