feat: automation rules
Owners and admins can set rules of the kind «when a ticket is created, moves, gets assigned, gets a comment or its pull request merges; if it is of this type, priority, area or label; then move it, assign it, label it, change its priority, comment or put it in the active sprint». Rules act as TaskStream within the workflow, run once per event and trigger no others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,9 @@ export * from './lib/sprints.service';
|
||||
export * from './lib/git.service';
|
||||
export * from './lib/webhooks.service';
|
||||
export * from './lib/transfer.service';
|
||||
export * from './lib/automations.service';
|
||||
export * from './lib/automations/automations';
|
||||
export * from './lib/automations/automation-rules';
|
||||
export * from './lib/transfer/import-export';
|
||||
export * from './lib/webhooks/webhooks';
|
||||
export * from './lib/webhooks/webhook-events';
|
||||
|
||||
49
libs/api/tracker/application/src/lib/automations.service.ts
Normal file
49
libs/api/tracker/application/src/lib/automations.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AutomationInput,
|
||||
CreateAutomationCommand,
|
||||
DeleteAutomationCommand,
|
||||
GetAutomationsQuery,
|
||||
UpdateAutomationCommand,
|
||||
} from './automations/automations';
|
||||
import { Automation } from './ports/automations.repository';
|
||||
|
||||
/** The project's automation rules (owners and admins). */
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
list(viewerId: string, projectId: string): Promise<Automation[]> {
|
||||
return this.queryBus.execute(new GetAutomationsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
create(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
rule: AutomationInput,
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
new CreateAutomationCommand(actorId, projectId, rule),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateAutomationCommand(actorId, automationId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, automationId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteAutomationCommand(actorId, automationId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
AutomationTriggerKind,
|
||||
normalizeRule,
|
||||
ruleApplies,
|
||||
triggerOf,
|
||||
} from './automation-rules';
|
||||
|
||||
const workflow = new ProjectWorkflow(defaultProjectSettings());
|
||||
const bug = {
|
||||
type: 'BUG',
|
||||
priority: IssuePriority.HIGH,
|
||||
area: 'FRONTEND',
|
||||
labels: ['ui'],
|
||||
};
|
||||
|
||||
describe('automation rules', () => {
|
||||
it('reads triggers from events, merged pull requests included', () => {
|
||||
expect(
|
||||
triggerOf(
|
||||
new IssueStatusChangedEvent('u', 'i1', 'p', 'TODO', 'IN_QA', null),
|
||||
),
|
||||
).toEqual({
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
issueId: 'i1',
|
||||
status: 'IN_QA',
|
||||
});
|
||||
const merged = new IssueDevLinkRecordedEvent(SYSTEM_ACTOR_ID, 'i2', 'p', {
|
||||
kind: DevLinkKind.PULL_REQUEST,
|
||||
url: 'https://x/pr/1',
|
||||
title: 'x',
|
||||
ref: '#1',
|
||||
state: DevLinkState.MERGED,
|
||||
provider: 'github',
|
||||
author: null,
|
||||
at: '',
|
||||
});
|
||||
expect(triggerOf(merged)?.kind).toBe(
|
||||
AutomationTriggerKind.PULL_REQUEST_MERGED,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the trigger, its status and every condition', () => {
|
||||
const rule = {
|
||||
trigger: { kind: AutomationTriggerKind.STATUS_CHANGED, status: 'IN_QA' },
|
||||
conditions: { types: ['BUG'], label: 'ui' },
|
||||
};
|
||||
const into = (status: string) => ({
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
status,
|
||||
});
|
||||
expect(ruleApplies(rule, into('IN_QA'), bug)).toBe(true);
|
||||
expect(ruleApplies(rule, into('DONE'), bug)).toBe(false);
|
||||
expect(ruleApplies(rule, into('IN_QA'), { ...bug, labels: [] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(ruleApplies(rule, into('IN_QA'), { ...bug, type: 'TASK' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses actions without a usable value', () => {
|
||||
const rule = (kind: AutomationActionKind, value: string | null) => ({
|
||||
name: 'R',
|
||||
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
|
||||
conditions: {},
|
||||
action: { kind, value },
|
||||
});
|
||||
const member = (id: string) => id === 'u-ana';
|
||||
expect(
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ADD_LABEL, ' Triage '),
|
||||
workflow,
|
||||
member,
|
||||
).action,
|
||||
).toEqual({
|
||||
kind: AutomationActionKind.ADD_LABEL,
|
||||
value: 'triage',
|
||||
});
|
||||
expect(() =>
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.SET_STATUS, 'NOPE'),
|
||||
workflow,
|
||||
member,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ASSIGN, 'u-bob'),
|
||||
workflow,
|
||||
member,
|
||||
),
|
||||
).toThrow();
|
||||
expect(
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ASSIGN_REPORTER, 'x'),
|
||||
workflow,
|
||||
member,
|
||||
).action.value,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
Issue,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
|
||||
export enum AutomationTriggerKind {
|
||||
ISSUE_CREATED = 'ISSUE_CREATED',
|
||||
/** Optionally only into `status`. */
|
||||
STATUS_CHANGED = 'STATUS_CHANGED',
|
||||
ISSUE_ASSIGNED = 'ISSUE_ASSIGNED',
|
||||
COMMENT_ADDED = 'COMMENT_ADDED',
|
||||
PULL_REQUEST_MERGED = 'PULL_REQUEST_MERGED',
|
||||
}
|
||||
|
||||
export enum AutomationActionKind {
|
||||
/** `value`: a status id. */
|
||||
SET_STATUS = 'SET_STATUS',
|
||||
/** `value`: a member's id. */
|
||||
ASSIGN = 'ASSIGN',
|
||||
ASSIGN_REPORTER = 'ASSIGN_REPORTER',
|
||||
/** `value`: the label. */
|
||||
ADD_LABEL = 'ADD_LABEL',
|
||||
/** `value`: a priority. */
|
||||
SET_PRIORITY = 'SET_PRIORITY',
|
||||
/** `value`: the text. */
|
||||
ADD_COMMENT = 'ADD_COMMENT',
|
||||
ADD_TO_ACTIVE_SPRINT = 'ADD_TO_ACTIVE_SPRINT',
|
||||
}
|
||||
|
||||
export interface AutomationTrigger {
|
||||
kind: AutomationTriggerKind;
|
||||
/** STATUS_CHANGED only: the status entered (null: any). */
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
/** All of them must hold; an empty list means "any". */
|
||||
export interface AutomationConditions {
|
||||
types?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
areas?: string[];
|
||||
/** The ticket carries this label. */
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationAction {
|
||||
kind: AutomationActionKind;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
/** What an event means for automations, if anything. */
|
||||
export function triggerOf(
|
||||
event: TrackerEvent,
|
||||
): { kind: AutomationTriggerKind; issueId: string; status?: string } | null {
|
||||
if (event instanceof IssueCreatedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.ISSUE_CREATED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
if (event instanceof IssueStatusChangedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
issueId: event.aggregateId,
|
||||
status: event.to,
|
||||
};
|
||||
if (event instanceof IssueAssignedEvent && event.assigneeId)
|
||||
return {
|
||||
kind: AutomationTriggerKind.ISSUE_ASSIGNED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
if (event instanceof CommentAddedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.COMMENT_ADDED,
|
||||
issueId: event.comment.issueId,
|
||||
};
|
||||
if (
|
||||
event instanceof IssueDevLinkRecordedEvent &&
|
||||
event.link.kind === DevLinkKind.PULL_REQUEST &&
|
||||
event.link.state === DevLinkState.MERGED
|
||||
)
|
||||
return {
|
||||
kind: AutomationTriggerKind.PULL_REQUEST_MERGED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a rule wants this trigger on this ticket. */
|
||||
export function ruleApplies(
|
||||
rule: { trigger: AutomationTrigger; conditions: AutomationConditions },
|
||||
trigger: { kind: AutomationTriggerKind; status?: string },
|
||||
issue: Pick<Issue, 'type' | 'priority' | 'area' | 'labels'>,
|
||||
): boolean {
|
||||
if (rule.trigger.kind !== trigger.kind) return false;
|
||||
if (
|
||||
rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED &&
|
||||
rule.trigger.status &&
|
||||
rule.trigger.status !== trigger.status
|
||||
)
|
||||
return false;
|
||||
const { types, priorities, areas, label } = rule.conditions;
|
||||
if (types?.length && !types.includes(issue.type)) return false;
|
||||
if (priorities?.length && !priorities.includes(issue.priority)) return false;
|
||||
if (areas?.length && !(issue.area && areas.includes(issue.area)))
|
||||
return false;
|
||||
if (label && !issue.labels.includes(label.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Checks a rule against the project's workflow; returns it tidied. */
|
||||
export function normalizeRule(
|
||||
rule: {
|
||||
name: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
},
|
||||
workflow: ProjectWorkflow,
|
||||
isMember: (userId: string) => boolean,
|
||||
) {
|
||||
const name = rule.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 80,
|
||||
'A rule needs a name (up to 80 characters)',
|
||||
);
|
||||
assertThat(
|
||||
Object.values(AutomationTriggerKind).includes(rule.trigger.kind),
|
||||
'Unknown trigger',
|
||||
);
|
||||
const status = rule.trigger.status || null;
|
||||
if (status) assertThat(!!workflow.status(status), `Unknown status ${status}`);
|
||||
const { kind, value } = rule.action;
|
||||
assertThat(
|
||||
Object.values(AutomationActionKind).includes(kind),
|
||||
'Unknown action',
|
||||
);
|
||||
const needs: Partial<Record<AutomationActionKind, (v: string) => boolean>> = {
|
||||
[AutomationActionKind.SET_STATUS]: (v) => !!workflow.status(v),
|
||||
[AutomationActionKind.ASSIGN]: (v) => isMember(v),
|
||||
[AutomationActionKind.ADD_LABEL]: (v) =>
|
||||
v.trim().length > 0 && v.length <= 40,
|
||||
[AutomationActionKind.SET_PRIORITY]: (v) =>
|
||||
(Object.values(IssuePriority) as string[]).includes(v),
|
||||
[AutomationActionKind.ADD_COMMENT]: (v) =>
|
||||
v.trim().length > 0 && v.length <= 2000,
|
||||
};
|
||||
const check = needs[kind];
|
||||
if (check)
|
||||
assertThat(
|
||||
!!value && check(value),
|
||||
`The action ${kind} needs a valid value`,
|
||||
);
|
||||
const list = <T>(values?: T[] | null) =>
|
||||
values?.length ? [...new Set(values)] : undefined;
|
||||
return {
|
||||
name,
|
||||
trigger: {
|
||||
kind: rule.trigger.kind,
|
||||
...(rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED
|
||||
? { status }
|
||||
: {}),
|
||||
},
|
||||
conditions: {
|
||||
types: list(rule.conditions.types),
|
||||
priorities: list(rule.conditions.priorities),
|
||||
areas: list(rule.conditions.areas),
|
||||
label: rule.conditions.label?.trim().toLowerCase() || undefined,
|
||||
},
|
||||
action: {
|
||||
kind,
|
||||
value: check
|
||||
? kind === AutomationActionKind.ADD_LABEL
|
||||
? value!.trim().toLowerCase()
|
||||
: value
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
376
libs/api/tracker/application/src/lib/automations/automations.ts
Normal file
376
libs/api/tracker/application/src/lib/automations/automations.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
EventPublisher,
|
||||
EventsHandler,
|
||||
ICommandHandler,
|
||||
IEventHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
CommentFactory,
|
||||
Issue,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
Project,
|
||||
SYSTEM_ACTOR_ID,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
Automation,
|
||||
AutomationsRepository,
|
||||
} from '../ports/automations.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import {
|
||||
AutomationAction,
|
||||
AutomationActionKind,
|
||||
AutomationConditions,
|
||||
AutomationTrigger,
|
||||
normalizeRule,
|
||||
ruleApplies,
|
||||
triggerOf,
|
||||
} from './automation-rules';
|
||||
|
||||
/** A project keeps at most this many rules. */
|
||||
export const MAX_AUTOMATIONS = 50;
|
||||
|
||||
export interface AutomationInput {
|
||||
name: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
}
|
||||
|
||||
export class CreateAutomationCommand extends Command<Automation> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly rule: AutomationInput,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateAutomationCommand extends Command<Automation> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly automationId: string,
|
||||
public readonly changes: { name?: string; active?: boolean },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteAutomationCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly automationId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Event handler only: one rule's action on one ticket, as TaskStream. */
|
||||
export class RunAutomationCommand extends Command<string | null> {
|
||||
constructor(
|
||||
public readonly automation: Automation,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAutomationsQuery extends Query<Automation[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
async function managedProject(
|
||||
access: WriteAccess,
|
||||
projectId: string,
|
||||
actorId: string,
|
||||
): Promise<Project> {
|
||||
const project = await access.project(projectId);
|
||||
project.assertCanManage(actorId);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function managedAutomation(
|
||||
automations: AutomationsRepository,
|
||||
access: WriteAccess,
|
||||
automationId: string,
|
||||
actorId: string,
|
||||
): Promise<Automation> {
|
||||
const automation = await automations.findById(automationId);
|
||||
if (!automation) throw new NotFoundError('Automation', automationId);
|
||||
await managedProject(access, automation.projectId, actorId);
|
||||
return automation;
|
||||
}
|
||||
|
||||
@CommandHandler(CreateAutomationCommand)
|
||||
export class CreateAutomationCommandHandler
|
||||
implements ICommandHandler<CreateAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateAutomationCommand): Promise<Automation> {
|
||||
const project = await managedProject(
|
||||
this.access,
|
||||
command.projectId,
|
||||
command.actorId,
|
||||
);
|
||||
if (
|
||||
(await this.automations.findByProject(project.id)).length >=
|
||||
MAX_AUTOMATIONS
|
||||
)
|
||||
throw new BusinessRuleError(
|
||||
`A project keeps at most ${MAX_AUTOMATIONS} rules`,
|
||||
);
|
||||
const rule = normalizeRule(command.rule, project.workflow, (id) =>
|
||||
project.isMember(id),
|
||||
);
|
||||
const users = await this.users.refs([command.actorId]);
|
||||
const automation: Automation = {
|
||||
id: randomUUID(),
|
||||
projectId: project.id,
|
||||
...rule,
|
||||
active: true,
|
||||
createdBy: users.of(command.actorId),
|
||||
createdAt: new Date().toISOString(),
|
||||
runs: 0,
|
||||
lastRunAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
await this.automations.insert(automation);
|
||||
return automation;
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateAutomationCommand)
|
||||
export class UpdateAutomationCommandHandler
|
||||
implements ICommandHandler<UpdateAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateAutomationCommand): Promise<Automation> {
|
||||
const automation = await managedAutomation(
|
||||
this.automations,
|
||||
this.access,
|
||||
command.automationId,
|
||||
command.actorId,
|
||||
);
|
||||
const changes: { name?: string; active?: boolean } = {};
|
||||
if (command.changes.name !== undefined) {
|
||||
const name = command.changes.name.trim();
|
||||
if (!name || name.length > 80)
|
||||
throw new BusinessRuleError(
|
||||
'A rule needs a name (up to 80 characters)',
|
||||
);
|
||||
changes.name = name;
|
||||
}
|
||||
if (command.changes.active !== undefined)
|
||||
changes.active = command.changes.active;
|
||||
await this.automations.update(automation.id, changes);
|
||||
return { ...automation, ...changes };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteAutomationCommand)
|
||||
export class DeleteAutomationCommandHandler
|
||||
implements ICommandHandler<DeleteAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteAutomationCommand): Promise<void> {
|
||||
const automation = await managedAutomation(
|
||||
this.automations,
|
||||
this.access,
|
||||
command.automationId,
|
||||
command.actorId,
|
||||
);
|
||||
await this.automations.delete(automation.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the action straight on the aggregates, as TaskStream (the system
|
||||
* actor): the rule was set up by someone who manages the project, and the
|
||||
* workflow still has the last word. Returns why nothing happened, if so.
|
||||
*/
|
||||
@CommandHandler(RunAutomationCommand)
|
||||
export class RunAutomationCommandHandler
|
||||
implements ICommandHandler<RunAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: RunAutomationCommand): Promise<string | null> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
const project = issue
|
||||
? await this.access.live(issue.projectId, Project)
|
||||
: null;
|
||||
if (!issue || !project) return 'The ticket is gone';
|
||||
const { kind, value } = command.automation.action;
|
||||
const actor = SYSTEM_ACTOR_ID;
|
||||
switch (kind) {
|
||||
case AutomationActionKind.SET_STATUS:
|
||||
if (!value || issue.status === value) return null;
|
||||
issue.changeStatus(actor, value, 'Automatización', project.workflow);
|
||||
break;
|
||||
case AutomationActionKind.ASSIGN:
|
||||
case AutomationActionKind.ASSIGN_REPORTER: {
|
||||
const assignee =
|
||||
kind === AutomationActionKind.ASSIGN
|
||||
? (value ?? null)
|
||||
: issue.reporterId;
|
||||
if (!assignee || !project.isMember(assignee))
|
||||
return 'That person is no longer a member';
|
||||
issue.assign(actor, assignee);
|
||||
break;
|
||||
}
|
||||
case AutomationActionKind.ADD_LABEL:
|
||||
if (!value || issue.labels.includes(value)) return null;
|
||||
issue.updateDetails(
|
||||
actor,
|
||||
{ labels: [...issue.labels, value] },
|
||||
project.workflow,
|
||||
);
|
||||
break;
|
||||
case AutomationActionKind.SET_PRIORITY:
|
||||
issue.updateDetails(
|
||||
actor,
|
||||
{ priority: value as IssuePriority },
|
||||
project.workflow,
|
||||
);
|
||||
break;
|
||||
case AutomationActionKind.ADD_TO_ACTIVE_SPRINT: {
|
||||
const active = await this.findSprints.findActive(project.id);
|
||||
if (!active) return 'No sprint is under way';
|
||||
issue.setSprint(actor, active.id);
|
||||
break;
|
||||
}
|
||||
case AutomationActionKind.ADD_COMMENT: {
|
||||
const comment = this.commentFactory.create({
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
authorId: actor,
|
||||
body: value ?? '',
|
||||
});
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
await comment.commitAndWait();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetAutomationsQuery)
|
||||
export class GetAutomationsQueryHandler
|
||||
implements IQueryHandler<GetAutomationsQuery, Automation[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
|
||||
await managedProject(this.access, query.projectId, query.viewerId);
|
||||
return this.automations.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the project's rules on what people do. What rules themselves do (as
|
||||
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
|
||||
* per event, also when the event is delivered again.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueAssignedEvent,
|
||||
CommentAddedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
)
|
||||
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly logger = new Logger(AutomationsEventHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent) {
|
||||
// Git reports come as the system too, and a merge is worth reacting to.
|
||||
if (
|
||||
event.actorId === SYSTEM_ACTOR_ID &&
|
||||
!(event instanceof IssueDevLinkRecordedEvent)
|
||||
)
|
||||
return;
|
||||
const trigger = triggerOf(event);
|
||||
if (!trigger) return;
|
||||
const rules = await this.automations.findActive(event.projectId);
|
||||
if (!rules.length) return;
|
||||
const issue = await this.access.live(trigger.issueId, Issue);
|
||||
if (!issue) return;
|
||||
for (const rule of rules) {
|
||||
if (!ruleApplies(rule, trigger, issue)) continue;
|
||||
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
|
||||
if (!(await this.automations.claimRun(rule.id, key))) continue;
|
||||
let error: string | null;
|
||||
try {
|
||||
error = await this.commandBus.execute(
|
||||
new RunAutomationCommand(rule, trigger.issueId),
|
||||
);
|
||||
} catch (failure) {
|
||||
error = failure instanceof Error ? failure.message : String(failure);
|
||||
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
|
||||
}
|
||||
await this.automations.recordRun(rule.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMATION_HANDLERS = [
|
||||
CreateAutomationCommandHandler,
|
||||
UpdateAutomationCommandHandler,
|
||||
DeleteAutomationCommandHandler,
|
||||
RunAutomationCommandHandler,
|
||||
GetAutomationsQueryHandler,
|
||||
AutomationsEventHandler,
|
||||
];
|
||||
@@ -40,6 +40,13 @@ import {
|
||||
} from './sprint.commands';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
|
||||
import { RunAutomationCommand } from '../automations/automations';
|
||||
import { RunAutomationCommandHandler } from '../automations/automations';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
AutomationTriggerKind,
|
||||
} from '../automations/automation-rules';
|
||||
import { Automation } from '../ports/automations.repository';
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { DevLinkKind } from '@task-stream/api/tracker/domain';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
@@ -92,6 +99,7 @@ async function setup() {
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
...GIT_HANDLERS,
|
||||
RunAutomationCommandHandler,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
@@ -520,6 +528,73 @@ describe('tracker commands (event sourced)', () => {
|
||||
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
|
||||
});
|
||||
|
||||
it('runs automation actions as TaskStream, within the workflow', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
const rule = (kind: AutomationActionKind, value: string | null) =>
|
||||
({
|
||||
id: 'r1',
|
||||
projectId,
|
||||
name: 'R',
|
||||
active: true,
|
||||
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
|
||||
conditions: {},
|
||||
action: { kind, value },
|
||||
}) as Automation;
|
||||
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.SET_STATUS, 'TODO'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_LABEL, 'triage'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ASSIGN_REPORTER, null),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_COMMENT, 'Gracias'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect([rebuilt.status, rebuilt.labels, rebuilt.assigneeId]).toEqual([
|
||||
'TODO',
|
||||
['triage'],
|
||||
'dev',
|
||||
]);
|
||||
expect(store.types()).toContain('CommentAddedEvent');
|
||||
// The workflow still decides: TODO cannot jump to DONE.
|
||||
await expect(
|
||||
run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.SET_STATUS, 'DONE'),
|
||||
issue.id,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
expect(
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_TO_ACTIVE_SPRINT, null),
|
||||
issue.id,
|
||||
),
|
||||
),
|
||||
).toBe('No sprint is under way');
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationAction,
|
||||
AutomationConditions,
|
||||
AutomationTrigger,
|
||||
} from '../automations/automation-rules';
|
||||
|
||||
/** «When this happens to a ticket, if it is like this, do that.» */
|
||||
export interface Automation {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
createdBy: UserRefReadModel;
|
||||
createdAt: string;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
/** Why the last run did nothing (a move the workflow does not allow…). */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/** Configuration, not history: kept outside the event store. */
|
||||
export abstract class AutomationsRepository {
|
||||
abstract findByProject(projectId: string): Promise<Automation[]>;
|
||||
abstract findActive(projectId: string): Promise<Automation[]>;
|
||||
abstract findById(id: string): Promise<Automation | null>;
|
||||
abstract insert(automation: Automation): Promise<void>;
|
||||
abstract update(
|
||||
id: string,
|
||||
changes: Partial<Pick<Automation, 'name' | 'active'>>,
|
||||
): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
/**
|
||||
* Takes the right to run `ruleId` for one event (false: it already ran, the
|
||||
* event was delivered again).
|
||||
*/
|
||||
abstract claimRun(ruleId: string, eventKey: string): Promise<boolean>;
|
||||
abstract recordRun(id: string, error: string | null): Promise<void>;
|
||||
}
|
||||
@@ -25,3 +25,4 @@ export * from './find-sprints.repository';
|
||||
export * from './upsert-materialized-sprint.repository';
|
||||
export * from './git-integrations.repository';
|
||||
export * from './outgoing-webhooks.repository';
|
||||
export * from './automations.repository';
|
||||
|
||||
@@ -38,6 +38,8 @@ import { WebhooksService } from './webhooks.service';
|
||||
import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
|
||||
import { TransferService } from './transfer.service';
|
||||
import { TRANSFER_HANDLERS } from './transfer/import-export';
|
||||
import { AutomationsService } from './automations.service';
|
||||
import { AUTOMATION_HANDLERS } from './automations/automations';
|
||||
|
||||
/** Notifications: derived from events, kept and dismissed per person. */
|
||||
const NOTIFICATIONS = [
|
||||
@@ -62,6 +64,7 @@ const SERVICES = [
|
||||
GitService,
|
||||
WebhooksService,
|
||||
TransferService,
|
||||
AutomationsService,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -87,6 +90,7 @@ const SERVICES = [
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
...AUTOMATION_HANDLERS,
|
||||
],
|
||||
exports: SERVICES,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user