feat: start a project from another one's configuration
Creating a project can name one the creator owns or administers: its statuses, transitions, types, areas, custom fields and automation rules are copied once (paused rules stay paused; rules assigning to someone who is not in the new project are left out). GraphQL templateProjectId, REST template (id or key), MCP create_project template, and a select in the web's new-project form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1279,4 +1279,91 @@ describe('task-stream (e2e)', () => {
|
|||||||
history.body.map((h: { summary: string }) => h.summary).join(' | '),
|
history.body.map((h: { summary: string }) => h.summary).join(' | '),
|
||||||
).toContain('los campos personalizados');
|
).toContain('los campos personalizados');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts a project from another one: its configuration and rules', async () => {
|
||||||
|
const stamp = Date.now().toString(36).slice(-4).toUpperCase();
|
||||||
|
const source = `T${stamp}`;
|
||||||
|
await rest('token-ana', 'POST', '/projects', { key: source, name: source });
|
||||||
|
const created = await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${source}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
const sourceId = created.body['id'];
|
||||||
|
await rest('token-ana', 'POST', `/projects/${source}/members`, {
|
||||||
|
userId: '102',
|
||||||
|
});
|
||||||
|
const settings = created.body['settings'];
|
||||||
|
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await rest('token-ana', 'PUT', `/projects/${source}/settings`, {
|
||||||
|
...settings,
|
||||||
|
customFields: [
|
||||||
|
{
|
||||||
|
id: 'CLIENT',
|
||||||
|
name: 'Cliente',
|
||||||
|
type: 'SELECT',
|
||||||
|
options: ['Acme'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
const rule = (name: string, kind: string, value: string) =>
|
||||||
|
gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($i: CreateAutomationInput!) { createAutomation(input: $i) { id } }`,
|
||||||
|
{
|
||||||
|
i: {
|
||||||
|
projectId: sourceId,
|
||||||
|
name,
|
||||||
|
trigger: { kind: 'ISSUE_CREATED' },
|
||||||
|
action: { kind, value },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const paused = (await rule('Etiquetar', 'ADD_LABEL', 'nuevo'))[
|
||||||
|
'createAutomation'
|
||||||
|
].id;
|
||||||
|
await gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($a: ID!) { updateAutomation(automationId: $a, active: false) { id } }`,
|
||||||
|
{ a: paused },
|
||||||
|
);
|
||||||
|
// Bob is in the source project only: this one stays behind.
|
||||||
|
await rule('Para Bob', 'ASSIGN', '102');
|
||||||
|
|
||||||
|
// Only who manages the source can copy it.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await rest('token-bob', 'POST', '/projects', {
|
||||||
|
key: `B${stamp}`,
|
||||||
|
name: 'Copia',
|
||||||
|
template: source,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
|
||||||
|
const copy = `C${stamp}`;
|
||||||
|
const { createProject } = await gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id key } }`,
|
||||||
|
{ i: { key: copy, name: 'Copia', templateProjectId: sourceId } },
|
||||||
|
);
|
||||||
|
expect(createProject.key).toBe(copy);
|
||||||
|
const copied = await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${copy}`),
|
||||||
|
(r) => r.status === 200 && r.body['settings'].customFields.length === 1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
copied.body['settings'].areas.map((a: { id: string }) => a.id),
|
||||||
|
).toContain('DATA');
|
||||||
|
expect(copied.body['settings'].customFields[0].options).toEqual(['Acme']);
|
||||||
|
const { automations } = await gql(
|
||||||
|
'token-ana',
|
||||||
|
`query($p: ID!) { automations(projectId: $p) { name active } }`,
|
||||||
|
{ p: createProject.id },
|
||||||
|
);
|
||||||
|
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10656,6 +10656,12 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
|
"description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"example": "API",
|
||||||
|
"description": "A project you own or administer (id or key) to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -313,6 +313,11 @@ input CreateProjectInput {
|
|||||||
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
|
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
|
||||||
key: String!
|
key: String!
|
||||||
name: String!
|
name: String!
|
||||||
|
|
||||||
|
"""
|
||||||
|
A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied
|
||||||
|
"""
|
||||||
|
templateProjectId: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateReleaseInput {
|
input CreateReleaseInput {
|
||||||
|
|||||||
@@ -1729,6 +1729,11 @@ export interface components {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
|
/**
|
||||||
|
* @description A project you own or administer (id or key) to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied
|
||||||
|
* @example API
|
||||||
|
*/
|
||||||
|
template?: string | null;
|
||||||
};
|
};
|
||||||
CommandResultDto: {
|
CommandResultDto: {
|
||||||
/** @description Id of the aggregate written */
|
/** @description Id of the aggregate written */
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class ProjectsResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'create_project',
|
name: 'create_project',
|
||||||
description:
|
description:
|
||||||
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later.',
|
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later. With template, it starts with the statuses, transitions, types, areas, custom fields and automation rules of that project.',
|
||||||
paramsSchema: z.object({
|
paramsSchema: z.object({
|
||||||
key: z
|
key: z
|
||||||
.string()
|
.string()
|
||||||
@@ -52,6 +52,12 @@ export class ProjectsResolver {
|
|||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
.describe('Anyone can read it, also without signing in'),
|
.describe('Anyone can read it, also without signing in'),
|
||||||
|
template: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Key of a project the user owns or administers to copy its configuration and automation rules from',
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
})
|
})
|
||||||
@@ -61,6 +67,7 @@ export class ProjectsResolver {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
|
template?: string;
|
||||||
},
|
},
|
||||||
ctx: McpContext,
|
ctx: McpContext,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ Reglas:
|
|||||||
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
|
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
|
||||||
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
|
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
|
||||||
|
|
||||||
|
Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en
|
||||||
|
`createProject` o `template` (id o clave) en `POST /api/projects`. Se copian una vez sus estados, transiciones, tipos,
|
||||||
|
áreas, campos personalizados y automatizaciones (las pausadas, pausadas; las que asignan a alguien que no está en el
|
||||||
|
nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no hay nada que copiar.
|
||||||
|
|
||||||
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
|
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
|
||||||
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
|
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
|
||||||
|
|
||||||
|
|||||||
@@ -34,53 +34,53 @@ flowchart LR
|
|||||||
|
|
||||||
57 herramientas, agrupadas por ámbito:
|
57 herramientas, agrupadas por ámbito:
|
||||||
|
|
||||||
| Ámbito | Herramienta | Qué hace |
|
| Ámbito | Herramienta | Qué hace |
|
||||||
| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||||
| Usuario | `whoami` | El usuario del token |
|
| Usuario | `whoami` | El usuario del token |
|
||||||
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||||
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
||||||
| | `mark_notifications_read` | Marcar como leídas unas o todas |
|
| | `mark_notifications_read` | Marcar como leídas unas o todas |
|
||||||
| Proyectos | `list_my_projects` | Mis proyectos |
|
| Proyectos | `list_my_projects` | Mis proyectos |
|
||||||
| | `list_public_projects` | Proyectos públicos |
|
| | `list_public_projects` | Proyectos públicos |
|
||||||
| | `create_project` | Crear un proyecto (quien lo crea es su dueño) |
|
| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) |
|
||||||
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
||||||
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
|
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
|
||||||
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||||
| | `get_project_stats` | Cifras de seguimiento |
|
| | `get_project_stats` | Cifras de seguimiento |
|
||||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||||
| | `get_project_activity` | Actividad reciente |
|
| | `get_project_activity` | Actividad reciente |
|
||||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
||||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||||
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||||
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
|
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
|
||||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||||
| | `assign_issue` | Asignar o desasignar |
|
| | `assign_issue` | Asignar o desasignar |
|
||||||
| | `set_issue_release` | Meter en una release o sacar |
|
| | `set_issue_release` | Meter en una release o sacar |
|
||||||
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
||||||
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||||
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
||||||
| | `vote_issue` | Votar un ticket o quitar el voto |
|
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||||
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||||
| | `rank_issue` | Mover un ticket en el backlog |
|
| | `rank_issue` | Mover un ticket en el backlog |
|
||||||
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
||||||
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
||||||
| Sprints | `list_sprints` | Sprints de un proyecto |
|
| Sprints | `list_sprints` | Sprints de un proyecto |
|
||||||
| | `get_sprint` | Progreso, tickets y burndown |
|
| | `get_sprint` | Progreso, tickets y burndown |
|
||||||
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
||||||
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
||||||
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
||||||
| Releases | `list_releases` | Releases de un proyecto |
|
| Releases | `list_releases` | Releases de un proyecto |
|
||||||
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||||
| | `get_release` | Progreso, notas, tickets y burnup |
|
| | `get_release` | Progreso, notas, tickets y burnup |
|
||||||
| | `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á; lo pendiente pasa a `moveTo` (otra release abierta) o la deja |
|
| | `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,
|
||||||
|
|||||||
@@ -30,30 +30,26 @@ cuando hay pistas, por dónde empezar.
|
|||||||
|
|
||||||
### En curso, en este orden
|
### En curso, en este orden
|
||||||
|
|
||||||
1. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
|
1. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
|
||||||
planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`).
|
|
||||||
2. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, campos
|
|
||||||
personalizados, automatizaciones y etiquetas.
|
|
||||||
3. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
|
|
||||||
para archivar.
|
para archivar.
|
||||||
|
|
||||||
Y en el panel del proyecto:
|
Y en el panel del proyecto:
|
||||||
|
|
||||||
4. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
|
2. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
|
||||||
5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
|
3. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
|
||||||
curso), cada grupo con enlace a la lista filtrada.
|
curso), cada grupo con enlace a la lista filtrada.
|
||||||
6. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
|
4. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
|
||||||
proyecto (p85 del tiempo de ciclo).
|
proyecto (p85 del tiempo de ciclo).
|
||||||
7. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
|
5. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
|
||||||
8. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
6. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
||||||
con semáforo.
|
con semáforo.
|
||||||
9. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
7. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
||||||
10. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
8. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
||||||
sobrecarga y sus bloqueados.
|
sobrecarga y sus bloqueados.
|
||||||
11. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
9. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
||||||
12. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
10. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
||||||
siguientes a cada release.
|
siguientes a cada release.
|
||||||
13. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
11. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
||||||
navegador»).
|
navegador»).
|
||||||
|
|
||||||
### Aceptadas, para después de esta tanda
|
### Aceptadas, para después de esta tanda
|
||||||
@@ -130,3 +126,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
MCP).
|
MCP).
|
||||||
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando
|
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando
|
||||||
una empieza antes de que acabe la que la bloquea.
|
una empieza antes de que acabe la que la bloquea.
|
||||||
|
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
|
||||||
|
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
|
||||||
|
existían, en Sprints.)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -2,8 +2,10 @@ import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
assertThat,
|
assertThat,
|
||||||
} from '@task-stream/api/shared/domain';
|
} from '@task-stream/api/shared/domain';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +38,12 @@ import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
|||||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||||
import { GetProjectQuery } from './queries/get-project.query';
|
import { GetProjectQuery } from './queries/get-project.query';
|
||||||
import { GetReleasesQuery } from './queries/get-releases.query';
|
import { GetReleasesQuery } from './queries/get-releases.query';
|
||||||
|
import {
|
||||||
|
CreateAutomationCommand,
|
||||||
|
GetAutomationsQuery,
|
||||||
|
UpdateAutomationCommand,
|
||||||
|
} from './automations/automations';
|
||||||
|
import { Automation } from './ports/automations.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProjectsService {
|
export class ProjectsService {
|
||||||
@@ -108,16 +116,28 @@ export class ProjectsService {
|
|||||||
return this.queryBus.execute(new GetShowcaseQuery(project));
|
return this.queryBus.execute(new GetShowcaseQuery(project));
|
||||||
}
|
}
|
||||||
|
|
||||||
create(
|
/**
|
||||||
|
* A new project. With `templateId`, it starts with a copy of that
|
||||||
|
* project's configuration (statuses, transitions, types, areas and custom
|
||||||
|
* fields) and automation rules, taken once: later changes to either one
|
||||||
|
* stay there. Only from a project the actor manages (rules are only shown
|
||||||
|
* to managers); a rule that names someone who is not in the new project
|
||||||
|
* is left out.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
actorId: string,
|
actorId: string,
|
||||||
input: {
|
input: {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
templateId?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<CommandResult> {
|
): Promise<CommandResult> {
|
||||||
return this.commandBus.execute(
|
const template = input.templateId
|
||||||
|
? await this.template(actorId, input.templateId)
|
||||||
|
: null;
|
||||||
|
const created: CommandResult = await this.commandBus.execute(
|
||||||
new CreateProjectCommand(
|
new CreateProjectCommand(
|
||||||
actorId,
|
actorId,
|
||||||
input.key,
|
input.key,
|
||||||
@@ -126,6 +146,61 @@ export class ProjectsService {
|
|||||||
input.isPublic ?? false,
|
input.isPublic ?? false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!template) return created;
|
||||||
|
const configured: CommandResult = await this.commandBus.execute(
|
||||||
|
new UpdateProjectSettingsCommand(
|
||||||
|
actorId,
|
||||||
|
created.id,
|
||||||
|
structuredClone(template.settings),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const rule of template.automations)
|
||||||
|
await this.copyRule(actorId, created.id, rule);
|
||||||
|
return { ...configured, key: created.key };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async template(
|
||||||
|
actorId: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<{
|
||||||
|
settings: ProjectSettings;
|
||||||
|
automations: Automation[];
|
||||||
|
}> {
|
||||||
|
const project = await this.access.project(projectId);
|
||||||
|
project.assertCanManage(actorId);
|
||||||
|
const automations: Automation[] = await this.queryBus.execute(
|
||||||
|
new GetAutomationsQuery(actorId, projectId),
|
||||||
|
);
|
||||||
|
return { settings: project.settings, automations };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyRule(
|
||||||
|
actorId: string,
|
||||||
|
projectId: string,
|
||||||
|
rule: Automation,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const copy: Automation = await this.commandBus.execute(
|
||||||
|
new CreateAutomationCommand(actorId, projectId, {
|
||||||
|
name: rule.name,
|
||||||
|
trigger: rule.trigger,
|
||||||
|
// Stored without conditions when it had none.
|
||||||
|
conditions: rule.conditions ?? {},
|
||||||
|
action: rule.action,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!rule.active)
|
||||||
|
await this.commandBus.execute(
|
||||||
|
new UpdateAutomationCommand(actorId, copy.id, { active: false }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Someone it assigns to is not a member here: that rule stays behind.
|
||||||
|
if (
|
||||||
|
!(error instanceof ValidationError) &&
|
||||||
|
!(error instanceof BusinessRuleError)
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export class CreateProjectInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
|
||||||
|
@Field(() => ID, {
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
templateProjectId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
|
|||||||
@@ -151,7 +151,11 @@ export class ProjectResolver {
|
|||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
@Args('input') input: CreateProjectInput,
|
@Args('input') input: CreateProjectInput,
|
||||||
) {
|
) {
|
||||||
return this.projects.create(me.id, input);
|
const { templateProjectId, ...fields } = input;
|
||||||
|
return this.projects.create(me.id, {
|
||||||
|
...fields,
|
||||||
|
templateId: templateProjectId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => CommandResultGql)
|
@Mutation(() => CommandResultGql)
|
||||||
|
|||||||
@@ -87,10 +87,21 @@ export class CreateProjectDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
example: 'API',
|
||||||
|
description:
|
||||||
|
'A project you own or administer (id or key) to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
template?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateProjectDto extends PartialType(
|
export class UpdateProjectDto extends PartialType(
|
||||||
OmitType(CreateProjectDto, ['key'] as const),
|
OmitType(CreateProjectDto, ['key', 'template'] as const),
|
||||||
) {
|
) {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ export class ProjectsController {
|
|||||||
@ApiOperation({ summary: 'Create a project (you become its owner)' })
|
@ApiOperation({ summary: 'Create a project (you become its owner)' })
|
||||||
@ApiCreatedResponse({ type: CommandResultDto })
|
@ApiCreatedResponse({ type: CommandResultDto })
|
||||||
@ApiCommandErrors()
|
@ApiCommandErrors()
|
||||||
create(@CurrentUser() me: User, @Body() body: CreateProjectDto) {
|
async create(@CurrentUser() me: User, @Body() body: CreateProjectDto) {
|
||||||
return this.projects.create(me.id, body);
|
const { template, ...fields } = body;
|
||||||
|
return this.projects.create(me.id, {
|
||||||
|
...fields,
|
||||||
|
templateId: template ? await this.refs.projectId(me.id, template) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':project')
|
@Get(':project')
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface CreateProjectInput {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
/** A project I manage whose configuration and rules it starts with. */
|
||||||
|
templateProjectId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateProjectInput {
|
export interface UpdateProjectInput {
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export type CreateProjectInput = {
|
|||||||
/** 2-10 chars, e.g. "TS" → tickets TS-1, TS-2... */
|
/** 2-10 chars, e.g. "TS" → tickets TS-1, TS-2... */
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied */
|
||||||
|
templateProjectId?: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateReleaseInput = {
|
export type CreateReleaseInput = {
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ import {
|
|||||||
PiCheckboxComponent,
|
PiCheckboxComponent,
|
||||||
PiEmptyStateComponent,
|
PiEmptyStateComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
|
PiSelectFieldComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
|
type PiSelectFieldOption,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||||
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
||||||
|
import { ProjectRole } from '@task-stream/web/tracker/domain';
|
||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { GoToComponent } from '../ui/go-to';
|
import { GoToComponent } from '../ui/go-to';
|
||||||
import { ProjectCardComponent } from '../ui/project-card';
|
import { ProjectCardComponent } from '../ui/project-card';
|
||||||
@@ -46,6 +49,7 @@ import { MyWorkComponent } from './my-work';
|
|||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
|
PiSelectFieldComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<header class="ts-page-header">
|
<header class="ts-page-header">
|
||||||
@@ -125,6 +129,18 @@ import { MyWorkComponent } from './my-work';
|
|||||||
>
|
>
|
||||||
<pi-label>Descripción</pi-label>
|
<pi-label>Descripción</pi-label>
|
||||||
</pi-textarea>
|
</pi-textarea>
|
||||||
|
@if (templateOptions().length > 1) {
|
||||||
|
<pi-select-field
|
||||||
|
[formField]="f.template"
|
||||||
|
[options]="templateOptions()"
|
||||||
|
>
|
||||||
|
<pi-label>Empezar con la configuración de</pi-label>
|
||||||
|
</pi-select-field>
|
||||||
|
<p class="ts-hint">
|
||||||
|
Copia sus estados, transiciones, tipos, áreas, campos y
|
||||||
|
automatizaciones. Después, cada proyecto cambia por su cuenta.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<div class="ts-public-check">
|
<div class="ts-public-check">
|
||||||
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
|
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
|
||||||
<p>
|
<p>
|
||||||
@@ -164,6 +180,11 @@ import { MyWorkComponent } from './my-work';
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.ts-hint {
|
||||||
|
margin: -0.5rem 0 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
.ts-public-check {
|
.ts-public-check {
|
||||||
p {
|
p {
|
||||||
/* Under the checkbox's text, past the box. */
|
/* Under the checkbox's text, past the box. */
|
||||||
@@ -194,7 +215,28 @@ export class ProjectsPage implements OnInit {
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
|
template: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Projects I own or administer: the ones whose configuration I can copy. */
|
||||||
|
protected readonly templateOptions = computed<PiSelectFieldOption<string>[]>(
|
||||||
|
() => {
|
||||||
|
const me = this.auth.user()?.id;
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'La de por defecto' },
|
||||||
|
...this.facade
|
||||||
|
.sorted()
|
||||||
|
.filter((p) =>
|
||||||
|
p.members.some(
|
||||||
|
(m) =>
|
||||||
|
m.user.id === me &&
|
||||||
|
(m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` })),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
protected readonly f = form(this.model, (p) => {
|
protected readonly f = form(this.model, (p) => {
|
||||||
required(p.key);
|
required(p.key);
|
||||||
pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, {
|
pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, {
|
||||||
@@ -211,18 +253,25 @@ export class ProjectsPage implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected openCreate(): void {
|
protected openCreate(): void {
|
||||||
this.model.set({ key: '', name: '', description: '', isPublic: false });
|
this.model.set({
|
||||||
|
key: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
isPublic: false,
|
||||||
|
template: '',
|
||||||
|
});
|
||||||
this.drawer.open('Nuevo proyecto', this.createForm());
|
this.drawer.open('Nuevo proyecto', this.createForm());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected create(): void {
|
protected create(): void {
|
||||||
if (this.f().invalid()) return;
|
if (this.f().invalid()) return;
|
||||||
const { key, name, description, isPublic } = this.model();
|
const { key, name, description, isPublic, template } = this.model();
|
||||||
this.facade.create({
|
this.facade.create({
|
||||||
key: key.toUpperCase(),
|
key: key.toUpperCase(),
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
isPublic,
|
isPublic,
|
||||||
|
templateProjectId: template || null,
|
||||||
});
|
});
|
||||||
this.drawer.closeAll();
|
this.drawer.closeAll();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user