feat: release notes

Each release can carry notes in Markdown, written before or after it
ships. A draft is generated from its finished tickets grouped by type, to
edit and copy from the releases page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:19:23 +02:00
parent 45a3dc1e16
commit 24b37b6628
40 changed files with 1251 additions and 31 deletions

View File

@@ -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": { "/api/releases/{releaseId}/archive": {
"post": { "post": {
"operationId": "ReleasesController_archive", "operationId": "ReleasesController_archive",
@@ -9481,6 +9672,11 @@
"nullable": true, "nullable": true,
"format": "date-time" "format": "date-time"
}, },
"notes": {
"type": "string",
"nullable": true,
"description": "What shipped, told for people (Markdown)"
},
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@@ -9497,6 +9693,7 @@
"targetDate", "targetDate",
"status", "status",
"releasedAt", "releasedAt",
"notes",
"createdAt", "createdAt",
"version" "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": { "Object": {
"type": "object", "type": "object",
"properties": {} "properties": {}

View File

@@ -496,6 +496,11 @@ type Mutation {
Follow a ticket: be told when it moves, is reassigned or gets a comment Follow a ticket: be told when it moves, is reassigned or gets a comment
""" """
watchIssue(issueId: ID!): CommandResult! watchIssue(issueId: ID!): CommandResult!
"""
Write the release notes (Markdown; null removes them). Also after shipping.
"""
writeReleaseNotes(notes: String, releaseId: ID!): CommandResult!
} }
type MyNotifications { type MyNotifications {
@@ -743,6 +748,14 @@ type Release {
"""Tickets included in this release""" """Tickets included in this release"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage! issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String! 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! progress: ReleaseProgress!
projectId: ID! projectId: ID!
releasedAt: String releasedAt: String

View File

@@ -782,6 +782,46 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/releases/{releaseId}/archive": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1737,6 +1777,8 @@ export interface components {
status: components["schemas"]["ReleaseStatus"]; status: components["schemas"]["ReleaseStatus"];
/** Format: date-time */ /** Format: date-time */
releasedAt: string | null; releasedAt: string | null;
/** @description What shipped, told for people (Markdown) */
notes: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
version: number; version: number;
@@ -1784,6 +1826,20 @@ export interface components {
*/ */
force: boolean; 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<string, never>; Object: Record<string, never>;
/** @enum {string} */ /** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; 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: { ReleasesController_archive: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -2,33 +2,34 @@
## Qué resuelve ## Qué resuelve
| Necesidad | Cómo se resuelve | | Necesidad | Cómo se resuelve |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | | 🐛 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 | | 🌍 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 | | 🗑️ 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 | | 🧩 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`) | | 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🧪 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) | | 🚀 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 | | 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 🔎 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 | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
| 🚦 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 | | 🔎 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 |
| 🎲 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 | | 🚦 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 |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 🎲 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 |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
| 📎 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 | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 📎 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 |
| 🏠 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 | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | | 🏠 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 |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | | 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | | 📈 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** 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 ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por

View File

@@ -142,6 +142,9 @@ stateDiagram-v2
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se - **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). 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`. - **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. - **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`. - **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 - **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` | | Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | | Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` | | 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 `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. 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` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions ### 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}` | | 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` | | 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}` | | 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) | | 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` | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |

View File

@@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler'; import { WatchIssueCommandHandler } from './watch-issue.command-handler';
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler'; import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
import { RankIssueCommandHandler } from './rank-issue.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 { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import { import {
@@ -72,6 +73,7 @@ export const COMMAND_HANDLERS = [
WatchIssueCommandHandler, WatchIssueCommandHandler,
SetIssueParentCommandHandler, SetIssueParentCommandHandler,
RankIssueCommandHandler, RankIssueCommandHandler,
WriteReleaseNotesCommandHandler,
...SPRINT_COMMAND_HANDLERS, ...SPRINT_COMMAND_HANDLERS,
AddChecklistItemCommandHandler, AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler, UpdateChecklistItemCommandHandler,

View File

@@ -34,3 +34,4 @@ export * from './detach-from-parent.command';
export * from './change-checklist.command'; export * from './change-checklist.command';
export * from './rank-issue.command'; export * from './rank-issue.command';
export * from './sprint.commands'; export * from './sprint.commands';
export * from './write-release-notes.command';

View File

@@ -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<WriteReleaseNotesCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: WriteReleaseNotesCommand): Promise<CommandResult> {
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);
}
}

View File

@@ -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<CommandResult> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
public readonly notes: string | null,
) {
super();
}
}

View File

@@ -25,6 +25,7 @@ import {
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectWorkflow, ProjectWorkflow,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
ReleaseCreatedEvent, ReleaseCreatedEvent,
ReleaseShippedEvent, ReleaseShippedEvent,
ReleaseUpdatedEvent, ReleaseUpdatedEvent,
@@ -191,6 +192,10 @@ export function describeEvent(
return `${actor} actualizó la release ${ctx.releaseName}`; return `${actor} actualizó la release ${ctx.releaseName}`;
if (event instanceof ReleaseShippedEvent) if (event instanceof ReleaseShippedEvent)
return `${actor} publicó la release ${ctx.releaseName} 🚀`; 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) if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`; return `${actor} archivó la release ${ctx.releaseName}`;
const sprint = ctx.sprintName || 'un sprint'; const sprint = ctx.sprintName || 'un sprint';

View File

@@ -23,6 +23,7 @@ import {
ProjectMemberRoleChangedEvent, ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
IssueSprintChangedEvent, IssueSprintChangedEvent,
SprintClosedEvent, SprintClosedEvent,
SprintCreatedEvent, SprintCreatedEvent,
@@ -77,6 +78,7 @@ import { versionOf } from './read-model-notifier';
ReleaseUpdatedEvent, ReleaseUpdatedEvent,
ReleaseShippedEvent, ReleaseShippedEvent,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
IssueSprintChangedEvent, IssueSprintChangedEvent,
SprintCreatedEvent, SprintCreatedEvent,
SprintUpdatedEvent, SprintUpdatedEvent,

View File

@@ -32,6 +32,7 @@ import { IssueParentChangedEventHandler } from './issue-parent-changed.event-han
import { IssueChecklistEventHandler } from './issue-checklist.event-handler'; import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler'; import { IssueRankedEventHandler } from './issue-ranked.event-handler';
import { SprintEventHandler } from './sprint.event-handler'; import { SprintEventHandler } from './sprint.event-handler';
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler'; import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
@@ -69,6 +70,7 @@ export const EVENT_HANDLERS = [
IssueChecklistEventHandler, IssueChecklistEventHandler,
IssueRankedEventHandler, IssueRankedEventHandler,
SprintEventHandler, SprintEventHandler,
ReleaseNotesWrittenEventHandler,
IssueSprintChangedEventHandler, IssueSprintChangedEventHandler,
]; ];

View File

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

View File

@@ -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<string> {
constructor(
public readonly viewerId: string | null,
public readonly releaseId: string,
) {
super();
}
}
@QueryHandler(GetReleaseNotesDraftQuery)
export class GetReleaseNotesDraftQueryHandler
implements IQueryHandler<GetReleaseNotesDraftQuery, string>
{
constructor(
private readonly findReleases: FindReleasesRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetReleaseNotesDraftQuery): Promise<string> {
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,
),
});
}
}

View File

@@ -19,3 +19,4 @@ export * from './get-public-projects.query';
export * from './get-attachment.query'; export * from './get-attachment.query';
export * from './get-epics.query'; export * from './get-epics.query';
export * from './sprint.queries'; export * from './sprint.queries';
export * from './get-release-notes-draft.query';

View File

@@ -18,6 +18,7 @@ import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-h
import { SearchIssuesQueryHandler } from './search-issues.query-handler'; import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { GetEpicsQueryHandler } from './get-epics.query-handler'; import { GetEpicsQueryHandler } from './get-epics.query-handler';
import { SPRINT_QUERY_HANDLERS } from './sprint.queries'; import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [ export const QUERY_HANDLERS = [
@@ -40,6 +41,7 @@ export const QUERY_HANDLERS = [
SearchIssuesQueryHandler, SearchIssuesQueryHandler,
GetEpicsQueryHandler, GetEpicsQueryHandler,
...SPRINT_QUERY_HANDLERS, ...SPRINT_QUERY_HANDLERS,
GetReleaseNotesDraftQueryHandler,
SearchMyIssuesQueryHandler, SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler, GetAttachmentQueryHandler,
]; ];

View File

@@ -10,6 +10,8 @@ import { CommandResult } from './commands/command-result';
import { CreateReleaseCommand } from './commands/create-release.command'; import { CreateReleaseCommand } from './commands/create-release.command';
import { PublishReleaseCommand } from './commands/publish-release.command'; import { PublishReleaseCommand } from './commands/publish-release.command';
import { UpdateReleaseCommand } from './commands/update-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 { GetReleaseProgressQuery } from './queries/get-release-progress.query';
import { GetReleaseQuery } from './queries/get-release.query'; import { GetReleaseQuery } from './queries/get-release.query';
import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query'; import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query';
@@ -82,6 +84,24 @@ export class ReleasesService {
return this.queryBus.execute(new GetUpcomingReleasesQuery(viewerId, limit)); 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<CommandResult> {
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<string> {
return this.queryBus.execute(
new GetReleaseNotesDraftQuery(viewerId, releaseId),
);
}
progress( progress(
viewerId: string | null, viewerId: string | null,
releaseId: string, releaseId: string,

View File

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

View File

@@ -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<string, string> = {
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';
}

View File

@@ -37,3 +37,4 @@ export * from './sprint-started.event';
export * from './sprint-closed.event'; export * from './sprint-closed.event';
export * from './sprint-deleted.event'; export * from './sprint-deleted.event';
export * from './issue-sprint-changed.event'; export * from './issue-sprint-changed.event';
export * from './release-notes-written.event';

View File

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

View File

@@ -8,6 +8,8 @@ export class ReleaseReadModel {
targetDate!: string | null; targetDate!: string | null;
status!: ReleaseStatus; status!: ReleaseStatus;
releasedAt!: string | null; releasedAt!: string | null;
/** Markdown, for people. */
notes!: string | null;
createdAt!: string; createdAt!: string;
version!: number; version!: number;
} }

View File

@@ -15,6 +15,19 @@ describe('Release', () => {
expect(release.status).toBe(ReleaseStatus.RELEASED); 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', () => { it('is locked once shipped, also after being rebuilt', () => {
const release = newRelease(); const release = newRelease();
release.ship('u', 0); release.ship('u', 0);

View File

@@ -6,6 +6,7 @@ import {
import { ReleaseArchivedEvent } from '../events/release-archived.event'; import { ReleaseArchivedEvent } from '../events/release-archived.event';
import { ReleaseCreatedEvent } from '../events/release-created.event'; import { ReleaseCreatedEvent } from '../events/release-created.event';
import { ReleaseDeletedEvent } from '../events/release-deleted.event'; import { ReleaseDeletedEvent } from '../events/release-deleted.event';
import { ReleaseNotesWrittenEvent } from '../events/release-notes-written.event';
import { ReleaseShippedEvent } from '../events/release-shipped.event'; import { ReleaseShippedEvent } from '../events/release-shipped.event';
import { ReleaseUpdatedEvent } from '../events/release-updated.event'; import { ReleaseUpdatedEvent } from '../events/release-updated.event';
import { ReleaseStatus } from './release-status'; import { ReleaseStatus } from './release-status';
@@ -22,6 +23,8 @@ export interface ReleaseProps {
createdAt: string; createdAt: string;
} }
export const MAX_RELEASE_NOTES = 20_000;
/** A version of the product: controls which tickets ship together. */ /** A version of the product: controls which tickets ship together. */
export class Release extends VersionedAggregateRoot implements ReleaseProps { export class Release extends VersionedAggregateRoot implements ReleaseProps {
public projectId!: string; public projectId!: string;
@@ -30,6 +33,8 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
public targetDate: string | null = null; public targetDate: string | null = null;
public status: ReleaseStatus = ReleaseStatus.PLANNED; public status: ReleaseStatus = ReleaseStatus.PLANNED;
public releasedAt: string | null = null; public releasedAt: string | null = null;
/** What shipped, told for people (Markdown). */
public notes: string | null = null;
public createdAt!: string; public createdAt!: string;
public deleted = false; 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 { archive(actorId: string): void {
if (this.status === ReleaseStatus.ARCHIVED) return; if (this.status === ReleaseStatus.ARCHIVED) return;
this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId)); this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId));
@@ -135,6 +158,10 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
this.releasedAt = event.occurredAt; this.releasedAt = event.occurredAt;
} }
onReleaseNotesWrittenEvent(event: ReleaseNotesWrittenEvent): void {
this.notes = event.notes;
}
onReleaseArchivedEvent(): void { onReleaseArchivedEvent(): void {
this.status = ReleaseStatus.ARCHIVED; this.status = ReleaseStatus.ARCHIVED;
} }

View File

@@ -63,6 +63,29 @@ export class ReleaseResolver {
return this.releases.progress(viewerOf(me), release.id); 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, { @ResolveField(() => ReleaseBurnupGql, {
description: 'Scope vs finished tickets, day by day', description: 'Scope vs finished tickets, day by day',
}) })

View File

@@ -285,6 +285,11 @@ export class ReleaseGql {
@Field(() => String, { nullable: true }) targetDate!: string | null; @Field(() => String, { nullable: true }) targetDate!: string | null;
@Field(() => ReleaseStatus) status!: ReleaseStatus; @Field(() => ReleaseStatus) status!: ReleaseStatus;
@Field(() => String, { nullable: true }) releasedAt!: string | null; @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() createdAt!: string;
@Field(() => Int) version!: number; @Field(() => Int) version!: number;
} }

View File

@@ -857,6 +857,19 @@ export class CreateReleaseDto {
export class UpdateReleaseDto extends PartialType(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}$/; const DAY = /^\d{4}-\d{2}-\d{2}$/;
export class CreateSprintDto { export class CreateSprintDto {

View File

@@ -331,6 +331,12 @@ export class ReleaseDto {
status!: ReleaseStatus; status!: ReleaseStatus;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
releasedAt!: string | null; 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({ format: 'date-time' }) createdAt!: string;
@ApiProperty() version!: number; @ApiProperty() version!: number;
} }
@@ -370,6 +376,13 @@ export class EpicDto {
@ApiProperty({ type: ChildProgressDto }) progress!: ChildProgressDto; @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 { export class ReleaseProgressDto {
@ApiProperty() total!: number; @ApiProperty() total!: number;
@ApiProperty() finished!: number; @ApiProperty() finished!: number;

View File

@@ -6,6 +6,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@@ -34,6 +35,7 @@ import {
CreateReleaseDto, CreateReleaseDto,
PageQueryDto, PageQueryDto,
PublishReleaseDto, PublishReleaseDto,
ReleaseNotesDto,
UpcomingReleasesQueryDto, UpcomingReleasesQueryDto,
UpdateReleaseDto, UpdateReleaseDto,
} from './dto/requests.dto'; } from './dto/requests.dto';
@@ -41,6 +43,7 @@ import {
CommandResultDto, CommandResultDto,
IssuePageDto, IssuePageDto,
ReleaseDto, ReleaseDto,
ReleaseNotesDraftDto,
ReleaseBurnupDto, ReleaseBurnupDto,
ReleaseProgressDto, ReleaseProgressDto,
} from './dto/responses.dto'; } from './dto/responses.dto';
@@ -194,6 +197,38 @@ export class ReleasesController {
return this.releases.publish(me.id, releaseId, body.force); 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') @Post('releases/:releaseId/archive')
@HttpCode(200) @HttpCode(200)
@ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' }) @ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' })

View File

@@ -27,6 +27,10 @@ export const projectPageEvents = eventGroup({
releaseCreateRequested: type<CreateReleaseInput>(), releaseCreateRequested: type<CreateReleaseInput>(),
releasePublishRequested: type<{ releaseId: string; force: boolean }>(), releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(), releaseArchiveRequested: type<{ releaseId: string }>(),
releaseNotesWriteRequested: type<{
releaseId: string;
notes: string | null;
}>(),
sprintCreateRequested: type<CreateSprintInput>(), sprintCreateRequested: type<CreateSprintInput>(),
sprintUpdateRequested: type<UpdateSprintInput>(), sprintUpdateRequested: type<UpdateSprintInput>(),
sprintStartRequested: type<{ sprintId: string }>(), sprintStartRequested: type<{ sprintId: string }>(),

View File

@@ -64,6 +64,7 @@ function gateways() {
}, },
list: { list: {
list: vi.fn(() => of([])), list: vi.fn(() => of([])),
writeNotes: vi.fn(() => of(ok)),
add: vi.fn(() => of({ ...ok, id: 'c1' })), add: vi.fn(() => of({ ...ok, id: 'c1' })),
}, },
attachments: { attachments: {
@@ -252,6 +253,17 @@ describe('ProjectFacade', () => {
expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null); 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', () => { it('makes the project public and deletes it, then goes home', () => {
const { project, fakes, navigate, notifications } = setup(); const { project, fakes, navigate, notifications } = setup();
project.open('WEB'); project.open('WEB');

View File

@@ -5,9 +5,11 @@ import {
CreateSprintInput, CreateSprintInput,
ProjectRole, ProjectRole,
ProjectSettings, ProjectSettings,
ReleasesGateway,
SprintStatus, SprintStatus,
UpdateSprintInput, UpdateSprintInput,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
import { projectPageEvents } from '../events/project.events'; import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store'; import { ProjectStore } from '../stores/project.store';
@@ -19,6 +21,7 @@ import { ProjectStore } from '../stores/project.store';
export class ProjectFacade { export class ProjectFacade {
private readonly store = inject(ProjectStore); private readonly store = inject(ProjectStore);
private readonly dispatch = injectDispatch(projectPageEvents); private readonly dispatch = injectDispatch(projectPageEvents);
private readonly releasesGateway = inject(ReleasesGateway);
readonly project = this.store.project; readonly project = this.store.project;
readonly members = this.store.members; readonly members = this.store.members;
@@ -128,6 +131,19 @@ export class ProjectFacade {
this.dispatch.releasePublishRequested({ releaseId, force }); 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<string> {
return this.releasesGateway.notesDraft(releaseId);
}
archiveRelease(releaseId: string): void { archiveRelease(releaseId: string): void {
this.dispatch.releaseArchiveRequested({ releaseId }); this.dispatch.releaseArchiveRequested({ releaseId });
} }

View File

@@ -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 events
.on(projectPageEvents.releaseArchiveRequested) .on(projectPageEvents.releaseArchiveRequested)
.pipe( .pipe(

View File

@@ -206,6 +206,8 @@ export interface Release {
targetDate: string | null; targetDate: string | null;
status: ReleaseStatus; status: ReleaseStatus;
releasedAt: string | null; releasedAt: string | null;
/** What shipped, told for people (Markdown). */
notes: string | null;
progress: { total: number; finished: number; percent: number }; progress: { total: number; finished: number; percent: number };
} }

View File

@@ -175,6 +175,13 @@ export abstract class ReleasesGateway {
): Observable<CommandResult>; ): Observable<CommandResult>;
abstract archive(releaseId: string): Observable<CommandResult>; abstract archive(releaseId: string): Observable<CommandResult>;
abstract burnup(releaseId: string): Observable<ReleaseBurnup>; abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
/** A Markdown draft from the release's finished tickets (not saved). */
abstract notesDraft(releaseId: string): Observable<string>;
/** null removes them. */
abstract writeNotes(
releaseId: string,
notes: string | null,
): Observable<CommandResult>;
} }
export abstract class SprintsGateway { export abstract class SprintsGateway {

View File

@@ -23,6 +23,8 @@ import {
IssueCommentsDocument, IssueCommentsDocument,
PublishReleaseDocument, PublishReleaseDocument,
ReleaseBurnupDocument, ReleaseBurnupDocument,
ReleaseNotesDraftDocument,
WriteReleaseNotesDocument,
ReleasesDocument, ReleasesDocument,
UpcomingReleasesDocument, UpcomingReleasesDocument,
SearchUsersDocument, SearchUsersDocument,
@@ -98,6 +100,21 @@ export class GraphqlReleasesGateway extends ReleasesGateway {
.query(ReleaseBurnupDocument, { id: releaseId }) .query(ReleaseBurnupDocument, { id: releaseId })
.pipe(map((d) => d.release.burnup)); .pipe(map((d) => d.release.burnup));
} }
notesDraft(releaseId: string): Observable<string> {
return this.gql
.query(ReleaseNotesDraftDocument, { id: releaseId })
.pipe(map((d) => d.release.notesDraft));
}
writeNotes(
releaseId: string,
notes: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(WriteReleaseNotesDocument, { releaseId, notes })
.pipe(map((d) => toCommandResult(d.writeReleaseNotes)));
}
} }
@Injectable() @Injectable()

View File

@@ -140,6 +140,7 @@ fragment ReleaseFields on Release {
targetDate targetDate
status status
releasedAt releasedAt
notes
} }
fragment ActivityFields on Activity { fragment ActivityFields on Activity {

View File

@@ -368,6 +368,7 @@ export type ReleaseFieldsFragment = {
targetDate: string | null; targetDate: string | null;
status: ReleaseStatus; status: ReleaseStatus;
releasedAt: string | null; releasedAt: string | null;
notes: string | null;
}; };
export type ActivityFieldsFragment = { export type ActivityFieldsFragment = {
@@ -1148,6 +1149,7 @@ export type ReleasesQuery = {
targetDate: string | null; targetDate: string | null;
status: ReleaseStatus; status: ReleaseStatus;
releasedAt: string | null; releasedAt: string | null;
notes: string | null;
progress: { total: number; finished: number; percent: number }; progress: { total: number; finished: number; percent: number };
}>; }>;
}; };
@@ -1208,6 +1210,7 @@ export type UpcomingReleasesQuery = {
targetDate: string | null; targetDate: string | null;
status: ReleaseStatus; status: ReleaseStatus;
releasedAt: string | null; releasedAt: string | null;
notes: string | null;
progress: { total: number; finished: number; percent: number }; 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 = { export type SavedFilterFieldsFragment = {
id: string; id: string;
projectId: string; projectId: string;
@@ -1489,6 +1509,7 @@ export type ReleaseChangedSubscription = {
targetDate: string | null; targetDate: string | null;
status: ReleaseStatus; status: ReleaseStatus;
releasedAt: string | null; 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: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, { 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: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, { 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: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, { 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<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>; } as unknown as DocumentNode<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>;
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 = { export const SavedFiltersDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -9123,6 +9282,7 @@ export const ReleaseChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'targetDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'notes' } },
], ],
}, },
}, },

View File

@@ -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
}
}

View File

@@ -1,6 +1,7 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
DestroyRef,
inject, inject,
signal, signal,
TemplateRef, TemplateRef,
@@ -15,7 +16,8 @@ import {
PiTextareaComponent, PiTextareaComponent,
PiTextFieldComponent, PiTextFieldComponent,
} from '@pi/ui'; } 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 { ProjectFacade } from '@task-stream/web/tracker/application';
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain'; import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
import { RELEASE_STATUS, releaseWhen } from '../ui/copy'; import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
@@ -83,6 +85,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
{{ release.progress.percent }}%</span {{ release.progress.percent }}%</span
> >
</div> </div>
@if (release.notes) {
<details class="ts-release__notes">
<summary>Notas de la versión</summary>
<pre class="ts-release__notes-text">{{ release.notes }}</pre>
</details>
}
<div class="ts-release__actions"> <div class="ts-release__actions">
<a <a
class="ts-release__link" class="ts-release__link"
@@ -91,6 +99,24 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
>Ver en el tablero →</a >Ver en el tablero →</a
> >
<span class="ts-spacer"></span> <span class="ts-spacer"></span>
@if (release.notes) {
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Copiar notas"
(execute)="copyNotes(release)"
/>
}
@if (facade.canManage() && release.status !== 'ARCHIVED') {
<pi-action-button
size="small"
appearance="outline"
color="grey"
[text]="release.notes ? 'Editar notas' : 'Escribir notas'"
(execute)="openNotes(release)"
/>
}
@if (facade.canManage() && release.status === 'PLANNED') { @if (facade.canManage() && release.status === 'PLANNED') {
<pi-action-button <pi-action-button
size="small" size="small"
@@ -118,6 +144,39 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
} }
</ol> </ol>
<ng-template #notesForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveNotes()"
novalidate
>
<p class="ts-muted">
Lo que trae la versión, contado para quien la usa (Markdown). El
borrador sale de los tickets terminados, agrupados por tipo.
</p>
<pi-action-button
size="small"
appearance="outline"
color="grey"
[text]="drafting() ? 'Generando…' : 'Generar borrador'"
[disabled]="drafting()"
(execute)="draftNotes()"
/>
<pi-textarea
placeholder="## Novedades&#10;- …"
[(value)]="notes"
>
<pi-label>Notas</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
text="Guardar notas"
(execute)="saveNotes()"
/>
</form>
</ng-template>
<ng-template #createForm> <ng-template #createForm>
<form <form
class="ts-form" class="ts-form"
@@ -144,6 +203,20 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
</ng-template> </ng-template>
`, `,
styles: ` 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. */ /* A timeline: one node per version on a vertical rail. */
.ts-releases { .ts-releases {
position: relative; position: relative;
@@ -276,6 +349,15 @@ export class ReleasesPage {
private readonly drawer = inject(DrawerService); private readonly drawer = inject(DrawerService);
private readonly createForm = private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm'); viewChild.required<TemplateRef<unknown>>('createForm');
private readonly notesForm =
viewChild.required<TemplateRef<unknown>>('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({ private readonly model = signal({
name: '', name: '',
@@ -327,6 +409,52 @@ export class ReleasesPage {
this.facade.publishRelease(release.id); 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 { protected archive(release: Release): void {
this.facade.archiveRelease(release.id); this.facade.archiveRelease(release.id);
} }