feat: bulk edit tickets

Select several tickets in the backlog and change their status, assignee,
sprint, priority or labels at once. Each ticket goes through the normal
rules and may refuse on its own; the result says which did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:26:53 +02:00
parent 5863d773be
commit 4148335457
26 changed files with 1092 additions and 7 deletions

View File

@@ -0,0 +1,27 @@
import { BulkChange } from '@task-stream/api/tracker/application';
import { IssuePriority } from '@task-stream/api/tracker/domain';
/** The API's flat shape (`unassign`, `noSprint`…) as the change to apply. */
export function toBulkChange(input: {
status?: string | null;
unassign?: boolean | null;
assigneeId?: string | null;
noSprint?: boolean | null;
sprintId?: string | null;
noRelease?: boolean | null;
releaseId?: string | null;
priority?: IssuePriority | null;
addLabels?: string[] | null;
}): BulkChange {
const change: BulkChange = {};
if (input.status) change.status = input.status;
if (input.unassign) change.assigneeId = null;
else if (input.assigneeId) change.assigneeId = input.assigneeId;
if (input.noSprint) change.sprintId = null;
else if (input.sprintId) change.sprintId = input.sprintId;
if (input.noRelease) change.releaseId = null;
else if (input.releaseId) change.releaseId = input.releaseId;
if (input.priority) change.priority = input.priority;
if (input.addLabels?.length) change.addLabels = input.addLabels;
return change;
}

View File

@@ -279,6 +279,49 @@ export class SaveFilterInput {
criteria!: SavedFilterCriteriaInput;
}
@InputType()
export class BulkUpdateIssuesInput {
@Field(() => [ID])
@IsArray()
@ArrayMaxSize(100)
@IsUUID('all', { each: true })
issueIds!: string[];
@Field({ nullable: true }) @IsOptional() @Matches(SETTING_ID) status?: string;
@Field({ nullable: true, description: 'Unassign everything' })
@IsOptional()
@IsBoolean()
unassign?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsString()
assigneeId?: string;
@Field({ nullable: true, description: 'Back to the backlog' })
@IsOptional()
@IsBoolean()
noSprint?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsUUID()
sprintId?: string;
@Field({ nullable: true, description: 'Out of their release' })
@IsOptional()
@IsBoolean()
noRelease?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsUUID()
releaseId?: string;
@Field(() => IssuePriority, { nullable: true })
@IsOptional()
@IsEnum(IssuePriority)
priority?: IssuePriority;
@Field(() => [String], { nullable: true })
@IsOptional()
@ArrayMaxSize(10)
@IsString({ each: true })
addLabels?: string[];
}
@InputType()
export class AutomationTriggerInput {
@Field(() => AutomationTriggerKind)

View File

@@ -24,6 +24,7 @@ import {
} from '@task-stream/api/tracker/domain';
import {
AddChecklistItemInput,
BulkUpdateIssuesInput,
ChangeIssueStatusInput,
CreateIssueInput,
IssueFilterInput,
@@ -34,8 +35,10 @@ import {
} from './inputs';
import { withoutNulls } from '../common/issue-input';
import { viewerOf } from '../common/viewer';
import { toBulkChange } from '../common/bulk';
import {
ActivityGql,
BulkResultGql,
CommandResultGql,
CommentPageGql,
EpicGql,
@@ -280,6 +283,21 @@ export class IssueResolver {
);
}
@Mutation(() => BulkResultGql, {
description:
'The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own',
})
bulkUpdateIssues(
@CurrentUser() me: User,
@Args('input') input: BulkUpdateIssuesInput,
) {
return this.issuesService.bulkUpdate(
me.id,
input.issueIds,
toBulkChange(input),
);
}
@Mutation(() => CommandResultGql, {
description: "Add a step to a ticket's checklist",
})

View File

@@ -240,6 +240,19 @@ export class AutomationGql {
lastError!: string | null;
}
@ObjectType('BulkIssueResult')
export class BulkIssueResultGql {
@Field(() => ID) issueId!: string;
@Field() ok!: boolean;
@Field(() => String, { nullable: true }) error!: string | null;
}
@ObjectType('BulkResult')
export class BulkResultGql {
@Field(() => Int) updated!: number;
@Field(() => [BulkIssueResultGql]) results!: BulkIssueResultGql[];
}
@ObjectType('WebhookDelivery')
export class WebhookDeliveryGql {
@Field() at!: string;

View File

@@ -789,6 +789,51 @@ export class ImportQueryDto {
dryRun = false;
}
export class BulkUpdateIssuesDto {
@ApiProperty({ type: [String], format: 'uuid', maxItems: 100 })
@IsArray()
@ArrayMaxSize(100)
@IsUUID('all', { each: true })
issueIds!: string[];
@ApiPropertyOptional({ example: 'IN_PROGRESS' })
@IsOptional()
@Matches(SETTING_ID)
status?: string;
@ApiPropertyOptional({ description: 'Unassign everything' })
@IsOptional()
@IsBoolean()
unassign?: boolean;
@ApiPropertyOptional({ description: 'Pireon user id of a member' })
@IsOptional()
@IsString()
assigneeId?: string;
@ApiPropertyOptional({ description: 'Back to the backlog' })
@IsOptional()
@IsBoolean()
noSprint?: boolean;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
sprintId?: string;
@ApiPropertyOptional({ description: 'Out of their release' })
@IsOptional()
@IsBoolean()
noRelease?: boolean;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
releaseId?: string;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional()
@IsEnum(IssuePriority)
priority?: IssuePriority;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@ArrayMaxSize(10)
@IsString({ each: true })
addLabels?: string[];
}
export class RankIssueDto {
@ApiPropertyOptional({
type: String,

View File

@@ -223,6 +223,17 @@ export class ImportReportDto {
@ApiProperty({ type: [ImportLineDto] }) warnings!: ImportLineDto[];
}
export class BulkIssueResultDto {
@ApiProperty({ format: 'uuid' }) issueId!: string;
@ApiProperty() ok!: boolean;
@ApiProperty({ type: String, nullable: true }) error!: string | null;
}
export class BulkResultDto {
@ApiProperty() updated!: number;
@ApiProperty({ type: [BulkIssueResultDto] }) results!: BulkIssueResultDto[];
}
export class GitDeliveryResultDto {
@ApiProperty({ description: 'Tickets that got something new' })
recorded!: number;

View File

@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
@@ -35,6 +36,7 @@ import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { withoutNulls } from '../common/issue-input';
import {
AssignIssueDto,
BulkUpdateIssuesDto,
ChangeStatusDto,
ChecklistItemBodyDto,
CommentBodyDto,
@@ -52,6 +54,7 @@ import {
} from './dto/requests.dto';
import {
ActivityDto,
BulkResultDto,
CommandResultDto,
CommentPageDto,
EpicDto,
@@ -59,6 +62,7 @@ import {
IssuePageDto,
} from './dto/responses.dto';
import { viewerOf } from '../common/viewer';
import { toBulkChange } from '../common/bulk';
import { Refs } from './refs';
const PROJECT = ApiParam({
@@ -306,6 +310,19 @@ export class IssuesController {
);
}
@Post('issues/bulk')
@HttpCode(200)
@ApiOperation({
summary: 'The same change on up to 100 tickets',
description:
'Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.',
})
@ApiOkResponse({ type: BulkResultDto })
@ApiCommandErrors()
bulkUpdate(@CurrentUser() me: User, @Body() body: BulkUpdateIssuesDto) {
return this.issues.bulkUpdate(me.id, body.issueIds, toBulkChange(body));
}
@Put('issues/:issue/parent')
@ISSUE
@ApiOperation({