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:
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
import { IssuePriority } from '@task-stream/api/tracker/domain';
|
||||
import { AssignIssueCommand } from './assign-issue.command';
|
||||
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { SetIssueSprintCommand } from './sprint.commands';
|
||||
import { UpdateIssueCommand } from './update-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** At most this many tickets per bulk change. */
|
||||
export const MAX_BULK = 100;
|
||||
|
||||
/** What to change on every ticket; absent fields stay as they are. */
|
||||
export interface BulkChange {
|
||||
status?: string;
|
||||
/** null unassigns. */
|
||||
assigneeId?: string | null;
|
||||
/** null: back to the backlog. */
|
||||
sprintId?: string | null;
|
||||
/** null: out of its release. */
|
||||
releaseId?: string | null;
|
||||
priority?: IssuePriority;
|
||||
/** Added to the ticket's labels. */
|
||||
addLabels?: string[];
|
||||
}
|
||||
|
||||
export interface BulkResult {
|
||||
updated: number;
|
||||
results: { issueId: string; ok: boolean; error: string | null }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same change on several tickets. Each ticket goes through the normal
|
||||
* commands (permissions, workflow, open sprints and releases), one after
|
||||
* another; one refusing does not stop the rest.
|
||||
*/
|
||||
export class BulkUpdateIssuesCommand extends Command<BulkResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueIds: string[],
|
||||
public readonly change: BulkChange,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(BulkUpdateIssuesCommand)
|
||||
export class BulkUpdateIssuesCommandHandler
|
||||
implements ICommandHandler<BulkUpdateIssuesCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: BulkUpdateIssuesCommand): Promise<BulkResult> {
|
||||
const ids = [...new Set(command.issueIds)];
|
||||
assertThat(ids.length > 0, 'Pick at least one ticket');
|
||||
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
|
||||
const change = command.change;
|
||||
assertThat(
|
||||
Object.values(change).some((v) => v !== undefined),
|
||||
'Nothing to change',
|
||||
);
|
||||
const actor = command.actorId;
|
||||
const result: BulkResult = { updated: 0, results: [] };
|
||||
for (const issueId of ids) {
|
||||
try {
|
||||
if (change.status !== undefined)
|
||||
await this.run(
|
||||
new ChangeIssueStatusCommand(actor, issueId, change.status),
|
||||
);
|
||||
if (change.assigneeId !== undefined)
|
||||
await this.run(
|
||||
new AssignIssueCommand(actor, issueId, change.assigneeId),
|
||||
);
|
||||
if (change.sprintId !== undefined)
|
||||
await this.run(
|
||||
new SetIssueSprintCommand(actor, issueId, change.sprintId),
|
||||
);
|
||||
if (change.releaseId !== undefined)
|
||||
await this.run(
|
||||
new SetIssueReleaseCommand(actor, issueId, change.releaseId),
|
||||
);
|
||||
const details: { priority?: IssuePriority; labels?: string[] } = {};
|
||||
if (change.priority) details.priority = change.priority;
|
||||
if (change.addLabels?.length) {
|
||||
const issue = await this.access.issue(issueId);
|
||||
details.labels = [...issue.labels, ...change.addLabels];
|
||||
}
|
||||
if (Object.keys(details).length)
|
||||
await this.run(new UpdateIssueCommand(actor, issueId, details));
|
||||
result.updated++;
|
||||
result.results.push({ issueId, ok: true, error: null });
|
||||
} catch (error) {
|
||||
result.results.push({
|
||||
issueId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private run(command: object): Promise<unknown> {
|
||||
return this.commandBus.execute(command as never);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler
|
||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
|
||||
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
@@ -75,6 +76,7 @@ export const COMMAND_HANDLERS = [
|
||||
RankIssueCommandHandler,
|
||||
WriteReleaseNotesCommandHandler,
|
||||
...SPRINT_COMMAND_HANDLERS,
|
||||
BulkUpdateIssuesCommandHandler,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
|
||||
@@ -78,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
|
||||
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -626,6 +627,45 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('changes several tickets at once and says which refused', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const ids: string[] = [];
|
||||
for (const title of ['A', 'B'])
|
||||
ids.push(
|
||||
(
|
||||
await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
|
||||
)
|
||||
).id,
|
||||
);
|
||||
for (const status of ['TODO', 'IN_PROGRESS'])
|
||||
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
|
||||
// A is in BACKLOG: the workflow does not let it jump to review.
|
||||
const result = (await run(
|
||||
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
|
||||
status: 'IN_REVIEW',
|
||||
assigneeId: 'qa',
|
||||
addLabels: ['sprint-14'],
|
||||
}),
|
||||
)) as unknown as BulkResult;
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([
|
||||
[ids[0], false],
|
||||
[ids[1], true],
|
||||
]);
|
||||
const b = await rehydrator.rehydrate(ids[1], Issue);
|
||||
expect([b.status, b.assigneeId, b.labels]).toEqual([
|
||||
'IN_REVIEW',
|
||||
'qa',
|
||||
['sprint-14'],
|
||||
]);
|
||||
await expect(
|
||||
run(new BulkUpdateIssuesCommand('dev', ids, {})),
|
||||
).rejects.toThrow(/Nothing to change/);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -35,3 +35,4 @@ export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
export * from './sprint.commands';
|
||||
export * from './write-release-notes.command';
|
||||
export * from './bulk-update-issues';
|
||||
|
||||
@@ -16,6 +16,11 @@ import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import {
|
||||
BulkChange,
|
||||
BulkResult,
|
||||
BulkUpdateIssuesCommand,
|
||||
} from './commands/bulk-update-issues';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
@@ -231,6 +236,17 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** The same change on several tickets; each one may succeed or not. */
|
||||
bulkUpdate(
|
||||
actorId: string,
|
||||
issueIds: string[],
|
||||
change: BulkChange,
|
||||
): Promise<BulkResult> {
|
||||
return this.commandBus.execute(
|
||||
new BulkUpdateIssuesCommand(actorId, issueIds, change),
|
||||
);
|
||||
}
|
||||
|
||||
addChecklistItem(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
|
||||
27
libs/api/tracker/presenters/src/lib/common/bulk.ts
Normal file
27
libs/api/tracker/presenters/src/lib/common/bulk.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user