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>
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
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,
|
|
];
|