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 { constructor( public readonly actorId: string, public readonly projectId: string, public readonly rule: AutomationInput, ) { super(); } } export class UpdateAutomationCommand extends Command { constructor( public readonly actorId: string, public readonly automationId: string, public readonly changes: { name?: string; active?: boolean }, ) { super(); } } export class DeleteAutomationCommand extends Command { 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 { constructor( public readonly automation: Automation, public readonly issueId: string, ) { super(); } } export class GetAutomationsQuery extends Query { constructor( public readonly viewerId: string, public readonly projectId: string, ) { super(); } } async function managedProject( access: WriteAccess, projectId: string, actorId: string, ): Promise { const project = await access.project(projectId); project.assertCanManage(actorId); return project; } async function managedAutomation( automations: AutomationsRepository, access: WriteAccess, automationId: string, actorId: string, ): Promise { 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 { constructor( private readonly automations: AutomationsRepository, private readonly access: WriteAccess, private readonly users: UserDirectory, ) {} async execute(command: CreateAutomationCommand): Promise { 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 { constructor( private readonly automations: AutomationsRepository, private readonly access: WriteAccess, ) {} async execute(command: UpdateAutomationCommand): Promise { 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 { constructor( private readonly automations: AutomationsRepository, private readonly access: WriteAccess, ) {} async execute(command: DeleteAutomationCommand): Promise { 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 { constructor( private readonly access: WriteAccess, private readonly findSprints: FindSprintsRepository, private readonly commentFactory: CommentFactory, private readonly eventPublisher: EventPublisher, ) {} async execute(command: RunAutomationCommand): Promise { 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 { constructor( private readonly automations: AutomationsRepository, private readonly access: WriteAccess, ) {} async execute(query: GetAutomationsQuery): Promise { 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 { 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, ];