diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 721118c..587b059 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -6572,6 +6572,7 @@ }, "/api/releases/{releaseId}/archive": { "post": { + "description": "Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.", "operationId": "ReleasesController_archive", "parameters": [ { @@ -6583,6 +6584,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveReleaseDto" + } + } + } + }, "responses": { "200": { "description": "", @@ -6663,7 +6674,7 @@ "pireon": [] } ], - "summary": "Archive a planned release (its tickets leave it)", + "summary": "Archive a planned release", "tags": [ "Releases" ] @@ -12342,6 +12353,17 @@ "markdown" ] }, + "ArchiveReleaseDto": { + "type": "object", + "properties": { + "moveTo": { + "type": "string", + "nullable": true, + "format": "uuid", + "description": "Open release of the same project that takes its unfinished tickets; null or absent: they leave it" + } + } + }, "ShowcaseProjectDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 500b743..b1625db 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -648,7 +648,13 @@ type Mutation { """Add a Pireon user to the project""" addProjectMember(input: ProjectMemberInput!): CommandResult! - archiveRelease(releaseId: ID!): CommandResult! + archiveRelease( + """ + Open release of the same project that takes its unfinished tickets; none: they leave it + """ + moveTo: ID + releaseId: ID! + ): CommandResult! """Assign (or unassign with null) a project member""" assignIssue(assigneeId: ID, issueId: ID!): CommandResult! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 5e09875..1812ae2 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -950,7 +950,10 @@ export interface paths { }; get?: never; put?: never; - /** Archive a planned release (its tickets leave it) */ + /** + * Archive a planned release + * @description Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it. + */ post: operations["ReleasesController_archive"]; delete?: never; options?: never; @@ -2332,6 +2335,13 @@ export interface components { */ markdown: string; }; + ArchiveReleaseDto: { + /** + * Format: uuid + * @description Open release of the same project that takes its unfinished tickets; null or absent: they leave it + */ + moveTo?: string | null; + }; ShowcaseProjectDto: { /** Format: uuid */ id: string; @@ -7225,7 +7235,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ArchiveReleaseDto"]; + }; + }; responses: { 200: { headers: { diff --git a/apps/mcp/src/app/tools/releases.resolver.ts b/apps/mcp/src/app/tools/releases.resolver.ts index a1cdfaf..d53f8b2 100644 --- a/apps/mcp/src/app/tools/releases.resolver.ts +++ b/apps/mcp/src/app/tools/releases.resolver.ts @@ -188,15 +188,26 @@ export class ReleasesResolver { @Tool({ name: 'archive_release', description: - 'Archive a planned release that will not ship; its tickets leave it.', - paramsSchema: z.object({ releaseId }), + 'Archive a planned release that will not ship. Its unfinished tickets move to moveTo (another open release of the project, from list_releases) or, without it, leave it.', + paramsSchema: z.object({ + releaseId, + moveTo: z + .string() + .uuid() + .optional() + .describe('Open release that takes the unfinished tickets'), + }), annotations: { readOnlyHint: false, destructiveHint: true }, }) - archive({ releaseId }: { releaseId: string }, ctx: McpContext) { + archive( + { releaseId, moveTo }: { releaseId: string; moveTo?: string }, + ctx: McpContext, + ) { return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/releases/{releaseId}/archive', { params: { path: { releaseId } }, + body: { moveTo: moveTo ?? null }, }), ), ); diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index 61996e4..0d13e6f 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -160,7 +160,7 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada | ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto | | | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él | -| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) | +| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar, a la release elegida al archivar o a ninguna (los terminados se quedan) | | `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog | | `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket | | `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo | diff --git a/docs/03-api.md b/docs/03-api.md index 134ae34..f337120 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -214,6 +214,9 @@ stateDiagram-v2 `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. +- **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`. - **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/docs/09-mcp.md b/docs/09-mcp.md index 215595d..50ae36e 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -80,7 +80,7 @@ flowchart LR | | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | | | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | | | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | -| | `archive_release` | Archivar una release que no saldrá (sus tickets la dejan) | +| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad, diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 3fe2d2e..6d505ee 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -28,17 +28,51 @@ cuando hay pistas, por dónde empezar. ## 10.3 Siguientes funcionalidades -En orden de lo que más aportaría: +### Elegidas para la próxima tanda -1. **Asistente en la web** (la base ya está en el MCP): - - resumir un ticket largo con sus comentarios; - - al crear un ticket, sugerir tipo, prioridad y área, y avisar si se parece a otro que ya existe; - - redactar las notas de versión en lenguaje natural a partir del borrador. -2. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran - bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana - movió WEB-12 a En revisión», con enlace. -3. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de - autorización y admitir el registro dinámico de clientes. +En este orden: + +1. **Calendario suscribible (ICS)**: una URL privada por persona con las fechas límite, los sprints y las releases de + sus proyectos, para Google Calendar, Outlook o Apple Calendar. La URL lleva un token propio (revocable), no el de + sesión. +2. **Notificaciones push (Web Push)**: los avisos de la campana como aviso del sistema, aunque la pestaña esté + cerrada. Un _service worker_ en la web (que la deja instalable como PWA), «Activar avisos» en los ajustes de + notificaciones, las suscripciones de cada persona guardadas en la API y el envío con Web Push (claves VAPID, dos + variables nuevas). En iPhone solo con la web instalada en la pantalla de inicio (iOS 16.4+). +3. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de + opciones, definidos en Configuración; editables en la ficha, filtrables y como columna en la vista de lista. + +### Valoradas para después + +4. **Dependencias en la hoja de ruta**: las relaciones «bloquea» / «depende de» entre épicas, como flechas en la vista + de calendario, para ver qué retrasa a qué. +5. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para + planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`). +6. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, + automatizaciones y etiquetas. +7. **Sincronizar con issues de Gitea o GitHub**: hoy los commits y PR se enlazan a tickets; además, que un issue del + repositorio cree o actualice un ticket, y al revés. +8. **Vistas guardadas en «Mi trabajo»**: montar la portada con filtros guardados de varios proyectos («Bugs críticos de + todos mis proyectos», «Lo que revisa QA esta semana»…). +9. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o + para archivar. +10. **Asistente en la web** (la base ya está en el MCP): resumir un ticket largo con sus comentarios; al crear un + ticket, sugerir tipo, prioridad y área y avisar si se parece a otro; redactar las notas de versión a partir del + borrador. Pendiente de decidir la clave de la API de Claude y su coste. +11. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran + bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana + movió WEB-12 a En revisión», con enlace. +12. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de + autorización y admitir el registro dinámico de clientes. + +### Otras ideas apuntadas, sin decidir + +- **Resumen diario por correo**: lo asignado, lo que vence hoy y lo que ha cambiado en lo que sigues, en un solo correo. +- **Tickets recurrentes**: «revisar copias cada lunes», creados solos con la plantilla del tipo. +- **Crear tickets por correo**: una dirección por proyecto; lo que llega es un ticket (con adjuntos) y las respuestas, + comentarios. Necesita un buzón de entrada. +- **Tiempo en cada estado**: cuánto pasa un ticket en «En revisión» o «En QA», y dónde se atascan. +- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS. ## 10.4 Descartado por ahora @@ -68,3 +102,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del dominio más el `_id` `ObjectId` propio. - Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web. +- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada + release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 1a40c51..2f73bbf 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index edea2f5..16b7ac4 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/commands/archive-release.command-handler.ts b/libs/api/tracker/application/src/lib/commands/archive-release.command-handler.ts index 9247f5b..d8d0942 100644 --- a/libs/api/tracker/application/src/lib/commands/archive-release.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/archive-release.command-handler.ts @@ -11,10 +11,12 @@ export class ArchiveReleaseCommandHandler async execute(command: ArchiveReleaseCommand): Promise { const release = await this.access.release(command.releaseId); - (await this.access.project(release.projectId)).assertCanManage( - command.actorId, - ); - release.archive(command.actorId); + const project = await this.access.project(release.projectId); + project.assertCanManage(command.actorId); + // The tickets can only go to an open release of the same project. + if (command.moveTo && command.moveTo !== release.id) + await this.access.openReleaseOf(project, command.moveTo); + release.archive(command.actorId, command.moveTo); await release.commitAndWait(); return ack(release); } diff --git a/libs/api/tracker/application/src/lib/commands/archive-release.command.ts b/libs/api/tracker/application/src/lib/commands/archive-release.command.ts index 8fb7c14..0b2c945 100644 --- a/libs/api/tracker/application/src/lib/commands/archive-release.command.ts +++ b/libs/api/tracker/application/src/lib/commands/archive-release.command.ts @@ -5,6 +5,8 @@ export class ArchiveReleaseCommand extends Command { constructor( public readonly actorId: string, public readonly releaseId: string, + /** Open release of the same project for its unfinished tickets. */ + public readonly moveTo: string | null = null, ) { super(); } diff --git a/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command-handler.ts b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command-handler.ts index 7d4388c..64d0199 100644 --- a/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command-handler.ts @@ -15,7 +15,21 @@ export class DetachIssueFromReleaseCommandHandler const project = await this.access.live(issue.projectId, Project); // Finished tickets stay: they are what the release shipped. if (!project || project.workflow.isFinished(issue.status)) return; - issue.setRelease(command.actorId, null); + issue.setRelease(command.actorId, await this.target(project, command)); await issue.commitAndWait(); } + + /** The release chosen when archiving, if it still takes tickets. */ + private async target( + project: Project, + command: DetachIssueFromReleaseCommand, + ): Promise { + if (!command.toReleaseId) return null; + try { + await this.access.openReleaseOf(project, command.toReleaseId); + return command.toReleaseId; + } catch { + return null; + } + } } diff --git a/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command.ts b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command.ts index 6d59b13..2a84943 100644 --- a/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command.ts +++ b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command.ts @@ -1,11 +1,15 @@ import { Command } from '@nestjs/cqrs'; -/** Saga only: takes an unfinished ticket out of an archived release. */ +/** + * Saga only: takes an unfinished ticket out of an archived release, to + * `toReleaseId` or to none. + */ export class DetachIssueFromReleaseCommand extends Command { constructor( public readonly actorId: string, public readonly issueId: string, public readonly releaseId: string, + public readonly toReleaseId: string | null = null, ) { super(); } diff --git a/libs/api/tracker/application/src/lib/releases.service.ts b/libs/api/tracker/application/src/lib/releases.service.ts index b14f2a4..1398965 100644 --- a/libs/api/tracker/application/src/lib/releases.service.ts +++ b/libs/api/tracker/application/src/lib/releases.service.ts @@ -66,9 +66,14 @@ export class ReleasesService { ); } - archive(actorId: string, releaseId: string): Promise { + /** @param moveTo open release for its unfinished tickets (none: they leave it) */ + archive( + actorId: string, + releaseId: string, + moveTo: string | null = null, + ): Promise { return this.commandBus.execute( - new ArchiveReleaseCommand(actorId, releaseId), + new ArchiveReleaseCommand(actorId, releaseId, moveTo), ); } diff --git a/libs/api/tracker/application/src/lib/sagas/release-archived.saga.ts b/libs/api/tracker/application/src/lib/sagas/release-archived.saga.ts index f90b76d..399a8ab 100644 --- a/libs/api/tracker/application/src/lib/sagas/release-archived.saga.ts +++ b/libs/api/tracker/application/src/lib/sagas/release-archived.saga.ts @@ -7,8 +7,9 @@ import { ReleaseIssuesRepository } from '../ports/release-issues.repository'; /** * An archived release no longer accepts changes, so its unfinished tickets - * would be stuck in it: they leave it (back to "no release"), attributed to - * whoever archived it. Finished tickets stay, as the record of what shipped. + * would be stuck in it: they move to the release chosen when archiving it, + * or back to "no release", attributed to whoever archived it. Finished + * tickets stay, as the record of what shipped. */ @Injectable() export class ReleaseArchivedSaga { @@ -27,6 +28,7 @@ export class ReleaseArchivedSaga { event.actorId, issueId, event.aggregateId, + event.moveTo ?? null, ), ), ), diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts index 73d1102..aa529f4 100644 --- a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts +++ b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts @@ -306,6 +306,45 @@ describe('sagas', () => { expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(release); }); + it('archiving a release can hand its unfinished tickets to another', async () => { + const { run, drain, rehydrator } = await setup(); + const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id; + const old = ( + await run(new CreateReleaseCommand('owner', web, 'v1', null, null)) + ).id; + const next = ( + await run(new CreateReleaseCommand('owner', web, 'v2', null, null)) + ).id; + const open = ( + await run( + new CreateIssueCommand('owner', web, 'TASK', 'open', {}, null, old), + ) + ).id; + const done = ( + await run( + new CreateIssueCommand('owner', web, 'TASK', 'done', {}, null, old), + ) + ).id; + for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE']) + await run(new ChangeIssueStatusCommand('owner', done, status)); + + // Only an open release of the same project takes them. + const other = (await run(new CreateProjectCommand('owner', 'API', 'Api'))) + .id; + const elsewhere = ( + await run(new CreateReleaseCommand('owner', other, 'v9', null, null)) + ).id; + await expect( + run(new ArchiveReleaseCommand('owner', old, elsewhere)), + ).rejects.toThrow(/another project/); + + await run(new ArchiveReleaseCommand('owner', old, next)); + await drain(); + + expect((await rehydrator.rehydrate(open, Issue)).releaseId).toBe(next); + expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(old); + }); + it('a user deactivated in Pireon leaves their open tickets unassigned', async () => { const { run, react, rehydrator, openAssigned } = await setup(); const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id; diff --git a/libs/api/tracker/domain/src/lib/events/release-archived.event.ts b/libs/api/tracker/domain/src/lib/events/release-archived.event.ts index 5fde5ab..5b326cc 100644 --- a/libs/api/tracker/domain/src/lib/events/release-archived.event.ts +++ b/libs/api/tracker/domain/src/lib/events/release-archived.event.ts @@ -1,10 +1,19 @@ import { AutowiredEvent } from '@task-stream/api/shared/domain'; import { TrackerEvent } from './tracker.event'; -/** The release was archived. */ +/** + * The release was archived. Its unfinished tickets move to `moveTo` (an + * open release of the same project) or, when null, leave it. Events stored + * before `moveTo` existed read as null. + */ @AutowiredEvent export class ReleaseArchivedEvent extends TrackerEvent { - constructor(actorId: string, releaseId: string, projectId: string) { + constructor( + actorId: string, + releaseId: string, + projectId: string, + readonly moveTo: string | null = null, + ) { super(actorId, releaseId, projectId); } } diff --git a/libs/api/tracker/domain/src/lib/release/release.ts b/libs/api/tracker/domain/src/lib/release/release.ts index 486477d..052d451 100644 --- a/libs/api/tracker/domain/src/lib/release/release.ts +++ b/libs/api/tracker/domain/src/lib/release/release.ts @@ -132,9 +132,13 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps { ); } - archive(actorId: string): void { + /** @param moveTo where its unfinished tickets go (null: no release) */ + archive(actorId: string, moveTo: string | null = null): void { if (this.status === ReleaseStatus.ARCHIVED) return; - this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId)); + assertThat(moveTo !== this.id, 'A release cannot take its own tickets'); + this.apply( + new ReleaseArchivedEvent(actorId, this.id, this.projectId, moveTo), + ); } /** Deletes the release (its project was deleted). Idempotent. */ diff --git a/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts index 87ca773..cfd4728 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts @@ -146,7 +146,14 @@ export class ReleaseResolver { archiveRelease( @CurrentUser() me: User, @Args('releaseId', { type: () => ID }) releaseId: string, + @Args('moveTo', { + type: () => ID, + nullable: true, + description: + 'Open release of the same project that takes its unfinished tickets; none: they leave it', + }) + moveTo?: string | null, ) { - return this.releases.archive(me.id, releaseId); + return this.releases.archive(me.id, releaseId, moveTo ?? null); } } 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 b58300b..13474cb 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 @@ -1148,6 +1148,19 @@ export class SetIssueSprintDto { sprintId!: string | null; } +export class ArchiveReleaseDto { + @ApiPropertyOptional({ + type: String, + nullable: true, + format: 'uuid', + description: + 'Open release of the same project that takes its unfinished tickets; null or absent: they leave it', + }) + @IsOptional() + @IsUUID() + moveTo?: string | null; +} + export class PublishReleaseDto { @ApiPropertyOptional({ type: Boolean, diff --git a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts index e27e428..ec2e167 100644 --- a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts @@ -34,6 +34,7 @@ import { import { CreateReleaseDto, PageQueryDto, + ArchiveReleaseDto, PublishReleaseDto, ReleaseNotesDto, UpcomingReleasesQueryDto, @@ -234,10 +235,18 @@ export class ReleasesController { @Post('releases/:releaseId/archive') @HttpCode(200) - @ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' }) + @ApiOperation({ + summary: 'Archive a planned release', + description: + 'Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.', + }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() - archive(@CurrentUser() me: User, @Param('releaseId') releaseId: string) { - return this.releases.archive(me.id, releaseId); + archive( + @CurrentUser() me: User, + @Param('releaseId') releaseId: string, + @Body() body: ArchiveReleaseDto, + ) { + return this.releases.archive(me.id, releaseId, body?.moveTo ?? null); } } diff --git a/libs/web/tracker/application/src/lib/events/project.events.ts b/libs/web/tracker/application/src/lib/events/project.events.ts index 4dd58e4..5b1f52f 100644 --- a/libs/web/tracker/application/src/lib/events/project.events.ts +++ b/libs/web/tracker/application/src/lib/events/project.events.ts @@ -26,7 +26,10 @@ export const projectPageEvents = eventGroup({ memberRemoveRequested: type<{ projectId: string; userId: string }>(), releaseCreateRequested: type(), releasePublishRequested: type<{ releaseId: string; force: boolean }>(), - releaseArchiveRequested: type<{ releaseId: string }>(), + releaseArchiveRequested: type<{ + releaseId: string; + moveTo: string | null; + }>(), releaseNotesWriteRequested: type<{ releaseId: string; notes: string | null; diff --git a/libs/web/tracker/application/src/lib/facades/project.facade.ts b/libs/web/tracker/application/src/lib/facades/project.facade.ts index c1ad6f3..6b9adca 100644 --- a/libs/web/tracker/application/src/lib/facades/project.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts @@ -144,8 +144,9 @@ export class ProjectFacade { return this.releasesGateway.notesDraft(releaseId); } - archiveRelease(releaseId: string): void { - this.dispatch.releaseArchiveRequested({ releaseId }); + /** Its unfinished tickets go to `moveTo` (an open release) or leave it. */ + archiveRelease(releaseId: string, moveTo: string | null = null): void { + this.dispatch.releaseArchiveRequested({ releaseId, moveTo }); } /** Replaces statuses, transitions, types and areas (owners and admins). */ diff --git a/libs/web/tracker/application/src/lib/stores/project.store.ts b/libs/web/tracker/application/src/lib/stores/project.store.ts index 763a4a7..a3642d3 100644 --- a/libs/web/tracker/application/src/lib/stores/project.store.ts +++ b/libs/web/tracker/application/src/lib/stores/project.store.ts @@ -550,7 +550,7 @@ export const ProjectStore = signalStore( .pipe( mergeMap(({ payload }) => runCommand( - releases.archive(payload.releaseId), + releases.archive(payload.releaseId, payload.moveTo), 'Release archivada', projectApiEvents.commandSucceeded, projectApiEvents.commandFailed, diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 03a7981..398b796 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -96,6 +96,7 @@ export interface IssueListFilter { priorities?: IssuePriority[]; assigneeId?: string; unassigned?: boolean; + releaseId?: string; limit: number; } @@ -134,6 +135,8 @@ export interface BulkChange { assigneeId?: string | null; /** null: back to the backlog. */ sprintId?: string | null; + /** null: out of its release. */ + releaseId?: string | null; priority?: IssuePriority; addLabels?: string[]; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index c5b19d3..9d3838f 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -289,7 +289,11 @@ export abstract class ReleasesGateway { releaseId: string, force: boolean, ): Observable; - abstract archive(releaseId: string): Observable; + /** Its unfinished tickets go to `moveTo` (an open release) or leave it. */ + abstract archive( + releaseId: string, + moveTo?: string | null, + ): Observable; abstract burnup(releaseId: string): Observable; /** A Markdown draft from the release's finished tickets (not saved). */ abstract notesDraft(releaseId: string): Observable; diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts index 84f4ac5..8d1cef6 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts @@ -104,9 +104,12 @@ export class GraphqlReleasesGateway extends ReleasesGateway { .pipe(map((d) => toCommandResult(d.publishRelease))); } - archive(releaseId: string): Observable { + archive( + releaseId: string, + moveTo: string | null = null, + ): Observable { return this.gql - .mutate(ArchiveReleaseDocument, { releaseId }) + .mutate(ArchiveReleaseDocument, { releaseId, moveTo }) .pipe(map((d) => toCommandResult(d.archiveRelease))); } 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 a93d295..5f0370d 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 @@ -255,6 +255,8 @@ export class GraphqlIssuesGateway extends IssuesGateway { assigneeId: change.assigneeId ?? null, noSprint: change.sprintId === null ? true : null, sprintId: change.sprintId ?? null, + noRelease: change.releaseId === null ? true : null, + releaseId: change.releaseId ?? null, priority: (change.priority ?? null) as never, addLabels: change.addLabels ?? null, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 0d4280f..d94082f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -1676,6 +1676,7 @@ export type PublishReleaseMutation = { export type ArchiveReleaseMutationVariables = Exact<{ releaseId: string; + moveTo?: string | null | undefined; }>; export type ArchiveReleaseMutation = { @@ -10488,6 +10489,14 @@ export const ArchiveReleaseDocument = { type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, }, }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'moveTo' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -10504,6 +10513,14 @@ export const ArchiveReleaseDocument = { name: { kind: 'Name', value: 'releaseId' }, }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'moveTo' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'moveTo' }, + }, + }, ], selectionSet: { kind: 'SelectionSet', diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql index 488c317..af53431 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql @@ -24,8 +24,8 @@ mutation PublishRelease($releaseId: ID!, $force: Boolean!) { } } -mutation ArchiveRelease($releaseId: ID!) { - archiveRelease(releaseId: $releaseId) { +mutation ArchiveRelease($releaseId: ID!, $moveTo: ID) { + archiveRelease(releaseId: $releaseId, moveTo: $moveTo) { ...CommandResultFields } } diff --git a/libs/web/tracker/presenters/src/lib/pages/list.page.ts b/libs/web/tracker/presenters/src/lib/pages/list.page.ts index 6c6ab53..99e197b 100644 --- a/libs/web/tracker/presenters/src/lib/pages/list.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/list.page.ts @@ -4,6 +4,7 @@ import { computed, effect, inject, + input, signal, untracked, } from '@angular/core'; @@ -105,6 +106,10 @@ const numberOf = (key: string) => Number(key.split('-').pop()); [options]="assigneeFilterOptions()" [(value)]="assignee" /> + @if (facade.loaded()) { {{ facade.total() }} tickets{{ @@ -430,6 +435,8 @@ const numberOf = (key: string) => Number(key.split('-').pop()); `, }) export class ListPage { + /** `?release=`: opens already filtered by that release. */ + readonly release = input(undefined); protected readonly facade = inject(ListFacade); protected readonly project = inject(ProjectFacade); protected readonly NOBODY = NOBODY; @@ -455,6 +462,11 @@ export class ListPage { protected readonly type = signal(ALL); protected readonly priority = signal(ALL); protected readonly assignee = signal(ALL); + protected readonly releaseFilter = signal(ALL); + private readonly releaseFromUrl = effect(() => { + const id = this.release(); + if (id) untracked(() => this.releaseFilter.set(id)); + }); protected readonly scopeOptions: PiSelectFieldOption[] = [ { value: 'open', label: 'Abiertos' }, { value: 'closed', label: 'Terminados o cancelados' }, @@ -483,6 +495,24 @@ export class ListPage { .members() .map((m) => ({ value: m.user.id, label: m.user.name })), ]); + /** Planned ones first: those are the ones tickets move between. */ + protected readonly releaseFilterOptions = computed< + PiSelectFieldOption[] + >(() => [ + { value: ALL, label: 'Todas las releases' }, + ...[...this.project.releases()] + .sort( + (a, b) => + Number(b.status === 'PLANNED') - Number(a.status === 'PLANNED'), + ) + .map((r) => ({ + value: r.id, + label: + r.status === 'PLANNED' + ? r.name + : `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`, + })), + ]); private readonly criteria = computed(() => { const scope = this.scope(); const assignee = this.assignee(); @@ -496,6 +526,7 @@ export class ListPage { : undefined, unassigned: assignee === NOBODY || undefined, assigneeId: assignee && assignee !== NOBODY ? assignee : undefined, + releaseId: this.releaseFilter() || undefined, }; }); diff --git a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts index abccb5d..76e3b59 100644 --- a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts @@ -14,6 +14,8 @@ import { PiActionButtonComponent, PiEmptyStateComponent, PiLabelDirective, + PiSelectFieldComponent, + PiSelectFieldOption, PiTextareaComponent, PiTextFieldComponent, } from '@pi/ui'; @@ -23,6 +25,9 @@ import { ProjectFacade } from '@task-stream/web/tracker/application'; import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain'; import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; +/** The «no release» choice of a select (an empty value). */ +const NO_RELEASE = ''; + @Component({ selector: 'ts-releases-page', changeDetection: ChangeDetectionStrategy.OnPush, @@ -33,6 +38,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; PiActionButtonComponent, PiEmptyStateComponent, PiLabelDirective, + PiSelectFieldComponent, PiTextFieldComponent, PiTextareaComponent, ], @@ -103,6 +109,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; [queryParams]="{ release: release.id }" >Ver en el tablero → + Ver en la lista → @if (release.notes) { + +
+

+ {{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en + esta release como registro de lo hecho. +

+ + Lo pendiente va a + + + +
+
>('createForm'); private readonly notesForm = viewChild.required>('notesForm'); + private readonly archiveForm = + viewChild.required>('archiveForm'); private readonly toast = inject(ToastService); private readonly destroyRef = inject(DestroyRef); @@ -457,7 +493,44 @@ export class ReleasesPage { .catch(() => this.toast.error('No se pudieron copiar')); } + /* Archive: where its unfinished tickets go, as when closing a sprint. */ + private archiving: Release | null = null; + protected readonly unfinished = signal(0); + protected readonly moveTo = signal(NO_RELEASE); + protected readonly moveToOptions = signal[]>([]); + protected archive(release: Release): void { - this.facade.archiveRelease(release.id); + this.archiving = release; + // The next one first: the nearest target date, then the oldest planned + // (the list comes newest first). + const open = this.facade + .releases() + .filter((r) => r.status === ReleaseStatus.PLANNED && r.id !== release.id) + .map((r, i) => ({ r, i })) + .sort( + (a, b) => + (a.r.targetDate ?? '9999').localeCompare(b.r.targetDate ?? '9999') || + b.i - a.i, + ) + .map(({ r }) => r); + this.unfinished.set(release.progress.total - release.progress.finished); + this.moveToOptions.set([ + { value: NO_RELEASE, label: 'Ninguna release' }, + ...open.map((r) => ({ value: r.id, label: r.name })), + ]); + this.moveTo.set(open[0]?.id ?? NO_RELEASE); + // Nothing to place: no need to ask. + if (this.unfinished() === 0) { + this.confirmArchive(); + return; + } + this.drawer.open(`Archivar ${release.name}`, this.archiveForm()); + } + + protected confirmArchive(): void { + if (!this.archiving) return; + this.facade.archiveRelease(this.archiving.id, this.moveTo() || null); + this.archiving = null; + this.drawer.closeAll(); } } diff --git a/libs/web/tracker/presenters/src/lib/ui/bulk-bar.ts b/libs/web/tracker/presenters/src/lib/ui/bulk-bar.ts index 5f40f47..f5c08f3 100644 --- a/libs/web/tracker/presenters/src/lib/ui/bulk-bar.ts +++ b/libs/web/tracker/presenters/src/lib/ui/bulk-bar.ts @@ -15,7 +15,11 @@ import { type PiSelectFieldOption, } from '@pi/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; -import { BulkChange, IssuePriority } from '@task-stream/web/tracker/domain'; +import { + BulkChange, + IssuePriority, + ReleaseStatus, +} from '@task-stream/web/tracker/domain'; import { options, PRIORITY } from './copy'; /** "Nobody" / "no sprint" in the selects. */ @@ -62,6 +66,9 @@ const NOBODY = '__none__'; @case ('sprint') { } + @case ('release') { + + } @case ('priority') { } @@ -112,6 +119,7 @@ export class BulkBarComponent { { value: 'status', label: 'Mover a…' }, { value: 'assignee', label: 'Asignar a…' }, { value: 'sprint', label: 'Sprint…' }, + { value: 'release', label: 'Release…' }, { value: 'priority', label: 'Prioridad…' }, { value: 'label', label: 'Añadir etiqueta…' }, ]; @@ -141,6 +149,16 @@ export class BulkBarComponent { .map((s) => ({ value: s.id, label: s.name })), ], ); + /** Only open releases take tickets. */ + protected readonly releaseOptions = computed[]>( + () => [ + { value: NOBODY, label: 'Ninguna release' }, + ...this.project + .releases() + .filter((r) => r.status === ReleaseStatus.PLANNED) + .map((r) => ({ value: r.id, label: r.name })), + ], + ); protected readonly priorityOptions = options(PRIORITY); protected readonly canApply = computed(() => this.field() === 'label' ? !!this.label().trim() : !!this.value(), @@ -159,6 +177,9 @@ export class BulkBarComponent { case 'sprint': change.sprintId = value === NOBODY ? null : value; break; + case 'release': + change.releaseId = value === NOBODY ? null : value; + break; case 'priority': if (value) change.priority = value as IssuePriority; break;