feat: per-project workflow settings, statistics and "my work" home
Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
"requires reason"; configurable transitions; ticket types (icon, colour) and
areas. Defaults are the previous fixed values; projects created before keep
them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
literals); Project.settings, updateProjectSettings, statusCategory,
myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).
Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
pass/fail and release burnup. GraphQL Project.analytics(days),
Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
keyboard navigation, legend and data table.
Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".
Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -433,6 +433,9 @@ describe('task-stream (e2e)', () => {
|
||||
'/api/projects/{project}/issues',
|
||||
'/api/issues/{issue}/status',
|
||||
'/api/releases/{releaseId}/publish',
|
||||
'/api/projects/{project}/settings',
|
||||
'/api/projects/{project}/analytics',
|
||||
'/api/releases/{releaseId}/burnup',
|
||||
'/api/users/me',
|
||||
]),
|
||||
);
|
||||
@@ -552,9 +555,323 @@ describe('task-stream (e2e)', () => {
|
||||
);
|
||||
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } });
|
||||
|
||||
// --- My work across projects ---
|
||||
const mine = await eventually(
|
||||
() => rest('token-qa', 'GET', '/me/issues'),
|
||||
(r) => r.body?.['items']?.some((i: { key: string }) => i.key === beKey),
|
||||
);
|
||||
// Created by Quinn but assigned to nobody: not in "assigned to me".
|
||||
expect(mine.body['items'].map((i: { key: string }) => i.key)).not.toContain(
|
||||
qaKey,
|
||||
);
|
||||
const todo = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
'/me/issues?assignedToMe=false&status=TODO',
|
||||
);
|
||||
expect(todo.body['items'].map((i: { key: string }) => i.key)).toContain(
|
||||
beKey,
|
||||
);
|
||||
const viaGraphql = await gql(
|
||||
'token-qa',
|
||||
`{ myIssues { total items { key assignee { id } } } }`,
|
||||
);
|
||||
expect(viaGraphql['myIssues'].items).toEqual(
|
||||
expect.arrayContaining([{ key: beKey, assignee: { id: '103' } }]),
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||
name: 'v9.9.9',
|
||||
targetDate: '2099-01-01',
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', '/me/releases?limit=50'),
|
||||
(r) =>
|
||||
Array.isArray(r.body) &&
|
||||
r.body.some((rel: { name: string }) => rel.name === 'v9.9.9'),
|
||||
);
|
||||
// Not a member of the project: none of its tickets.
|
||||
const outsider = await rest('token-eve', 'GET', '/me/issues');
|
||||
expect(
|
||||
outsider.body['items'].map((i: { key: string }) => i.key),
|
||||
).not.toContain(beKey);
|
||||
|
||||
// --- Same authorisation as GraphQL ---
|
||||
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets each project customise its workflow, types and areas, and measures it', async () => {
|
||||
const key = 'S' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
const projectId = (
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`,
|
||||
{ i: { key, name: 'Custom workflow' } },
|
||||
)
|
||||
).createProject.id;
|
||||
await eventually(
|
||||
() =>
|
||||
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
|
||||
k: key,
|
||||
}),
|
||||
(d) => !!d.project,
|
||||
);
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
|
||||
{ i: { projectId, userId: '102', role: 'MEMBER' } },
|
||||
);
|
||||
|
||||
// --- Every project starts with the default settings ---
|
||||
const settingsQuery = `query($k: String!) { project(idOrKey: $k) { settings {
|
||||
statuses { id name category color requiresReason archived }
|
||||
transitions { from to }
|
||||
issueTypes { id name icon color archived }
|
||||
areas { id name archived }
|
||||
} } }`;
|
||||
const { settings } = (await gql('token-ana', settingsQuery, { k: key }))
|
||||
.project;
|
||||
expect(settings.statuses[0]).toEqual({
|
||||
id: 'BACKLOG',
|
||||
name: 'Backlog',
|
||||
category: 'TODO',
|
||||
color: 'stone',
|
||||
requiresReason: false,
|
||||
archived: false,
|
||||
});
|
||||
expect(settings.areas.map((a: { id: string }) => a.id)).toContain('MOBILE');
|
||||
|
||||
// --- A staging step, a Spike type, a Data area; Mobile archived ---
|
||||
type Item = { id: string; archived?: boolean };
|
||||
const reviewAt = settings.statuses.findIndex(
|
||||
(s: Item) => s.id === 'IN_REVIEW',
|
||||
);
|
||||
settings.statuses.splice(reviewAt + 1, 0, {
|
||||
id: 'STAGING',
|
||||
name: 'En staging',
|
||||
category: 'QA',
|
||||
color: 'teal',
|
||||
requiresReason: false,
|
||||
archived: false,
|
||||
});
|
||||
settings.transitions = settings.transitions.map(
|
||||
(t: { from: string; to: string[] }) =>
|
||||
t.from === 'IN_REVIEW'
|
||||
? { from: 'IN_REVIEW', to: ['STAGING', 'IN_PROGRESS'] }
|
||||
: t,
|
||||
);
|
||||
settings.transitions.push({ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] });
|
||||
settings.issueTypes.push({
|
||||
id: 'SPIKE',
|
||||
name: 'Spike',
|
||||
icon: 'light-bulb',
|
||||
color: 'teal',
|
||||
archived: false,
|
||||
});
|
||||
settings.areas = settings.areas.map((a: Item) =>
|
||||
a.id === 'MOBILE' ? { ...a, archived: true } : a,
|
||||
);
|
||||
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
|
||||
|
||||
const save = (token: string, next: object) =>
|
||||
gql(
|
||||
token,
|
||||
`mutation($p: ID!, $s: ProjectSettingsInput!) { updateProjectSettings(projectId: $p, settings: $s) { version } }`,
|
||||
{ p: projectId, s: next },
|
||||
);
|
||||
await expect(save('token-bob', settings)).rejects.toThrow(/owner|admin/i);
|
||||
await expect(
|
||||
save('token-ana', {
|
||||
...settings,
|
||||
areas: settings.areas.filter((a: Item) => a.id !== 'QA'),
|
||||
}),
|
||||
).rejects.toThrow(/archívalo/);
|
||||
await save('token-ana', settings);
|
||||
await eventually(
|
||||
() => gql('token-ana', settingsQuery, { k: key }),
|
||||
(d) => d.project.settings.statuses.some((s: Item) => s.id === 'STAGING'),
|
||||
);
|
||||
|
||||
// --- Tickets follow the project's settings ---
|
||||
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key } }`;
|
||||
await expect(
|
||||
gql('token-bob', create, {
|
||||
i: { projectId, type: 'EPIC', title: 'Unknown type' },
|
||||
}),
|
||||
).rejects.toThrow(/Unknown ticket type/);
|
||||
await expect(
|
||||
gql('token-bob', create, {
|
||||
i: { projectId, type: 'TASK', title: 'Old area', area: 'MOBILE' },
|
||||
}),
|
||||
).rejects.toThrow(/archived/);
|
||||
const spike = (
|
||||
await gql('token-bob', create, {
|
||||
i: {
|
||||
projectId,
|
||||
type: 'SPIKE',
|
||||
title: 'Evaluate a column store',
|
||||
area: 'DATA',
|
||||
assigneeId: '102',
|
||||
},
|
||||
})
|
||||
).createIssue;
|
||||
|
||||
const release = (
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
|
||||
{ i: { projectId, name: 'v1.0.0' } },
|
||||
)
|
||||
).createRelease;
|
||||
await eventually(
|
||||
() =>
|
||||
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
|
||||
id: release.id,
|
||||
}),
|
||||
(d) => !!d.release,
|
||||
);
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
|
||||
{ i: spike.id, r: release.id },
|
||||
);
|
||||
|
||||
const move = (status: string) =>
|
||||
gql(
|
||||
'token-bob',
|
||||
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
|
||||
{ i: { issueId: spike.id, status } },
|
||||
);
|
||||
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW'])
|
||||
await move(status);
|
||||
const inReview = await eventually(
|
||||
() =>
|
||||
gql(
|
||||
'token-bob',
|
||||
`query($k: String!) { issue(idOrKey: $k) { status statusCategory allowedTransitions } }`,
|
||||
{ k: spike.key },
|
||||
),
|
||||
(d) => d.issue.status === 'IN_REVIEW',
|
||||
);
|
||||
expect(inReview.issue).toEqual({
|
||||
status: 'IN_REVIEW',
|
||||
statusCategory: 'IN_PROGRESS',
|
||||
allowedTransitions: ['STAGING', 'IN_PROGRESS'],
|
||||
});
|
||||
await expect(move('READY_FOR_QA')).rejects.toThrow(/Cannot move/);
|
||||
await move('STAGING');
|
||||
await move('DONE');
|
||||
|
||||
// --- Board, history, releases and statistics use the categories ---
|
||||
const { project } = await eventually(
|
||||
() =>
|
||||
gql(
|
||||
'token-ana',
|
||||
`query($k: String!) { project(idOrKey: $k) {
|
||||
board { status total }
|
||||
stats { total open }
|
||||
releases { progress { finished percent } burnup { scope done } }
|
||||
analytics(days: 30) {
|
||||
createdVsResolved { created resolved }
|
||||
cumulativeFlow { series { status counts } }
|
||||
cycleTime { count }
|
||||
leadTime { count }
|
||||
throughput { user { id } count }
|
||||
qa { passed failed failureRate }
|
||||
}
|
||||
} }`,
|
||||
{ k: key },
|
||||
),
|
||||
(d) => d.project.analytics.cycleTime.count === 1,
|
||||
);
|
||||
const columns = project.board.map((c: { status: string }) => c.status);
|
||||
expect(columns.indexOf('STAGING')).toBe(columns.indexOf('IN_REVIEW') + 1);
|
||||
expect(columns).not.toContain('CANCELLED');
|
||||
expect(project.stats).toEqual({ total: 1, open: 0 });
|
||||
expect(project.releases[0].progress).toEqual({ finished: 1, percent: 100 });
|
||||
expect(project.releases[0].burnup.scope.at(-1)).toBe(1);
|
||||
expect(project.releases[0].burnup.done.at(-1)).toBe(1);
|
||||
const analytics = project.analytics;
|
||||
const sum = (
|
||||
rows: { created: number; resolved: number }[],
|
||||
f: 'created' | 'resolved',
|
||||
) => rows.reduce((total, row) => total + row[f], 0);
|
||||
expect(sum(analytics.createdVsResolved, 'created')).toBe(1);
|
||||
expect(sum(analytics.createdVsResolved, 'resolved')).toBe(1);
|
||||
expect(analytics.cumulativeFlow.series).toEqual([
|
||||
expect.objectContaining({ status: 'DONE' }),
|
||||
]);
|
||||
expect(analytics.leadTime.count).toBe(1);
|
||||
expect(analytics.throughput).toEqual([{ user: { id: '102' }, count: 1 }]);
|
||||
// STAGING is a QA-category step: leaving it to DONE is a QA pass.
|
||||
expect(analytics.qa).toEqual({ passed: 1, failed: 0, failureRate: 0 });
|
||||
|
||||
const history = await gql(
|
||||
'token-ana',
|
||||
`query($k: String!) { issue(idOrKey: $k) { history { summary } } }`,
|
||||
{ k: spike.key },
|
||||
);
|
||||
expect(
|
||||
history.issue.history.map((h: { summary: string }) => h.summary),
|
||||
).toContain(
|
||||
`Bob (frontend) movió ${spike.key} de En revisión a En staging`,
|
||||
);
|
||||
const done = await gql(
|
||||
'token-bob',
|
||||
`{ myIssues(categories: [DONE]) { items { key } } }`,
|
||||
);
|
||||
expect(done.myIssues.items).toEqual(
|
||||
expect.arrayContaining([{ key: spike.key }]),
|
||||
);
|
||||
|
||||
// --- Same over REST ---
|
||||
const viaRest = await rest('token-ana', 'GET', `/projects/${key}`);
|
||||
expect(viaRest.body['settings']['transitions']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] },
|
||||
]),
|
||||
);
|
||||
const renamed = viaRest.body['settings'];
|
||||
renamed.statuses = renamed.statuses.map((s: Item & { name: string }) =>
|
||||
s.id === 'STAGING' ? { ...s, name: 'Preproducción' } : s,
|
||||
);
|
||||
expect(
|
||||
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, renamed))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const removed = await rest(
|
||||
'token-ana',
|
||||
'PUT',
|
||||
`/projects/${key}/settings`,
|
||||
{ ...renamed, statuses: [] },
|
||||
);
|
||||
expect(removed.status).toBe(422);
|
||||
expect(removed.body['message']).toMatch(/archívalo/);
|
||||
const neon = await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
|
||||
...renamed,
|
||||
areas: [...renamed.areas, { id: 'X', name: 'X', color: 'neon' }],
|
||||
issueTypes: [
|
||||
...renamed.issueTypes,
|
||||
{ id: 'Y', name: 'Y', icon: 'bug', color: 'neon' },
|
||||
],
|
||||
});
|
||||
expect(neon.status).toBe(400);
|
||||
const stats = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/analytics?days=30`,
|
||||
);
|
||||
expect(stats.status).toBe(200);
|
||||
expect(stats.body['cycleTime']['count']).toBe(1);
|
||||
const burnup = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/releases/${release.id}/burnup`,
|
||||
);
|
||||
expect(burnup.body).toMatchObject({ releaseId: release.id });
|
||||
expect(
|
||||
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
|
||||
).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user