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