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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user