From 4148335457297305b5133e25adf08a3a441bec8f Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 18:26:53 +0200 Subject: [PATCH] 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 --- apps/api/openapi.json | 197 +++++++++++++++++ apps/api/schema.gql | 35 +++ apps/mcp/src/app/api/taskstream.schema.ts | 126 +++++++++++ docs/01-vision-general.md | 1 + docs/03-api.md | 4 + .../src/lib/commands/bulk-update-issues.ts | 115 ++++++++++ .../src/lib/commands/command-handlers.ts | 2 + .../src/lib/commands/commands.spec.ts | 40 ++++ .../application/src/lib/commands/index.ts | 1 + .../application/src/lib/issues.service.ts | 16 ++ .../tracker/presenters/src/lib/common/bulk.ts | 27 +++ .../presenters/src/lib/graphql/inputs.ts | 43 ++++ .../src/lib/graphql/issue.resolver.ts | 18 ++ .../presenters/src/lib/graphql/types.ts | 13 ++ .../src/lib/http/dto/requests.dto.ts | 45 ++++ .../src/lib/http/dto/responses.dto.ts | 11 + .../src/lib/http/issues.controller.ts | 17 ++ .../src/lib/events/backlog.events.ts | 8 +- .../src/lib/facades/backlog.facade.ts | 6 + .../src/lib/stores/backlog.store.ts | 24 ++- .../tracker/domain/src/lib/models/inputs.ts | 16 ++ .../tracker/domain/src/lib/ports/gateways.ts | 7 + .../lib/adapters/graphql-issues.gateway.ts | 20 ++ .../src/lib/graphql/generated.ts | 96 +++++++++ .../src/lib/graphql/issues.graphql | 11 + .../presenters/src/lib/pages/backlog.page.ts | 200 +++++++++++++++++- 26 files changed, 1092 insertions(+), 7 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/commands/bulk-update-issues.ts create mode 100644 libs/api/tracker/presenters/src/lib/common/bulk.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7215325..2a276ca 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3076,6 +3076,107 @@ ] } }, + "/api/issues/bulk": { + "post": { + "description": "Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.", + "operationId": "IssuesController_bulkUpdate", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkUpdateIssuesDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "The same change on up to 100 tickets", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/parent": { "put": { "description": "Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.", @@ -10641,6 +10742,102 @@ "releaseId" ] }, + "BulkUpdateIssuesDto": { + "type": "object", + "properties": { + "issueIds": { + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "status": { + "type": "string", + "example": "IN_PROGRESS" + }, + "unassign": { + "type": "boolean", + "description": "Unassign everything" + }, + "assigneeId": { + "type": "string", + "description": "Pireon user id of a member" + }, + "noSprint": { + "type": "boolean", + "description": "Back to the backlog" + }, + "sprintId": { + "type": "string", + "format": "uuid" + }, + "noRelease": { + "type": "boolean", + "description": "Out of their release" + }, + "releaseId": { + "type": "string", + "format": "uuid" + }, + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/IssuePriority" + } + ] + }, + "addLabels": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "issueIds" + ] + }, + "BulkIssueResultDto": { + "type": "object", + "properties": { + "issueId": { + "type": "string", + "format": "uuid" + }, + "ok": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + } + }, + "required": [ + "issueId", + "ok", + "error" + ] + }, + "BulkResultDto": { + "type": "object", + "properties": { + "updated": { + "type": "number" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkIssueResultDto" + } + } + }, + "required": [ + "updated", + "results" + ] + }, "SetIssueParentDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index a45b45b..bf80598 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -128,6 +128,36 @@ type BoardColumn { total: Int! } +type BulkIssueResult { + error: String + issueId: ID! + ok: Boolean! +} + +type BulkResult { + results: [BulkIssueResult!]! + updated: Int! +} + +input BulkUpdateIssuesInput { + addLabels: [String!] + assigneeId: ID + issueIds: [ID!]! + + """Out of their release""" + noRelease: Boolean + + """Back to the backlog""" + noSprint: Boolean + priority: IssuePriority + releaseId: ID + sprintId: ID + status: String + + """Unassign everything""" + unassign: Boolean +} + input ChangeIssueStatusInput { issueId: ID! @@ -558,6 +588,11 @@ type Mutation { """Assign (or unassign with null) a project member""" assignIssue(assigneeId: ID, issueId: ID!): CommandResult! + """ + The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own + """ + bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult! + """Move a ticket through the workflow (incl. QA)""" changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index bc40548..f033a39 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -455,6 +455,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/issues/bulk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 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. + */ + post: operations["IssuesController_bulkUpdate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/parent": { parameters: { query?: never; @@ -1877,6 +1897,35 @@ export interface components { */ releaseId: string | null; }; + BulkUpdateIssuesDto: { + issueIds: string[]; + /** @example IN_PROGRESS */ + status?: string; + /** @description Unassign everything */ + unassign?: boolean; + /** @description Pireon user id of a member */ + assigneeId?: string; + /** @description Back to the backlog */ + noSprint?: boolean; + /** Format: uuid */ + sprintId?: string; + /** @description Out of their release */ + noRelease?: boolean; + /** Format: uuid */ + releaseId?: string; + priority?: components["schemas"]["IssuePriority"]; + addLabels?: string[]; + }; + BulkIssueResultDto: { + /** Format: uuid */ + issueId: string; + ok: boolean; + error: string | null; + }; + BulkResultDto: { + updated: number; + results: components["schemas"]["BulkIssueResultDto"][]; + }; SetIssueParentDto: { /** * @description Epic or ticket of the same project (id or key), or null to take it back to the top @@ -4329,6 +4378,83 @@ export interface operations { }; }; }; + IssuesController_bulkUpdate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkUpdateIssuesDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; IssuesController_setParent: { parameters: { query?: never; diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index e705e42..e833238 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -20,6 +20,7 @@ | 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | diff --git a/docs/03-api.md b/docs/03-api.md index 3961613..c5407ec 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -167,6 +167,10 @@ stateDiagram-v2 actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el event store. +- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o + `unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada + uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto; + la respuesta dice cuáles. - **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción (hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas). diff --git a/libs/api/tracker/application/src/lib/commands/bulk-update-issues.ts b/libs/api/tracker/application/src/lib/commands/bulk-update-issues.ts new file mode 100644 index 0000000..b71ce13 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/bulk-update-issues.ts @@ -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 { + constructor( + public readonly actorId: string, + public readonly issueIds: string[], + public readonly change: BulkChange, + ) { + super(); + } +} + +@CommandHandler(BulkUpdateIssuesCommand) +export class BulkUpdateIssuesCommandHandler + implements ICommandHandler +{ + constructor( + private readonly commandBus: CommandBus, + private readonly access: WriteAccess, + ) {} + + async execute(command: BulkUpdateIssuesCommand): Promise { + 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 { + return this.commandBus.execute(command as never); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index 46d010b..e1e8ad6 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -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, diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 2012871..7076fd5 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -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); diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 8a54df3..410fded 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index 10a5d54..f8bd2ac 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -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 { + return this.commandBus.execute( + new BulkUpdateIssuesCommand(actorId, issueIds, change), + ); + } + addChecklistItem( actorId: string, issueId: string, diff --git a/libs/api/tracker/presenters/src/lib/common/bulk.ts b/libs/api/tracker/presenters/src/lib/common/bulk.ts new file mode 100644 index 0000000..2d510d3 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/common/bulk.ts @@ -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; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 2a64f4b..01b0ca5 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -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) diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 5634457..7c1142a 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -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", }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index d891278..113c9aa 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -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; diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 25e9763..2ecfe79 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -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, diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 8daf50f..166dc99 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -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; diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index d3c9311..cf7c6a1 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -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({ diff --git a/libs/web/tracker/application/src/lib/events/backlog.events.ts b/libs/web/tracker/application/src/lib/events/backlog.events.ts index f35e8cc..e6f72c0 100644 --- a/libs/web/tracker/application/src/lib/events/backlog.events.ts +++ b/libs/web/tracker/application/src/lib/events/backlog.events.ts @@ -1,6 +1,10 @@ import { type } from '@ngrx/signals'; import { eventGroup } from '@ngrx/signals/events'; -import { IssuePage } from '@task-stream/web/tracker/domain'; +import { + BulkChange, + BulkResult, + IssuePage, +} from '@task-stream/web/tracker/domain'; export const backlogPageEvents = eventGroup({ source: 'Backlog Page', @@ -8,6 +12,7 @@ export const backlogPageEvents = eventGroup({ opened: type<{ projectId: string }>(), /** Put the ticket at `index` of the backlog as shown now. */ moveRequested: type<{ issueId: string; index: number }>(), + bulkRequested: type<{ issueIds: string[]; change: BulkChange }>(), }, }); @@ -18,5 +23,6 @@ export const backlogApiEvents = eventGroup({ loadFailed: type(), moved: type(), moveFailed: type(), + bulkDone: type(), }, }); diff --git a/libs/web/tracker/application/src/lib/facades/backlog.facade.ts b/libs/web/tracker/application/src/lib/facades/backlog.facade.ts index 64d0c9a..881ab3b 100644 --- a/libs/web/tracker/application/src/lib/facades/backlog.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/backlog.facade.ts @@ -1,5 +1,6 @@ import { inject, Injectable, Provider } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; +import { BulkChange } from '@task-stream/web/tracker/domain'; import { backlogPageEvents } from '../events/backlog.events'; import { BacklogStore } from '../stores/backlog.store'; @@ -20,6 +21,11 @@ export class BacklogFacade { this.dispatch.opened({ projectId }); } + /** The same change on several tickets (each may refuse on its own). */ + bulkUpdate(issueIds: string[], change: BulkChange): void { + if (issueIds.length) this.dispatch.bulkRequested({ issueIds, change }); + } + /** Puts the ticket at `index` of the list as shown now. */ move(issueId: string, index: number): void { this.dispatch.moveRequested({ issueId, index }); diff --git a/libs/web/tracker/application/src/lib/stores/backlog.store.ts b/libs/web/tracker/application/src/lib/stores/backlog.store.ts index 7ff731a..e1ecc0c 100644 --- a/libs/web/tracker/application/src/lib/stores/backlog.store.ts +++ b/libs/web/tracker/application/src/lib/stores/backlog.store.ts @@ -79,7 +79,7 @@ export const BacklogStore = signalStore( switchMap(({ payload }) => merge( realtime.issueChanges(payload.projectId).pipe(debounceTime(400)), - events.on(backlogApiEvents.moveFailed), + events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone), ).pipe( startWith(null), switchMap(() => fetch(payload.projectId)), @@ -111,6 +111,28 @@ export const BacklogStore = signalStore( ); }), ), + bulk$: events.on(backlogPageEvents.bulkRequested).pipe( + concatMap(({ payload }) => + issues.bulkUpdate(payload.issueIds, payload.change).pipe( + mapResponse({ + next: (result) => backlogApiEvents.bulkDone(result), + error: (error) => + backlogApiEvents.moveFailed(describeError(error)), + }), + ), + ), + ), + bulkFeedback$: events.on(backlogApiEvents.bulkDone).pipe( + tap(({ payload }) => { + const refused = payload.results.filter((r) => !r.ok); + if (!refused.length) + notifications.success(`${payload.updated} tickets actualizados`); + else + notifications.error( + `${payload.updated} actualizados; ${refused.length} no: ${refused[0].error}`, + ); + }), + ), feedback$: events .on(backlogApiEvents.moveFailed) .pipe(tap(({ payload }) => notifications.error(payload))), diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index c1477f2..2b8d015 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -92,6 +92,22 @@ export interface BoardFilter { sprintId?: string; } +/** The same change on several tickets; absent fields stay as they are. */ +export interface BulkChange { + status?: string; + /** null unassigns. */ + assigneeId?: string | null; + /** null: back to the backlog. */ + sprintId?: string | null; + priority?: IssuePriority; + addLabels?: string[]; +} + +export interface BulkResult { + updated: number; + results: { issueId: string; ok: boolean; error: string | null }[]; +} + export interface CreateSprintInput { projectId: string; name: string; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 81595e0..52b3e67 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -41,6 +41,8 @@ import { UpdateIssueInput, UpdateProjectInput, CreateSprintInput, + BulkChange, + BulkResult, UpdateSprintInput, } from '../models/inputs'; @@ -113,6 +115,11 @@ export abstract class IssuesGateway { parentId: string | null, ): Observable; abstract epics(projectId: string): Observable; + /** The same change on several tickets; each may refuse on its own. */ + abstract bulkUpdate( + issueIds: string[], + change: BulkChange, + ): Observable; /** Tickets still to start (TODO category), in backlog order. */ abstract backlog(projectId: string): Observable; /** Moves a ticket between two others of the backlog (null: an end). */ diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index bbc180d..af005a7 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -2,6 +2,8 @@ import { inject, Injectable } from '@angular/core'; import { BoardColumn, BoardFilter, + BulkChange, + BulkResult, ChangeStatusInput, CommandResult, CreateIssueInput, @@ -19,6 +21,7 @@ import { AddChecklistItemDocument, AssignIssueDocument, BacklogDocument, + BulkUpdateIssuesDocument, BoardDocument, ChangeIssueStatusDocument, CreateIssueDocument, @@ -176,6 +179,23 @@ export class GraphqlIssuesGateway extends IssuesGateway { ); } + bulkUpdate(issueIds: string[], change: BulkChange): Observable { + return this.gql + .mutate(BulkUpdateIssuesDocument, { + input: { + issueIds, + status: change.status ?? null, + unassign: change.assigneeId === null ? true : null, + assigneeId: change.assigneeId ?? null, + noSprint: change.sprintId === null ? true : null, + sprintId: change.sprintId ?? null, + priority: (change.priority ?? null) as never, + addLabels: change.addLabels ?? null, + }, + }) + .pipe(map((d) => d.bulkUpdateIssues)); + } + backlog(projectId: string): Observable { return this.gql .query(BacklogDocument, { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index f18505a..46aad04 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -53,6 +53,22 @@ export type AutomationTriggerInput = { status?: string | null | undefined; }; +export type BulkUpdateIssuesInput = { + addLabels?: Array | null | undefined; + assigneeId?: string | null | undefined; + issueIds: Array; + /** Out of their release */ + noRelease?: boolean | null | undefined; + /** Back to the backlog */ + noSprint?: boolean | null | undefined; + priority?: IssuePriority | null | undefined; + releaseId?: string | null | undefined; + sprintId?: string | null | undefined; + status?: string | null | undefined; + /** Unassign everything */ + unassign?: boolean | null | undefined; +}; + export type ChangeIssueStatusInput = { issueId: string; /** Required by statuses with requiresReason */ @@ -1040,6 +1056,17 @@ export type RankIssueMutation = { rankIssue: { id: string; key: string | null; version: number }; }; +export type BulkUpdateIssuesMutationVariables = Exact<{ + input: BulkUpdateIssuesInput; +}>; + +export type BulkUpdateIssuesMutation = { + bulkUpdateIssues: { + updated: number; + results: Array<{ issueId: string; ok: boolean; error: string | null }>; + }; +}; + export type NotificationFieldsFragment = { id: string; kind: NotificationKind; @@ -6533,6 +6560,75 @@ export const RankIssueDocument = { }, ], } as unknown as DocumentNode; +export const BulkUpdateIssuesDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'BulkUpdateIssues' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'BulkUpdateIssuesInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'bulkUpdateIssues' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'updated' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'results' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'issueId' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'ok' } }, + { kind: 'Field', name: { kind: 'Name', value: 'error' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + BulkUpdateIssuesMutation, + BulkUpdateIssuesMutationVariables +>; export const MyNotificationsDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 46c0cba..e051bcb 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -208,3 +208,14 @@ mutation RankIssue($input: RankIssueInput!) { ...CommandResultFields } } + +mutation BulkUpdateIssues($input: BulkUpdateIssuesInput!) { + bulkUpdateIssues(input: $input) { + updated + results { + issueId + ok + error + } + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts index b6d73ec..d4bfb63 100644 --- a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts @@ -4,24 +4,37 @@ import { Component, computed, effect, + signal, inject, untracked, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { + PiActionButtonComponent, PiEmptyStateComponent, PiIconButtonComponent, PiIconComponent, PiNoteComponent, + PiSelectFieldComponent, PiSkeletonComponent, + PiTextFieldComponent, + type PiSelectFieldOption, } from '@pi/ui'; import { BacklogFacade, ProjectFacade, provideBacklogFacade, } from '@task-stream/web/tracker/application'; -import { IssueSummary, sumPoints } from '@task-stream/web/tracker/domain'; -import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy'; +import { + BulkChange, + IssuePriority, + IssueSummary, + sumPoints, +} from '@task-stream/web/tracker/domain'; +import { initials, options, PRIORITY, statusLook, typeLook } from '../ui/copy'; + +/** "Nobody" / "no sprint" in the bulk selects. */ +const NOBODY = '__none__'; /** /p/:key/backlog — what is still to start, in the order the team takes it. */ @Component({ @@ -31,11 +44,14 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy'; RouterLink, CdkDrag, CdkDropList, + PiActionButtonComponent, PiEmptyStateComponent, PiIconButtonComponent, PiIconComponent, PiNoteComponent, + PiSelectFieldComponent, PiSkeletonComponent, + PiTextFieldComponent, ], providers: [provideBacklogFacade()], template: ` @@ -68,6 +84,62 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy'; } @else if (!facade.loaded()) { } @else if (facade.issues().length) { + @if (project.canContribute()) { + + }
    @for (issue of facade.issues(); track issue.id; let i = $index) { -
  1. +
  2. + {{ i + 1 }} ()); + protected readonly allSelected = computed( + () => + this.facade.issues().length > 0 && + this.facade.issues().every((i) => this.selected().has(i.id)), + ); + protected readonly bulkFields: PiSelectFieldOption[] = [ + { value: 'status', label: 'Mover a…' }, + { value: 'assignee', label: 'Asignar a…' }, + { value: 'sprint', label: 'Sprint…' }, + { value: 'priority', label: 'Prioridad…' }, + { value: 'label', label: 'Añadir etiqueta…' }, + ]; + protected readonly bulkField = signal('status'); + protected readonly bulkValue = signal(null); + protected readonly bulkLabel = signal(''); + protected readonly statusOptions = computed[]>( + () => + this.project + .workflow() + .settings.statuses.filter((s) => !s.archived) + .map((s) => ({ value: s.id, label: s.name })), + ); + protected readonly assigneeOptions = computed[]>( + () => [ + { value: NOBODY, label: 'Nadie (sin asignar)' }, + ...this.project + .members() + .map((m) => ({ value: m.user.id, label: m.user.name })), + ], + ); + protected readonly sprintOptions = computed[]>( + () => [ + { value: NOBODY, label: 'Backlog (sin sprint)' }, + ...this.project + .openSprints() + .map((s) => ({ value: s.id, label: s.name })), + ], + ); + protected readonly priorityOptions = options(PRIORITY); + protected readonly canApply = computed(() => + this.bulkField() === 'label' + ? !!this.bulkLabel().trim() + : !!this.bulkValue(), + ); + + protected toggle(issueId: string, on: boolean): void { + this.selected.update((set) => { + const next = new Set(set); + if (on) next.add(issueId); + else next.delete(issueId); + return next; + }); + } + + protected selectAll(on: boolean): void { + this.selected.set(new Set(on ? this.facade.issues().map((i) => i.id) : [])); + } + + protected applyBulk(): void { + const value = this.bulkValue(); + const change: BulkChange = {}; + switch (this.bulkField()) { + case 'status': + if (value) change.status = value; + break; + case 'assignee': + change.assigneeId = value === NOBODY ? null : value; + break; + case 'sprint': + change.sprintId = value === NOBODY ? null : value; + break; + case 'priority': + if (value) change.priority = value as IssuePriority; + break; + case 'label': + change.addLabels = [this.bulkLabel().trim().toLowerCase()]; + break; + } + this.facade.bulkUpdate([...this.selected()], change); + this.selected.set(new Set()); + this.bulkLabel.set(''); + } protected readonly points = computed(() => sumPoints(this.facade.issues())); protected readonly unestimated = computed( () => this.facade.issues().filter((i) => i.storyPoints === null).length,