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,