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
26 changes: 22 additions & 4 deletions src/triggers/jira/status-changed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -29,13 +49,12 @@ export class JiraStatusChangedTrigger implements TriggerHandler {
async handle(ctx: TriggerContext): Promise<TriggerResult | null> {
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;
}
Expand Down Expand Up @@ -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,
});
Expand Down
13 changes: 10 additions & 3 deletions src/triggers/linear/status-changed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TriggerResult | null> {
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/cli/credential-scoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
83 changes: 79 additions & 4 deletions tests/unit/triggers/jira-status-changed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' },
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, unknown>).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);
Expand Down
40 changes: 38 additions & 2 deletions tests/unit/triggers/linear-status-changed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});
});
Loading