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:
@@ -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