Skip to content
Closed
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: 11 additions & 0 deletions src/agents/definitions/backlog-manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/agents/definitions/implementation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/agents/definitions/planning.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/agents/definitions/splitting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
90 changes: 63 additions & 27 deletions src/triggers/jira/status-changed.ts
Original file line number Diff line number Diff line change
@@ -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, string>,
): 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<string, unknown>): boolean {
if (isCreate) return parameters.onCreate === true;
return parameters.onMove !== false; // default true
}

export class JiraStatusChangedTrigger implements TriggerHandler {
Expand All @@ -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<TriggerResult | null> {
Expand All @@ -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,
Expand All @@ -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;

Expand Down
83 changes: 56 additions & 27 deletions src/triggers/linear/status-changed.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): { 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<string, unknown>): 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';
Expand All @@ -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<string, unknown>;
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';
}
Expand Down Expand Up @@ -60,40 +84,45 @@ 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,
configuredStatuses: linearConfig.statuses,
});
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;
Expand Down
Loading
Loading