fix(web): bulk changes of more than 100 tickets go in batches

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 19:34:09 +02:00
parent 199b60e4ce
commit 179d6a5e94
5 changed files with 64 additions and 2 deletions

View File

@@ -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';

View File

@@ -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' },
],
});
});
});

View File

@@ -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),
};
}

View File

@@ -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<BulkResult> {
return from(bulkBatches(issueIds)).pipe(
concatMap((batch) => this.bulkBatch(batch, change)),
toArray(),
map(mergeBulkResults),
);
}
private bulkBatch(
issueIds: string[],
change: BulkChange,
): Observable<BulkResult> {
return this.gql
.mutate(BulkUpdateIssuesDocument, {
input: {