feat: an ordered backlog

A Backlog tab lists the tickets still to start in the order the team will
take them; drag them, or use the arrows, to reorder. Moving a ticket only
rewrites that ticket's rank, read from the event store, and board columns
follow the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:01:44 +02:00
parent d4da33d4e8
commit 0606f53026
53 changed files with 1599 additions and 32 deletions

View File

@@ -2188,6 +2188,15 @@
"type": "string"
}
},
{
"name": "sort",
"required": false,
"in": "query",
"description": "RECENT (default): last updated first. RANK: backlog order.",
"schema": {
"$ref": "#/components/schemas/IssueSort"
}
},
{
"name": "search",
"required": false,
@@ -3098,6 +3107,118 @@
]
}
},
"/api/issues/{issue}/rank": {
"put": {
"description": "Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order.",
"operationId": "IssuesController_rank",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RankIssueDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Move the ticket in the backlog",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/checklist": {
"post": {
"operationId": "IssuesController_addChecklistItem",
@@ -6940,6 +7061,10 @@
}
]
},
"rank": {
"type": "string",
"description": "Place in the backlog and its board column (ascending, as text)"
},
"checklist": {
"type": "array",
"items": {
@@ -6986,6 +7111,7 @@
"attachments",
"watchers",
"parent",
"rank",
"checklist",
"commentCount",
"createdAt",
@@ -7421,6 +7547,13 @@
"total"
]
},
"IssueSort": {
"type": "string",
"enum": [
"RECENT",
"RANK"
]
},
"CreateIssueDto": {
"type": "object",
"properties": {
@@ -7656,6 +7789,23 @@
"parent"
]
},
"RankIssueDto": {
"type": "object",
"properties": {
"above": {
"type": "string",
"nullable": true,
"example": "WEB-4",
"description": "The ticket that ends up right above (id or key; null: the top)"
},
"below": {
"type": "string",
"nullable": true,
"example": "WEB-9",
"description": "The ticket that ends up right below (id or key; null: the bottom)"
}
}
},
"ChecklistItemBodyDto": {
"type": "object",
"properties": {

View File

@@ -242,6 +242,11 @@ type Issue {
priority: IssuePriority!
projectId: ID!
projectKey: String!
"""
Place in the backlog and in its board column (ascending, compare as text)
"""
rank: String!
release: ReleaseRef
reporter: UserRef!
@@ -281,6 +286,7 @@ input IssueFilterInput {
"""Matches key, title or description"""
search: String
sort: IssueSort = RECENT
statuses: [String!]
types: [String!]
unassigned: Boolean
@@ -322,6 +328,12 @@ enum IssuePriority {
MEDIUM
}
"""RECENT: last updated first. RANK: backlog order."""
enum IssueSort {
RANK
RECENT
}
type IssueTypeOption {
archived: Boolean!
color: String!
@@ -400,6 +412,9 @@ type Mutation {
"""
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
"""Move a ticket in the backlog, between two others of the same project"""
rankIssue(input: RankIssueInput!): CommandResult!
"""
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
"""
@@ -641,6 +656,15 @@ type Query {
users(limit: Int! = 20, search: String! = ""): [User!]!
}
input RankIssueInput {
"""The ticket that ends up right above (null: the top)"""
aboveIssueId: ID
"""The ticket that ends up right below (null: the bottom)"""
belowIssueId: ID
issueId: ID!
}
type Release {
"""Scope vs finished tickets, day by day"""
burnup: ReleaseBurnup!

View File

@@ -475,6 +475,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/issues/{issue}/rank": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Move the ticket in the backlog
* @description Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order.
*/
put: operations["IssuesController_rank"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/checklist": {
parameters: {
query?: never;
@@ -1208,6 +1228,8 @@ export interface components {
watchers: components["schemas"]["UserRefDto"][];
/** @description The epic or ticket this one breaks down */
parent: components["schemas"]["IssueParentDto"] | null;
/** @description Place in the backlog and its board column (ascending, as text) */
rank: string;
checklist: components["schemas"]["ChecklistItemDto"][];
commentCount: number;
/** Format: date-time */
@@ -1334,6 +1356,8 @@ export interface components {
/** @description Total matches (for pagination) */
total: number;
};
/** @enum {string} */
IssueSort: "RECENT" | "RANK";
CreateIssueDto: {
description?: string | null;
/**
@@ -1425,6 +1449,18 @@ export interface components {
*/
parent: string | null;
};
RankIssueDto: {
/**
* @description The ticket that ends up right above (id or key; null: the top)
* @example WEB-4
*/
above?: string | null;
/**
* @description The ticket that ends up right below (id or key; null: the bottom)
* @example WEB-9
*/
below?: string | null;
};
ChecklistItemBodyDto: {
/** @example Actualizar el changelog */
text: string;
@@ -3017,6 +3053,8 @@ export interface operations {
label?: string[];
/** @description Only the tickets hanging from this one (id or key) */
parent?: string;
/** @description RECENT (default): last updated first. RANK: backlog order. */
sort?: components["schemas"]["IssueSort"];
/** @description Matches key, title or description */
search?: string;
};
@@ -3671,6 +3709,86 @@ export interface operations {
};
};
};
IssuesController_rank: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RankIssueDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_addChecklistItem: {
parameters: {
query?: never;

View File

@@ -28,6 +28,7 @@ describe('appRoutes', () => {
'',
'board',
'issues/:issueKey',
'backlog',
'epics',
'releases',
'dashboard',