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:
2026-10-08 15:53:31 +02:00
parent 1076205acc
commit 8994590b54
30 changed files with 2543 additions and 4 deletions

View File

@@ -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';

View File

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

View File

@@ -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));
}
}

View File

@@ -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)
}

View File

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

View File

@@ -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 },
]);
}

View File

@@ -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" />
}

View 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);
}
}