diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 1d89547..3bd5e6f 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -5532,6 +5532,197 @@ ] } }, + "/api/releases/{releaseId}/notes": { + "put": { + "description": "Also after the release ships; not once archived.", + "operationId": "ReleasesController_writeNotes", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseNotesDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "Write the release notes (Markdown)", + "tags": [ + "Releases" + ] + } + }, + "/api/releases/{releaseId}/notes/draft": { + "get": { + "description": "Grouped by type; nothing is saved.", + "operationId": "ReleasesController_notesDraft", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseNotesDraftDto" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + }, + {} + ], + "summary": "A Markdown draft of the notes from the finished tickets", + "tags": [ + "Releases" + ] + } + }, "/api/releases/{releaseId}/archive": { "post": { "operationId": "ReleasesController_archive", @@ -9481,6 +9672,11 @@ "nullable": true, "format": "date-time" }, + "notes": { + "type": "string", + "nullable": true, + "description": "What shipped, told for people (Markdown)" + }, "createdAt": { "type": "string", "format": "date-time" @@ -9497,6 +9693,7 @@ "targetDate", "status", "releasedAt", + "notes", "createdAt", "version" ] @@ -9625,6 +9822,32 @@ } } }, + "ReleaseNotesDto": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "nullable": true, + "maxLength": 20000, + "description": "Markdown; null removes the notes" + } + }, + "required": [ + "notes" + ] + }, + "ReleaseNotesDraftDto": { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "example": "## v1.4.0\n\n### Novedades\n\n- Pago con tarjeta (WEB-1)\n" + } + }, + "required": [ + "markdown" + ] + }, "Object": { "type": "object", "properties": {} diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 57229e2..2a05b32 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -496,6 +496,11 @@ type Mutation { Follow a ticket: be told when it moves, is reassigned or gets a comment """ watchIssue(issueId: ID!): CommandResult! + + """ + Write the release notes (Markdown; null removes them). Also after shipping. + """ + writeReleaseNotes(notes: String, releaseId: ID!): CommandResult! } type MyNotifications { @@ -743,6 +748,14 @@ type Release { """Tickets included in this release""" issues(limit: Int! = 100, offset: Int! = 0): IssuePage! name: String! + + """What shipped, told for people (Markdown)""" + notes: String + + """ + A Markdown draft of the notes from its finished tickets, by type (not saved) + """ + notesDraft: String! progress: ReleaseProgress! projectId: ID! releasedAt: String diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index f689eac..d8b8c46 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -782,6 +782,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/releases/{releaseId}/notes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Write the release notes (Markdown) + * @description Also after the release ships; not once archived. + */ + put: operations["ReleasesController_writeNotes"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/releases/{releaseId}/notes/draft": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A Markdown draft of the notes from the finished tickets + * @description Grouped by type; nothing is saved. + */ + get: operations["ReleasesController_notesDraft"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/releases/{releaseId}/archive": { parameters: { query?: never; @@ -1737,6 +1777,8 @@ export interface components { status: components["schemas"]["ReleaseStatus"]; /** Format: date-time */ releasedAt: string | null; + /** @description What shipped, told for people (Markdown) */ + notes: string | null; /** Format: date-time */ createdAt: string; version: number; @@ -1784,6 +1826,20 @@ export interface components { */ force: boolean; }; + ReleaseNotesDto: { + /** @description Markdown; null removes the notes */ + notes: string | null; + }; + ReleaseNotesDraftDto: { + /** + * @example ## v1.4.0 + * + * ### Novedades + * + * - Pago con tarjeta (WEB-1) + */ + markdown: string; + }; Object: Record; /** @enum {string} */ NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; @@ -5712,6 +5768,142 @@ export interface operations { }; }; }; + ReleasesController_writeNotes: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReleaseNotesDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + ReleasesController_notesDraft: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseNotesDraftDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; ReleasesController_archive: { parameters: { query?: never; diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 0cad5cd..5e6f258 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,33 +2,34 @@ ## Qué resuelve -| Necesidad | Cómo se resuelve | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | -| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | -| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | -| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | -| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | -| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | -| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | -| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | -| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | -| 🎲 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 | -| 🗺️ 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 | -| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | -| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | -| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | -| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | -| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | -| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | +| Necesidad | Cómo se resuelve | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | +| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | +| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | +| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | +| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | +| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | +| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | +| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | +| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | +| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | +| 🎲 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 | +| 🗺️ 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 | +| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | +| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | +| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | +| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/03-api.md b/docs/03-api.md index bd370a8..3efcd45 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -142,6 +142,9 @@ stateDiagram-v2 - **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino). - **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`. + Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada); + `notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo + (los de QA quedan fuera). - **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. - **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`. - **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por @@ -205,7 +208,7 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado). | Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` | | Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | | Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` | -| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | +| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | `IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado. @@ -268,7 +271,7 @@ Todas devuelven `CommandResult { id, key, version }`. | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | -| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | +| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | ### Subscriptions @@ -475,7 +478,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | 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 631ff82..46d010b 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler'; import { WatchIssueCommandHandler } from './watch-issue.command-handler'; 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 { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { @@ -72,6 +73,7 @@ export const COMMAND_HANDLERS = [ WatchIssueCommandHandler, SetIssueParentCommandHandler, RankIssueCommandHandler, + WriteReleaseNotesCommandHandler, ...SPRINT_COMMAND_HANDLERS, AddChecklistItemCommandHandler, UpdateChecklistItemCommandHandler, diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 87aed4c..8a54df3 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -34,3 +34,4 @@ export * from './detach-from-parent.command'; export * from './change-checklist.command'; export * from './rank-issue.command'; export * from './sprint.commands'; +export * from './write-release-notes.command'; diff --git a/libs/api/tracker/application/src/lib/commands/write-release-notes.command-handler.ts b/libs/api/tracker/application/src/lib/commands/write-release-notes.command-handler.ts new file mode 100644 index 0000000..ced2b67 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/write-release-notes.command-handler.ts @@ -0,0 +1,21 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CommandResult, ack } from './command-result'; +import { WriteReleaseNotesCommand } from './write-release-notes.command'; +import { WriteAccess } from './write-access'; + +@CommandHandler(WriteReleaseNotesCommand) +export class WriteReleaseNotesCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: WriteReleaseNotesCommand): Promise { + const release = await this.access.release(command.releaseId); + (await this.access.project(release.projectId)).assertCanManage( + command.actorId, + ); + release.writeNotes(command.actorId, command.notes); + await release.commitAndWait(); + return ack(release); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/write-release-notes.command.ts b/libs/api/tracker/application/src/lib/commands/write-release-notes.command.ts new file mode 100644 index 0000000..5fc2dd2 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/write-release-notes.command.ts @@ -0,0 +1,13 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +/** Writes (or with null, removes) a release's notes. */ +export class WriteReleaseNotesCommand extends Command { + constructor( + public readonly actorId: string, + public readonly releaseId: string, + public readonly notes: string | null, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index 679eaf6..e704b9a 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -25,6 +25,7 @@ import { ProjectUpdatedEvent, ProjectWorkflow, ReleaseArchivedEvent, + ReleaseNotesWrittenEvent, ReleaseCreatedEvent, ReleaseShippedEvent, ReleaseUpdatedEvent, @@ -191,6 +192,10 @@ export function describeEvent( return `${actor} actualizó la release ${ctx.releaseName}`; if (event instanceof ReleaseShippedEvent) return `${actor} publicó la release ${ctx.releaseName} 🚀`; + if (event instanceof ReleaseNotesWrittenEvent) + return event.notes + ? `${actor} escribió las notas de la release ${ctx.releaseName}` + : `${actor} quitó las notas de la release ${ctx.releaseName}`; if (event instanceof ReleaseArchivedEvent) return `${actor} archivó la release ${ctx.releaseName}`; const sprint = ctx.sprintName || 'un sprint'; diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts index a9a9b6c..8d3d2b2 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts @@ -23,6 +23,7 @@ import { ProjectMemberRoleChangedEvent, ProjectUpdatedEvent, ReleaseArchivedEvent, + ReleaseNotesWrittenEvent, IssueSprintChangedEvent, SprintClosedEvent, SprintCreatedEvent, @@ -77,6 +78,7 @@ import { versionOf } from './read-model-notifier'; ReleaseUpdatedEvent, ReleaseShippedEvent, ReleaseArchivedEvent, + ReleaseNotesWrittenEvent, IssueSprintChangedEvent, SprintCreatedEvent, SprintUpdatedEvent, diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index 3959aad..a101d8f 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -32,6 +32,7 @@ import { IssueParentChangedEventHandler } from './issue-parent-changed.event-han import { IssueChecklistEventHandler } from './issue-checklist.event-handler'; import { IssueRankedEventHandler } from './issue-ranked.event-handler'; import { SprintEventHandler } from './sprint.event-handler'; +import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler'; import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ @@ -69,6 +70,7 @@ export const EVENT_HANDLERS = [ IssueChecklistEventHandler, IssueRankedEventHandler, SprintEventHandler, + ReleaseNotesWrittenEventHandler, IssueSprintChangedEventHandler, ]; diff --git a/libs/api/tracker/application/src/lib/event-handlers/release-notes-written.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/release-notes-written.event-handler.ts new file mode 100644 index 0000000..edb442a --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/release-notes-written.event-handler.ts @@ -0,0 +1,23 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { ReleaseNotesWrittenEvent } from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(ReleaseNotesWrittenEvent) +export class ReleaseNotesWrittenEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: ReleaseNotesWrittenEvent) { + await this.upsertMaterializedReleaseRepository.upsert({ + id: event.aggregateId, + notes: event.notes, + version: versionOf(event), + }); + await this.notifier.release(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-release-notes-draft.query.ts b/libs/api/tracker/application/src/lib/queries/get-release-notes-draft.query.ts new file mode 100644 index 0000000..cd7b973 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-release-notes-draft.query.ts @@ -0,0 +1,60 @@ +import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; +import { + DEFAULT_PROJECT_SETTINGS, + ProjectWorkflow, +} from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { FindReleasesRepository } from '../ports/find-releases.repository'; +import { draftReleaseNotes } from '../releases/release-notes'; +import { ReadAccess } from './read-access'; + +/** A Markdown draft of a release's notes, from its finished tickets. */ +export class GetReleaseNotesDraftQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly releaseId: string, + ) { + super(); + } +} + +@QueryHandler(GetReleaseNotesDraftQuery) +export class GetReleaseNotesDraftQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findReleases: FindReleasesRepository, + private readonly findIssues: FindIssuesRepository, + private readonly findProjects: FindProjectsRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetReleaseNotesDraftQuery): Promise { + const release = this.access.assertFound( + await this.findReleases.findById(query.releaseId), + 'Release', + query.releaseId, + ); + await this.access.assertCanView(release.projectId, query.viewerId, { + entity: 'Release', + ref: query.releaseId, + }); + const [page, project] = await Promise.all([ + this.findIssues.search({ + projectId: release.projectId, + releaseId: release.id, + limit: 500, + offset: 0, + }), + this.findProjects.findById(release.projectId), + ]); + return draftReleaseNotes({ + release, + issues: page.items, + workflow: new ProjectWorkflow( + project?.settings ?? DEFAULT_PROJECT_SETTINGS, + ), + }); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index 2e151ee..acf447f 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -19,3 +19,4 @@ export * from './get-public-projects.query'; export * from './get-attachment.query'; export * from './get-epics.query'; export * from './sprint.queries'; +export * from './get-release-notes-draft.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index f2e60ea..c50508c 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -18,6 +18,7 @@ import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-h import { SearchIssuesQueryHandler } from './search-issues.query-handler'; import { GetEpicsQueryHandler } from './get-epics.query-handler'; import { SPRINT_QUERY_HANDLERS } from './sprint.queries'; +import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; export const QUERY_HANDLERS = [ @@ -40,6 +41,7 @@ export const QUERY_HANDLERS = [ SearchIssuesQueryHandler, GetEpicsQueryHandler, ...SPRINT_QUERY_HANDLERS, + GetReleaseNotesDraftQueryHandler, SearchMyIssuesQueryHandler, GetAttachmentQueryHandler, ]; diff --git a/libs/api/tracker/application/src/lib/releases.service.ts b/libs/api/tracker/application/src/lib/releases.service.ts index 67a3542..b14f2a4 100644 --- a/libs/api/tracker/application/src/lib/releases.service.ts +++ b/libs/api/tracker/application/src/lib/releases.service.ts @@ -10,6 +10,8 @@ import { CommandResult } from './commands/command-result'; import { CreateReleaseCommand } from './commands/create-release.command'; import { PublishReleaseCommand } from './commands/publish-release.command'; import { UpdateReleaseCommand } from './commands/update-release.command'; +import { WriteReleaseNotesCommand } from './commands/write-release-notes.command'; +import { GetReleaseNotesDraftQuery } from './queries/get-release-notes-draft.query'; import { GetReleaseProgressQuery } from './queries/get-release-progress.query'; import { GetReleaseQuery } from './queries/get-release.query'; import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query'; @@ -82,6 +84,24 @@ export class ReleasesService { return this.queryBus.execute(new GetUpcomingReleasesQuery(viewerId, limit)); } + /** Writes the notes (null removes them); also after the release ships. */ + writeNotes( + actorId: string, + releaseId: string, + notes: string | null, + ): Promise { + return this.commandBus.execute( + new WriteReleaseNotesCommand(actorId, releaseId, notes), + ); + } + + /** A Markdown draft from the release's finished tickets (not saved). */ + notesDraft(viewerId: string | null, releaseId: string): Promise { + return this.queryBus.execute( + new GetReleaseNotesDraftQuery(viewerId, releaseId), + ); + } + progress( viewerId: string | null, releaseId: string, diff --git a/libs/api/tracker/application/src/lib/releases/release-notes.spec.ts b/libs/api/tracker/application/src/lib/releases/release-notes.spec.ts new file mode 100644 index 0000000..4283ff5 --- /dev/null +++ b/libs/api/tracker/application/src/lib/releases/release-notes.spec.ts @@ -0,0 +1,58 @@ +import { + defaultProjectSettings, + IssueReadModel, + ProjectWorkflow, + ReleaseReadModel, + StatusCategory, +} from '@task-stream/api/tracker/domain'; +import { draftReleaseNotes } from './release-notes'; + +const issue = ( + number: number, + type: string, + title: string, + statusCategory = StatusCategory.DONE, +) => + ({ + number, + key: `WEB-${number}`, + type, + title, + statusCategory, + }) as IssueReadModel; + +describe('release notes draft', () => { + it('lists finished tickets by type, news first, QA left out', () => { + const notes = draftReleaseNotes({ + release: { + name: 'v1.4.0', + description: 'Pagos.', + targetDate: '2026-11-14', + releasedAt: null, + } as ReleaseReadModel, + issues: [ + issue(3, 'BUG', 'El botón de pagar no responde en Safari'), + issue(1, 'FEATURE', 'Pago con tarjeta'), + issue(2, 'QA', 'Plan de pruebas de pagos'), + issue(4, 'FEATURE', 'Recibo por correo', StatusCategory.IN_PROGRESS), + ], + workflow: new ProjectWorkflow(defaultProjectSettings()), + }); + expect(notes).toBe( + [ + '## v1.4.0 — 14 de noviembre de 2026', + '', + 'Pagos.', + '', + '### Correcciones', + '', + '- El botón de pagar no responde en Safari (WEB-3)', + '', + '### Novedades', + '', + '- Pago con tarjeta (WEB-1)', + '', + ].join('\n'), + ); + }); +}); diff --git a/libs/api/tracker/application/src/lib/releases/release-notes.ts b/libs/api/tracker/application/src/lib/releases/release-notes.ts new file mode 100644 index 0000000..0e0b065 --- /dev/null +++ b/libs/api/tracker/application/src/lib/releases/release-notes.ts @@ -0,0 +1,59 @@ +import { + IssueReadModel, + ProjectWorkflow, + ReleaseReadModel, + StatusCategory, +} from '@task-stream/api/tracker/domain'; + +/** How the default types are told in release notes; others use their name. */ +const HEADING: Record = { + FEATURE: 'Novedades', + UI_CHANGE: 'Cambios en la interfaz', + BUG: 'Correcciones', + TASK: 'Mejoras internas', +}; +/** Verification work is not news for whoever reads the notes. */ +const LEFT_OUT = new Set(['QA']); + +/** + * A first draft of a release's notes, in Markdown: its finished tickets + * grouped by type (in the project's order of types), newest version header + * first. Someone edits it before publishing it; it is never saved by itself. + */ +export function draftReleaseNotes(input: { + release: ReleaseReadModel; + issues: IssueReadModel[]; + workflow: ProjectWorkflow; +}): string { + const { release, issues, workflow } = input; + const done = issues.filter( + (i) => i.statusCategory === StatusCategory.DONE && !LEFT_OUT.has(i.type), + ); + const day = release.releasedAt ?? release.targetDate; + const date = day + ? ` — ${new Date(day).toLocaleDateString('es', { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + })}` + : ''; + const lines = [`## ${release.name}${date}`]; + if (release.description) lines.push('', release.description); + const order = workflow.settings.issueTypes.map((t) => t.id); + const types = [...new Set(done.map((i) => i.type))].sort( + (a, b) => + (order.indexOf(a) + 1 || order.length + 1) - + (order.indexOf(b) + 1 || order.length + 1), + ); + for (const type of types) { + lines.push('', `### ${HEADING[type] ?? workflow.typeName(type)}`, ''); + for (const issue of done + .filter((i) => i.type === type) + .sort((a, b) => a.number - b.number)) + lines.push(`- ${issue.title} (${issue.key})`); + } + if (!done.length) + lines.push('', '_Aún no hay tickets terminados en esta release._'); + return lines.join('\n') + '\n'; +} diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 530e0e9..5758522 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -37,3 +37,4 @@ export * from './sprint-started.event'; export * from './sprint-closed.event'; export * from './sprint-deleted.event'; export * from './issue-sprint-changed.event'; +export * from './release-notes-written.event'; diff --git a/libs/api/tracker/domain/src/lib/events/release-notes-written.event.ts b/libs/api/tracker/domain/src/lib/events/release-notes-written.event.ts new file mode 100644 index 0000000..a006a96 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/release-notes-written.event.ts @@ -0,0 +1,15 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** The release notes were written or rewritten (null: removed). */ +@AutowiredEvent +export class ReleaseNotesWrittenEvent extends TrackerEvent { + constructor( + actorId: string, + releaseId: string, + projectId: string, + readonly notes: string | null, + ) { + super(actorId, releaseId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts index b6c3bae..a5701f5 100644 --- a/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/release.read-model.ts @@ -8,6 +8,8 @@ export class ReleaseReadModel { targetDate!: string | null; status!: ReleaseStatus; releasedAt!: string | null; + /** Markdown, for people. */ + notes!: string | null; createdAt!: string; version!: number; } diff --git a/libs/api/tracker/domain/src/lib/release/release.spec.ts b/libs/api/tracker/domain/src/lib/release/release.spec.ts index b915843..2763df5 100644 --- a/libs/api/tracker/domain/src/lib/release/release.spec.ts +++ b/libs/api/tracker/domain/src/lib/release/release.spec.ts @@ -15,6 +15,19 @@ describe('Release', () => { expect(release.status).toBe(ReleaseStatus.RELEASED); }); + it('takes notes before and after shipping, not once archived', () => { + const release = newRelease(); + release.writeNotes('u', ' ## Novedades '); + release.ship('u', 0); + release.writeNotes('u', '## Novedades\n- Pago con tarjeta'); + release.writeNotes('u', '## Novedades\n- Pago con tarjeta'); + expect(replay(release, Release).rebuilt.notes).toBe( + '## Novedades\n- Pago con tarjeta', + ); + release.archive('u'); + expect(() => release.writeNotes('u', null)).toThrow(BusinessRuleError); + }); + it('is locked once shipped, also after being rebuilt', () => { const release = newRelease(); release.ship('u', 0); diff --git a/libs/api/tracker/domain/src/lib/release/release.ts b/libs/api/tracker/domain/src/lib/release/release.ts index b8efdd9..486477d 100644 --- a/libs/api/tracker/domain/src/lib/release/release.ts +++ b/libs/api/tracker/domain/src/lib/release/release.ts @@ -6,6 +6,7 @@ import { import { ReleaseArchivedEvent } from '../events/release-archived.event'; import { ReleaseCreatedEvent } from '../events/release-created.event'; import { ReleaseDeletedEvent } from '../events/release-deleted.event'; +import { ReleaseNotesWrittenEvent } from '../events/release-notes-written.event'; import { ReleaseShippedEvent } from '../events/release-shipped.event'; import { ReleaseUpdatedEvent } from '../events/release-updated.event'; import { ReleaseStatus } from './release-status'; @@ -22,6 +23,8 @@ export interface ReleaseProps { createdAt: string; } +export const MAX_RELEASE_NOTES = 20_000; + /** A version of the product: controls which tickets ship together. */ export class Release extends VersionedAggregateRoot implements ReleaseProps { public projectId!: string; @@ -30,6 +33,8 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps { public targetDate: string | null = null; public status: ReleaseStatus = ReleaseStatus.PLANNED; public releasedAt: string | null = null; + /** What shipped, told for people (Markdown). */ + public notes: string | null = null; public createdAt!: string; public deleted = false; @@ -109,6 +114,24 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps { ); } + /** + * Writes the notes. Unlike the rest of the release they can be written + * after it ships: that is usually when they are needed. Not once archived. + */ + writeNotes(actorId: string, notes: string | null): void { + if (this.status === ReleaseStatus.ARCHIVED) + throw new BusinessRuleError(`Release ${this.name} is archived`); + const next = notes?.trim() || null; + assertThat( + (next?.length ?? 0) <= MAX_RELEASE_NOTES, + `Release notes are at most ${MAX_RELEASE_NOTES} characters`, + ); + if (next === this.notes) return; + this.apply( + new ReleaseNotesWrittenEvent(actorId, this.id, this.projectId, next), + ); + } + archive(actorId: string): void { if (this.status === ReleaseStatus.ARCHIVED) return; this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId)); @@ -135,6 +158,10 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps { this.releasedAt = event.occurredAt; } + onReleaseNotesWrittenEvent(event: ReleaseNotesWrittenEvent): void { + this.notes = event.notes; + } + onReleaseArchivedEvent(): void { this.status = ReleaseStatus.ARCHIVED; } 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 842980e..87ca773 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts @@ -63,6 +63,29 @@ export class ReleaseResolver { return this.releases.progress(viewerOf(me), release.id); } + @ResolveField(() => String, { + description: + 'A Markdown draft of the notes from its finished tickets, by type (not saved)', + }) + notesDraft( + @CurrentUser() me: User | undefined, + @Parent() release: ReleaseReadModel, + ) { + return this.releases.notesDraft(viewerOf(me), release.id); + } + + @Mutation(() => CommandResultGql, { + description: + 'Write the release notes (Markdown; null removes them). Also after shipping.', + }) + writeReleaseNotes( + @CurrentUser() me: User, + @Args('releaseId', { type: () => ID }) releaseId: string, + @Args('notes', { type: () => String, nullable: true }) notes: string | null, + ) { + return this.releases.writeNotes(me.id, releaseId, notes ?? null); + } + @ResolveField(() => ReleaseBurnupGql, { description: 'Scope vs finished tickets, day by day', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 1e62936..409947b 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -285,6 +285,11 @@ export class ReleaseGql { @Field(() => String, { nullable: true }) targetDate!: string | null; @Field(() => ReleaseStatus) status!: ReleaseStatus; @Field(() => String, { nullable: true }) releasedAt!: string | null; + @Field(() => String, { + nullable: true, + description: 'What shipped, told for people (Markdown)', + }) + notes!: string | null; @Field() createdAt!: string; @Field(() => Int) version!: number; } 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 f367137..6612c78 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 @@ -857,6 +857,19 @@ export class CreateReleaseDto { export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {} +export class ReleaseNotesDto { + @ApiProperty({ + type: String, + nullable: true, + maxLength: 20000, + description: 'Markdown; null removes the notes', + }) + @ValidateIf((dto: ReleaseNotesDto) => dto.notes !== null) + @IsString() + @MaxLength(20000) + notes!: string | null; +} + const DAY = /^\d{4}-\d{2}-\d{2}$/; export class CreateSprintDto { 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 670d3d8..ab4ba2b 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 @@ -331,6 +331,12 @@ export class ReleaseDto { status!: ReleaseStatus; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) releasedAt!: string | null; + @ApiProperty({ + type: String, + nullable: true, + description: 'What shipped, told for people (Markdown)', + }) + notes!: string | null; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty() version!: number; } @@ -370,6 +376,13 @@ export class EpicDto { @ApiProperty({ type: ChildProgressDto }) progress!: ChildProgressDto; } +export class ReleaseNotesDraftDto { + @ApiProperty({ + example: '## v1.4.0\n\n### Novedades\n\n- Pago con tarjeta (WEB-1)\n', + }) + markdown!: string; +} + export class ReleaseProgressDto { @ApiProperty() total!: number; @ApiProperty() finished!: number; 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 5298da2..e59ff43 100644 --- a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts @@ -6,6 +6,7 @@ import { Param, Patch, Post, + Put, Query, } from '@nestjs/common'; import { @@ -34,6 +35,7 @@ import { CreateReleaseDto, PageQueryDto, PublishReleaseDto, + ReleaseNotesDto, UpcomingReleasesQueryDto, UpdateReleaseDto, } from './dto/requests.dto'; @@ -41,6 +43,7 @@ import { CommandResultDto, IssuePageDto, ReleaseDto, + ReleaseNotesDraftDto, ReleaseBurnupDto, ReleaseProgressDto, } from './dto/responses.dto'; @@ -194,6 +197,38 @@ export class ReleasesController { return this.releases.publish(me.id, releaseId, body.force); } + @Put('releases/:releaseId/notes') + @ApiOperation({ + summary: 'Write the release notes (Markdown)', + description: 'Also after the release ships; not once archived.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + writeNotes( + @CurrentUser() me: User, + @Param('releaseId') releaseId: string, + @Body() body: ReleaseNotesDto, + ) { + return this.releases.writeNotes(me.id, releaseId, body.notes); + } + + @Get('releases/:releaseId/notes/draft') + @ApiOptionalPireonAuth() + @ApiOperation({ + summary: 'A Markdown draft of the notes from the finished tickets', + description: 'Grouped by type; nothing is saved.', + }) + @ApiOkResponse({ type: ReleaseNotesDraftDto }) + @ApiReadErrors() + async notesDraft( + @CurrentUser() me: User | undefined, + @Param('releaseId') releaseId: string, + ) { + return { + markdown: await this.releases.notesDraft(viewerOf(me), releaseId), + }; + } + @Post('releases/:releaseId/archive') @HttpCode(200) @ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' }) 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 8551288..5068887 100644 --- a/libs/web/tracker/application/src/lib/events/project.events.ts +++ b/libs/web/tracker/application/src/lib/events/project.events.ts @@ -27,6 +27,10 @@ export const projectPageEvents = eventGroup({ releaseCreateRequested: type(), releasePublishRequested: type<{ releaseId: string; force: boolean }>(), releaseArchiveRequested: type<{ releaseId: string }>(), + releaseNotesWriteRequested: type<{ + releaseId: string; + notes: string | null; + }>(), sprintCreateRequested: type(), sprintUpdateRequested: type(), sprintStartRequested: type<{ sprintId: string }>(), diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index fa38508..414674c 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -64,6 +64,7 @@ function gateways() { }, list: { list: vi.fn(() => of([])), + writeNotes: vi.fn(() => of(ok)), add: vi.fn(() => of({ ...ok, id: 'c1' })), }, attachments: { @@ -252,6 +253,17 @@ describe('ProjectFacade', () => { expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null); }); + it('writes the notes of a release', () => { + const { project, fakes } = setup(); + project.open('WEB'); + project.writeReleaseNotes('r1', ' ## Novedades '); + project.writeReleaseNotes('r1', ' '); + expect(fakes.list.writeNotes.mock.calls).toEqual([ + ['r1', '## Novedades'], + ['r1', null], + ]); + }); + it('makes the project public and deletes it, then goes home', () => { const { project, fakes, navigate, notifications } = setup(); project.open('WEB'); 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 5203c21..df47b6c 100644 --- a/libs/web/tracker/application/src/lib/facades/project.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts @@ -5,9 +5,11 @@ import { CreateSprintInput, ProjectRole, ProjectSettings, + ReleasesGateway, SprintStatus, UpdateSprintInput, } from '@task-stream/web/tracker/domain'; +import { Observable } from 'rxjs'; import { projectPageEvents } from '../events/project.events'; import { ProjectStore } from '../stores/project.store'; @@ -19,6 +21,7 @@ import { ProjectStore } from '../stores/project.store'; export class ProjectFacade { private readonly store = inject(ProjectStore); private readonly dispatch = injectDispatch(projectPageEvents); + private readonly releasesGateway = inject(ReleasesGateway); readonly project = this.store.project; readonly members = this.store.members; @@ -128,6 +131,19 @@ export class ProjectFacade { this.dispatch.releasePublishRequested({ releaseId, force }); } + /** Writes the notes (null removes them); also after it ships. */ + writeReleaseNotes(releaseId: string, notes: string | null): void { + this.dispatch.releaseNotesWriteRequested({ + releaseId, + notes: notes?.trim() || null, + }); + } + + /** A Markdown draft from the release's finished tickets, to start from. */ + releaseNotesDraft(releaseId: string): Observable { + return this.releasesGateway.notesDraft(releaseId); + } + archiveRelease(releaseId: string): void { this.dispatch.releaseArchiveRequested({ releaseId }); } 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 9363e93..f2e8e11 100644 --- a/libs/web/tracker/application/src/lib/stores/project.store.ts +++ b/libs/web/tracker/application/src/lib/stores/project.store.ts @@ -505,6 +505,18 @@ export const ProjectStore = signalStore( ), ), ), + events + .on(projectPageEvents.releaseNotesWriteRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + releases.writeNotes(payload.releaseId, payload.notes), + payload.notes ? 'Notas guardadas' : 'Notas quitadas', + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), events .on(projectPageEvents.releaseArchiveRequested) .pipe( diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index f46a51f..3b77e23 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -206,6 +206,8 @@ export interface Release { targetDate: string | null; status: ReleaseStatus; releasedAt: string | null; + /** What shipped, told for people (Markdown). */ + notes: string | null; progress: { total: number; finished: number; percent: number }; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index caf78f8..09baff9 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -175,6 +175,13 @@ export abstract class ReleasesGateway { ): Observable; abstract archive(releaseId: string): Observable; abstract burnup(releaseId: string): Observable; + /** A Markdown draft from the release's finished tickets (not saved). */ + abstract notesDraft(releaseId: string): Observable; + /** null removes them. */ + abstract writeNotes( + releaseId: string, + notes: string | null, + ): Observable; } export abstract class SprintsGateway { 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 96d3923..52ea763 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 @@ -23,6 +23,8 @@ import { IssueCommentsDocument, PublishReleaseDocument, ReleaseBurnupDocument, + ReleaseNotesDraftDocument, + WriteReleaseNotesDocument, ReleasesDocument, UpcomingReleasesDocument, SearchUsersDocument, @@ -98,6 +100,21 @@ export class GraphqlReleasesGateway extends ReleasesGateway { .query(ReleaseBurnupDocument, { id: releaseId }) .pipe(map((d) => d.release.burnup)); } + + notesDraft(releaseId: string): Observable { + return this.gql + .query(ReleaseNotesDraftDocument, { id: releaseId }) + .pipe(map((d) => d.release.notesDraft)); + } + + writeNotes( + releaseId: string, + notes: string | null, + ): Observable { + return this.gql + .mutate(WriteReleaseNotesDocument, { releaseId, notes }) + .pipe(map((d) => toCommandResult(d.writeReleaseNotes))); + } } @Injectable() diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index d876665..06d35ea 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -140,6 +140,7 @@ fragment ReleaseFields on Release { targetDate status releasedAt + notes } fragment ActivityFields on Activity { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 8de4d1f..f9bb9da 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -368,6 +368,7 @@ export type ReleaseFieldsFragment = { targetDate: string | null; status: ReleaseStatus; releasedAt: string | null; + notes: string | null; }; export type ActivityFieldsFragment = { @@ -1148,6 +1149,7 @@ export type ReleasesQuery = { targetDate: string | null; status: ReleaseStatus; releasedAt: string | null; + notes: string | null; progress: { total: number; finished: number; percent: number }; }>; }; @@ -1208,6 +1210,7 @@ export type UpcomingReleasesQuery = { targetDate: string | null; status: ReleaseStatus; releasedAt: string | null; + notes: string | null; progress: { total: number; finished: number; percent: number }; }>; }; @@ -1229,6 +1232,23 @@ export type ReleaseBurnupQuery = { }; }; +export type ReleaseNotesDraftQueryVariables = Exact<{ + id: string; +}>; + +export type ReleaseNotesDraftQuery = { + release: { id: string; notesDraft: string }; +}; + +export type WriteReleaseNotesMutationVariables = Exact<{ + releaseId: string; + notes?: string | null | undefined; +}>; + +export type WriteReleaseNotesMutation = { + writeReleaseNotes: { id: string; key: string | null; version: number }; +}; + export type SavedFilterFieldsFragment = { id: string; projectId: string; @@ -1489,6 +1509,7 @@ export type ReleaseChangedSubscription = { targetDate: string | null; status: ReleaseStatus; releasedAt: string | null; + notes: string | null; }; }; }; @@ -2119,6 +2140,7 @@ export const ReleaseFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'targetDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'notes' } }, ], }, }, @@ -7298,6 +7320,7 @@ export const ReleasesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'targetDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'notes' } }, ], }, }, @@ -7741,6 +7764,7 @@ export const UpcomingReleasesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'targetDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'notes' } }, ], }, }, @@ -7814,6 +7838,141 @@ export const ReleaseBurnupDocument = { }, ], } as unknown as DocumentNode; +export const ReleaseNotesDraftDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'ReleaseNotesDraft' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'notesDraft' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + ReleaseNotesDraftQuery, + ReleaseNotesDraftQueryVariables +>; +export const WriteReleaseNotesDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'WriteReleaseNotes' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'releaseId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'notes' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'writeReleaseNotes' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'releaseId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'releaseId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'notes' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'notes' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + WriteReleaseNotesMutation, + WriteReleaseNotesMutationVariables +>; export const SavedFiltersDocument = { kind: 'Document', definitions: [ @@ -9123,6 +9282,7 @@ export const ReleaseChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'targetDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'notes' } }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql index f20131f..488c317 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql @@ -59,3 +59,16 @@ query ReleaseBurnup($id: ID!) { } } } + +query ReleaseNotesDraft($id: ID!) { + release(id: $id) { + id + notesDraft + } +} + +mutation WriteReleaseNotes($releaseId: ID!, $notes: String) { + writeReleaseNotes(releaseId: $releaseId, notes: $notes) { + ...CommandResultFields + } +} 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 a6ef7d9..6698a37 100644 --- a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, inject, signal, TemplateRef, @@ -15,7 +16,8 @@ import { PiTextareaComponent, PiTextFieldComponent, } from '@pi/ui'; -import { DrawerService } from '@task-stream/web/shared/ui'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DrawerService, ToastService } from '@task-stream/web/shared/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain'; import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; @@ -83,6 +85,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; {{ release.progress.percent }}% + @if (release.notes) { +
+ Notas de la versión +
{{ release.notes }}
+
+ }
Ver en el tablero → + @if (release.notes) { + + } + @if (facade.canManage() && release.status !== 'ARCHIVED') { + + } @if (facade.canManage() && release.status === 'PLANNED') { + +
+

+ Lo que trae la versión, contado para quien la usa (Markdown). El + borrador sale de los tickets terminados, agrupados por tipo. +

+ + + Notas + + + +
+
`, styles: ` + .ts-release__notes { + margin-top: 0.25rem; + summary { + cursor: pointer; + font-size: 0.875rem; + color: var(--ts-muted); + } + } + .ts-release__notes-text { + margin: 0.5rem 0 0; + white-space: pre-wrap; + font: inherit; + font-size: 0.875rem; + } /* A timeline: one node per version on a vertical rail. */ .ts-releases { position: relative; @@ -276,6 +349,15 @@ export class ReleasesPage { private readonly drawer = inject(DrawerService); private readonly createForm = viewChild.required>('createForm'); + private readonly notesForm = + viewChild.required>('notesForm'); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + + /* Release notes */ + private writing: Release | null = null; + protected readonly notes = signal(''); + protected readonly drafting = signal(false); private readonly model = signal({ name: '', @@ -327,6 +409,52 @@ export class ReleasesPage { this.facade.publishRelease(release.id); } + protected openNotes(release: Release): void { + this.writing = release; + this.notes.set(release.notes ?? ''); + this.drawer.open(`Notas de ${release.name}`, this.notesForm(), '40rem'); + if (!release.notes) this.draftNotes(); + } + + protected draftNotes(): void { + if (!this.writing) return; + // Replacing what someone wrote needs a yes. + if ( + this.notes().trim() && + this.notes() !== this.writing.notes && + !confirm('¿Sustituir lo escrito por el borrador?') + ) + return; + this.drafting.set(true); + this.facade + .releaseNotesDraft(this.writing.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (draft) => { + this.notes.set(draft); + this.drafting.set(false); + }, + error: () => { + this.drafting.set(false); + this.toast.error('No se pudo generar el borrador'); + }, + }); + } + + protected saveNotes(): void { + if (!this.writing) return; + this.facade.writeReleaseNotes(this.writing.id, this.notes()); + this.writing = null; + this.drawer.closeAll(); + } + + protected copyNotes(release: Release): void { + navigator.clipboard + ?.writeText(release.notes ?? '') + .then(() => this.toast.success('Notas copiadas')) + .catch(() => this.toast.error('No se pudieron copiar')); + } + protected archive(release: Release): void { this.facade.archiveRelease(release.id); }