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