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
140 changes: 140 additions & 0 deletions src/api/routers/_shared/triggerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
* These types are used by both the backend (tRPC router) and frontend (dashboard).
*/

import {
CONTEXT_STEP_NAMES,
type ContextStepName,
type KnownProvider,
} from '../../../agents/definitions/schema.js';

// Re-export for convenience
export type { ContextStepName, KnownProvider };
export { CONTEXT_STEP_NAMES };

// ============================================================================
// Parameter Types
// ============================================================================
Expand Down Expand Up @@ -98,3 +108,133 @@ export const TRIGGER_CATEGORY_LABELS: Record<string, string> = {
* Valid trigger categories.
*/
export type TriggerCategory = 'pm' | 'scm' | 'email' | 'sms';

// ============================================================================
// Known Trigger Registry
// ============================================================================

/**
* A known trigger event definition with metadata.
* Used for populating the definition editor's trigger selection UI.
*/
export interface KnownTriggerEvent {
/** Event identifier (e.g., "pm:card-moved", "scm:check-suite-success") */
event: string;
/** Human-readable label */
label: string;
/** Description of when this trigger fires */
description: string;
/** Context pipeline elements this trigger typically brings */
contextPipeline: ContextStepName[];
/** Provider restrictions (if provider-specific) */
providers?: KnownProvider[];
}

/**
* Registry of all known trigger events organized by category.
* Used by the definition editor to show available triggers.
*/
export const TRIGGER_REGISTRY: Record<TriggerCategory, KnownTriggerEvent[]> = {
pm: [
{
event: 'pm:card-moved',
label: 'Card Moved',
description: 'Card moved to a list',
contextPipeline: ['workItem'],
providers: ['trello'],
},
{
event: 'pm:issue-transitioned',
label: 'Issue Transitioned',
description: 'Issue status changed',
contextPipeline: ['workItem'],
providers: ['jira'],
},
{
event: 'pm:label-added',
label: 'Label Added',
description: 'Label added to card/issue',
contextPipeline: ['workItem'],
},
{
event: 'pm:comment-mention',
label: 'Comment Mention',
description: 'Bot mentioned in comment',
contextPipeline: ['workItem'],
},
],
scm: [
{
event: 'scm:check-suite-success',
label: 'CI Passed',
description: 'CI check suite passed',
contextPipeline: ['prContext'],
providers: ['github'],
},
{
event: 'scm:check-suite-failure',
label: 'CI Failed',
description: 'CI check suite failed',
contextPipeline: ['prContext'],
providers: ['github'],
},
{
event: 'scm:pr-review-submitted',
label: 'PR Review Submitted',
description: 'Review submitted on PR',
contextPipeline: ['prContext', 'prConversation'],
providers: ['github'],
},
{
event: 'scm:review-requested',
label: 'Review Requested',
description: 'Review requested on PR',
contextPipeline: ['prContext'],
providers: ['github'],
},
{
event: 'scm:pr-opened',
label: 'PR Opened',
description: 'PR opened',
contextPipeline: ['prContext'],
providers: ['github'],
},
{
event: 'scm:pr-comment',
label: 'PR Comment',
description: 'Comment added to PR',
contextPipeline: ['prContext', 'prConversation'],
providers: ['github'],
},
{
event: 'scm:pr-merged',
label: 'PR Merged',
description: 'PR merged to base branch',
contextPipeline: ['prContext'],
providers: ['github'],
},
{
event: 'scm:pr-ready-to-merge',
label: 'PR Ready to Merge',
description: 'PR approved and CI passed',
contextPipeline: ['prContext'],
providers: ['github'],
},
],
email: [
{
event: 'email:received',
label: 'Email Received',
description: 'Email received',
contextPipeline: ['prefetchedEmails'],
},
],
sms: [
{
event: 'sms:received',
label: 'SMS Received',
description: 'SMS received',
contextPipeline: [],
},
],
};
2 changes: 2 additions & 0 deletions src/api/routers/agentDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '../../db/repositories/agentDefinitionsRepository.js';
import { loadPartials } from '../../db/repositories/partialsRepository.js';
import { protectedProcedure, publicProcedure, router, superAdminProcedure } from '../trpc.js';
import { TRIGGER_REGISTRY } from './_shared/triggerTypes.js';

async function validatePromptIfPresent(prompt: string | null | undefined) {
if (!prompt) return;
Expand Down Expand Up @@ -352,6 +353,7 @@ export const agentDefinitionsRouter = router({
capabilities: [...CAPABILITIES],
contextStepNames: [...CONTEXT_STEP_NAMES],
compactionNames: [...COMPACTION_NAMES],
triggerRegistry: TRIGGER_REGISTRY,
};
}),
});
102 changes: 102 additions & 0 deletions src/cli/dashboard/definitions/triggers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Args } from '@oclif/core';
import type { SupportedTrigger, TriggerParameter } from '../../../agents/definitions/schema.js';
import { TRIGGER_CATEGORY_LABELS } from '../../../api/routers/_shared/triggerTypes.js';
import { DashboardCommand } from '../_shared/base.js';

function groupTriggersByCategory(triggers: SupportedTrigger[]): Map<string, SupportedTrigger[]> {
const grouped = new Map<string, SupportedTrigger[]>();
for (const trigger of triggers) {
const category = trigger.event.split(':')[0];
const list = grouped.get(category) ?? [];
list.push(trigger);
grouped.set(category, list);
}
return grouped;
}

function formatParameter(param: TriggerParameter): string {
const typeInfo =
param.type === 'select' && param.options
? `${param.type}: ${param.options.join('|')}`
: param.type;
const defaultInfo = param.defaultValue !== undefined ? ` = ${param.defaultValue}` : '';
return ` ${param.name} (${typeInfo})${defaultInfo}`;
}

function formatTrigger(trigger: SupportedTrigger): string[] {
const lines: string[] = [];
const enabledMark = trigger.defaultEnabled ? '\u2713' : '\u2717';
lines.push(` ${enabledMark} ${trigger.event} (${trigger.label})`);

if (trigger.providers && trigger.providers.length > 0) {
lines.push(` - providers: ${trigger.providers.join(', ')}`);
}

lines.push(` - defaultEnabled: ${trigger.defaultEnabled}`);

if (trigger.contextPipeline && trigger.contextPipeline.length > 0) {
lines.push(` - contextPipeline: ${trigger.contextPipeline.join(', ')}`);
}

if (trigger.parameters.length > 0) {
lines.push(' - parameters:');
for (const param of trigger.parameters) {
lines.push(formatParameter(param));
}
}

lines.push('');
return lines;
}

export default class DefinitionsTriggers extends DashboardCommand {
static override description = 'Show triggers defined in an agent definition.';

static override args = {
agentType: Args.string({ description: 'Agent type', required: true }),
};

static override flags = {
...DashboardCommand.baseFlags,
};

async run(): Promise<void> {
const { args, flags } = await this.parse(DefinitionsTriggers);

try {
const result = await this.client.agentDefinitions.get.query({
agentType: args.agentType,
});

const triggers = result.definition.triggers as SupportedTrigger[];

if (flags.json) {
this.outputJson({ agentType: args.agentType, triggers });
return;
}

this.log(`Triggers for: ${args.agentType}`);
this.log('');

if (triggers.length === 0) {
this.log('No triggers defined.');
return;
}

const grouped = groupTriggersByCategory(triggers);

for (const [category, categoryTriggers] of grouped) {
this.log(TRIGGER_CATEGORY_LABELS[category] ?? `${category.toUpperCase()} Triggers`);

for (const trigger of categoryTriggers) {
const lines = formatTrigger(trigger);
for (const line of lines) {
this.log(line);
}
}
}
} catch (err) {
this.handleError(err);
}
}
}
102 changes: 102 additions & 0 deletions tests/unit/api/routers/_shared/triggerTypes.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest';
import { CONTEXT_STEP_NAMES } from '../../../../../src/agents/definitions/schema.js';
import {
type KnownTriggerEvent,
type ProjectTriggersView,
type ResolvedTrigger,
TRIGGER_CATEGORY_LABELS,
TRIGGER_REGISTRY,
type TriggerCategory,
type TriggerParameterDef,
type TriggerParameterValue,
Expand Down Expand Up @@ -96,4 +99,103 @@ describe('triggerTypes', () => {
expect(view.integrations.scm).toBe('github');
});
});

describe('TRIGGER_REGISTRY', () => {
it('has all four categories', () => {
expect(Object.keys(TRIGGER_REGISTRY)).toEqual(['pm', 'scm', 'email', 'sms']);
});

it('pm category has expected triggers', () => {
const pmEvents = TRIGGER_REGISTRY.pm.map((t) => t.event);
expect(pmEvents).toContain('pm:card-moved');
expect(pmEvents).toContain('pm:issue-transitioned');
expect(pmEvents).toContain('pm:label-added');
expect(pmEvents).toContain('pm:comment-mention');
});

it('scm category has all GitHub triggers including pr-merged and pr-ready-to-merge', () => {
const scmEvents = TRIGGER_REGISTRY.scm.map((t) => t.event);
expect(scmEvents).toContain('scm:check-suite-success');
expect(scmEvents).toContain('scm:check-suite-failure');
expect(scmEvents).toContain('scm:pr-review-submitted');
expect(scmEvents).toContain('scm:review-requested');
expect(scmEvents).toContain('scm:pr-opened');
expect(scmEvents).toContain('scm:pr-comment');
expect(scmEvents).toContain('scm:pr-merged');
expect(scmEvents).toContain('scm:pr-ready-to-merge');
});

it('email category has expected triggers', () => {
const emailEvents = TRIGGER_REGISTRY.email.map((t) => t.event);
expect(emailEvents).toContain('email:received');
});

it('sms category has expected triggers', () => {
const smsEvents = TRIGGER_REGISTRY.sms.map((t) => t.event);
expect(smsEvents).toContain('sms:received');
});

it('all triggers have required KnownTriggerEvent fields', () => {
for (const [category, triggers] of Object.entries(TRIGGER_REGISTRY)) {
for (const trigger of triggers) {
expect(trigger.event).toBeTruthy();
expect(trigger.label).toBeTruthy();
expect(trigger.description).toBeTruthy();
expect(Array.isArray(trigger.contextPipeline)).toBe(true);
}
}
});

it('all context pipeline values are valid ContextStepNames', () => {
const validSteps = new Set(CONTEXT_STEP_NAMES);
for (const triggers of Object.values(TRIGGER_REGISTRY)) {
for (const trigger of triggers) {
for (const step of trigger.contextPipeline) {
expect(validSteps.has(step)).toBe(true);
}
}
}
});

it('all provider values are valid KnownProviders', () => {
const validProviders = new Set(['trello', 'jira', 'github', 'imap', 'gmail', 'twilio']);
const allProviders = Object.values(TRIGGER_REGISTRY)
.flat()
.flatMap((t) => t.providers ?? []);
for (const provider of allProviders) {
expect(validProviders.has(provider)).toBe(true);
}
});

it('scm triggers specify github as provider', () => {
for (const trigger of TRIGGER_REGISTRY.scm) {
expect(trigger.providers).toContain('github');
}
});

it('pm:card-moved specifies trello provider', () => {
const cardMoved = TRIGGER_REGISTRY.pm.find((t) => t.event === 'pm:card-moved');
expect(cardMoved?.providers).toContain('trello');
});

it('pm:issue-transitioned specifies jira provider', () => {
const issueTransitioned = TRIGGER_REGISTRY.pm.find(
(t) => t.event === 'pm:issue-transitioned',
);
expect(issueTransitioned?.providers).toContain('jira');
});

it('KnownTriggerEvent type has correct shape', () => {
const trigger: KnownTriggerEvent = {
event: 'test:event',
label: 'Test Event',
description: 'A test event',
contextPipeline: ['workItem'],
providers: ['trello'],
};

expect(trigger.event).toBe('test:event');
expect(trigger.contextPipeline).toEqual(['workItem']);
});
});
});
Loading