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:
@@ -191,6 +191,46 @@ export interface Webhook {
|
||||
lastDelivery: WebhookDelivery | null;
|
||||
}
|
||||
|
||||
export type AutomationTriggerKind =
|
||||
| 'ISSUE_CREATED'
|
||||
| 'STATUS_CHANGED'
|
||||
| 'ISSUE_ASSIGNED'
|
||||
| 'COMMENT_ADDED'
|
||||
| 'PULL_REQUEST_MERGED';
|
||||
|
||||
export type AutomationActionKind =
|
||||
| 'SET_STATUS'
|
||||
| 'ASSIGN'
|
||||
| 'ASSIGN_REPORTER'
|
||||
| 'ADD_LABEL'
|
||||
| 'SET_PRIORITY'
|
||||
| 'ADD_COMMENT'
|
||||
| 'ADD_TO_ACTIVE_SPRINT';
|
||||
|
||||
/** «When this happens to a ticket, if it is like this, do that.» */
|
||||
export interface Automation {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
trigger: { kind: AutomationTriggerKind; status?: string | null };
|
||||
conditions: {
|
||||
types?: string[] | null;
|
||||
priorities?: IssuePriority[] | null;
|
||||
areas?: string[] | null;
|
||||
label?: string | null;
|
||||
};
|
||||
action: { kind: AutomationActionKind; value?: string | null };
|
||||
createdBy: UserRef;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export type AutomationInput = Pick<
|
||||
Automation,
|
||||
'name' | 'trigger' | 'conditions' | 'action'
|
||||
>;
|
||||
|
||||
/** What an import did (or, as a preview, would do). */
|
||||
export interface ImportReport {
|
||||
format: 'taskstream' | 'jira';
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Comment,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
Automation,
|
||||
AutomationInput,
|
||||
ImportReport,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
@@ -134,6 +136,20 @@ export abstract class IssuesGateway {
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
/** The project's automation rules (owners and admins). */
|
||||
export abstract class AutomationsGateway {
|
||||
abstract list(projectId: string): Observable<Automation[]>;
|
||||
abstract create(
|
||||
projectId: string,
|
||||
rule: AutomationInput,
|
||||
): Observable<Automation>;
|
||||
abstract update(
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Observable<Automation>;
|
||||
abstract remove(automationId: string): Observable<void>;
|
||||
}
|
||||
|
||||
/** Tickets in and out as CSV. */
|
||||
export abstract class TransferGateway {
|
||||
abstract exportCsv(projectId: string): Observable<Blob>;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
Automation,
|
||||
AutomationInput,
|
||||
AutomationsGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
AutomationsDocument,
|
||||
CreateAutomationDocument,
|
||||
DeleteAutomationDocument,
|
||||
UpdateAutomationDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlAutomationsGateway extends AutomationsGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
list(projectId: string): Observable<Automation[]> {
|
||||
return this.gql
|
||||
.query(AutomationsDocument, { projectId })
|
||||
.pipe(map((d) => d.automations as Automation[]));
|
||||
}
|
||||
|
||||
create(projectId: string, rule: AutomationInput): Observable<Automation> {
|
||||
return this.gql
|
||||
.mutate(CreateAutomationDocument, {
|
||||
input: { projectId, ...rule } as never,
|
||||
})
|
||||
.pipe(map((d) => d.createAutomation as Automation));
|
||||
}
|
||||
|
||||
update(
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Observable<Automation> {
|
||||
return this.gql
|
||||
.mutate(UpdateAutomationDocument, {
|
||||
automationId,
|
||||
name: changes.name ?? null,
|
||||
active: changes.active ?? null,
|
||||
})
|
||||
.pipe(map((d) => d.updateAutomation as Automation));
|
||||
}
|
||||
|
||||
remove(automationId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(DeleteAutomationDocument, { automationId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
fragment AutomationFields on Automation {
|
||||
id
|
||||
name
|
||||
active
|
||||
runs
|
||||
lastRunAt
|
||||
lastError
|
||||
trigger {
|
||||
kind
|
||||
status
|
||||
}
|
||||
conditions {
|
||||
types
|
||||
priorities
|
||||
areas
|
||||
label
|
||||
}
|
||||
action {
|
||||
kind
|
||||
value
|
||||
}
|
||||
createdBy {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
|
||||
query Automations($projectId: ID!) {
|
||||
automations(projectId: $projectId) {
|
||||
...AutomationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateAutomation($input: CreateAutomationInput!) {
|
||||
createAutomation(input: $input) {
|
||||
...AutomationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UpdateAutomation($automationId: ID!, $name: String, $active: Boolean) {
|
||||
updateAutomation(automationId: $automationId, name: $name, active: $active) {
|
||||
...AutomationFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation DeleteAutomation($automationId: ID!) {
|
||||
deleteAutomation(automationId: $automationId)
|
||||
}
|
||||
@@ -18,6 +18,41 @@ export type AreaOptionInput = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** What a rule does to the ticket */
|
||||
export type AutomationAction =
|
||||
| 'ADD_COMMENT'
|
||||
| 'ADD_LABEL'
|
||||
| 'ADD_TO_ACTIVE_SPRINT'
|
||||
| 'ASSIGN'
|
||||
| 'ASSIGN_REPORTER'
|
||||
| 'SET_PRIORITY'
|
||||
| 'SET_STATUS';
|
||||
|
||||
export type AutomationActionInput = {
|
||||
kind: AutomationAction;
|
||||
value?: string | null | undefined;
|
||||
};
|
||||
|
||||
export type AutomationConditionsInput = {
|
||||
areas?: Array<string> | null | undefined;
|
||||
label?: string | null | undefined;
|
||||
priorities?: Array<IssuePriority> | null | undefined;
|
||||
types?: Array<string> | null | undefined;
|
||||
};
|
||||
|
||||
/** What starts a rule */
|
||||
export type AutomationTrigger =
|
||||
| 'COMMENT_ADDED'
|
||||
| 'ISSUE_ASSIGNED'
|
||||
| 'ISSUE_CREATED'
|
||||
| 'PULL_REQUEST_MERGED'
|
||||
| 'STATUS_CHANGED';
|
||||
|
||||
export type AutomationTriggerInput = {
|
||||
kind: AutomationTrigger;
|
||||
status?: string | null | undefined;
|
||||
};
|
||||
|
||||
export type ChangeIssueStatusInput = {
|
||||
issueId: string;
|
||||
/** Required by statuses with requiresReason */
|
||||
@@ -26,6 +61,14 @@ export type ChangeIssueStatusInput = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAutomationInput = {
|
||||
action: AutomationActionInput;
|
||||
conditions?: AutomationConditionsInput | null | undefined;
|
||||
name: string;
|
||||
projectId: string;
|
||||
trigger: AutomationTriggerInput;
|
||||
};
|
||||
|
||||
export type CreateIssueInput = {
|
||||
/** An area id */
|
||||
area?: string | null | undefined;
|
||||
@@ -265,6 +308,104 @@ export type WorkflowStatusInput = {
|
||||
wipLimit?: number | null | undefined;
|
||||
};
|
||||
|
||||
export type AutomationFieldsFragment = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
lastError: string | null;
|
||||
trigger: { kind: AutomationTrigger; status: string | null };
|
||||
conditions: {
|
||||
types: Array<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | null;
|
||||
label: string | null;
|
||||
};
|
||||
action: { kind: AutomationAction; value: string | null };
|
||||
createdBy: { id: string; name: string };
|
||||
};
|
||||
|
||||
export type AutomationsQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type AutomationsQuery = {
|
||||
automations: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
lastError: string | null;
|
||||
trigger: { kind: AutomationTrigger; status: string | null };
|
||||
conditions: {
|
||||
types: Array<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | null;
|
||||
label: string | null;
|
||||
};
|
||||
action: { kind: AutomationAction; value: string | null };
|
||||
createdBy: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CreateAutomationMutationVariables = Exact<{
|
||||
input: CreateAutomationInput;
|
||||
}>;
|
||||
|
||||
export type CreateAutomationMutation = {
|
||||
createAutomation: {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
lastError: string | null;
|
||||
trigger: { kind: AutomationTrigger; status: string | null };
|
||||
conditions: {
|
||||
types: Array<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | null;
|
||||
label: string | null;
|
||||
};
|
||||
action: { kind: AutomationAction; value: string | null };
|
||||
createdBy: { id: string; name: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdateAutomationMutationVariables = Exact<{
|
||||
automationId: string;
|
||||
name?: string | null | undefined;
|
||||
active?: boolean | null | undefined;
|
||||
}>;
|
||||
|
||||
export type UpdateAutomationMutation = {
|
||||
updateAutomation: {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
lastError: string | null;
|
||||
trigger: { kind: AutomationTrigger; status: string | null };
|
||||
conditions: {
|
||||
types: Array<string> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
areas: Array<string> | null;
|
||||
label: string | null;
|
||||
};
|
||||
action: { kind: AutomationAction; value: string | null };
|
||||
createdBy: { id: string; name: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteAutomationMutationVariables = Exact<{
|
||||
automationId: string;
|
||||
}>;
|
||||
|
||||
export type DeleteAutomationMutation = { deleteAutomation: boolean };
|
||||
|
||||
export type UserRefFieldsFragment = { id: string; name: string };
|
||||
|
||||
export type ProjectFieldsFragment = {
|
||||
@@ -1790,6 +1931,93 @@ export const UserRefFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<UserRefFieldsFragment, unknown>;
|
||||
export const AutomationFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AutomationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Automation' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'runs' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastError' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'trigger' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'conditions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'label' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'action' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createdBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<AutomationFieldsFragment, unknown>;
|
||||
export const ProjectFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -2703,6 +2931,488 @@ export const WebhookFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<WebhookFieldsFragment, unknown>;
|
||||
export const AutomationsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'Automations' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'automations' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'AutomationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AutomationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Automation' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'runs' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastError' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'trigger' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'conditions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'label' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'action' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createdBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<AutomationsQuery, AutomationsQueryVariables>;
|
||||
export const CreateAutomationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'CreateAutomation' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CreateAutomationInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createAutomation' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'AutomationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AutomationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Automation' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'runs' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastError' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'trigger' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'conditions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'label' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'action' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createdBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
CreateAutomationMutation,
|
||||
CreateAutomationMutationVariables
|
||||
>;
|
||||
export const UpdateAutomationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UpdateAutomation' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'automationId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } },
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'active' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'updateAutomation' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'automationId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'automationId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'name' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'name' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'active' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'active' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'AutomationFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'AutomationFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Automation' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'active' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'runs' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastRunAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'lastError' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'trigger' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'conditions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'label' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'action' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createdBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
UpdateAutomationMutation,
|
||||
UpdateAutomationMutationVariables
|
||||
>;
|
||||
export const DeleteAutomationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'DeleteAutomation' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'automationId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'deleteAutomation' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'automationId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'automationId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
DeleteAutomationMutation,
|
||||
DeleteAutomationMutationVariables
|
||||
>;
|
||||
export const GitIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
GitGateway,
|
||||
WebhooksGateway,
|
||||
TransferGateway,
|
||||
AutomationsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -34,6 +35,7 @@ import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
||||
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
||||
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
||||
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
|
||||
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
|
||||
|
||||
/** Ports → GraphQL adapters (and REST for files). */
|
||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
@@ -52,5 +54,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
||||
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
||||
{ provide: TransferGateway, useClass: HttpTransferGateway },
|
||||
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { TransferComponent } from '../ui/transfer';
|
||||
import { AutomationsComponent } from '../ui/automations';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
ProjectSettings,
|
||||
@@ -57,6 +58,7 @@ function idFrom(name: string): string {
|
||||
GitIntegrationComponent,
|
||||
WebhooksComponent,
|
||||
TransferComponent,
|
||||
AutomationsComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -472,6 +474,7 @@ function idFrom(name: string): string {
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
<ts-automations [projectId]="project.id" />
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
||||
}
|
||||
|
||||
394
libs/web/tracker/presenters/src/lib/ui/automations.ts
Normal file
394
libs/web/tracker/presenters/src/lib/ui/automations.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Automation,
|
||||
AutomationActionKind,
|
||||
AutomationsGateway,
|
||||
AutomationTriggerKind,
|
||||
IssuePriority,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { options, PRIORITY, statusLook, timeAgo, typeLook } from './copy';
|
||||
|
||||
const ANY = '';
|
||||
|
||||
const TRIGGER: Record<AutomationTriggerKind, string> = {
|
||||
ISSUE_CREATED: 'se crea un ticket',
|
||||
STATUS_CHANGED: 'un ticket cambia de estado',
|
||||
ISSUE_ASSIGNED: 'se asigna un ticket',
|
||||
COMMENT_ADDED: 'alguien comenta',
|
||||
PULL_REQUEST_MERGED: 'se fusiona una pull request',
|
||||
};
|
||||
|
||||
const ACTION: Record<AutomationActionKind, string> = {
|
||||
SET_STATUS: 'moverlo a',
|
||||
ASSIGN: 'asignarlo a',
|
||||
ASSIGN_REPORTER: 'asignarlo a quien lo abrió',
|
||||
ADD_LABEL: 'añadir la etiqueta',
|
||||
SET_PRIORITY: 'poner la prioridad',
|
||||
ADD_COMMENT: 'comentar',
|
||||
ADD_TO_ACTIVE_SPRINT: 'meterlo en el sprint en curso',
|
||||
};
|
||||
|
||||
/** Settings card: rules that act on tickets for the team. */
|
||||
@Component({
|
||||
selector: 'ts-automations',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiActionButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<section class="ts-panel ts-block ts-rules" aria-labelledby="rules-title">
|
||||
<div>
|
||||
<h3 id="rules-title">Automatizaciones</h3>
|
||||
<p class="ts-muted">
|
||||
Reglas «cuando… si… entonces…» que actúan como TaskStream. El flujo de
|
||||
trabajo sigue mandando, y lo que hace una regla no dispara otras.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@for (rule of rules(); track rule.id) {
|
||||
<article class="ts-rules__item" [class.is-paused]="!rule.active">
|
||||
<p class="ts-rules__name">
|
||||
<strong>{{ rule.name }}</strong>
|
||||
@if (!rule.active) {
|
||||
<span class="ts-pill">En pausa</span>
|
||||
}
|
||||
</p>
|
||||
<p class="ts-muted">{{ describe(rule) }}</p>
|
||||
<p class="ts-muted ts-rules__runs">
|
||||
{{ rule.runs }} ejecuciones
|
||||
@if (rule.lastRunAt) {
|
||||
· la última {{ ago(rule.lastRunAt) }}
|
||||
}
|
||||
@if (rule.lastError) {
|
||||
<span class="ts-rules__error">· {{ rule.lastError }}</span>
|
||||
}
|
||||
</p>
|
||||
<div class="ts-rules__actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[text]="rule.active ? 'Pausar' : 'Reanudar'"
|
||||
(execute)="toggle(rule)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
text="Borrar"
|
||||
(execute)="remove(rule)"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
<form
|
||||
class="ts-rules__new"
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field placeholder="Nombre de la regla" [(value)]="name" />
|
||||
<div class="ts-rules__row">
|
||||
<span class="ts-rules__word">Cuando</span>
|
||||
<pi-select-field [options]="triggerOptions" [(value)]="trigger" />
|
||||
@if (trigger() === 'STATUS_CHANGED') {
|
||||
<pi-select-field
|
||||
[options]="statusOptionsAny()"
|
||||
[(value)]="triggerStatus"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div class="ts-rules__row">
|
||||
<span class="ts-rules__word">Si</span>
|
||||
<pi-select-field [options]="typeOptions()" [(value)]="type" />
|
||||
<pi-select-field [options]="priorityOptions" [(value)]="priority" />
|
||||
<pi-text-field placeholder="con la etiqueta…" [(value)]="label" />
|
||||
</div>
|
||||
<div class="ts-rules__row">
|
||||
<span class="ts-rules__word">Entonces</span>
|
||||
<pi-select-field [options]="actionOptions" [(value)]="action" />
|
||||
@switch (action()) {
|
||||
@case ('SET_STATUS') {
|
||||
<pi-select-field [options]="statusOptions()" [(value)]="value" />
|
||||
}
|
||||
@case ('ASSIGN') {
|
||||
<pi-select-field [options]="memberOptions()" [(value)]="value" />
|
||||
}
|
||||
@case ('SET_PRIORITY') {
|
||||
<pi-select-field [options]="priorityValues" [(value)]="value" />
|
||||
}
|
||||
@case ('ADD_LABEL') {
|
||||
<pi-text-field placeholder="etiqueta" [(value)]="textValue" />
|
||||
}
|
||||
@case ('ADD_COMMENT') {
|
||||
<pi-text-field
|
||||
placeholder="texto del comentario"
|
||||
[(value)]="textValue"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Añadir regla"
|
||||
[disabled]="!canCreate()"
|
||||
(execute)="create()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.ts-rules {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
}
|
||||
.ts-rules__item {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
padding: 0.625rem 0;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
&.is-paused {
|
||||
opacity: 0.7;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.ts-rules__name {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-rules__runs {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-rules__error {
|
||||
color: var(--ts-c-red, var(--ts-ink));
|
||||
}
|
||||
.ts-rules__actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ts-rules__new {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
}
|
||||
.ts-rules__row {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.ts-rules__word {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AutomationsComponent {
|
||||
readonly projectId = input.required<string>();
|
||||
|
||||
private readonly gateway = inject(AutomationsGateway);
|
||||
private readonly project = inject(ProjectFacade);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly rules = signal<Automation[]>([]);
|
||||
protected readonly ago = timeAgo;
|
||||
|
||||
protected readonly name = signal('');
|
||||
protected readonly trigger = signal<string | null>('ISSUE_CREATED');
|
||||
protected readonly triggerStatus = signal<string | null>(ANY);
|
||||
protected readonly type = signal<string | null>(ANY);
|
||||
protected readonly priority = signal<string | null>(ANY);
|
||||
protected readonly label = signal('');
|
||||
protected readonly action = signal<string | null>('ASSIGN_REPORTER');
|
||||
protected readonly value = signal<string | null>(ANY);
|
||||
protected readonly textValue = signal('');
|
||||
|
||||
protected readonly triggerOptions = options(TRIGGER);
|
||||
protected readonly actionOptions = options(ACTION);
|
||||
protected readonly priorityValues = options(PRIORITY);
|
||||
protected readonly priorityOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: ANY, label: 'cualquier prioridad' },
|
||||
...options(PRIORITY),
|
||||
];
|
||||
protected readonly statusOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.settings.statuses.filter((s) => !s.archived)
|
||||
.map((s) => ({ value: s.id, label: s.name })),
|
||||
);
|
||||
protected readonly statusOptionsAny = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: ANY, label: 'a cualquier estado' },
|
||||
...this.statusOptions().map((o) => ({ ...o, label: `a ${o.label}` })),
|
||||
],
|
||||
);
|
||||
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: ANY, label: 'cualquier tipo' },
|
||||
...this.project
|
||||
.workflow()
|
||||
.settings.issueTypes.filter((t) => !t.archived)
|
||||
.map((t) => ({ value: t.id, label: t.name })),
|
||||
],
|
||||
);
|
||||
protected readonly memberOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() =>
|
||||
this.project
|
||||
.members()
|
||||
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||
);
|
||||
protected readonly canCreate = computed(() => {
|
||||
if (!this.name().trim() || !this.trigger() || !this.action()) return false;
|
||||
const action = this.action() as AutomationActionKind;
|
||||
if (['SET_STATUS', 'ASSIGN', 'SET_PRIORITY'].includes(action))
|
||||
return !!this.value();
|
||||
if (['ADD_LABEL', 'ADD_COMMENT'].includes(action))
|
||||
return !!this.textValue().trim();
|
||||
return true;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.projectId();
|
||||
untracked(() => this.reload(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected describe(rule: Automation): string {
|
||||
const workflow = this.project.workflow();
|
||||
let when = TRIGGER[rule.trigger.kind];
|
||||
if (rule.trigger.kind === 'STATUS_CHANGED' && rule.trigger.status)
|
||||
when = `un ticket pasa a ${statusLook(workflow, rule.trigger.status).label}`;
|
||||
const ifs = [
|
||||
...(rule.conditions.types ?? []).map(
|
||||
(t) => `es ${typeLook(workflow, t).label}`,
|
||||
),
|
||||
...(rule.conditions.priorities ?? []).map(
|
||||
(p) =>
|
||||
`prioridad ${PRIORITY[p as IssuePriority]?.label.toLowerCase() ?? p}`,
|
||||
),
|
||||
...(rule.conditions.label ? [`lleva #${rule.conditions.label}`] : []),
|
||||
];
|
||||
const value = rule.action.value;
|
||||
const then =
|
||||
rule.action.kind === 'SET_STATUS' && value
|
||||
? `moverlo a ${statusLook(workflow, value).label}`
|
||||
: rule.action.kind === 'ASSIGN' && value
|
||||
? `asignarlo a ${this.project.members().find((m) => m.user.id === value)?.user.name ?? 'alguien que ya no está'}`
|
||||
: rule.action.kind === 'SET_PRIORITY' && value
|
||||
? `poner la prioridad ${PRIORITY[value as IssuePriority]?.label.toLowerCase() ?? value}`
|
||||
: value
|
||||
? `${ACTION[rule.action.kind]} «${value}»`
|
||||
: ACTION[rule.action.kind];
|
||||
return `Cuando ${when}${ifs.length ? `, si ${ifs.join(' y ')}` : ''}: ${then}.`;
|
||||
}
|
||||
|
||||
protected create(): void {
|
||||
if (!this.canCreate()) return;
|
||||
const action = this.action() as AutomationActionKind;
|
||||
const text = ['ADD_LABEL', 'ADD_COMMENT'].includes(action);
|
||||
this.gateway
|
||||
.create(this.projectId(), {
|
||||
name: this.name().trim(),
|
||||
trigger: {
|
||||
kind: this.trigger() as AutomationTriggerKind,
|
||||
status:
|
||||
this.trigger() === 'STATUS_CHANGED'
|
||||
? this.triggerStatus() || null
|
||||
: null,
|
||||
},
|
||||
conditions: {
|
||||
types: this.type() ? [this.type() as string] : null,
|
||||
priorities: this.priority()
|
||||
? [this.priority() as IssuePriority]
|
||||
: null,
|
||||
label: this.label().trim() || null,
|
||||
},
|
||||
action: {
|
||||
kind: action,
|
||||
value: text ? this.textValue().trim() : this.value() || null,
|
||||
},
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Regla añadida');
|
||||
this.name.set('');
|
||||
this.textValue.set('');
|
||||
this.reload(this.projectId());
|
||||
},
|
||||
error: (error) => this.fail(error, 'No se pudo añadir la regla'),
|
||||
});
|
||||
}
|
||||
|
||||
protected toggle(rule: Automation): void {
|
||||
this.gateway
|
||||
.update(rule.id, { active: !rule.active })
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.reload(this.projectId()),
|
||||
error: (error) => this.fail(error, 'No se pudo cambiar'),
|
||||
});
|
||||
}
|
||||
|
||||
protected remove(rule: Automation): void {
|
||||
if (!confirm(`¿Borrar la regla «${rule.name}»?`)) return;
|
||||
this.gateway
|
||||
.remove(rule.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.reload(this.projectId()),
|
||||
error: (error) => this.fail(error, 'No se pudo borrar'),
|
||||
});
|
||||
}
|
||||
|
||||
private reload(projectId: string): void {
|
||||
this.gateway
|
||||
.list(projectId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (rules) => this.rules.set(rules),
|
||||
error: (error) => this.fail(error, 'No se pudieron leer las reglas'),
|
||||
});
|
||||
}
|
||||
|
||||
private fail(error: unknown, fallback: string): void {
|
||||
const message = (error as { message?: string })?.message?.split('\n')[0];
|
||||
this.toast.error(message || fallback);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user