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:
@@ -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": {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## 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 |
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
| ⚙️ 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) |
|
||||||
|
| 📝 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 |
|
| 👥 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 |
|
| 💬 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 |
|
| 🔎 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 |
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)' })
|
||||||
|
|||||||
@@ -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 }>(),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ fragment ReleaseFields on Release {
|
|||||||
targetDate
|
targetDate
|
||||||
status
|
status
|
||||||
releasedAt
|
releasedAt
|
||||||
|
notes
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ActivityFields on Activity {
|
fragment ActivityFields on Activity {
|
||||||
|
|||||||
@@ -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' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 - …"
|
||||||
|
[(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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user