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