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:
2026-10-06 20:53:45 +02:00
parent 240ea9a539
commit 0e301ad545
143 changed files with 10291 additions and 1052 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,20 +13,32 @@ type Activity {
type: String!
}
type AreaOption {
archived: Boolean!
id: String!
name: String!
}
input AreaOptionInput {
archived: Boolean! = false
id: String!
name: String!
}
type BoardColumn {
issues: [Issue!]!
status: IssueStatus!
status: String!
total: Int!
}
input ChangeIssueStatusInput {
issueId: ID!
"""
Required for QA_FAILED and CANCELLED
"""
"""Required by statuses with requiresReason"""
reason: String
status: IssueStatus!
"""A status id"""
status: String!
}
"""
@@ -52,9 +64,7 @@ type CommentChange {
action: String!
actorId: ID!
"""
Null when deleted
"""
"""Null when deleted"""
comment: Comment
commentId: ID!
issueId: ID!
@@ -69,9 +79,7 @@ type CommentPage {
type CountByAssignee {
count: Int!
"""
Null = unassigned
"""
"""Null = unassigned"""
user: UserRef
}
@@ -82,18 +90,19 @@ type CountByKey {
type CountByStatus {
count: Int!
key: IssueStatus!
"""Status id"""
key: String!
}
input CreateIssueInput {
area: IssueArea
"""An area id"""
area: String
assigneeId: ID
description: String
dueDate: String
"""
Figma frame for UI changes
"""
"""Figma frame for UI changes"""
figmaUrl: String
labels: [String!]
priority: IssuePriority
@@ -101,15 +110,15 @@ input CreateIssueInput {
releaseId: ID
storyPoints: Int
title: String!
type: IssueType!
"""A ticket type id"""
type: String!
}
input CreateProjectInput {
description: String
"""
2-10 chars, e.g. "TS" → tickets TS-1, TS-2...
"""
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
key: String!
name: String!
}
@@ -117,20 +126,44 @@ input CreateProjectInput {
input CreateReleaseInput {
description: String
"""
e.g. v1.4.0
"""
"""e.g. v1.4.0"""
name: String!
projectId: ID!
targetDate: String
}
type CreatedResolved {
created: Int!
resolved: Int!
"""Monday of the week (YYYY-MM-DD)"""
week: String!
}
type CumulativeFlow {
dates: [String!]!
series: [FlowSeries!]!
}
type DurationStats {
averageDays: Float
count: Int!
"""85% of tickets take at most this long"""
p85Days: Float
}
type FlowSeries {
counts: [Int!]!
status: String!
}
type Issue {
"""
Statuses this ticket can move to next
"""
allowedTransitions: [IssueStatus!]!
area: IssueArea
"""Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]!
"""An area id"""
area: String
assignee: UserRef
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
@@ -139,21 +172,15 @@ type Issue {
dueDate: String
figmaUrl: String
"""
History of this ticket
"""
"""History of this ticket"""
history(limit: Int! = 50): [Activity!]!
id: ID!
"""
Human key, e.g. TS-42
"""
"""Human key, e.g. TS-42"""
key: String!
labels: [String!]!
"""
Linked tickets I can see
"""
"""Linked tickets I can see"""
linkedIssues: [Issue!]!
links: [IssueLink!]!
number: Int!
@@ -162,26 +189,19 @@ type Issue {
projectKey: String!
release: ReleaseRef
reporter: UserRef!
status: IssueStatus!
"""A status id of the project's settings"""
status: String!
statusCategory: StatusCategory!
storyPoints: Int
title: String!
type: IssueType!
"""A ticket type id of the project's settings"""
type: String!
updatedAt: String!
version: Int!
}
"""
Team/layer: links FE ↔ BE ↔ QA work
"""
enum IssueArea {
BACKEND
DESIGN
DEVOPS
FRONTEND
MOBILE
QA
}
type IssueChange {
action: String!
actorId: ID!
@@ -190,26 +210,23 @@ type IssueChange {
}
input IssueFilterInput {
areas: [IssueArea!]
areas: [String!]
assigneeId: ID
categories: [StatusCategory!]
labels: [String!]
priorities: [IssuePriority!]
projectId: ID!
releaseId: ID
"""
Matches key, title or description
"""
"""Matches key, title or description"""
search: String
statuses: [IssueStatus!]
types: [IssueType!]
statuses: [String!]
types: [String!]
unassigned: Boolean
}
type IssueLink {
"""
OUTWARD: this issue → other. INWARD: other → this issue.
"""
"""OUTWARD: this issue → other. INWARD: other → this issue."""
direction: LinkDirection!
issueId: ID!
issueKey: String!
@@ -237,24 +254,20 @@ enum IssuePriority {
MEDIUM
}
enum IssueStatus {
BACKLOG
CANCELLED
DONE
IN_PROGRESS
IN_QA
IN_REVIEW
QA_FAILED
READY_FOR_QA
TODO
type IssueTypeOption {
archived: Boolean!
color: String!
icon: String!
id: String!
name: String!
}
enum IssueType {
BUG
FEATURE
QA
TASK
UI_CHANGE
input IssueTypeOptionInput {
archived: Boolean! = false
color: String!
icon: String!
id: String!
name: String!
}
enum LinkDirection {
@@ -269,53 +282,39 @@ input LinkIssuesInput {
}
type Me {
"""
Roles inside the task-stream OAuth client
"""
"""Roles inside the task-stream OAuth client"""
appRoles: [String!]!
email: String!
id: ID!
isActive: Boolean!
name: String!
"""
Global roles in Pireon
"""
"""Global roles in Pireon"""
roles: [String!]!
}
type Mutation {
addComment(body: String!, issueId: ID!): CommandResult!
"""
Add a Pireon user to the project
"""
"""Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(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!
"""
Move a ticket through the workflow (incl. QA)
"""
"""Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult!
"""
Author or project admin
"""
"""Author or project admin"""
deleteComment(commentId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""
Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE
"""
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult!
"""
@@ -324,26 +323,27 @@ type Mutation {
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""
Put a ticket in a release (null removes it)
"""
"""Put a ticket in a release (null removes it)"""
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult!
updateProject(input: UpdateProjectInput!): CommandResult!
"""
Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.
"""
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult!
}
type Project {
"""
Kanban board, one column per status
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
"""
board(
area: IssueArea
assigneeId: ID
perColumn: Int! = 50
releaseId: ID
): [BoardColumn!]!
analytics(days: Int! = 90): ProjectAnalytics!
"""Kanban board, one column per status"""
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
createdAt: String!
description: String
id: ID!
@@ -352,15 +352,33 @@ type Project {
name: String!
owner: UserRef!
releases: [Release!]!
settings: ProjectSettings!
"""
Project tracking dashboard
"""
"""Project tracking dashboard"""
stats: ProjectStats!
updatedAt: String!
version: Int!
}
type ProjectAnalytics {
createdVsResolved: [CreatedResolved!]!
"""Tickets in each status at the end of every day"""
cumulativeFlow: CumulativeFlow!
"""Start of work → done"""
cycleTime: DurationStats!
from: String!
"""Creation → done"""
leadTime: DurationStats!
qa: QaStats!
"""Finished per person and week"""
throughput: [Throughput!]!
to: String!
}
type ProjectChange {
action: String!
actorId: ID!
@@ -377,9 +395,7 @@ input ProjectMemberInput {
projectId: ID!
role: ProjectRole! = MEMBER
"""
Pireon user id
"""
"""Pireon user id"""
userId: ID!
}
@@ -390,12 +406,28 @@ enum ProjectRole {
VIEWER
}
"""Statuses (board order), transitions, ticket types and areas"""
type ProjectSettings {
areas: [AreaOption!]!
issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]!
transitions: [StatusTransitions!]!
}
"""
The whole configuration (replaces the previous one). Statuses go in board order.
"""
input ProjectSettingsInput {
areas: [AreaOptionInput!]!
issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]!
transitions: [StatusTransitionsInput!]!
}
type ProjectStats {
byArea: [CountByKey!]!
"""
Open tickets per person
"""
"""Open tickets per person"""
byAssignee: [CountByAssignee!]!
byPriority: [CountByKey!]!
byStatus: [CountByStatus!]!
@@ -404,63 +436,59 @@ type ProjectStats {
total: Int!
}
type QaStats {
failed: Int!
failureRate: Float
passed: Int!
}
type Query {
"""
Project activity feed (newest first)
"""
"""Project activity feed (newest first)"""
activity(
"""
ISO date cursor
"""
"""ISO date cursor"""
before: String
issueId: ID
limit: Int! = 50
projectId: ID!
): [Activity!]!
issue(
"""
Issue id or key (e.g. "TS-42")
"""
"""Issue id or key (e.g. "TS-42")"""
idOrKey: String!
): Issue!
issues(
filter: IssueFilterInput!
limit: Int! = 50
offset: Int! = 0
): IssuePage!
issues(filter: IssueFilterInput!, limit: Int! = 50, offset: Int! = 0): IssuePage!
"""
The authenticated user
"""
"""The authenticated user"""
me: Me!
"""
Projects where I am a member
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
"""
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
"""Projects where I am a member"""
myProjects: [Project!]!
project(
"""
Project id or key (e.g. "TS")
"""
"""Project id or key (e.g. "TS")"""
idOrKey: String!
): Project!
release(id: ID!): Release!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
user(id: String!): User
"""
Search users by name or email (to assign / add members)
"""
"""Search users by name or email (to assign / add members)"""
users(limit: Int! = 20, search: String! = ""): [User!]!
}
type Release {
"""Scope vs finished tickets, day by day"""
burnup: ReleaseBurnup!
createdAt: String!
description: String
id: ID!
"""
Tickets included in this release
"""
"""Tickets included in this release"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String!
progress: ReleaseProgress!
@@ -471,6 +499,16 @@ type Release {
version: Int!
}
type ReleaseBurnup {
dates: [String!]!
done: [Int!]!
releaseId: ID!
"""Tickets in the release (not cancelled)"""
scope: [Int!]!
targetDate: String
}
type ReleaseChange {
action: String!
actorId: ID!
@@ -496,41 +534,61 @@ enum ReleaseStatus {
RELEASED
}
"""
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
"""
enum StatusCategory {
CANCELLED
DONE
IN_PROGRESS
QA
TODO
}
type StatusTransitions {
from: String!
to: [String!]!
}
input StatusTransitionsInput {
from: String!
to: [String!]!
}
type Subscription {
"""
Live activity feed
"""
"""Live activity feed"""
activityAdded(projectId: ID!): Activity!
commentChanged(issueId: ID!): CommentChange!
"""
Tickets created/updated/moved in a project (board in realtime)
"""
"""Tickets created/updated/moved in a project (board in realtime)"""
issueChanged(projectId: ID!): IssueChange!
"""
Projects I am added to / removed from
"""
"""Projects I am added to / removed from"""
myProjectsChanged: ProjectChange!
projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange!
}
type Throughput {
count: Int!
user: UserRef
week: String!
}
input UpdateIssueInput {
area: IssueArea
"""An area id"""
area: String
description: String
dueDate: String
"""
Figma frame for UI changes
"""
"""Figma frame for UI changes"""
figmaUrl: String
issueId: ID!
labels: [String!]
priority: IssuePriority
storyPoints: Int
title: String
type: IssueType
type: String
}
input UpdateProjectInput {
@@ -546,9 +604,7 @@ input UpdateReleaseInput {
targetDate: String
}
"""
User managed by the Pireon identity server
"""
"""User managed by the Pireon identity server"""
type User {
email: String!
id: ID!
@@ -560,3 +616,28 @@ type UserRef {
id: ID!
name: String!
}
type WorkflowStatus {
"""No new tickets or transitions into it"""
archived: Boolean!
category: StatusCategory!
"""Colour token: slate, violet, green…"""
color: String!
"""Stable id stored on tickets, e.g. IN_REVIEW"""
id: String!
name: String!
"""Entering it needs a reason"""
requiresReason: Boolean!
}
input WorkflowStatusInput {
archived: Boolean! = false
category: StatusCategory!
color: String!
id: String!
name: String!
requiresReason: Boolean! = false
}

View File

@@ -19,6 +19,8 @@ describe('appRoutes', () => {
'issues/:issueKey',
'releases',
'dashboard',
'analytics',
'settings',
'activity',
'members',
]);

View File

@@ -114,6 +114,31 @@ html {
--ts-t-ui: #d6409f;
--ts-t-task: #64748b;
--ts-t-qa: #d97706;
// Palette of the per-project settings (statuses and ticket types pick one).
--ts-c-slate: #64748b;
--ts-c-stone: #8b8b9a;
--ts-c-violet: #a347e1;
--ts-c-blue: #1879bf;
--ts-c-sky: #0284c7;
--ts-c-teal: #0f766e;
--ts-c-green: #15803d;
--ts-c-amber: #b7791f;
--ts-c-orange: #ea580c;
--ts-c-red: #dc2626;
--ts-c-pink: #d6409f;
// Charts (validated with the dataviz checks against --ts-surface, light and
// dark): two categorical slots, an ordinal ramp for flow stages and the
// reserved good/critical status colours.
--ts-viz-1: #2a78d6;
--ts-viz-2: #eb6834;
--ts-viz-ramp-1: #86b6ef;
--ts-viz-ramp-2: #5598e7;
--ts-viz-ramp-3: #256abf;
--ts-viz-ramp-4: #104281;
--ts-viz-good: #0ca30c;
--ts-viz-critical: #d03b3b;
}
html,
@@ -316,6 +341,19 @@ h3 {
}
}
/* Text for screen readers only (same helper as pireon.dev). */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.ts-prewrap {
white-space: pre-wrap;
}
@@ -501,6 +539,25 @@ html.dark-mode .pi-shell {
--ts-t-task: #94a3b8;
--ts-t-qa: #fbbf24;
--ts-c-slate: #94a3b8;
--ts-c-stone: #8a91a4;
--ts-c-violet: #ae5de5;
--ts-c-blue: #339be6;
--ts-c-sky: #38bdf8;
--ts-c-teal: #2dd4bf;
--ts-c-green: #34c98a;
--ts-c-amber: #eab308;
--ts-c-orange: #fb923c;
--ts-c-red: #f07070;
--ts-c-pink: #f472b6;
--ts-viz-1: #3987e5;
--ts-viz-2: #d95926;
--ts-viz-ramp-1: #184f95;
--ts-viz-ramp-2: #2a78d6;
--ts-viz-ramp-3: #6da7ec;
--ts-viz-ramp-4: #b7d3f6;
--pi-color-grey-10: #12141a;
--pi-color-grey-25: #161922;
--pi-color-grey-50: #1a1e28;