From 179d6a5e948b55f2c748fe16fe773c9722f98f97 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Tue, 20 Oct 2026 19:34:09 +0200 Subject: [PATCH] fix(web): bulk changes of more than 100 tickets go in batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list shows up to 200 tickets and «select all» picks them all, but the API takes 100 per bulk change: above that the whole change failed («At most 100 tickets at once») and nothing changed. The gateway now sends batches of 100 one after another and adds up the results, for the list and the backlog alike. Co-Authored-By: Claude Opus 5.5 --- docs/03-api.md | 3 ++- libs/web/tracker/domain/src/index.ts | 1 + .../domain/src/lib/models/bulk.spec.ts | 26 +++++++++++++++++++ .../web/tracker/domain/src/lib/models/bulk.ts | 20 ++++++++++++++ .../lib/adapters/graphql-issues.gateway.ts | 16 +++++++++++- 5 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 libs/web/tracker/domain/src/lib/models/bulk.spec.ts create mode 100644 libs/web/tracker/domain/src/lib/models/bulk.ts diff --git a/docs/03-api.md b/docs/03-api.md index e082a95..3f98d53 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -309,7 +309,8 @@ stateDiagram-v2 - **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. + la respuesta dice cuáles. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras + otra, y suma el resultado. - **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`). Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release; los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`. diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 9081384..a7e107d 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/models/enums'; export * from './lib/models/models'; +export * from './lib/models/bulk'; export * from './lib/models/inputs'; export * from './lib/models/workflow'; export * from './lib/ports/gateways'; diff --git a/libs/web/tracker/domain/src/lib/models/bulk.spec.ts b/libs/web/tracker/domain/src/lib/models/bulk.spec.ts new file mode 100644 index 0000000..e41816a --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/bulk.spec.ts @@ -0,0 +1,26 @@ +import { bulkBatches, MAX_BULK, mergeBulkResults } from './bulk'; + +describe('bulk changes', () => { + it('split into batches the API takes, in order', () => { + const ids = Array.from({ length: 230 }, (_, i) => `i${i}`); + const batches = bulkBatches(ids); + expect(batches.map((b) => b.length)).toEqual([MAX_BULK, MAX_BULK, 30]); + expect(batches.flat()).toEqual(ids); + expect(bulkBatches([])).toEqual([]); + }); + + it('add the batches up', () => { + expect( + mergeBulkResults([ + { updated: 2, results: [{ issueId: 'a', ok: true, error: null }] }, + { updated: 0, results: [{ issueId: 'b', ok: false, error: 'No' }] }, + ]), + ).toEqual({ + updated: 2, + results: [ + { issueId: 'a', ok: true, error: null }, + { issueId: 'b', ok: false, error: 'No' }, + ], + }); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/bulk.ts b/libs/web/tracker/domain/src/lib/models/bulk.ts new file mode 100644 index 0000000..c2f2c39 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/bulk.ts @@ -0,0 +1,20 @@ +import { BulkResult } from './inputs'; + +/** The API takes at most this many tickets per bulk change. */ +export const MAX_BULK = 100; + +/** The tickets in batches the API takes, in order. */ +export function bulkBatches(issueIds: string[], size = MAX_BULK): string[][] { + const batches: string[][] = []; + for (let start = 0; start < issueIds.length; start += size) + batches.push(issueIds.slice(start, start + size)); + return batches; +} + +/** Several batches' results as one. */ +export function mergeBulkResults(results: BulkResult[]): BulkResult { + return { + updated: results.reduce((sum, r) => sum + r.updated, 0), + results: results.flatMap((r) => r.results), + }; +} 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 5d91ddd..a562594 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 @@ -4,6 +4,7 @@ import { BoardFilter, BulkChange, BulkResult, + bulkBatches, ChangeStatusInput, CommandResult, CreateIssueInput, @@ -16,9 +17,10 @@ import { IssueSummary, LinkIssuesInput, MyIssuesFilter, + mergeBulkResults, UpdateIssueInput, } from '@task-stream/web/tracker/domain'; -import { map, Observable } from 'rxjs'; +import { concatMap, from, map, Observable, toArray } from 'rxjs'; import { AddChecklistItemDocument, AssignIssueDocument, @@ -288,7 +290,19 @@ export class GraphqlIssuesGateway extends IssuesGateway { .pipe(map((d) => d.trash.map(toIssueSummary))); } + /** In batches of what the API takes, one after another, added up. */ bulkUpdate(issueIds: string[], change: BulkChange): Observable { + return from(bulkBatches(issueIds)).pipe( + concatMap((batch) => this.bulkBatch(batch, change)), + toArray(), + map(mergeBulkResults), + ); + } + + private bulkBatch( + issueIds: string[], + change: BulkChange, + ): Observable { return this.gql .mutate(BulkUpdateIssuesDocument, { input: {