diff --git a/apps/api/schema.gql b/apps/api/schema.gql index ae0da8d..5a66ebc 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -46,6 +46,82 @@ type Attachment { uploadedBy: UserRef! } +"""When this happens to a ticket, if it is like this, do that""" +type Automation { + action: AutomationActionSpec! + active: Boolean! + conditions: AutomationConditions! + createdAt: String! + createdBy: UserRef! + id: ID! + + """Why the last run did nothing""" + lastError: String + lastRunAt: String + name: String! + projectId: ID! + runs: Int! + trigger: AutomationTriggerSpec! +} + +"""What a rule does to the ticket""" +enum AutomationAction { + ADD_COMMENT + ADD_LABEL + ADD_TO_ACTIVE_SPRINT + ASSIGN + ASSIGN_REPORTER + SET_PRIORITY + SET_STATUS +} + +input AutomationActionInput { + kind: AutomationAction! + value: String +} + +type AutomationActionSpec { + kind: AutomationAction! + + """Status, member id, label, priority or comment text""" + value: String +} + +type AutomationConditions { + areas: [String!] + label: String + priorities: [IssuePriority!] + types: [String!] +} + +input AutomationConditionsInput { + areas: [String!] + label: String + priorities: [IssuePriority!] + types: [String!] +} + +"""What starts a rule""" +enum AutomationTrigger { + COMMENT_ADDED + ISSUE_ASSIGNED + ISSUE_CREATED + PULL_REQUEST_MERGED + STATUS_CHANGED +} + +input AutomationTriggerInput { + kind: AutomationTrigger! + status: String +} + +type AutomationTriggerSpec { + kind: AutomationTrigger! + + """STATUS_CHANGED: only into this status""" + status: String +} + type BoardColumn { issues: [Issue!]! status: String! @@ -133,6 +209,14 @@ type CountByStatus { key: String! } +input CreateAutomationInput { + action: AutomationActionInput! + conditions: AutomationConditionsInput + name: String! + projectId: ID! + trigger: AutomationTriggerInput! +} + input CreateIssueInput { """An area id""" area: String @@ -466,6 +550,11 @@ type Mutation { Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null. """ closeSprint(carryOverToSprintId: ID, sprintId: ID!): CommandResult! + + """ + Add a rule. It acts as TaskStream, within the workflow, and what it does triggers no other rule. + """ + createAutomation(input: CreateAutomationInput!): Automation! createIssue(input: CreateIssueInput!): CommandResult! createProject(input: CreateProjectInput!): CommandResult! createRelease(input: CreateReleaseInput!): CommandResult! @@ -473,6 +562,7 @@ type Mutation { """Plan a sprint (owners and admins)""" createSprint(input: CreateSprintInput!): CommandResult! createWebhook(input: CreateWebhookInput!): WebhookCreated! + deleteAutomation(automationId: ID!): Boolean! """Author or project admin""" deleteComment(commentId: ID!): CommandResult! @@ -539,6 +629,9 @@ type Mutation { """Stop following a ticket""" unwatchIssue(issueId: ID!): CommandResult! + """Rename, pause or resume""" + updateAutomation(active: Boolean, automationId: ID!, name: String): Automation! + """Reword, tick or untick a checklist step""" updateChecklistItem(input: UpdateChecklistItemInput!): CommandResult! updateIssue(input: UpdateIssueInput!): CommandResult! @@ -737,6 +830,7 @@ type Query { limit: Int! = 50 projectId: ID! ): [Activity!]! + automations(projectId: ID!): [Automation!]! """ A project's epics: top-level tickets that group others, with the progress of those diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index ccb1079..1d16f0d 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -27,6 +27,7 @@ | 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | +| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | | 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | | 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | | 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | diff --git a/docs/03-api.md b/docs/03-api.md index 99dead2..33ac1d0 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -167,6 +167,14 @@ stateDiagram-v2 actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el event store. +- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`, + `createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente + a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o + etiqueta. Acción: mover a un estado, asignar (a alguien o a quien lo abrió), añadir una etiqueta, poner una + prioridad, comentar o meter en el sprint en curso. Actúan como `system` («TaskStream») y respetando el flujo: si la + transición no está permitida, la regla no hace nada y lo deja en `lastError`. Lo que hace una regla no dispara + otras (no hay cadenas ni bucles) y cada regla corre una sola vez por evento aunque se reentregue + (`automation_runs`). Viven en MongoDB (`automations`), fuera del event store. - **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos (`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en `webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index d2a2196..e48a18d 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/automations.service.ts b/libs/api/tracker/application/src/lib/automations.service.ts new file mode 100644 index 0000000..6e641ce --- /dev/null +++ b/libs/api/tracker/application/src/lib/automations.service.ts @@ -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 { + return this.queryBus.execute(new GetAutomationsQuery(viewerId, projectId)); + } + + create( + actorId: string, + projectId: string, + rule: AutomationInput, + ): Promise { + return this.commandBus.execute( + new CreateAutomationCommand(actorId, projectId, rule), + ); + } + + update( + actorId: string, + automationId: string, + changes: { name?: string; active?: boolean }, + ): Promise { + return this.commandBus.execute( + new UpdateAutomationCommand(actorId, automationId, changes), + ); + } + + delete(actorId: string, automationId: string): Promise { + return this.commandBus.execute( + new DeleteAutomationCommand(actorId, automationId), + ); + } +} diff --git a/libs/api/tracker/application/src/lib/automations/automation-rules.spec.ts b/libs/api/tracker/application/src/lib/automations/automation-rules.spec.ts new file mode 100644 index 0000000..41ce561 --- /dev/null +++ b/libs/api/tracker/application/src/lib/automations/automation-rules.spec.ts @@ -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(); + }); +}); diff --git a/libs/api/tracker/application/src/lib/automations/automation-rules.ts b/libs/api/tracker/application/src/lib/automations/automation-rules.ts new file mode 100644 index 0000000..2016bff --- /dev/null +++ b/libs/api/tracker/application/src/lib/automations/automation-rules.ts @@ -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, +): 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 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 = (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, + }, + }; +} diff --git a/libs/api/tracker/application/src/lib/automations/automations.ts b/libs/api/tracker/application/src/lib/automations/automations.ts new file mode 100644 index 0000000..40ccdf8 --- /dev/null +++ b/libs/api/tracker/application/src/lib/automations/automations.ts @@ -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 { + 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, +]; diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index b7e76e2..b159451 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -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); diff --git a/libs/api/tracker/application/src/lib/ports/automations.repository.ts b/libs/api/tracker/application/src/lib/ports/automations.repository.ts new file mode 100644 index 0000000..8e6c619 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/automations.repository.ts @@ -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; + abstract findActive(projectId: string): Promise; + abstract findById(id: string): Promise; + abstract insert(automation: Automation): Promise; + abstract update( + id: string, + changes: Partial>, + ): Promise; + abstract delete(id: string): Promise; + /** + * 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; + abstract recordRun(id: string, error: string | null): Promise; +} diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index e4b7189..0d5920d 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 3d84e7d..faba987 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -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, }) diff --git a/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts b/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts index a6c3546..0566d0e 100644 --- a/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts +++ b/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts @@ -1,3 +1,7 @@ +import { + SYSTEM_ACTOR_ID, + SYSTEM_ACTOR_NAME, +} from '@task-stream/api/tracker/domain'; import { Injectable } from '@nestjs/common'; import { QueryBus } from '@nestjs/cqrs'; import { @@ -47,7 +51,10 @@ export class IdentityUserDirectory extends UserDirectory { ? await this.queryBus.execute(new FindUsersByIdsQuery(wanted)) : []; const names = new Map(users.map((u) => [u.id, u.name])); - const of = (id: string) => ({ id, name: names.get(id) ?? id }); + const of = (id: string) => ({ + id, + name: names.get(id) ?? (id === SYSTEM_ACTOR_ID ? SYSTEM_ACTOR_NAME : id), + }); return { of, ofNullable: (id) => (id ? of(id) : null) }; } } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/automations.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/automations.repository.ts new file mode 100644 index 0000000..81745ef --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/automations.repository.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { + Automation, + AutomationsRepository, +} from '@task-stream/api/tracker/application'; +import { Model } from 'mongoose'; +import { + AutomationDoc, + AutomationRunDoc, + ReadModels, + fromDoc, +} from './read-model.schemas'; + +const DUPLICATE_KEY = 11000; + +@Injectable() +export class MongoAutomationsRepository extends AutomationsRepository { + constructor( + @InjectModel(ReadModels.Automation) + private readonly automations: Model, + @InjectModel(ReadModels.AutomationRun) + private readonly runs: Model, + ) { + super(); + } + + async findByProject(projectId: string): Promise { + const docs = await this.automations + .find({ projectId }) + .sort({ createdAt: 1 }) + .lean(); + return docs.map((doc) => fromDoc(doc) as Automation); + } + + async findActive(projectId: string): Promise { + const docs = await this.automations + .find({ projectId, active: true }) + .lean(); + return docs.map((doc) => fromDoc(doc) as Automation); + } + + async findById(id: string): Promise { + const doc = await this.automations.findById(id).lean(); + return doc ? (fromDoc(doc) as Automation) : null; + } + + async insert({ id, ...rest }: Automation): Promise { + await this.automations.create({ _id: id, ...rest }); + } + + async update( + id: string, + changes: Partial>, + ): Promise { + if (Object.keys(changes).length) + await this.automations.updateOne({ _id: id }, { $set: changes }); + } + + async delete(id: string): Promise { + await this.automations.deleteOne({ _id: id }); + } + + async claimRun(ruleId: string, eventKey: string): Promise { + try { + await this.runs.create({ _id: `${ruleId}:${eventKey}`, at: new Date() }); + return true; + } catch (error) { + if ((error as { code?: number }).code === DUPLICATE_KEY) return false; + throw error; + } + } + + async recordRun(id: string, error: string | null): Promise { + await this.automations.updateOne( + { _id: id }, + { + $inc: { runs: 1 }, + $set: { lastRunAt: new Date().toISOString(), lastError: error }, + }, + ); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index d4a1c3e..80cd2cb 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -1,4 +1,7 @@ -import { OutgoingWebhook } from '@task-stream/api/tracker/application'; +import { + Automation, + OutgoingWebhook, +} from '@task-stream/api/tracker/application'; import { ActivityReadModel, CommentReadModel, @@ -32,6 +35,12 @@ export type TimelineDoc = Doc; export type NotificationDoc = Doc; export type SavedFilterDoc = Doc; export type OutgoingWebhookDoc = Omit & { _id: string }; +export type AutomationDoc = Omit & { _id: string }; +export interface AutomationRunDoc { + /** `ruleId:eventKey`: a rule runs once per event. */ + _id: string; + at: Date; +} export interface GitIntegrationDoc { _id: string; secret: string; @@ -55,6 +64,8 @@ export const ReadModels = { Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', OutgoingWebhook: 'OutgoingWebhook', + Automation: 'Automation', + AutomationRun: 'AutomationRun', } as const; const userRef = { _id: false, id: String, name: String }; @@ -218,6 +229,24 @@ export const OutgoingWebhookSchema = new Schema( { ...loose, collection: 'outgoing_webhooks' }, ); +export const AutomationSchema = new Schema( + { + _id: String, + projectId: { type: String, index: true }, + active: Boolean, + createdBy: userRef, + createdAt: String, + }, + { ...loose, collection: 'automations' }, +); + +export const AutomationRunSchema = new Schema( + { _id: String, at: Date }, + { ...loose, collection: 'automation_runs' }, +); +// Redeliveries happen within minutes; a month of memory is plenty. +AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 }); + export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Issue, schema: IssueSchema }, @@ -226,6 +255,8 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Sprint, schema: SprintSchema }, { name: ReadModels.GitIntegration, schema: GitIntegrationSchema }, { name: ReadModels.OutgoingWebhook, schema: OutgoingWebhookSchema }, + { name: ReadModels.Automation, schema: AutomationSchema }, + { name: ReadModels.AutomationRun, schema: AutomationRunSchema }, { name: ReadModels.Activity, schema: ActivitySchema }, { name: ReadModels.Timeline, schema: TimelineSchema }, { name: ReadModels.Notification, schema: NotificationSchema }, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 9cda173..3c1bf74 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -34,6 +34,7 @@ import { SavedFilterDoc, GitIntegrationDoc, OutgoingWebhookDoc, + AutomationDoc, TimelineDoc, } from './read-model.schemas'; import { upsertIfNewer } from './upsert-if-newer'; @@ -55,6 +56,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized private readonly gitIntegrations: Model, @InjectModel(ReadModels.OutgoingWebhook) private readonly outgoingWebhooks: Model, + @InjectModel(ReadModels.Automation) + private readonly automations: Model, ) { super(); } @@ -68,6 +71,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized this.savedFilters.deleteMany({ projectId }), this.gitIntegrations.deleteOne({ _id: projectId }), this.outgoingWebhooks.deleteMany({ projectId }), + this.automations.deleteMany({ projectId }), ]); } diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index cb71a7c..f28e0d2 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -10,6 +10,7 @@ import { SavedFiltersRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, + AutomationsRepository, WebhookSender, FindActivityRepository, FindCommentsRepository, @@ -66,6 +67,7 @@ import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { HttpWebhookSender } from './webhooks/http-webhook.sender'; +import { MongoAutomationsRepository } from './mongo/automations.repository'; import { MongoFindSprintsRepository, MongoUpsertMaterializedSprintRepository, @@ -136,6 +138,7 @@ const PORTS = [ useClass: MongoOutgoingWebhooksRepository, }, { provide: WebhookSender, useClass: HttpWebhookSender }, + { provide: AutomationsRepository, useClass: MongoAutomationsRepository }, { provide: NotificationMailer, useClass: SmtpNotificationMailer }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, diff --git a/libs/api/tracker/presenters/src/lib/graphql/automation.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/automation.resolver.ts new file mode 100644 index 0000000..2d7836e --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/graphql/automation.resolver.ts @@ -0,0 +1,58 @@ +import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { User } from '@task-stream/api/identity/domain'; +import { CurrentUser } from '@task-stream/api/identity/presenters'; +import { AutomationsService } from '@task-stream/api/tracker/application'; +import { CreateAutomationInput } from './inputs'; +import { AutomationGql } from './types'; + +/** Automation rules of a project (owners and admins). */ +@Resolver(() => AutomationGql) +export class AutomationResolver { + constructor(private readonly automationsService: AutomationsService) {} + + @Query(() => [AutomationGql]) + automations( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.automationsService.list(me.id, projectId); + } + + @Mutation(() => AutomationGql, { + description: + 'Add a rule. It acts as TaskStream, within the workflow, and what it does triggers no other rule.', + }) + createAutomation( + @CurrentUser() me: User, + @Args('input') input: CreateAutomationInput, + ) { + const { projectId, conditions, ...rule } = input; + return this.automationsService.create(me.id, projectId, { + ...rule, + conditions: conditions ?? {}, + }); + } + + @Mutation(() => AutomationGql, { description: 'Rename, pause or resume' }) + updateAutomation( + @CurrentUser() me: User, + @Args('automationId', { type: () => ID }) automationId: string, + @Args('name', { type: () => String, nullable: true }) name?: string | null, + @Args('active', { type: () => Boolean, nullable: true }) + active?: boolean | null, + ) { + return this.automationsService.update(me.id, automationId, { + ...(name != null && { name }), + ...(active != null && { active }), + }); + } + + @Mutation(() => Boolean) + async deleteAutomation( + @CurrentUser() me: User, + @Args('automationId', { type: () => ID }) automationId: string, + ) { + await this.automationsService.delete(me.id, automationId); + return true; + } +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index 25c9068..29a65bf 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -1,5 +1,9 @@ import { registerEnumType } from '@nestjs/graphql'; -import { IssueSort } from '@task-stream/api/tracker/application'; +import { + AutomationActionKind, + AutomationTriggerKind, + IssueSort, +} from '@task-stream/api/tracker/application'; import { IssueLinkType, IssuePriority, @@ -33,6 +37,14 @@ registerEnumType(SprintStatus, { description: 'PLANNED, ACTIVE (one per project at a time) or CLOSED', }); registerEnumType(LinkDirection, { name: 'LinkDirection' }); +registerEnumType(AutomationTriggerKind, { + name: 'AutomationTrigger', + description: 'What starts a rule', +}); +registerEnumType(AutomationActionKind, { + name: 'AutomationAction', + description: 'What a rule does to the ticket', +}); registerEnumType(IssueSort, { name: 'IssueSort', description: 'RECENT: last updated first. RANK: backlog order.', diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 9748afe..107e6de 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -5,7 +5,11 @@ import { ProjectRole, StatusCategory, } from '@task-stream/api/tracker/domain'; -import { IssueSort } from '@task-stream/api/tracker/application'; +import { + AutomationActionKind, + AutomationTriggerKind, + IssueSort, +} from '@task-stream/api/tracker/application'; import { Type } from 'class-transformer'; import { ArrayMaxSize, @@ -275,6 +279,69 @@ export class SaveFilterInput { criteria!: SavedFilterCriteriaInput; } +@InputType() +export class AutomationTriggerInput { + @Field(() => AutomationTriggerKind) + @IsEnum(AutomationTriggerKind) + kind!: AutomationTriggerKind; + @Field(() => String, { nullable: true }) + @IsOptional() + @Matches(SETTING_ID) + status?: string | null; +} + +@InputType() +export class AutomationConditionsInput { + @Field(() => [String], { nullable: true }) + @IsOptional() + @Matches(SETTING_ID, { each: true }) + types?: string[]; + @Field(() => [IssuePriority], { nullable: true }) + @IsOptional() + @IsEnum(IssuePriority, { each: true }) + priorities?: IssuePriority[]; + @Field(() => [String], { nullable: true }) + @IsOptional() + @Matches(SETTING_ID, { each: true }) + areas?: string[]; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @MaxLength(40) + label?: string | null; +} + +@InputType() +export class AutomationActionInput { + @Field(() => AutomationActionKind) + @IsEnum(AutomationActionKind) + kind!: AutomationActionKind; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @MaxLength(2000) + value?: string | null; +} + +@InputType() +export class CreateAutomationInput { + @Field(() => ID) @IsUUID() projectId!: string; + @Field() @IsString() @MinLength(1) @MaxLength(80) name!: string; + @Field(() => AutomationTriggerInput) + @ValidateNested() + @Type(() => AutomationTriggerInput) + trigger!: AutomationTriggerInput; + @Field(() => AutomationConditionsInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => AutomationConditionsInput) + conditions?: AutomationConditionsInput; + @Field(() => AutomationActionInput) + @ValidateNested() + @Type(() => AutomationActionInput) + action!: AutomationActionInput; +} + @InputType() export class CreateWebhookInput { @Field(() => ID) @IsUUID() projectId!: string; diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 40a1a93..1c0a8db 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -10,6 +10,10 @@ import { DevLinkKind, DevLinkState, } from '@task-stream/api/tracker/domain'; +import { + AutomationActionKind, + AutomationTriggerKind, +} from '@task-stream/api/tracker/application'; import { LinkDirection } from './enums'; @ObjectType('UserRef') @@ -170,6 +174,57 @@ export class GitIntegrationGql { webhookPath!: string; } +@ObjectType('AutomationTriggerSpec') +export class AutomationTriggerGql { + @Field(() => AutomationTriggerKind) kind!: AutomationTriggerKind; + @Field(() => String, { + nullable: true, + description: 'STATUS_CHANGED: only into this status', + }) + status?: string | null; +} + +@ObjectType('AutomationConditions') +export class AutomationConditionsGql { + @Field(() => [String], { nullable: true }) types?: string[]; + @Field(() => [IssuePriority], { nullable: true }) + priorities?: IssuePriority[]; + @Field(() => [String], { nullable: true }) areas?: string[]; + @Field(() => String, { nullable: true }) label?: string | null; +} + +@ObjectType('AutomationActionSpec') +export class AutomationActionGql { + @Field(() => AutomationActionKind) kind!: AutomationActionKind; + @Field(() => String, { + nullable: true, + description: 'Status, member id, label, priority or comment text', + }) + value?: string | null; +} + +@ObjectType('Automation', { + description: 'When this happens to a ticket, if it is like this, do that', +}) +export class AutomationGql { + @Field(() => ID) id!: string; + @Field(() => ID) projectId!: string; + @Field() name!: string; + @Field() active!: boolean; + @Field(() => AutomationTriggerGql) trigger!: AutomationTriggerGql; + @Field(() => AutomationConditionsGql) conditions!: AutomationConditionsGql; + @Field(() => AutomationActionGql) action!: AutomationActionGql; + @Field(() => UserRefGql) createdBy!: UserRefGql; + @Field() createdAt!: string; + @Field(() => Int) runs!: number; + @Field(() => String, { nullable: true }) lastRunAt!: string | null; + @Field(() => String, { + nullable: true, + description: 'Why the last run did nothing', + }) + lastError!: string | null; +} + @ObjectType('WebhookDelivery') export class WebhookDeliveryGql { @Field() at!: string; diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index f313f89..d064545 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -25,6 +25,7 @@ import { GitResolver } from './graphql/git.resolver'; import { WebhookResolver } from './graphql/webhook.resolver'; import { WebhooksController } from './http/webhooks.controller'; import { TransferController } from './http/transfer.controller'; +import { AutomationResolver } from './graphql/automation.resolver'; /** * Driving adapters of the tracker context: GraphQL (queries, mutations, @@ -72,6 +73,7 @@ import { TransferController } from './http/transfer.controller'; SprintResolver, GitResolver, WebhookResolver, + AutomationResolver, ], }) export class TrackerPresentersModule { diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index a042146..7d5064f 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -191,6 +191,46 @@ export interface Webhook { lastDelivery: WebhookDelivery | null; } +export type AutomationTriggerKind = + | 'ISSUE_CREATED' + | 'STATUS_CHANGED' + | 'ISSUE_ASSIGNED' + | 'COMMENT_ADDED' + | 'PULL_REQUEST_MERGED'; + +export type AutomationActionKind = + | 'SET_STATUS' + | 'ASSIGN' + | 'ASSIGN_REPORTER' + | 'ADD_LABEL' + | 'SET_PRIORITY' + | 'ADD_COMMENT' + | 'ADD_TO_ACTIVE_SPRINT'; + +/** «When this happens to a ticket, if it is like this, do that.» */ +export interface Automation { + id: string; + name: string; + active: boolean; + trigger: { kind: AutomationTriggerKind; status?: string | null }; + conditions: { + types?: string[] | null; + priorities?: IssuePriority[] | null; + areas?: string[] | null; + label?: string | null; + }; + action: { kind: AutomationActionKind; value?: string | null }; + createdBy: UserRef; + runs: number; + lastRunAt: string | null; + lastError: string | null; +} + +export type AutomationInput = Pick< + Automation, + 'name' | 'trigger' | 'conditions' | 'action' +>; + /** What an import did (or, as a preview, would do). */ export interface ImportReport { format: 'taskstream' | 'jira'; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index ec7faf8..81595e0 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -6,6 +6,8 @@ import { Comment, Epic, GitIntegration, + Automation, + AutomationInput, ImportReport, Webhook, WebhookDelivery, @@ -134,6 +136,20 @@ export abstract class IssuesGateway { ): Observable; } +/** The project's automation rules (owners and admins). */ +export abstract class AutomationsGateway { + abstract list(projectId: string): Observable; + abstract create( + projectId: string, + rule: AutomationInput, + ): Observable; + abstract update( + automationId: string, + changes: { name?: string; active?: boolean }, + ): Observable; + abstract remove(automationId: string): Observable; +} + /** Tickets in and out as CSV. */ export abstract class TransferGateway { abstract exportCsv(projectId: string): Observable; diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-automations.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-automations.gateway.ts new file mode 100644 index 0000000..d235975 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-automations.gateway.ts @@ -0,0 +1,52 @@ +import { inject, Injectable } from '@angular/core'; +import { + Automation, + AutomationInput, + AutomationsGateway, +} from '@task-stream/web/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { + AutomationsDocument, + CreateAutomationDocument, + DeleteAutomationDocument, + UpdateAutomationDocument, +} from '../graphql/generated'; +import { GraphqlOperations } from './graphql-operations'; + +@Injectable() +export class GraphqlAutomationsGateway extends AutomationsGateway { + private readonly gql = inject(GraphqlOperations); + + list(projectId: string): Observable { + return this.gql + .query(AutomationsDocument, { projectId }) + .pipe(map((d) => d.automations as Automation[])); + } + + create(projectId: string, rule: AutomationInput): Observable { + return this.gql + .mutate(CreateAutomationDocument, { + input: { projectId, ...rule } as never, + }) + .pipe(map((d) => d.createAutomation as Automation)); + } + + update( + automationId: string, + changes: { name?: string; active?: boolean }, + ): Observable { + return this.gql + .mutate(UpdateAutomationDocument, { + automationId, + name: changes.name ?? null, + active: changes.active ?? null, + }) + .pipe(map((d) => d.updateAutomation as Automation)); + } + + remove(automationId: string): Observable { + return this.gql + .mutate(DeleteAutomationDocument, { automationId }) + .pipe(map(() => undefined)); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/automations.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/automations.graphql new file mode 100644 index 0000000..0398f8b --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/automations.graphql @@ -0,0 +1,47 @@ +fragment AutomationFields on Automation { + id + name + active + runs + lastRunAt + lastError + trigger { + kind + status + } + conditions { + types + priorities + areas + label + } + action { + kind + value + } + createdBy { + ...UserRefFields + } +} + +query Automations($projectId: ID!) { + automations(projectId: $projectId) { + ...AutomationFields + } +} + +mutation CreateAutomation($input: CreateAutomationInput!) { + createAutomation(input: $input) { + ...AutomationFields + } +} + +mutation UpdateAutomation($automationId: ID!, $name: String, $active: Boolean) { + updateAutomation(automationId: $automationId, name: $name, active: $active) { + ...AutomationFields + } +} + +mutation DeleteAutomation($automationId: ID!) { + deleteAutomation(automationId: $automationId) +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 3037ad1..99c8ebf 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -18,6 +18,41 @@ export type AreaOptionInput = { name: string; }; +/** What a rule does to the ticket */ +export type AutomationAction = + | 'ADD_COMMENT' + | 'ADD_LABEL' + | 'ADD_TO_ACTIVE_SPRINT' + | 'ASSIGN' + | 'ASSIGN_REPORTER' + | 'SET_PRIORITY' + | 'SET_STATUS'; + +export type AutomationActionInput = { + kind: AutomationAction; + value?: string | null | undefined; +}; + +export type AutomationConditionsInput = { + areas?: Array | null | undefined; + label?: string | null | undefined; + priorities?: Array | null | undefined; + types?: Array | null | undefined; +}; + +/** What starts a rule */ +export type AutomationTrigger = + | 'COMMENT_ADDED' + | 'ISSUE_ASSIGNED' + | 'ISSUE_CREATED' + | 'PULL_REQUEST_MERGED' + | 'STATUS_CHANGED'; + +export type AutomationTriggerInput = { + kind: AutomationTrigger; + status?: string | null | undefined; +}; + export type ChangeIssueStatusInput = { issueId: string; /** Required by statuses with requiresReason */ @@ -26,6 +61,14 @@ export type ChangeIssueStatusInput = { status: string; }; +export type CreateAutomationInput = { + action: AutomationActionInput; + conditions?: AutomationConditionsInput | null | undefined; + name: string; + projectId: string; + trigger: AutomationTriggerInput; +}; + export type CreateIssueInput = { /** An area id */ area?: string | null | undefined; @@ -265,6 +308,104 @@ export type WorkflowStatusInput = { wipLimit?: number | null | undefined; }; +export type AutomationFieldsFragment = { + id: string; + name: string; + active: boolean; + runs: number; + lastRunAt: string | null; + lastError: string | null; + trigger: { kind: AutomationTrigger; status: string | null }; + conditions: { + types: Array | null; + priorities: Array | null; + areas: Array | null; + label: string | null; + }; + action: { kind: AutomationAction; value: string | null }; + createdBy: { id: string; name: string }; +}; + +export type AutomationsQueryVariables = Exact<{ + projectId: string; +}>; + +export type AutomationsQuery = { + automations: Array<{ + id: string; + name: string; + active: boolean; + runs: number; + lastRunAt: string | null; + lastError: string | null; + trigger: { kind: AutomationTrigger; status: string | null }; + conditions: { + types: Array | null; + priorities: Array | null; + areas: Array | null; + label: string | null; + }; + action: { kind: AutomationAction; value: string | null }; + createdBy: { id: string; name: string }; + }>; +}; + +export type CreateAutomationMutationVariables = Exact<{ + input: CreateAutomationInput; +}>; + +export type CreateAutomationMutation = { + createAutomation: { + id: string; + name: string; + active: boolean; + runs: number; + lastRunAt: string | null; + lastError: string | null; + trigger: { kind: AutomationTrigger; status: string | null }; + conditions: { + types: Array | null; + priorities: Array | null; + areas: Array | null; + label: string | null; + }; + action: { kind: AutomationAction; value: string | null }; + createdBy: { id: string; name: string }; + }; +}; + +export type UpdateAutomationMutationVariables = Exact<{ + automationId: string; + name?: string | null | undefined; + active?: boolean | null | undefined; +}>; + +export type UpdateAutomationMutation = { + updateAutomation: { + id: string; + name: string; + active: boolean; + runs: number; + lastRunAt: string | null; + lastError: string | null; + trigger: { kind: AutomationTrigger; status: string | null }; + conditions: { + types: Array | null; + priorities: Array | null; + areas: Array | null; + label: string | null; + }; + action: { kind: AutomationAction; value: string | null }; + createdBy: { id: string; name: string }; + }; +}; + +export type DeleteAutomationMutationVariables = Exact<{ + automationId: string; +}>; + +export type DeleteAutomationMutation = { deleteAutomation: boolean }; + export type UserRefFieldsFragment = { id: string; name: string }; export type ProjectFieldsFragment = { @@ -1790,6 +1931,93 @@ export const UserRefFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const AutomationFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'AutomationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Automation' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'active' } }, + { kind: 'Field', name: { kind: 'Name', value: 'runs' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastError' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'trigger' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'conditions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'types' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priorities' } }, + { kind: 'Field', name: { kind: 'Name', value: 'areas' } }, + { kind: 'Field', name: { kind: 'Name', value: 'label' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'action' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const ProjectFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -2703,6 +2931,488 @@ export const WebhookFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const AutomationsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Automations' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'automations' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'AutomationFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'AutomationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Automation' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'active' } }, + { kind: 'Field', name: { kind: 'Name', value: 'runs' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastError' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'trigger' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'conditions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'types' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priorities' } }, + { kind: 'Field', name: { kind: 'Name', value: 'areas' } }, + { kind: 'Field', name: { kind: 'Name', value: 'label' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'action' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const CreateAutomationDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'CreateAutomation' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CreateAutomationInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'createAutomation' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'AutomationFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'AutomationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Automation' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'active' } }, + { kind: 'Field', name: { kind: 'Name', value: 'runs' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastError' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'trigger' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'conditions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'types' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priorities' } }, + { kind: 'Field', name: { kind: 'Name', value: 'areas' } }, + { kind: 'Field', name: { kind: 'Name', value: 'label' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'action' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + CreateAutomationMutation, + CreateAutomationMutationVariables +>; +export const UpdateAutomationDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UpdateAutomation' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'automationId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'active' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'updateAutomation' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'automationId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'automationId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'name' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'active' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'active' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'AutomationFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'AutomationFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Automation' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'active' } }, + { kind: 'Field', name: { kind: 'Name', value: 'runs' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastError' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'trigger' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'conditions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'types' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priorities' } }, + { kind: 'Field', name: { kind: 'Name', value: 'areas' } }, + { kind: 'Field', name: { kind: 'Name', value: 'label' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'action' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'kind' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'createdBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UpdateAutomationMutation, + UpdateAutomationMutationVariables +>; +export const DeleteAutomationDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'DeleteAutomation' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'automationId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'deleteAutomation' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'automationId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'automationId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + DeleteAutomationMutation, + DeleteAutomationMutationVariables +>; export const GitIntegrationDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index dc18260..462d63d 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -15,6 +15,7 @@ import { GitGateway, WebhooksGateway, TransferGateway, + AutomationsGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -34,6 +35,7 @@ import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway'; import { GraphqlGitGateway } from './adapters/graphql-git.gateway'; import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway'; import { HttpTransferGateway } from './adapters/http-transfer.gateway'; +import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway'; /** Ports → GraphQL adapters (and REST for files). */ export function provideTrackerInfrastructure(): EnvironmentProviders { @@ -52,5 +54,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: GitGateway, useClass: GraphqlGitGateway }, { provide: WebhooksGateway, useClass: GraphqlWebhooksGateway }, { provide: TransferGateway, useClass: HttpTransferGateway }, + { provide: AutomationsGateway, useClass: GraphqlAutomationsGateway }, ]); } diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index e2c33ff..e813920 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -19,6 +19,7 @@ import { PiIconComponent, PiNoteComponent } from '@pi/ui'; import { GitIntegrationComponent } from '../ui/git-integration'; import { WebhooksComponent } from '../ui/webhooks'; import { TransferComponent } from '../ui/transfer'; +import { AutomationsComponent } from '../ui/automations'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectSettings, @@ -57,6 +58,7 @@ function idFrom(name: string): string { GitIntegrationComponent, WebhooksComponent, TransferComponent, + AutomationsComponent, CdkDropList, CdkDrag, CdkDragHandle, @@ -472,6 +474,7 @@ function idFrom(name: string): string { [projectId]="project.id" [projectKey]="project.key" /> + } diff --git a/libs/web/tracker/presenters/src/lib/ui/automations.ts b/libs/web/tracker/presenters/src/lib/ui/automations.ts new file mode 100644 index 0000000..730c68c --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/automations.ts @@ -0,0 +1,394 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + PiActionButtonComponent, + PiSelectFieldComponent, + PiTextFieldComponent, + type PiSelectFieldOption, +} from '@pi/ui'; +import { ToastService } from '@task-stream/web/shared/ui'; +import { ProjectFacade } from '@task-stream/web/tracker/application'; +import { + Automation, + AutomationActionKind, + AutomationsGateway, + AutomationTriggerKind, + IssuePriority, +} from '@task-stream/web/tracker/domain'; +import { options, PRIORITY, statusLook, timeAgo, typeLook } from './copy'; + +const ANY = ''; + +const TRIGGER: Record = { + ISSUE_CREATED: 'se crea un ticket', + STATUS_CHANGED: 'un ticket cambia de estado', + ISSUE_ASSIGNED: 'se asigna un ticket', + COMMENT_ADDED: 'alguien comenta', + PULL_REQUEST_MERGED: 'se fusiona una pull request', +}; + +const ACTION: Record = { + SET_STATUS: 'moverlo a', + ASSIGN: 'asignarlo a', + ASSIGN_REPORTER: 'asignarlo a quien lo abrió', + ADD_LABEL: 'añadir la etiqueta', + SET_PRIORITY: 'poner la prioridad', + ADD_COMMENT: 'comentar', + ADD_TO_ACTIVE_SPRINT: 'meterlo en el sprint en curso', +}; + +/** Settings card: rules that act on tickets for the team. */ +@Component({ + selector: 'ts-automations', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + PiActionButtonComponent, + PiSelectFieldComponent, + PiTextFieldComponent, + ], + template: ` +
+
+

Automatizaciones

+

+ Reglas «cuando… si… entonces…» que actúan como TaskStream. El flujo de + trabajo sigue mandando, y lo que hace una regla no dispara otras. +

+
+ + @for (rule of rules(); track rule.id) { +
+

+ {{ rule.name }} + @if (!rule.active) { + En pausa + } +

+

{{ describe(rule) }}

+

+ {{ rule.runs }} ejecuciones + @if (rule.lastRunAt) { + · la última {{ ago(rule.lastRunAt) }} + } + @if (rule.lastError) { + · {{ rule.lastError }} + } +

+
+ + +
+
+ } + +
+ +
+ Cuando + + @if (trigger() === 'STATUS_CHANGED') { + + } +
+
+ Si + + + +
+
+ Entonces + + @switch (action()) { + @case ('SET_STATUS') { + + } + @case ('ASSIGN') { + + } + @case ('SET_PRIORITY') { + + } + @case ('ADD_LABEL') { + + } + @case ('ADD_COMMENT') { + + } + } +
+
+ +
+ +
+ `, + styles: ` + .ts-rules { + display: grid; + gap: 0.75rem; + h3 { + margin: 0 0 0.25rem; + } + } + .ts-rules__item { + display: grid; + gap: 0.125rem; + padding: 0.625rem 0; + border-top: 1px solid var(--ts-line); + &.is-paused { + opacity: 0.7; + } + p { + margin: 0; + } + } + .ts-rules__name { + display: flex; + gap: 0.5rem; + align-items: center; + } + .ts-rules__runs { + font-size: 0.8125rem; + } + .ts-rules__error { + color: var(--ts-c-red, var(--ts-ink)); + } + .ts-rules__actions { + display: flex; + gap: 0.25rem; + } + .ts-rules__new { + display: grid; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--ts-line); + } + .ts-rules__row { + display: grid; + grid-template-columns: 5rem repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.5rem; + align-items: center; + @media (max-width: 640px) { + grid-template-columns: minmax(0, 1fr); + } + } + .ts-rules__word { + color: var(--ts-muted); + font-size: 0.875rem; + } + `, +}) +export class AutomationsComponent { + readonly projectId = input.required(); + + private readonly gateway = inject(AutomationsGateway); + private readonly project = inject(ProjectFacade); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + protected readonly rules = signal([]); + protected readonly ago = timeAgo; + + protected readonly name = signal(''); + protected readonly trigger = signal('ISSUE_CREATED'); + protected readonly triggerStatus = signal(ANY); + protected readonly type = signal(ANY); + protected readonly priority = signal(ANY); + protected readonly label = signal(''); + protected readonly action = signal('ASSIGN_REPORTER'); + protected readonly value = signal(ANY); + protected readonly textValue = signal(''); + + protected readonly triggerOptions = options(TRIGGER); + protected readonly actionOptions = options(ACTION); + protected readonly priorityValues = options(PRIORITY); + protected readonly priorityOptions: PiSelectFieldOption[] = [ + { value: ANY, label: 'cualquier prioridad' }, + ...options(PRIORITY), + ]; + protected readonly statusOptions = computed[]>( + () => + this.project + .workflow() + .settings.statuses.filter((s) => !s.archived) + .map((s) => ({ value: s.id, label: s.name })), + ); + protected readonly statusOptionsAny = computed[]>( + () => [ + { value: ANY, label: 'a cualquier estado' }, + ...this.statusOptions().map((o) => ({ ...o, label: `a ${o.label}` })), + ], + ); + protected readonly typeOptions = computed[]>( + () => [ + { value: ANY, label: 'cualquier tipo' }, + ...this.project + .workflow() + .settings.issueTypes.filter((t) => !t.archived) + .map((t) => ({ value: t.id, label: t.name })), + ], + ); + protected readonly memberOptions = computed[]>( + () => + this.project + .members() + .map((m) => ({ value: m.user.id, label: m.user.name })), + ); + protected readonly canCreate = computed(() => { + if (!this.name().trim() || !this.trigger() || !this.action()) return false; + const action = this.action() as AutomationActionKind; + if (['SET_STATUS', 'ASSIGN', 'SET_PRIORITY'].includes(action)) + return !!this.value(); + if (['ADD_LABEL', 'ADD_COMMENT'].includes(action)) + return !!this.textValue().trim(); + return true; + }); + + constructor() { + effect(() => { + const projectId = this.projectId(); + untracked(() => this.reload(projectId)); + }); + } + + protected describe(rule: Automation): string { + const workflow = this.project.workflow(); + let when = TRIGGER[rule.trigger.kind]; + if (rule.trigger.kind === 'STATUS_CHANGED' && rule.trigger.status) + when = `un ticket pasa a ${statusLook(workflow, rule.trigger.status).label}`; + const ifs = [ + ...(rule.conditions.types ?? []).map( + (t) => `es ${typeLook(workflow, t).label}`, + ), + ...(rule.conditions.priorities ?? []).map( + (p) => + `prioridad ${PRIORITY[p as IssuePriority]?.label.toLowerCase() ?? p}`, + ), + ...(rule.conditions.label ? [`lleva #${rule.conditions.label}`] : []), + ]; + const value = rule.action.value; + const then = + rule.action.kind === 'SET_STATUS' && value + ? `moverlo a ${statusLook(workflow, value).label}` + : rule.action.kind === 'ASSIGN' && value + ? `asignarlo a ${this.project.members().find((m) => m.user.id === value)?.user.name ?? 'alguien que ya no está'}` + : rule.action.kind === 'SET_PRIORITY' && value + ? `poner la prioridad ${PRIORITY[value as IssuePriority]?.label.toLowerCase() ?? value}` + : value + ? `${ACTION[rule.action.kind]} «${value}»` + : ACTION[rule.action.kind]; + return `Cuando ${when}${ifs.length ? `, si ${ifs.join(' y ')}` : ''}: ${then}.`; + } + + protected create(): void { + if (!this.canCreate()) return; + const action = this.action() as AutomationActionKind; + const text = ['ADD_LABEL', 'ADD_COMMENT'].includes(action); + this.gateway + .create(this.projectId(), { + name: this.name().trim(), + trigger: { + kind: this.trigger() as AutomationTriggerKind, + status: + this.trigger() === 'STATUS_CHANGED' + ? this.triggerStatus() || null + : null, + }, + conditions: { + types: this.type() ? [this.type() as string] : null, + priorities: this.priority() + ? [this.priority() as IssuePriority] + : null, + label: this.label().trim() || null, + }, + action: { + kind: action, + value: text ? this.textValue().trim() : this.value() || null, + }, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toast.success('Regla añadida'); + this.name.set(''); + this.textValue.set(''); + this.reload(this.projectId()); + }, + error: (error) => this.fail(error, 'No se pudo añadir la regla'), + }); + } + + protected toggle(rule: Automation): void { + this.gateway + .update(rule.id, { active: !rule.active }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.reload(this.projectId()), + error: (error) => this.fail(error, 'No se pudo cambiar'), + }); + } + + protected remove(rule: Automation): void { + if (!confirm(`¿Borrar la regla «${rule.name}»?`)) return; + this.gateway + .remove(rule.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.reload(this.projectId()), + error: (error) => this.fail(error, 'No se pudo borrar'), + }); + } + + private reload(projectId: string): void { + this.gateway + .list(projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (rules) => this.rules.set(rules), + error: (error) => this.fail(error, 'No se pudieron leer las reglas'), + }); + } + + private fail(error: unknown, fallback: string): void { + const message = (error as { message?: string })?.message?.split('\n')[0]; + this.toast.error(message || fallback); + } +}