feat: automation rules
Owners and admins can set rules of the kind «when a ticket is created, moves, gets assigned, gets a comment or its pull request merges; if it is of this type, priority, area or label; then move it, assign it, label it, change its priority, comment or put it in the active sprint». Rules act as TaskStream within the workflow, run once per event and trigger no others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,9 @@ export * from './lib/sprints.service';
|
||||
export * from './lib/git.service';
|
||||
export * from './lib/webhooks.service';
|
||||
export * from './lib/transfer.service';
|
||||
export * from './lib/automations.service';
|
||||
export * from './lib/automations/automations';
|
||||
export * from './lib/automations/automation-rules';
|
||||
export * from './lib/transfer/import-export';
|
||||
export * from './lib/webhooks/webhooks';
|
||||
export * from './lib/webhooks/webhook-events';
|
||||
|
||||
49
libs/api/tracker/application/src/lib/automations.service.ts
Normal file
49
libs/api/tracker/application/src/lib/automations.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AutomationInput,
|
||||
CreateAutomationCommand,
|
||||
DeleteAutomationCommand,
|
||||
GetAutomationsQuery,
|
||||
UpdateAutomationCommand,
|
||||
} from './automations/automations';
|
||||
import { Automation } from './ports/automations.repository';
|
||||
|
||||
/** The project's automation rules (owners and admins). */
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
list(viewerId: string, projectId: string): Promise<Automation[]> {
|
||||
return this.queryBus.execute(new GetAutomationsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
create(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
rule: AutomationInput,
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
new CreateAutomationCommand(actorId, projectId, rule),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateAutomationCommand(actorId, automationId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, automationId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteAutomationCommand(actorId, automationId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
AutomationTriggerKind,
|
||||
normalizeRule,
|
||||
ruleApplies,
|
||||
triggerOf,
|
||||
} from './automation-rules';
|
||||
|
||||
const workflow = new ProjectWorkflow(defaultProjectSettings());
|
||||
const bug = {
|
||||
type: 'BUG',
|
||||
priority: IssuePriority.HIGH,
|
||||
area: 'FRONTEND',
|
||||
labels: ['ui'],
|
||||
};
|
||||
|
||||
describe('automation rules', () => {
|
||||
it('reads triggers from events, merged pull requests included', () => {
|
||||
expect(
|
||||
triggerOf(
|
||||
new IssueStatusChangedEvent('u', 'i1', 'p', 'TODO', 'IN_QA', null),
|
||||
),
|
||||
).toEqual({
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
issueId: 'i1',
|
||||
status: 'IN_QA',
|
||||
});
|
||||
const merged = new IssueDevLinkRecordedEvent(SYSTEM_ACTOR_ID, 'i2', 'p', {
|
||||
kind: DevLinkKind.PULL_REQUEST,
|
||||
url: 'https://x/pr/1',
|
||||
title: 'x',
|
||||
ref: '#1',
|
||||
state: DevLinkState.MERGED,
|
||||
provider: 'github',
|
||||
author: null,
|
||||
at: '',
|
||||
});
|
||||
expect(triggerOf(merged)?.kind).toBe(
|
||||
AutomationTriggerKind.PULL_REQUEST_MERGED,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the trigger, its status and every condition', () => {
|
||||
const rule = {
|
||||
trigger: { kind: AutomationTriggerKind.STATUS_CHANGED, status: 'IN_QA' },
|
||||
conditions: { types: ['BUG'], label: 'ui' },
|
||||
};
|
||||
const into = (status: string) => ({
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
status,
|
||||
});
|
||||
expect(ruleApplies(rule, into('IN_QA'), bug)).toBe(true);
|
||||
expect(ruleApplies(rule, into('DONE'), bug)).toBe(false);
|
||||
expect(ruleApplies(rule, into('IN_QA'), { ...bug, labels: [] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(ruleApplies(rule, into('IN_QA'), { ...bug, type: 'TASK' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses actions without a usable value', () => {
|
||||
const rule = (kind: AutomationActionKind, value: string | null) => ({
|
||||
name: 'R',
|
||||
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
|
||||
conditions: {},
|
||||
action: { kind, value },
|
||||
});
|
||||
const member = (id: string) => id === 'u-ana';
|
||||
expect(
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ADD_LABEL, ' Triage '),
|
||||
workflow,
|
||||
member,
|
||||
).action,
|
||||
).toEqual({
|
||||
kind: AutomationActionKind.ADD_LABEL,
|
||||
value: 'triage',
|
||||
});
|
||||
expect(() =>
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.SET_STATUS, 'NOPE'),
|
||||
workflow,
|
||||
member,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ASSIGN, 'u-bob'),
|
||||
workflow,
|
||||
member,
|
||||
),
|
||||
).toThrow();
|
||||
expect(
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ASSIGN_REPORTER, 'x'),
|
||||
workflow,
|
||||
member,
|
||||
).action.value,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
Issue,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
|
||||
export enum AutomationTriggerKind {
|
||||
ISSUE_CREATED = 'ISSUE_CREATED',
|
||||
/** Optionally only into `status`. */
|
||||
STATUS_CHANGED = 'STATUS_CHANGED',
|
||||
ISSUE_ASSIGNED = 'ISSUE_ASSIGNED',
|
||||
COMMENT_ADDED = 'COMMENT_ADDED',
|
||||
PULL_REQUEST_MERGED = 'PULL_REQUEST_MERGED',
|
||||
}
|
||||
|
||||
export enum AutomationActionKind {
|
||||
/** `value`: a status id. */
|
||||
SET_STATUS = 'SET_STATUS',
|
||||
/** `value`: a member's id. */
|
||||
ASSIGN = 'ASSIGN',
|
||||
ASSIGN_REPORTER = 'ASSIGN_REPORTER',
|
||||
/** `value`: the label. */
|
||||
ADD_LABEL = 'ADD_LABEL',
|
||||
/** `value`: a priority. */
|
||||
SET_PRIORITY = 'SET_PRIORITY',
|
||||
/** `value`: the text. */
|
||||
ADD_COMMENT = 'ADD_COMMENT',
|
||||
ADD_TO_ACTIVE_SPRINT = 'ADD_TO_ACTIVE_SPRINT',
|
||||
}
|
||||
|
||||
export interface AutomationTrigger {
|
||||
kind: AutomationTriggerKind;
|
||||
/** STATUS_CHANGED only: the status entered (null: any). */
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
/** All of them must hold; an empty list means "any". */
|
||||
export interface AutomationConditions {
|
||||
types?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
areas?: string[];
|
||||
/** The ticket carries this label. */
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationAction {
|
||||
kind: AutomationActionKind;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
/** What an event means for automations, if anything. */
|
||||
export function triggerOf(
|
||||
event: TrackerEvent,
|
||||
): { kind: AutomationTriggerKind; issueId: string; status?: string } | null {
|
||||
if (event instanceof IssueCreatedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.ISSUE_CREATED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
if (event instanceof IssueStatusChangedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
issueId: event.aggregateId,
|
||||
status: event.to,
|
||||
};
|
||||
if (event instanceof IssueAssignedEvent && event.assigneeId)
|
||||
return {
|
||||
kind: AutomationTriggerKind.ISSUE_ASSIGNED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
if (event instanceof CommentAddedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.COMMENT_ADDED,
|
||||
issueId: event.comment.issueId,
|
||||
};
|
||||
if (
|
||||
event instanceof IssueDevLinkRecordedEvent &&
|
||||
event.link.kind === DevLinkKind.PULL_REQUEST &&
|
||||
event.link.state === DevLinkState.MERGED
|
||||
)
|
||||
return {
|
||||
kind: AutomationTriggerKind.PULL_REQUEST_MERGED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a rule wants this trigger on this ticket. */
|
||||
export function ruleApplies(
|
||||
rule: { trigger: AutomationTrigger; conditions: AutomationConditions },
|
||||
trigger: { kind: AutomationTriggerKind; status?: string },
|
||||
issue: Pick<Issue, 'type' | 'priority' | 'area' | 'labels'>,
|
||||
): boolean {
|
||||
if (rule.trigger.kind !== trigger.kind) return false;
|
||||
if (
|
||||
rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED &&
|
||||
rule.trigger.status &&
|
||||
rule.trigger.status !== trigger.status
|
||||
)
|
||||
return false;
|
||||
const { types, priorities, areas, label } = rule.conditions;
|
||||
if (types?.length && !types.includes(issue.type)) return false;
|
||||
if (priorities?.length && !priorities.includes(issue.priority)) return false;
|
||||
if (areas?.length && !(issue.area && areas.includes(issue.area)))
|
||||
return false;
|
||||
if (label && !issue.labels.includes(label.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Checks a rule against the project's workflow; returns it tidied. */
|
||||
export function normalizeRule(
|
||||
rule: {
|
||||
name: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
},
|
||||
workflow: ProjectWorkflow,
|
||||
isMember: (userId: string) => boolean,
|
||||
) {
|
||||
const name = rule.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 80,
|
||||
'A rule needs a name (up to 80 characters)',
|
||||
);
|
||||
assertThat(
|
||||
Object.values(AutomationTriggerKind).includes(rule.trigger.kind),
|
||||
'Unknown trigger',
|
||||
);
|
||||
const status = rule.trigger.status || null;
|
||||
if (status) assertThat(!!workflow.status(status), `Unknown status ${status}`);
|
||||
const { kind, value } = rule.action;
|
||||
assertThat(
|
||||
Object.values(AutomationActionKind).includes(kind),
|
||||
'Unknown action',
|
||||
);
|
||||
const needs: Partial<Record<AutomationActionKind, (v: string) => boolean>> = {
|
||||
[AutomationActionKind.SET_STATUS]: (v) => !!workflow.status(v),
|
||||
[AutomationActionKind.ASSIGN]: (v) => isMember(v),
|
||||
[AutomationActionKind.ADD_LABEL]: (v) =>
|
||||
v.trim().length > 0 && v.length <= 40,
|
||||
[AutomationActionKind.SET_PRIORITY]: (v) =>
|
||||
(Object.values(IssuePriority) as string[]).includes(v),
|
||||
[AutomationActionKind.ADD_COMMENT]: (v) =>
|
||||
v.trim().length > 0 && v.length <= 2000,
|
||||
};
|
||||
const check = needs[kind];
|
||||
if (check)
|
||||
assertThat(
|
||||
!!value && check(value),
|
||||
`The action ${kind} needs a valid value`,
|
||||
);
|
||||
const list = <T>(values?: T[] | null) =>
|
||||
values?.length ? [...new Set(values)] : undefined;
|
||||
return {
|
||||
name,
|
||||
trigger: {
|
||||
kind: rule.trigger.kind,
|
||||
...(rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED
|
||||
? { status }
|
||||
: {}),
|
||||
},
|
||||
conditions: {
|
||||
types: list(rule.conditions.types),
|
||||
priorities: list(rule.conditions.priorities),
|
||||
areas: list(rule.conditions.areas),
|
||||
label: rule.conditions.label?.trim().toLowerCase() || undefined,
|
||||
},
|
||||
action: {
|
||||
kind,
|
||||
value: check
|
||||
? kind === AutomationActionKind.ADD_LABEL
|
||||
? value!.trim().toLowerCase()
|
||||
: value
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
376
libs/api/tracker/application/src/lib/automations/automations.ts
Normal file
376
libs/api/tracker/application/src/lib/automations/automations.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
EventPublisher,
|
||||
EventsHandler,
|
||||
ICommandHandler,
|
||||
IEventHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
CommentFactory,
|
||||
Issue,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
Project,
|
||||
SYSTEM_ACTOR_ID,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
Automation,
|
||||
AutomationsRepository,
|
||||
} from '../ports/automations.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import {
|
||||
AutomationAction,
|
||||
AutomationActionKind,
|
||||
AutomationConditions,
|
||||
AutomationTrigger,
|
||||
normalizeRule,
|
||||
ruleApplies,
|
||||
triggerOf,
|
||||
} from './automation-rules';
|
||||
|
||||
/** A project keeps at most this many rules. */
|
||||
export const MAX_AUTOMATIONS = 50;
|
||||
|
||||
export interface AutomationInput {
|
||||
name: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
}
|
||||
|
||||
export class CreateAutomationCommand extends Command<Automation> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly rule: AutomationInput,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateAutomationCommand extends Command<Automation> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly automationId: string,
|
||||
public readonly changes: { name?: string; active?: boolean },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteAutomationCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly automationId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Event handler only: one rule's action on one ticket, as TaskStream. */
|
||||
export class RunAutomationCommand extends Command<string | null> {
|
||||
constructor(
|
||||
public readonly automation: Automation,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAutomationsQuery extends Query<Automation[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
async function managedProject(
|
||||
access: WriteAccess,
|
||||
projectId: string,
|
||||
actorId: string,
|
||||
): Promise<Project> {
|
||||
const project = await access.project(projectId);
|
||||
project.assertCanManage(actorId);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function managedAutomation(
|
||||
automations: AutomationsRepository,
|
||||
access: WriteAccess,
|
||||
automationId: string,
|
||||
actorId: string,
|
||||
): Promise<Automation> {
|
||||
const automation = await automations.findById(automationId);
|
||||
if (!automation) throw new NotFoundError('Automation', automationId);
|
||||
await managedProject(access, automation.projectId, actorId);
|
||||
return automation;
|
||||
}
|
||||
|
||||
@CommandHandler(CreateAutomationCommand)
|
||||
export class CreateAutomationCommandHandler
|
||||
implements ICommandHandler<CreateAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateAutomationCommand): Promise<Automation> {
|
||||
const project = await managedProject(
|
||||
this.access,
|
||||
command.projectId,
|
||||
command.actorId,
|
||||
);
|
||||
if (
|
||||
(await this.automations.findByProject(project.id)).length >=
|
||||
MAX_AUTOMATIONS
|
||||
)
|
||||
throw new BusinessRuleError(
|
||||
`A project keeps at most ${MAX_AUTOMATIONS} rules`,
|
||||
);
|
||||
const rule = normalizeRule(command.rule, project.workflow, (id) =>
|
||||
project.isMember(id),
|
||||
);
|
||||
const users = await this.users.refs([command.actorId]);
|
||||
const automation: Automation = {
|
||||
id: randomUUID(),
|
||||
projectId: project.id,
|
||||
...rule,
|
||||
active: true,
|
||||
createdBy: users.of(command.actorId),
|
||||
createdAt: new Date().toISOString(),
|
||||
runs: 0,
|
||||
lastRunAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
await this.automations.insert(automation);
|
||||
return automation;
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateAutomationCommand)
|
||||
export class UpdateAutomationCommandHandler
|
||||
implements ICommandHandler<UpdateAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateAutomationCommand): Promise<Automation> {
|
||||
const automation = await managedAutomation(
|
||||
this.automations,
|
||||
this.access,
|
||||
command.automationId,
|
||||
command.actorId,
|
||||
);
|
||||
const changes: { name?: string; active?: boolean } = {};
|
||||
if (command.changes.name !== undefined) {
|
||||
const name = command.changes.name.trim();
|
||||
if (!name || name.length > 80)
|
||||
throw new BusinessRuleError(
|
||||
'A rule needs a name (up to 80 characters)',
|
||||
);
|
||||
changes.name = name;
|
||||
}
|
||||
if (command.changes.active !== undefined)
|
||||
changes.active = command.changes.active;
|
||||
await this.automations.update(automation.id, changes);
|
||||
return { ...automation, ...changes };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteAutomationCommand)
|
||||
export class DeleteAutomationCommandHandler
|
||||
implements ICommandHandler<DeleteAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteAutomationCommand): Promise<void> {
|
||||
const automation = await managedAutomation(
|
||||
this.automations,
|
||||
this.access,
|
||||
command.automationId,
|
||||
command.actorId,
|
||||
);
|
||||
await this.automations.delete(automation.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the action straight on the aggregates, as TaskStream (the system
|
||||
* actor): the rule was set up by someone who manages the project, and the
|
||||
* workflow still has the last word. Returns why nothing happened, if so.
|
||||
*/
|
||||
@CommandHandler(RunAutomationCommand)
|
||||
export class RunAutomationCommandHandler
|
||||
implements ICommandHandler<RunAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: RunAutomationCommand): Promise<string | null> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
const project = issue
|
||||
? await this.access.live(issue.projectId, Project)
|
||||
: null;
|
||||
if (!issue || !project) return 'The ticket is gone';
|
||||
const { kind, value } = command.automation.action;
|
||||
const actor = SYSTEM_ACTOR_ID;
|
||||
switch (kind) {
|
||||
case AutomationActionKind.SET_STATUS:
|
||||
if (!value || issue.status === value) return null;
|
||||
issue.changeStatus(actor, value, 'Automatización', project.workflow);
|
||||
break;
|
||||
case AutomationActionKind.ASSIGN:
|
||||
case AutomationActionKind.ASSIGN_REPORTER: {
|
||||
const assignee =
|
||||
kind === AutomationActionKind.ASSIGN
|
||||
? (value ?? null)
|
||||
: issue.reporterId;
|
||||
if (!assignee || !project.isMember(assignee))
|
||||
return 'That person is no longer a member';
|
||||
issue.assign(actor, assignee);
|
||||
break;
|
||||
}
|
||||
case AutomationActionKind.ADD_LABEL:
|
||||
if (!value || issue.labels.includes(value)) return null;
|
||||
issue.updateDetails(
|
||||
actor,
|
||||
{ labels: [...issue.labels, value] },
|
||||
project.workflow,
|
||||
);
|
||||
break;
|
||||
case AutomationActionKind.SET_PRIORITY:
|
||||
issue.updateDetails(
|
||||
actor,
|
||||
{ priority: value as IssuePriority },
|
||||
project.workflow,
|
||||
);
|
||||
break;
|
||||
case AutomationActionKind.ADD_TO_ACTIVE_SPRINT: {
|
||||
const active = await this.findSprints.findActive(project.id);
|
||||
if (!active) return 'No sprint is under way';
|
||||
issue.setSprint(actor, active.id);
|
||||
break;
|
||||
}
|
||||
case AutomationActionKind.ADD_COMMENT: {
|
||||
const comment = this.commentFactory.create({
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
authorId: actor,
|
||||
body: value ?? '',
|
||||
});
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
await comment.commitAndWait();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetAutomationsQuery)
|
||||
export class GetAutomationsQueryHandler
|
||||
implements IQueryHandler<GetAutomationsQuery, Automation[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
|
||||
await managedProject(this.access, query.projectId, query.viewerId);
|
||||
return this.automations.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the project's rules on what people do. What rules themselves do (as
|
||||
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
|
||||
* per event, also when the event is delivered again.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueAssignedEvent,
|
||||
CommentAddedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
)
|
||||
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly logger = new Logger(AutomationsEventHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent) {
|
||||
// Git reports come as the system too, and a merge is worth reacting to.
|
||||
if (
|
||||
event.actorId === SYSTEM_ACTOR_ID &&
|
||||
!(event instanceof IssueDevLinkRecordedEvent)
|
||||
)
|
||||
return;
|
||||
const trigger = triggerOf(event);
|
||||
if (!trigger) return;
|
||||
const rules = await this.automations.findActive(event.projectId);
|
||||
if (!rules.length) return;
|
||||
const issue = await this.access.live(trigger.issueId, Issue);
|
||||
if (!issue) return;
|
||||
for (const rule of rules) {
|
||||
if (!ruleApplies(rule, trigger, issue)) continue;
|
||||
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
|
||||
if (!(await this.automations.claimRun(rule.id, key))) continue;
|
||||
let error: string | null;
|
||||
try {
|
||||
error = await this.commandBus.execute(
|
||||
new RunAutomationCommand(rule, trigger.issueId),
|
||||
);
|
||||
} catch (failure) {
|
||||
error = failure instanceof Error ? failure.message : String(failure);
|
||||
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
|
||||
}
|
||||
await this.automations.recordRun(rule.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMATION_HANDLERS = [
|
||||
CreateAutomationCommandHandler,
|
||||
UpdateAutomationCommandHandler,
|
||||
DeleteAutomationCommandHandler,
|
||||
RunAutomationCommandHandler,
|
||||
GetAutomationsQueryHandler,
|
||||
AutomationsEventHandler,
|
||||
];
|
||||
@@ -40,6 +40,13 @@ import {
|
||||
} from './sprint.commands';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
|
||||
import { RunAutomationCommand } from '../automations/automations';
|
||||
import { RunAutomationCommandHandler } from '../automations/automations';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
AutomationTriggerKind,
|
||||
} from '../automations/automation-rules';
|
||||
import { Automation } from '../ports/automations.repository';
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { DevLinkKind } from '@task-stream/api/tracker/domain';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
@@ -92,6 +99,7 @@ async function setup() {
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
...GIT_HANDLERS,
|
||||
RunAutomationCommandHandler,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
@@ -520,6 +528,73 @@ describe('tracker commands (event sourced)', () => {
|
||||
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
|
||||
});
|
||||
|
||||
it('runs automation actions as TaskStream, within the workflow', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
const rule = (kind: AutomationActionKind, value: string | null) =>
|
||||
({
|
||||
id: 'r1',
|
||||
projectId,
|
||||
name: 'R',
|
||||
active: true,
|
||||
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
|
||||
conditions: {},
|
||||
action: { kind, value },
|
||||
}) as Automation;
|
||||
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.SET_STATUS, 'TODO'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_LABEL, 'triage'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ASSIGN_REPORTER, null),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_COMMENT, 'Gracias'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect([rebuilt.status, rebuilt.labels, rebuilt.assigneeId]).toEqual([
|
||||
'TODO',
|
||||
['triage'],
|
||||
'dev',
|
||||
]);
|
||||
expect(store.types()).toContain('CommentAddedEvent');
|
||||
// The workflow still decides: TODO cannot jump to DONE.
|
||||
await expect(
|
||||
run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.SET_STATUS, 'DONE'),
|
||||
issue.id,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
expect(
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_TO_ACTIVE_SPRINT, null),
|
||||
issue.id,
|
||||
),
|
||||
),
|
||||
).toBe('No sprint is under way');
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationAction,
|
||||
AutomationConditions,
|
||||
AutomationTrigger,
|
||||
} from '../automations/automation-rules';
|
||||
|
||||
/** «When this happens to a ticket, if it is like this, do that.» */
|
||||
export interface Automation {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
createdBy: UserRefReadModel;
|
||||
createdAt: string;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
/** Why the last run did nothing (a move the workflow does not allow…). */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/** Configuration, not history: kept outside the event store. */
|
||||
export abstract class AutomationsRepository {
|
||||
abstract findByProject(projectId: string): Promise<Automation[]>;
|
||||
abstract findActive(projectId: string): Promise<Automation[]>;
|
||||
abstract findById(id: string): Promise<Automation | null>;
|
||||
abstract insert(automation: Automation): Promise<void>;
|
||||
abstract update(
|
||||
id: string,
|
||||
changes: Partial<Pick<Automation, 'name' | 'active'>>,
|
||||
): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
/**
|
||||
* Takes the right to run `ruleId` for one event (false: it already ran, the
|
||||
* event was delivered again).
|
||||
*/
|
||||
abstract claimRun(ruleId: string, eventKey: string): Promise<boolean>;
|
||||
abstract recordRun(id: string, error: string | null): Promise<void>;
|
||||
}
|
||||
@@ -25,3 +25,4 @@ export * from './find-sprints.repository';
|
||||
export * from './upsert-materialized-sprint.repository';
|
||||
export * from './git-integrations.repository';
|
||||
export * from './outgoing-webhooks.repository';
|
||||
export * from './automations.repository';
|
||||
|
||||
@@ -38,6 +38,8 @@ import { WebhooksService } from './webhooks.service';
|
||||
import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
|
||||
import { TransferService } from './transfer.service';
|
||||
import { TRANSFER_HANDLERS } from './transfer/import-export';
|
||||
import { AutomationsService } from './automations.service';
|
||||
import { AUTOMATION_HANDLERS } from './automations/automations';
|
||||
|
||||
/** Notifications: derived from events, kept and dismissed per person. */
|
||||
const NOTIFICATIONS = [
|
||||
@@ -62,6 +64,7 @@ const SERVICES = [
|
||||
GitService,
|
||||
WebhooksService,
|
||||
TransferService,
|
||||
AutomationsService,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -87,6 +90,7 @@ const SERVICES = [
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
...AUTOMATION_HANDLERS,
|
||||
],
|
||||
exports: SERVICES,
|
||||
})
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AutomationDoc>,
|
||||
@InjectModel(ReadModels.AutomationRun)
|
||||
private readonly runs: Model<AutomationRunDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<Automation[]> {
|
||||
const docs = await this.automations
|
||||
.find({ projectId })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean<AutomationDoc[]>();
|
||||
return docs.map((doc) => fromDoc(doc) as Automation);
|
||||
}
|
||||
|
||||
async findActive(projectId: string): Promise<Automation[]> {
|
||||
const docs = await this.automations
|
||||
.find({ projectId, active: true })
|
||||
.lean<AutomationDoc[]>();
|
||||
return docs.map((doc) => fromDoc(doc) as Automation);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Automation | null> {
|
||||
const doc = await this.automations.findById(id).lean<AutomationDoc>();
|
||||
return doc ? (fromDoc(doc) as Automation) : null;
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: Automation): Promise<void> {
|
||||
await this.automations.create({ _id: id, ...rest });
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
changes: Partial<Pick<Automation, 'name' | 'active'>>,
|
||||
): Promise<void> {
|
||||
if (Object.keys(changes).length)
|
||||
await this.automations.updateOne({ _id: id }, { $set: changes });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.automations.deleteOne({ _id: id });
|
||||
}
|
||||
|
||||
async claimRun(ruleId: string, eventKey: string): Promise<boolean> {
|
||||
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<void> {
|
||||
await this.automations.updateOne(
|
||||
{ _id: id },
|
||||
{
|
||||
$inc: { runs: 1 },
|
||||
$set: { lastRunAt: new Date().toISOString(), lastError: error },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
|
||||
export type AutomationDoc = Omit<Automation, 'id'> & { _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<OutgoingWebhookDoc>(
|
||||
{ ...loose, collection: 'outgoing_webhooks' },
|
||||
);
|
||||
|
||||
export const AutomationSchema = new Schema<AutomationDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
active: Boolean,
|
||||
createdBy: userRef,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'automations' },
|
||||
);
|
||||
|
||||
export const AutomationRunSchema = new Schema<AutomationRunDoc>(
|
||||
{ _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 },
|
||||
|
||||
@@ -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<GitIntegrationDoc>,
|
||||
@InjectModel(ReadModels.OutgoingWebhook)
|
||||
private readonly outgoingWebhooks: Model<OutgoingWebhookDoc>,
|
||||
@InjectModel(ReadModels.Automation)
|
||||
private readonly automations: Model<AutomationDoc>,
|
||||
) {
|
||||
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 }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Comment,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
Automation,
|
||||
AutomationInput,
|
||||
ImportReport,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
@@ -134,6 +136,20 @@ export abstract class IssuesGateway {
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
/** The project's automation rules (owners and admins). */
|
||||
export abstract class AutomationsGateway {
|
||||
abstract list(projectId: string): Observable<Automation[]>;
|
||||
abstract create(
|
||||
projectId: string,
|
||||
rule: AutomationInput,
|
||||
): Observable<Automation>;
|
||||
abstract update(
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Observable<Automation>;
|
||||
abstract remove(automationId: string): Observable<void>;
|
||||
}
|
||||
|
||||
/** Tickets in and out as CSV. */
|
||||
export abstract class TransferGateway {
|
||||
abstract exportCsv(projectId: string): Observable<Blob>;
|
||||
|
||||
@@ -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<Automation[]> {
|
||||
return this.gql
|
||||
.query(AutomationsDocument, { projectId })
|
||||
.pipe(map((d) => d.automations as Automation[]));
|
||||
}
|
||||
|
||||
create(projectId: string, rule: AutomationInput): Observable<Automation> {
|
||||
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<Automation> {
|
||||
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<void> {
|
||||
return this.gql
|
||||
.mutate(DeleteAutomationDocument, { automationId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<string> | null | undefined;
|
||||
label?: string | null | undefined;
|
||||
priorities?: Array<IssuePriority> | null | undefined;
|
||||
types?: Array<string> | 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<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | 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<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | 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<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | 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<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | 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<UserRefFieldsFragment, unknown>;
|
||||
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<AutomationFieldsFragment, unknown>;
|
||||
export const ProjectFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -2703,6 +2931,488 @@ export const WebhookFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<WebhookFieldsFragment, unknown>;
|
||||
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<AutomationsQuery, AutomationsQueryVariables>;
|
||||
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: [
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<ts-automations [projectId]="project.id" />
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
||||
}
|
||||
|
||||
394
libs/web/tracker/presenters/src/lib/ui/automations.ts
Normal file
394
libs/web/tracker/presenters/src/lib/ui/automations.ts
Normal file
@@ -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<AutomationTriggerKind, string> = {
|
||||
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<AutomationActionKind, string> = {
|
||||
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: `
|
||||
<section class="ts-panel ts-block ts-rules" aria-labelledby="rules-title">
|
||||
<div>
|
||||
<h3 id="rules-title">Automatizaciones</h3>
|
||||
<p class="ts-muted">
|
||||
Reglas «cuando… si… entonces…» que actúan como TaskStream. El flujo de
|
||||
trabajo sigue mandando, y lo que hace una regla no dispara otras.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@for (rule of rules(); track rule.id) {
|
||||
<article class="ts-rules__item" [class.is-paused]="!rule.active">
|
||||
<p class="ts-rules__name">
|
||||
<strong>{{ rule.name }}</strong>
|
||||
@if (!rule.active) {
|
||||
<span class="ts-pill">En pausa</span>
|
||||
}
|
||||
</p>
|
||||
<p class="ts-muted">{{ describe(rule) }}</p>
|
||||
<p class="ts-muted ts-rules__runs">
|
||||
{{ rule.runs }} ejecuciones
|
||||
@if (rule.lastRunAt) {
|
||||
· la última {{ ago(rule.lastRunAt) }}
|
||||
}
|
||||
@if (rule.lastError) {
|
||||
<span class="ts-rules__error">· {{ rule.lastError }}</span>
|
||||
}
|
||||
</p>
|
||||
<div class="ts-rules__actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[text]="rule.active ? 'Pausar' : 'Reanudar'"
|
||||
(execute)="toggle(rule)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
text="Borrar"
|
||||
(execute)="remove(rule)"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
<form
|
||||
class="ts-rules__new"
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field placeholder="Nombre de la regla" [(value)]="name" />
|
||||
<div class="ts-rules__row">
|
||||
<span class="ts-rules__word">Cuando</span>
|
||||
<pi-select-field [options]="triggerOptions" [(value)]="trigger" />
|
||||
@if (trigger() === 'STATUS_CHANGED') {
|
||||
<pi-select-field
|
||||
[options]="statusOptionsAny()"
|
||||
[(value)]="triggerStatus"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div class="ts-rules__row">
|
||||
<span class="ts-rules__word">Si</span>
|
||||
<pi-select-field [options]="typeOptions()" [(value)]="type" />
|
||||
<pi-select-field [options]="priorityOptions" [(value)]="priority" />
|
||||
<pi-text-field placeholder="con la etiqueta…" [(value)]="label" />
|
||||
</div>
|
||||
<div class="ts-rules__row">
|
||||
<span class="ts-rules__word">Entonces</span>
|
||||
<pi-select-field [options]="actionOptions" [(value)]="action" />
|
||||
@switch (action()) {
|
||||
@case ('SET_STATUS') {
|
||||
<pi-select-field [options]="statusOptions()" [(value)]="value" />
|
||||
}
|
||||
@case ('ASSIGN') {
|
||||
<pi-select-field [options]="memberOptions()" [(value)]="value" />
|
||||
}
|
||||
@case ('SET_PRIORITY') {
|
||||
<pi-select-field [options]="priorityValues" [(value)]="value" />
|
||||
}
|
||||
@case ('ADD_LABEL') {
|
||||
<pi-text-field placeholder="etiqueta" [(value)]="textValue" />
|
||||
}
|
||||
@case ('ADD_COMMENT') {
|
||||
<pi-text-field
|
||||
placeholder="texto del comentario"
|
||||
[(value)]="textValue"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Añadir regla"
|
||||
[disabled]="!canCreate()"
|
||||
(execute)="create()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
`,
|
||||
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<string>();
|
||||
|
||||
private readonly gateway = inject(AutomationsGateway);
|
||||
private readonly project = inject(ProjectFacade);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly rules = signal<Automation[]>([]);
|
||||
protected readonly ago = timeAgo;
|
||||
|
||||
protected readonly name = signal('');
|
||||
protected readonly trigger = signal<string | null>('ISSUE_CREATED');
|
||||
protected readonly triggerStatus = signal<string | null>(ANY);
|
||||
protected readonly type = signal<string | null>(ANY);
|
||||
protected readonly priority = signal<string | null>(ANY);
|
||||
protected readonly label = signal('');
|
||||
protected readonly action = signal<string | null>('ASSIGN_REPORTER');
|
||||
protected readonly value = signal<string | null>(ANY);
|
||||
protected readonly textValue = signal('');
|
||||
|
||||
protected readonly triggerOptions = options(TRIGGER);
|
||||
protected readonly actionOptions = options(ACTION);
|
||||
protected readonly priorityValues = options(PRIORITY);
|
||||
protected readonly priorityOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: ANY, label: 'cualquier prioridad' },
|
||||
...options(PRIORITY),
|
||||
];
|
||||
protected readonly statusOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.settings.statuses.filter((s) => !s.archived)
|
||||
.map((s) => ({ value: s.id, label: s.name })),
|
||||
);
|
||||
protected readonly statusOptionsAny = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: ANY, label: 'a cualquier estado' },
|
||||
...this.statusOptions().map((o) => ({ ...o, label: `a ${o.label}` })),
|
||||
],
|
||||
);
|
||||
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ 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<PiSelectFieldOption<string>[]>(
|
||||
() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user