diff --git a/src/triggers/jira/status-changed.ts b/src/triggers/jira/status-changed.ts index 156952fe..6ea2ae00 100644 --- a/src/triggers/jira/status-changed.ts +++ b/src/triggers/jira/status-changed.ts @@ -11,6 +11,20 @@ import { logger } from '../../utils/logging.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import { type JiraWebhookPayload, STATUS_TO_AGENT } from './types.js'; +/** + * 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 + 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; +} + export class JiraStatusChangedTrigger implements TriggerHandler { name = 'jira-status-changed'; description = 'Triggers agent when a JIRA issue transitions to a configured status'; @@ -19,6 +33,12 @@ export class JiraStatusChangedTrigger implements TriggerHandler { if (ctx.source !== 'jira') return false; const payload = ctx.payload as JiraWebhookPayload; + + // Issue created directly in a status + if (payload.webhookEvent === 'jira:issue_created') { + return true; + } + if (!payload.webhookEvent?.startsWith('jira:issue_updated')) return false; // Must have a status change in changelog @@ -29,13 +49,12 @@ export class JiraStatusChangedTrigger implements TriggerHandler { async handle(ctx: TriggerContext): Promise { const payload = ctx.payload as JiraWebhookPayload; const issueKey = payload.issue?.key; - const statusChange = payload.changelog?.items?.find((item) => item.field === 'status'); - if (!issueKey || !statusChange) { + if (!issueKey) { return null; } - const newStatus = statusChange.toString; + const newStatus = resolveNewStatus(payload); if (!newStatus) { return null; } @@ -73,7 +92,6 @@ export class JiraStatusChangedTrigger implements TriggerHandler { logger.info('JIRA issue transitioned to agent-triggering status', { issueKey, - fromStatus: statusChange.fromString, toStatus: newStatus, agentType, }); diff --git a/src/triggers/linear/status-changed.ts b/src/triggers/linear/status-changed.ts index c9b8aa5f..f6114274 100644 --- a/src/triggers/linear/status-changed.ts +++ b/src/triggers/linear/status-changed.ts @@ -24,10 +24,17 @@ export class LinearStatusChangedTrigger implements TriggerHandler { if (ctx.source !== 'linear') return false; const payload = ctx.payload as LinearWebhookTriggerPayload; - if (payload.action !== 'update' || payload.type !== 'Issue') return false; + if (payload.type !== 'Issue') return false; - // Must have a state change indicated by updatedFrom.stateId - return typeof payload.updatedFrom?.stateId === 'string'; + // Issue created directly in a state (no updatedFrom on create events) + if (payload.action === 'create') return true; + + // Issue updated with a state change indicated by updatedFrom.stateId + if (payload.action === 'update') { + return typeof payload.updatedFrom?.stateId === 'string'; + } + + return false; } async handle(ctx: TriggerContext): Promise { diff --git a/tests/unit/cli/credential-scoping.test.ts b/tests/unit/cli/credential-scoping.test.ts index 6d442591..daa1535e 100644 --- a/tests/unit/cli/credential-scoping.test.ts +++ b/tests/unit/cli/credential-scoping.test.ts @@ -84,6 +84,11 @@ describe('CredentialScopedCommand', () => { delete process.env.CASCADE_LINEAR_TEAM_ID; delete process.env.CASCADE_LINEAR_PROJECT_ID; delete process.env.CASCADE_LINEAR_STATUSES; + // Clear JIRA vars so resolvePmType() falls back to 'trello' when not + // explicitly testing JIRA behaviour (env may be set on CI/dev machines). + delete process.env.JIRA_EMAIL; + delete process.env.JIRA_API_TOKEN; + delete process.env.JIRA_BASE_URL; vi.mocked(withLinearCredentials).mockClear(); }); diff --git a/tests/unit/triggers/jira-status-changed.test.ts b/tests/unit/triggers/jira-status-changed.test.ts index 9e7ab05b..db979f91 100644 --- a/tests/unit/triggers/jira-status-changed.test.ts +++ b/tests/unit/triggers/jira-status-changed.test.ts @@ -40,6 +40,8 @@ function buildCtx( issueKey?: string; statusChangeItems?: Array<{ field?: string; fromString?: string; toString?: string }>; noJiraConfig?: boolean; + /** Status name in issue.fields.status.name (for creation events) */ + issueStatusName?: string; } = {}, ): TriggerContext { const project = overrides.noJiraConfig ? { ...mockProject, jira: undefined } : mockProject; @@ -51,8 +53,24 @@ function buildCtx( webhookEvent: overrides.webhookEvent ?? 'jira:issue_updated', issue: overrides.issueKey !== undefined - ? { key: overrides.issueKey, fields: { summary: 'Test Issue' } } - : { key: 'PROJ-42', fields: { summary: 'Test Issue' } }, + ? { + 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 } } + : {}), + }, + }, changelog: { items: overrides.statusChangeItems ?? [ { field: 'status', fromString: 'Backlog', toString: 'Splitting' }, @@ -80,8 +98,12 @@ describe('JiraStatusChangedTrigger', () => { expect(trigger.matches(buildCtx({ source: 'trello' }))).toBe(false); }); - it('does not match non-issue_updated webhook events', () => { - expect(trigger.matches(buildCtx({ webhookEvent: 'jira:issue_created' }))).toBe(false); + it('does not match unrelated webhook events', () => { + 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('does not match when no status change in changelog', () => { @@ -210,6 +232,59 @@ describe('JiraStatusChangedTrigger', () => { expect(result).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('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'); + }); + + 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 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); + + expect(result).toBeNull(); + }); + }); + describe('per-agent statusChanged toggle (via checkTriggerEnabled)', () => { it('fires when trigger is enabled for agent', async () => { vi.mocked(checkTriggerEnabled).mockResolvedValue(true); diff --git a/tests/unit/triggers/linear-status-changed.test.ts b/tests/unit/triggers/linear-status-changed.test.ts index 7a4c99ef..216849ea 100644 --- a/tests/unit/triggers/linear-status-changed.test.ts +++ b/tests/unit/triggers/linear-status-changed.test.ts @@ -110,8 +110,12 @@ describe('LinearStatusChangedTrigger', () => { expect(trigger.matches(buildCtx({ source: 'jira' }))).toBe(false); }); - it('does not match non-update actions', () => { - expect(trigger.matches(buildCtx({ action: 'create' }))).toBe(false); + it('does not match remove actions', () => { + expect(trigger.matches(buildCtx({ action: 'remove' }))).toBe(false); + }); + + it('matches create/Issue events (issue created directly in a state)', () => { + expect(trigger.matches(buildCtx({ action: 'create', noUpdatedFrom: true }))).toBe(true); }); it('does not match non-Issue types', () => { @@ -252,5 +256,37 @@ describe('LinearStatusChangedTrigger', () => { expect(result?.workItemId).toBe('fallback-id'); }); + + 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(); + }); + }); }); });