feat: move unfinished tickets between releases
- Bulk editing gets «Release…» (open releases or none), and the list view a release filter, also reachable from each release («Ver en la lista», ?release=). - Archiving a release asks where its unfinished tickets go, as closing a sprint does: ReleaseArchivedEvent carries `moveTo` (an open release of the same project, or none) and the saga moves them there; finished ones stay. In GraphQL, REST and the MCP's archive_release. - Chapter 10 lists the next features and the ideas to weigh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6572,6 +6572,7 @@
|
|||||||
},
|
},
|
||||||
"/api/releases/{releaseId}/archive": {
|
"/api/releases/{releaseId}/archive": {
|
||||||
"post": {
|
"post": {
|
||||||
|
"description": "Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.",
|
||||||
"operationId": "ReleasesController_archive",
|
"operationId": "ReleasesController_archive",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -6583,6 +6584,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ArchiveReleaseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -6663,7 +6674,7 @@
|
|||||||
"pireon": []
|
"pireon": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "Archive a planned release (its tickets leave it)",
|
"summary": "Archive a planned release",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Releases"
|
"Releases"
|
||||||
]
|
]
|
||||||
@@ -12342,6 +12353,17 @@
|
|||||||
"markdown"
|
"markdown"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ArchiveReleaseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"moveTo": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Open release of the same project that takes its unfinished tickets; null or absent: they leave it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ShowcaseProjectDto": {
|
"ShowcaseProjectDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -648,7 +648,13 @@ type Mutation {
|
|||||||
|
|
||||||
"""Add a Pireon user to the project"""
|
"""Add a Pireon user to the project"""
|
||||||
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
||||||
archiveRelease(releaseId: ID!): CommandResult!
|
archiveRelease(
|
||||||
|
"""
|
||||||
|
Open release of the same project that takes its unfinished tickets; none: they leave it
|
||||||
|
"""
|
||||||
|
moveTo: ID
|
||||||
|
releaseId: ID!
|
||||||
|
): CommandResult!
|
||||||
|
|
||||||
"""Assign (or unassign with null) a project member"""
|
"""Assign (or unassign with null) a project member"""
|
||||||
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
|
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
|
||||||
|
|||||||
@@ -950,7 +950,10 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Archive a planned release (its tickets leave it) */
|
/**
|
||||||
|
* Archive a planned release
|
||||||
|
* @description Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.
|
||||||
|
*/
|
||||||
post: operations["ReleasesController_archive"];
|
post: operations["ReleasesController_archive"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -2332,6 +2335,13 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
markdown: string;
|
markdown: string;
|
||||||
};
|
};
|
||||||
|
ArchiveReleaseDto: {
|
||||||
|
/**
|
||||||
|
* Format: uuid
|
||||||
|
* @description Open release of the same project that takes its unfinished tickets; null or absent: they leave it
|
||||||
|
*/
|
||||||
|
moveTo?: string | null;
|
||||||
|
};
|
||||||
ShowcaseProjectDto: {
|
ShowcaseProjectDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7225,7 +7235,11 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ArchiveReleaseDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -188,15 +188,26 @@ export class ReleasesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'archive_release',
|
name: 'archive_release',
|
||||||
description:
|
description:
|
||||||
'Archive a planned release that will not ship; its tickets leave it.',
|
'Archive a planned release that will not ship. Its unfinished tickets move to moveTo (another open release of the project, from list_releases) or, without it, leave it.',
|
||||||
paramsSchema: z.object({ releaseId }),
|
paramsSchema: z.object({
|
||||||
|
releaseId,
|
||||||
|
moveTo: z
|
||||||
|
.string()
|
||||||
|
.uuid()
|
||||||
|
.optional()
|
||||||
|
.describe('Open release that takes the unfinished tickets'),
|
||||||
|
}),
|
||||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
})
|
})
|
||||||
archive({ releaseId }: { releaseId: string }, ctx: McpContext) {
|
archive(
|
||||||
|
{ releaseId, moveTo }: { releaseId: string; moveTo?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
return toolResult(() =>
|
return toolResult(() =>
|
||||||
this.api.data(
|
this.api.data(
|
||||||
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
|
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
|
||||||
params: { path: { releaseId } },
|
params: { path: { releaseId } },
|
||||||
|
body: { moveTo: moveTo ?? null },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
|
|||||||
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
|
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
|
||||||
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
||||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar, a la release elegida al archivar o a ninguna (los terminados se quedan) |
|
||||||
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
||||||
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
||||||
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
|
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
|
||||||
|
|||||||
@@ -214,6 +214,9 @@ stateDiagram-v2
|
|||||||
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
|
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
|
||||||
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
|
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
|
||||||
la respuesta dice cuáles.
|
la respuesta dice cuáles.
|
||||||
|
- **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`).
|
||||||
|
Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release;
|
||||||
|
los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`.
|
||||||
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
|
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
|
||||||
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
|
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
|
||||||
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
|
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ flowchart LR
|
|||||||
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
||||||
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
||||||
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||||
| | `archive_release` | Archivar una release que no saldrá (sus tickets la dejan) |
|
| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja |
|
||||||
|
|
||||||
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
|
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
|
||||||
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
|
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
|
||||||
|
|||||||
@@ -28,17 +28,51 @@ cuando hay pistas, por dónde empezar.
|
|||||||
|
|
||||||
## 10.3 Siguientes funcionalidades
|
## 10.3 Siguientes funcionalidades
|
||||||
|
|
||||||
En orden de lo que más aportaría:
|
### Elegidas para la próxima tanda
|
||||||
|
|
||||||
1. **Asistente en la web** (la base ya está en el MCP):
|
En este orden:
|
||||||
- resumir un ticket largo con sus comentarios;
|
|
||||||
- al crear un ticket, sugerir tipo, prioridad y área, y avisar si se parece a otro que ya existe;
|
1. **Calendario suscribible (ICS)**: una URL privada por persona con las fechas límite, los sprints y las releases de
|
||||||
- redactar las notas de versión en lenguaje natural a partir del borrador.
|
sus proyectos, para Google Calendar, Outlook o Apple Calendar. La URL lleva un token propio (revocable), no el de
|
||||||
2. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran
|
sesión.
|
||||||
bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana
|
2. **Notificaciones push (Web Push)**: los avisos de la campana como aviso del sistema, aunque la pestaña esté
|
||||||
movió WEB-12 a En revisión», con enlace.
|
cerrada. Un _service worker_ en la web (que la deja instalable como PWA), «Activar avisos» en los ajustes de
|
||||||
3. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de
|
notificaciones, las suscripciones de cada persona guardadas en la API y el envío con Web Push (claves VAPID, dos
|
||||||
autorización y admitir el registro dinámico de clientes.
|
variables nuevas). En iPhone solo con la web instalada en la pantalla de inicio (iOS 16.4+).
|
||||||
|
3. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de
|
||||||
|
opciones, definidos en Configuración; editables en la ficha, filtrables y como columna en la vista de lista.
|
||||||
|
|
||||||
|
### Valoradas para después
|
||||||
|
|
||||||
|
4. **Dependencias en la hoja de ruta**: las relaciones «bloquea» / «depende de» entre épicas, como flechas en la vista
|
||||||
|
de calendario, para ver qué retrasa a qué.
|
||||||
|
5. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
|
||||||
|
planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`).
|
||||||
|
6. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas,
|
||||||
|
automatizaciones y etiquetas.
|
||||||
|
7. **Sincronizar con issues de Gitea o GitHub**: hoy los commits y PR se enlazan a tickets; además, que un issue del
|
||||||
|
repositorio cree o actualice un ticket, y al revés.
|
||||||
|
8. **Vistas guardadas en «Mi trabajo»**: montar la portada con filtros guardados de varios proyectos («Bugs críticos de
|
||||||
|
todos mis proyectos», «Lo que revisa QA esta semana»…).
|
||||||
|
9. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
|
||||||
|
para archivar.
|
||||||
|
10. **Asistente en la web** (la base ya está en el MCP): resumir un ticket largo con sus comentarios; al crear un
|
||||||
|
ticket, sugerir tipo, prioridad y área y avisar si se parece a otro; redactar las notas de versión a partir del
|
||||||
|
borrador. Pendiente de decidir la clave de la API de Claude y su coste.
|
||||||
|
11. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran
|
||||||
|
bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana
|
||||||
|
movió WEB-12 a En revisión», con enlace.
|
||||||
|
12. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de
|
||||||
|
autorización y admitir el registro dinámico de clientes.
|
||||||
|
|
||||||
|
### Otras ideas apuntadas, sin decidir
|
||||||
|
|
||||||
|
- **Resumen diario por correo**: lo asignado, lo que vence hoy y lo que ha cambiado en lo que sigues, en un solo correo.
|
||||||
|
- **Tickets recurrentes**: «revisar copias cada lunes», creados solos con la plantilla del tipo.
|
||||||
|
- **Crear tickets por correo**: una dirección por proyecto; lo que llega es un ticket (con adjuntos) y las respuestas,
|
||||||
|
comentarios. Necesita un buzón de entrada.
|
||||||
|
- **Tiempo en cada estado**: cuánto pasa un ticket en «En revisión» o «En QA», y dónde se atascan.
|
||||||
|
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
||||||
|
|
||||||
## 10.4 Descartado por ahora
|
## 10.4 Descartado por ahora
|
||||||
|
|
||||||
@@ -68,3 +102,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
- Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del
|
- Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del
|
||||||
dominio más el `_id` `ObjectId` propio.
|
dominio más el `_id` `ObjectId` propio.
|
||||||
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
|
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
|
||||||
|
- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada
|
||||||
|
release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -11,10 +11,12 @@ export class ArchiveReleaseCommandHandler
|
|||||||
|
|
||||||
async execute(command: ArchiveReleaseCommand): Promise<CommandResult> {
|
async execute(command: ArchiveReleaseCommand): Promise<CommandResult> {
|
||||||
const release = await this.access.release(command.releaseId);
|
const release = await this.access.release(command.releaseId);
|
||||||
(await this.access.project(release.projectId)).assertCanManage(
|
const project = await this.access.project(release.projectId);
|
||||||
command.actorId,
|
project.assertCanManage(command.actorId);
|
||||||
);
|
// The tickets can only go to an open release of the same project.
|
||||||
release.archive(command.actorId);
|
if (command.moveTo && command.moveTo !== release.id)
|
||||||
|
await this.access.openReleaseOf(project, command.moveTo);
|
||||||
|
release.archive(command.actorId, command.moveTo);
|
||||||
await release.commitAndWait();
|
await release.commitAndWait();
|
||||||
return ack(release);
|
return ack(release);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export class ArchiveReleaseCommand extends Command<CommandResult> {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly actorId: string,
|
public readonly actorId: string,
|
||||||
public readonly releaseId: string,
|
public readonly releaseId: string,
|
||||||
|
/** Open release of the same project for its unfinished tickets. */
|
||||||
|
public readonly moveTo: string | null = null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,21 @@ export class DetachIssueFromReleaseCommandHandler
|
|||||||
const project = await this.access.live(issue.projectId, Project);
|
const project = await this.access.live(issue.projectId, Project);
|
||||||
// Finished tickets stay: they are what the release shipped.
|
// Finished tickets stay: they are what the release shipped.
|
||||||
if (!project || project.workflow.isFinished(issue.status)) return;
|
if (!project || project.workflow.isFinished(issue.status)) return;
|
||||||
issue.setRelease(command.actorId, null);
|
issue.setRelease(command.actorId, await this.target(project, command));
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The release chosen when archiving, if it still takes tickets. */
|
||||||
|
private async target(
|
||||||
|
project: Project,
|
||||||
|
command: DetachIssueFromReleaseCommand,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!command.toReleaseId) return null;
|
||||||
|
try {
|
||||||
|
await this.access.openReleaseOf(project, command.toReleaseId);
|
||||||
|
return command.toReleaseId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Command } from '@nestjs/cqrs';
|
import { Command } from '@nestjs/cqrs';
|
||||||
|
|
||||||
/** Saga only: takes an unfinished ticket out of an archived release. */
|
/**
|
||||||
|
* Saga only: takes an unfinished ticket out of an archived release, to
|
||||||
|
* `toReleaseId` or to none.
|
||||||
|
*/
|
||||||
export class DetachIssueFromReleaseCommand extends Command<void> {
|
export class DetachIssueFromReleaseCommand extends Command<void> {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly actorId: string,
|
public readonly actorId: string,
|
||||||
public readonly issueId: string,
|
public readonly issueId: string,
|
||||||
public readonly releaseId: string,
|
public readonly releaseId: string,
|
||||||
|
public readonly toReleaseId: string | null = null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,14 @@ export class ReleasesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
archive(actorId: string, releaseId: string): Promise<CommandResult> {
|
/** @param moveTo open release for its unfinished tickets (none: they leave it) */
|
||||||
|
archive(
|
||||||
|
actorId: string,
|
||||||
|
releaseId: string,
|
||||||
|
moveTo: string | null = null,
|
||||||
|
): Promise<CommandResult> {
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
new ArchiveReleaseCommand(actorId, releaseId),
|
new ArchiveReleaseCommand(actorId, releaseId, moveTo),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* An archived release no longer accepts changes, so its unfinished tickets
|
* An archived release no longer accepts changes, so its unfinished tickets
|
||||||
* would be stuck in it: they leave it (back to "no release"), attributed to
|
* would be stuck in it: they move to the release chosen when archiving it,
|
||||||
* whoever archived it. Finished tickets stay, as the record of what shipped.
|
* or back to "no release", attributed to whoever archived it. Finished
|
||||||
|
* tickets stay, as the record of what shipped.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReleaseArchivedSaga {
|
export class ReleaseArchivedSaga {
|
||||||
@@ -27,6 +28,7 @@ export class ReleaseArchivedSaga {
|
|||||||
event.actorId,
|
event.actorId,
|
||||||
issueId,
|
issueId,
|
||||||
event.aggregateId,
|
event.aggregateId,
|
||||||
|
event.moveTo ?? null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -306,6 +306,45 @@ describe('sagas', () => {
|
|||||||
expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(release);
|
expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(release);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('archiving a release can hand its unfinished tickets to another', async () => {
|
||||||
|
const { run, drain, rehydrator } = await setup();
|
||||||
|
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||||
|
const old = (
|
||||||
|
await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
|
||||||
|
).id;
|
||||||
|
const next = (
|
||||||
|
await run(new CreateReleaseCommand('owner', web, 'v2', null, null))
|
||||||
|
).id;
|
||||||
|
const open = (
|
||||||
|
await run(
|
||||||
|
new CreateIssueCommand('owner', web, 'TASK', 'open', {}, null, old),
|
||||||
|
)
|
||||||
|
).id;
|
||||||
|
const done = (
|
||||||
|
await run(
|
||||||
|
new CreateIssueCommand('owner', web, 'TASK', 'done', {}, null, old),
|
||||||
|
)
|
||||||
|
).id;
|
||||||
|
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
|
||||||
|
await run(new ChangeIssueStatusCommand('owner', done, status));
|
||||||
|
|
||||||
|
// Only an open release of the same project takes them.
|
||||||
|
const other = (await run(new CreateProjectCommand('owner', 'API', 'Api')))
|
||||||
|
.id;
|
||||||
|
const elsewhere = (
|
||||||
|
await run(new CreateReleaseCommand('owner', other, 'v9', null, null))
|
||||||
|
).id;
|
||||||
|
await expect(
|
||||||
|
run(new ArchiveReleaseCommand('owner', old, elsewhere)),
|
||||||
|
).rejects.toThrow(/another project/);
|
||||||
|
|
||||||
|
await run(new ArchiveReleaseCommand('owner', old, next));
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect((await rehydrator.rehydrate(open, Issue)).releaseId).toBe(next);
|
||||||
|
expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(old);
|
||||||
|
});
|
||||||
|
|
||||||
it('a user deactivated in Pireon leaves their open tickets unassigned', async () => {
|
it('a user deactivated in Pireon leaves their open tickets unassigned', async () => {
|
||||||
const { run, react, rehydrator, openAssigned } = await setup();
|
const { run, react, rehydrator, openAssigned } = await setup();
|
||||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
import { TrackerEvent } from './tracker.event';
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
/** The release was archived. */
|
/**
|
||||||
|
* The release was archived. Its unfinished tickets move to `moveTo` (an
|
||||||
|
* open release of the same project) or, when null, leave it. Events stored
|
||||||
|
* before `moveTo` existed read as null.
|
||||||
|
*/
|
||||||
@AutowiredEvent
|
@AutowiredEvent
|
||||||
export class ReleaseArchivedEvent extends TrackerEvent {
|
export class ReleaseArchivedEvent extends TrackerEvent {
|
||||||
constructor(actorId: string, releaseId: string, projectId: string) {
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
releaseId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly moveTo: string | null = null,
|
||||||
|
) {
|
||||||
super(actorId, releaseId, projectId);
|
super(actorId, releaseId, projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,9 +132,13 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
archive(actorId: string): void {
|
/** @param moveTo where its unfinished tickets go (null: no release) */
|
||||||
|
archive(actorId: string, moveTo: string | null = null): void {
|
||||||
if (this.status === ReleaseStatus.ARCHIVED) return;
|
if (this.status === ReleaseStatus.ARCHIVED) return;
|
||||||
this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId));
|
assertThat(moveTo !== this.id, 'A release cannot take its own tickets');
|
||||||
|
this.apply(
|
||||||
|
new ReleaseArchivedEvent(actorId, this.id, this.projectId, moveTo),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes the release (its project was deleted). Idempotent. */
|
/** Deletes the release (its project was deleted). Idempotent. */
|
||||||
|
|||||||
@@ -146,7 +146,14 @@ export class ReleaseResolver {
|
|||||||
archiveRelease(
|
archiveRelease(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
@Args('releaseId', { type: () => ID }) releaseId: string,
|
@Args('releaseId', { type: () => ID }) releaseId: string,
|
||||||
|
@Args('moveTo', {
|
||||||
|
type: () => ID,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Open release of the same project that takes its unfinished tickets; none: they leave it',
|
||||||
|
})
|
||||||
|
moveTo?: string | null,
|
||||||
) {
|
) {
|
||||||
return this.releases.archive(me.id, releaseId);
|
return this.releases.archive(me.id, releaseId, moveTo ?? null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1148,6 +1148,19 @@ export class SetIssueSprintDto {
|
|||||||
sprintId!: string | null;
|
sprintId!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ArchiveReleaseDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Open release of the same project that takes its unfinished tickets; null or absent: they leave it',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
moveTo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class PublishReleaseDto {
|
export class PublishReleaseDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CreateReleaseDto,
|
CreateReleaseDto,
|
||||||
PageQueryDto,
|
PageQueryDto,
|
||||||
|
ArchiveReleaseDto,
|
||||||
PublishReleaseDto,
|
PublishReleaseDto,
|
||||||
ReleaseNotesDto,
|
ReleaseNotesDto,
|
||||||
UpcomingReleasesQueryDto,
|
UpcomingReleasesQueryDto,
|
||||||
@@ -234,10 +235,18 @@ export class ReleasesController {
|
|||||||
|
|
||||||
@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',
|
||||||
|
description:
|
||||||
|
'Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.',
|
||||||
|
})
|
||||||
@ApiOkResponse({ type: CommandResultDto })
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
@ApiCommandErrors()
|
@ApiCommandErrors()
|
||||||
archive(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
|
archive(
|
||||||
return this.releases.archive(me.id, releaseId);
|
@CurrentUser() me: User,
|
||||||
|
@Param('releaseId') releaseId: string,
|
||||||
|
@Body() body: ArchiveReleaseDto,
|
||||||
|
) {
|
||||||
|
return this.releases.archive(me.id, releaseId, body?.moveTo ?? null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export const projectPageEvents = eventGroup({
|
|||||||
memberRemoveRequested: type<{ projectId: string; userId: string }>(),
|
memberRemoveRequested: type<{ projectId: string; userId: string }>(),
|
||||||
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;
|
||||||
|
moveTo: string | null;
|
||||||
|
}>(),
|
||||||
releaseNotesWriteRequested: type<{
|
releaseNotesWriteRequested: type<{
|
||||||
releaseId: string;
|
releaseId: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
|||||||
@@ -144,8 +144,9 @@ export class ProjectFacade {
|
|||||||
return this.releasesGateway.notesDraft(releaseId);
|
return this.releasesGateway.notesDraft(releaseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveRelease(releaseId: string): void {
|
/** Its unfinished tickets go to `moveTo` (an open release) or leave it. */
|
||||||
this.dispatch.releaseArchiveRequested({ releaseId });
|
archiveRelease(releaseId: string, moveTo: string | null = null): void {
|
||||||
|
this.dispatch.releaseArchiveRequested({ releaseId, moveTo });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces statuses, transitions, types and areas (owners and admins). */
|
/** Replaces statuses, transitions, types and areas (owners and admins). */
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ export const ProjectStore = signalStore(
|
|||||||
.pipe(
|
.pipe(
|
||||||
mergeMap(({ payload }) =>
|
mergeMap(({ payload }) =>
|
||||||
runCommand(
|
runCommand(
|
||||||
releases.archive(payload.releaseId),
|
releases.archive(payload.releaseId, payload.moveTo),
|
||||||
'Release archivada',
|
'Release archivada',
|
||||||
projectApiEvents.commandSucceeded,
|
projectApiEvents.commandSucceeded,
|
||||||
projectApiEvents.commandFailed,
|
projectApiEvents.commandFailed,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface IssueListFilter {
|
|||||||
priorities?: IssuePriority[];
|
priorities?: IssuePriority[];
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
|
releaseId?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +135,8 @@ export interface BulkChange {
|
|||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
/** null: back to the backlog. */
|
/** null: back to the backlog. */
|
||||||
sprintId?: string | null;
|
sprintId?: string | null;
|
||||||
|
/** null: out of its release. */
|
||||||
|
releaseId?: string | null;
|
||||||
priority?: IssuePriority;
|
priority?: IssuePriority;
|
||||||
addLabels?: string[];
|
addLabels?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,11 @@ export abstract class ReleasesGateway {
|
|||||||
releaseId: string,
|
releaseId: string,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
): Observable<CommandResult>;
|
): Observable<CommandResult>;
|
||||||
abstract archive(releaseId: string): Observable<CommandResult>;
|
/** Its unfinished tickets go to `moveTo` (an open release) or leave it. */
|
||||||
|
abstract archive(
|
||||||
|
releaseId: string,
|
||||||
|
moveTo?: string | null,
|
||||||
|
): 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). */
|
/** A Markdown draft from the release's finished tickets (not saved). */
|
||||||
abstract notesDraft(releaseId: string): Observable<string>;
|
abstract notesDraft(releaseId: string): Observable<string>;
|
||||||
|
|||||||
@@ -104,9 +104,12 @@ export class GraphqlReleasesGateway extends ReleasesGateway {
|
|||||||
.pipe(map((d) => toCommandResult(d.publishRelease)));
|
.pipe(map((d) => toCommandResult(d.publishRelease)));
|
||||||
}
|
}
|
||||||
|
|
||||||
archive(releaseId: string): Observable<CommandResult> {
|
archive(
|
||||||
|
releaseId: string,
|
||||||
|
moveTo: string | null = null,
|
||||||
|
): Observable<CommandResult> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(ArchiveReleaseDocument, { releaseId })
|
.mutate(ArchiveReleaseDocument, { releaseId, moveTo })
|
||||||
.pipe(map((d) => toCommandResult(d.archiveRelease)));
|
.pipe(map((d) => toCommandResult(d.archiveRelease)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
assigneeId: change.assigneeId ?? null,
|
assigneeId: change.assigneeId ?? null,
|
||||||
noSprint: change.sprintId === null ? true : null,
|
noSprint: change.sprintId === null ? true : null,
|
||||||
sprintId: change.sprintId ?? null,
|
sprintId: change.sprintId ?? null,
|
||||||
|
noRelease: change.releaseId === null ? true : null,
|
||||||
|
releaseId: change.releaseId ?? null,
|
||||||
priority: (change.priority ?? null) as never,
|
priority: (change.priority ?? null) as never,
|
||||||
addLabels: change.addLabels ?? null,
|
addLabels: change.addLabels ?? null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1676,6 +1676,7 @@ export type PublishReleaseMutation = {
|
|||||||
|
|
||||||
export type ArchiveReleaseMutationVariables = Exact<{
|
export type ArchiveReleaseMutationVariables = Exact<{
|
||||||
releaseId: string;
|
releaseId: string;
|
||||||
|
moveTo?: string | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ArchiveReleaseMutation = {
|
export type ArchiveReleaseMutation = {
|
||||||
@@ -10488,6 +10489,14 @@ export const ArchiveReleaseDocument = {
|
|||||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'moveTo' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
@@ -10504,6 +10513,14 @@ export const ArchiveReleaseDocument = {
|
|||||||
name: { kind: 'Name', value: 'releaseId' },
|
name: { kind: 'Name', value: 'releaseId' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'moveTo' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'moveTo' },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ mutation PublishRelease($releaseId: ID!, $force: Boolean!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation ArchiveRelease($releaseId: ID!) {
|
mutation ArchiveRelease($releaseId: ID!, $moveTo: ID) {
|
||||||
archiveRelease(releaseId: $releaseId) {
|
archiveRelease(releaseId: $releaseId, moveTo: $moveTo) {
|
||||||
...CommandResultFields
|
...CommandResultFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
input,
|
||||||
signal,
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -105,6 +106,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
|||||||
[options]="assigneeFilterOptions()"
|
[options]="assigneeFilterOptions()"
|
||||||
[(value)]="assignee"
|
[(value)]="assignee"
|
||||||
/>
|
/>
|
||||||
|
<pi-select-field
|
||||||
|
[options]="releaseFilterOptions()"
|
||||||
|
[(value)]="releaseFilter"
|
||||||
|
/>
|
||||||
@if (facade.loaded()) {
|
@if (facade.loaded()) {
|
||||||
<span class="ts-muted ts-mono ts-list__count">
|
<span class="ts-muted ts-mono ts-list__count">
|
||||||
{{ facade.total() }} tickets{{
|
{{ facade.total() }} tickets{{
|
||||||
@@ -430,6 +435,8 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ListPage {
|
export class ListPage {
|
||||||
|
/** `?release=<id>`: opens already filtered by that release. */
|
||||||
|
readonly release = input<string | undefined>(undefined);
|
||||||
protected readonly facade = inject(ListFacade);
|
protected readonly facade = inject(ListFacade);
|
||||||
protected readonly project = inject(ProjectFacade);
|
protected readonly project = inject(ProjectFacade);
|
||||||
protected readonly NOBODY = NOBODY;
|
protected readonly NOBODY = NOBODY;
|
||||||
@@ -455,6 +462,11 @@ export class ListPage {
|
|||||||
protected readonly type = signal<string | null>(ALL);
|
protected readonly type = signal<string | null>(ALL);
|
||||||
protected readonly priority = signal<string | null>(ALL);
|
protected readonly priority = signal<string | null>(ALL);
|
||||||
protected readonly assignee = signal<string | null>(ALL);
|
protected readonly assignee = signal<string | null>(ALL);
|
||||||
|
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||||
|
private readonly releaseFromUrl = effect(() => {
|
||||||
|
const id = this.release();
|
||||||
|
if (id) untracked(() => this.releaseFilter.set(id));
|
||||||
|
});
|
||||||
protected readonly scopeOptions: PiSelectFieldOption<string>[] = [
|
protected readonly scopeOptions: PiSelectFieldOption<string>[] = [
|
||||||
{ value: 'open', label: 'Abiertos' },
|
{ value: 'open', label: 'Abiertos' },
|
||||||
{ value: 'closed', label: 'Terminados o cancelados' },
|
{ value: 'closed', label: 'Terminados o cancelados' },
|
||||||
@@ -483,6 +495,24 @@ export class ListPage {
|
|||||||
.members()
|
.members()
|
||||||
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||||
]);
|
]);
|
||||||
|
/** Planned ones first: those are the ones tickets move between. */
|
||||||
|
protected readonly releaseFilterOptions = computed<
|
||||||
|
PiSelectFieldOption<string>[]
|
||||||
|
>(() => [
|
||||||
|
{ value: ALL, label: 'Todas las releases' },
|
||||||
|
...[...this.project.releases()]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.status === 'PLANNED') - Number(a.status === 'PLANNED'),
|
||||||
|
)
|
||||||
|
.map((r) => ({
|
||||||
|
value: r.id,
|
||||||
|
label:
|
||||||
|
r.status === 'PLANNED'
|
||||||
|
? r.name
|
||||||
|
: `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
private readonly criteria = computed<ListCriteria>(() => {
|
private readonly criteria = computed<ListCriteria>(() => {
|
||||||
const scope = this.scope();
|
const scope = this.scope();
|
||||||
const assignee = this.assignee();
|
const assignee = this.assignee();
|
||||||
@@ -496,6 +526,7 @@ export class ListPage {
|
|||||||
: undefined,
|
: undefined,
|
||||||
unassigned: assignee === NOBODY || undefined,
|
unassigned: assignee === NOBODY || undefined,
|
||||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||||
|
releaseId: this.releaseFilter() || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiEmptyStateComponent,
|
PiEmptyStateComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
|
PiSelectFieldComponent,
|
||||||
|
PiSelectFieldOption,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
@@ -23,6 +25,9 @@ import { ProjectFacade } from '@task-stream/web/tracker/application';
|
|||||||
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
|
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
|
||||||
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||||
|
|
||||||
|
/** The «no release» choice of a select (an empty value). */
|
||||||
|
const NO_RELEASE = '';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-releases-page',
|
selector: 'ts-releases-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -33,6 +38,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
|||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiEmptyStateComponent,
|
PiEmptyStateComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
|
PiSelectFieldComponent,
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
],
|
],
|
||||||
@@ -103,6 +109,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
|||||||
[queryParams]="{ release: release.id }"
|
[queryParams]="{ release: release.id }"
|
||||||
>Ver en el tablero →</a
|
>Ver en el tablero →</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
class="ts-release__link"
|
||||||
|
[routerLink]="['../list']"
|
||||||
|
[queryParams]="{ release: release.id }"
|
||||||
|
>Ver en la lista →</a
|
||||||
|
>
|
||||||
<span class="ts-spacer"></span>
|
<span class="ts-spacer"></span>
|
||||||
@if (release.notes) {
|
@if (release.notes) {
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
@@ -179,6 +191,28 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
|||||||
</form>
|
</form>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #archiveForm>
|
||||||
|
<form
|
||||||
|
class="ts-form"
|
||||||
|
(submit)="$event.preventDefault(); confirmArchive()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<p class="ts-muted">
|
||||||
|
{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en
|
||||||
|
esta release como registro de lo hecho.
|
||||||
|
</p>
|
||||||
|
<pi-select-field [options]="moveToOptions()" [(value)]="moveTo">
|
||||||
|
<pi-label>Lo pendiente va a</pi-label>
|
||||||
|
</pi-select-field>
|
||||||
|
<pi-action-button
|
||||||
|
size="w100"
|
||||||
|
color="primary"
|
||||||
|
text="Archivar release"
|
||||||
|
(execute)="confirmArchive()"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #createForm>
|
<ng-template #createForm>
|
||||||
<form
|
<form
|
||||||
class="ts-form"
|
class="ts-form"
|
||||||
@@ -353,6 +387,8 @@ export class ReleasesPage {
|
|||||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||||
private readonly notesForm =
|
private readonly notesForm =
|
||||||
viewChild.required<TemplateRef<unknown>>('notesForm');
|
viewChild.required<TemplateRef<unknown>>('notesForm');
|
||||||
|
private readonly archiveForm =
|
||||||
|
viewChild.required<TemplateRef<unknown>>('archiveForm');
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
@@ -457,7 +493,44 @@ export class ReleasesPage {
|
|||||||
.catch(() => this.toast.error('No se pudieron copiar'));
|
.catch(() => this.toast.error('No se pudieron copiar'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Archive: where its unfinished tickets go, as when closing a sprint. */
|
||||||
|
private archiving: Release | null = null;
|
||||||
|
protected readonly unfinished = signal(0);
|
||||||
|
protected readonly moveTo = signal<string | null>(NO_RELEASE);
|
||||||
|
protected readonly moveToOptions = signal<PiSelectFieldOption<string>[]>([]);
|
||||||
|
|
||||||
protected archive(release: Release): void {
|
protected archive(release: Release): void {
|
||||||
this.facade.archiveRelease(release.id);
|
this.archiving = release;
|
||||||
|
// The next one first: the nearest target date, then the oldest planned
|
||||||
|
// (the list comes newest first).
|
||||||
|
const open = this.facade
|
||||||
|
.releases()
|
||||||
|
.filter((r) => r.status === ReleaseStatus.PLANNED && r.id !== release.id)
|
||||||
|
.map((r, i) => ({ r, i }))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.r.targetDate ?? '9999').localeCompare(b.r.targetDate ?? '9999') ||
|
||||||
|
b.i - a.i,
|
||||||
|
)
|
||||||
|
.map(({ r }) => r);
|
||||||
|
this.unfinished.set(release.progress.total - release.progress.finished);
|
||||||
|
this.moveToOptions.set([
|
||||||
|
{ value: NO_RELEASE, label: 'Ninguna release' },
|
||||||
|
...open.map((r) => ({ value: r.id, label: r.name })),
|
||||||
|
]);
|
||||||
|
this.moveTo.set(open[0]?.id ?? NO_RELEASE);
|
||||||
|
// Nothing to place: no need to ask.
|
||||||
|
if (this.unfinished() === 0) {
|
||||||
|
this.confirmArchive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.drawer.open(`Archivar ${release.name}`, this.archiveForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected confirmArchive(): void {
|
||||||
|
if (!this.archiving) return;
|
||||||
|
this.facade.archiveRelease(this.archiving.id, this.moveTo() || null);
|
||||||
|
this.archiving = null;
|
||||||
|
this.drawer.closeAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
type PiSelectFieldOption,
|
type PiSelectFieldOption,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||||
import { BulkChange, IssuePriority } from '@task-stream/web/tracker/domain';
|
import {
|
||||||
|
BulkChange,
|
||||||
|
IssuePriority,
|
||||||
|
ReleaseStatus,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { options, PRIORITY } from './copy';
|
import { options, PRIORITY } from './copy';
|
||||||
|
|
||||||
/** "Nobody" / "no sprint" in the selects. */
|
/** "Nobody" / "no sprint" in the selects. */
|
||||||
@@ -62,6 +66,9 @@ const NOBODY = '__none__';
|
|||||||
@case ('sprint') {
|
@case ('sprint') {
|
||||||
<pi-select-field [options]="sprintOptions()" [(value)]="value" />
|
<pi-select-field [options]="sprintOptions()" [(value)]="value" />
|
||||||
}
|
}
|
||||||
|
@case ('release') {
|
||||||
|
<pi-select-field [options]="releaseOptions()" [(value)]="value" />
|
||||||
|
}
|
||||||
@case ('priority') {
|
@case ('priority') {
|
||||||
<pi-select-field [options]="priorityOptions" [(value)]="value" />
|
<pi-select-field [options]="priorityOptions" [(value)]="value" />
|
||||||
}
|
}
|
||||||
@@ -112,6 +119,7 @@ export class BulkBarComponent {
|
|||||||
{ value: 'status', label: 'Mover a…' },
|
{ value: 'status', label: 'Mover a…' },
|
||||||
{ value: 'assignee', label: 'Asignar a…' },
|
{ value: 'assignee', label: 'Asignar a…' },
|
||||||
{ value: 'sprint', label: 'Sprint…' },
|
{ value: 'sprint', label: 'Sprint…' },
|
||||||
|
{ value: 'release', label: 'Release…' },
|
||||||
{ value: 'priority', label: 'Prioridad…' },
|
{ value: 'priority', label: 'Prioridad…' },
|
||||||
{ value: 'label', label: 'Añadir etiqueta…' },
|
{ value: 'label', label: 'Añadir etiqueta…' },
|
||||||
];
|
];
|
||||||
@@ -141,6 +149,16 @@ export class BulkBarComponent {
|
|||||||
.map((s) => ({ value: s.id, label: s.name })),
|
.map((s) => ({ value: s.id, label: s.name })),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
/** Only open releases take tickets. */
|
||||||
|
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
|
||||||
|
() => [
|
||||||
|
{ value: NOBODY, label: 'Ninguna release' },
|
||||||
|
...this.project
|
||||||
|
.releases()
|
||||||
|
.filter((r) => r.status === ReleaseStatus.PLANNED)
|
||||||
|
.map((r) => ({ value: r.id, label: r.name })),
|
||||||
|
],
|
||||||
|
);
|
||||||
protected readonly priorityOptions = options(PRIORITY);
|
protected readonly priorityOptions = options(PRIORITY);
|
||||||
protected readonly canApply = computed(() =>
|
protected readonly canApply = computed(() =>
|
||||||
this.field() === 'label' ? !!this.label().trim() : !!this.value(),
|
this.field() === 'label' ? !!this.label().trim() : !!this.value(),
|
||||||
@@ -159,6 +177,9 @@ export class BulkBarComponent {
|
|||||||
case 'sprint':
|
case 'sprint':
|
||||||
change.sprintId = value === NOBODY ? null : value;
|
change.sprintId = value === NOBODY ? null : value;
|
||||||
break;
|
break;
|
||||||
|
case 'release':
|
||||||
|
change.releaseId = value === NOBODY ? null : value;
|
||||||
|
break;
|
||||||
case 'priority':
|
case 'priority':
|
||||||
if (value) change.priority = value as IssuePriority;
|
if (value) change.priority = value as IssuePriority;
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user