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:
2026-10-15 10:25:00 +02:00
parent 21bc598590
commit e5f4e6c683
18 changed files with 342 additions and 72 deletions

View File

@@ -1279,4 +1279,91 @@ describe('task-stream (e2e)', () => {
history.body.map((h: { summary: string }) => h.summary).join(' | '),
).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 }]);
});
});

View File

@@ -10656,6 +10656,12 @@
"type": "boolean",
"default": false,
"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": [

View File

@@ -313,6 +313,11 @@ input CreateProjectInput {
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
key: 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 {

View File

@@ -1729,6 +1729,11 @@ export interface components {
* @default false
*/
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: {
/** @description Id of the aggregate written */

View File

@@ -40,7 +40,7 @@ export class ProjectsResolver {
@Tool({
name: 'create_project',
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({
key: z
.string()
@@ -52,6 +52,12 @@ export class ProjectsResolver {
.boolean()
.default(false)
.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 },
})
@@ -61,6 +67,7 @@ export class ProjectsResolver {
name: string;
description?: string;
isPublic: boolean;
template?: string;
},
ctx: McpContext,
) {

View File

@@ -110,6 +110,11 @@ Reglas:
- 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`.
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
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.

View File

@@ -35,14 +35,14 @@ flowchart LR
57 herramientas, agrupadas por ámbito:
| Ámbito | Herramienta | Qué hace |
| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Usuario | `whoami` | El usuario del token |
| | `search_users` | Buscar personas de Pireon (para asignar) |
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
| | `mark_notifications_read` | Marcar como leídas unas o todas |
| Proyectos | `list_my_projects` | Mis proyectos |
| | `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_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) |

View File

@@ -30,30 +30,26 @@ cuando hay pistas, por dónde empezar.
### 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
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
1. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
para archivar.
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.
5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
2. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
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.
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).
7. **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),
5. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
6. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
con semáforo.
9. **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
7. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
8. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
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.
12. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
9. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
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.
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»).
### 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).
- 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.
- 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.

View File

@@ -2,8 +2,10 @@ import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
BusinessRuleError,
ForbiddenError,
NotFoundError,
ValidationError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
@@ -36,6 +38,12 @@ import { GetProjectStatsQuery } from './queries/get-project-stats.query';
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
import { GetProjectQuery } from './queries/get-project.query';
import { GetReleasesQuery } from './queries/get-releases.query';
import {
CreateAutomationCommand,
GetAutomationsQuery,
UpdateAutomationCommand,
} from './automations/automations';
import { Automation } from './ports/automations.repository';
@Injectable()
export class ProjectsService {
@@ -108,16 +116,28 @@ export class ProjectsService {
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,
input: {
key: string;
name: string;
description?: string | null;
isPublic?: boolean;
templateId?: string | null;
},
): 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(
actorId,
input.key,
@@ -126,6 +146,61 @@ export class ProjectsService {
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(

View File

@@ -55,6 +55,15 @@ export class CreateProjectInput {
@IsOptional()
@IsBoolean()
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()

View File

@@ -151,7 +151,11 @@ export class ProjectResolver {
@CurrentUser() me: User,
@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)

View File

@@ -87,10 +87,21 @@ export class CreateProjectDto {
@IsOptional()
@IsBoolean()
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(
OmitType(CreateProjectDto, ['key'] as const),
OmitType(CreateProjectDto, ['key', 'template'] as const),
) {
@ApiPropertyOptional({
description:

View File

@@ -83,8 +83,12 @@ export class ProjectsController {
@ApiOperation({ summary: 'Create a project (you become its owner)' })
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
create(@CurrentUser() me: User, @Body() body: CreateProjectDto) {
return this.projects.create(me.id, body);
async create(@CurrentUser() me: User, @Body() body: CreateProjectDto) {
const { template, ...fields } = body;
return this.projects.create(me.id, {
...fields,
templateId: template ? await this.refs.projectId(me.id, template) : null,
});
}
@Get(':project')

View File

@@ -11,6 +11,8 @@ export interface CreateProjectInput {
name: string;
description?: string | null;
isPublic?: boolean;
/** A project I manage whose configuration and rules it starts with. */
templateProjectId?: string | null;
}
export interface UpdateProjectInput {

View File

@@ -116,6 +116,8 @@ export type CreateProjectInput = {
/** 2-10 chars, e.g. "TS" → tickets TS-1, TS-2... */
key: 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 = {

View File

@@ -20,12 +20,15 @@ import {
PiCheckboxComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
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 { GoToComponent } from '../ui/go-to';
import { ProjectCardComponent } from '../ui/project-card';
@@ -46,6 +49,7 @@ import { MyWorkComponent } from './my-work';
PiTextFieldComponent,
PiTextareaComponent,
PiLabelDirective,
PiSelectFieldComponent,
],
template: `
<header class="ts-page-header">
@@ -125,6 +129,18 @@ import { MyWorkComponent } from './my-work';
>
<pi-label>Descripción</pi-label>
</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">
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
<p>
@@ -164,6 +180,11 @@ import { MyWorkComponent } from './my-work';
font-weight: 400;
}
}
.ts-hint {
margin: -0.5rem 0 0;
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-public-check {
p {
/* Under the checkbox's text, past the box. */
@@ -194,7 +215,28 @@ export class ProjectsPage implements OnInit {
name: '',
description: '',
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) => {
required(p.key);
pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, {
@@ -211,18 +253,25 @@ export class ProjectsPage implements OnInit {
}
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());
}
protected create(): void {
if (this.f().invalid()) return;
const { key, name, description, isPublic } = this.model();
const { key, name, description, isPublic, template } = this.model();
this.facade.create({
key: key.toUpperCase(),
name,
description: description || null,
isPublic,
templateProjectId: template || null,
});
this.drawer.closeAll();
}