feat(tickets): tri de la liste de tickets côté backend (#21)

Ajoute le paramètre de tri dans TicketListQuery et l'expose via
ticket_list (surface app-tauri + MCP). Ports et adaptateur mock
frontend alignés sur le contrat de tri. La partie UI (contrôle de tri
dans TicketFacetsBar) suivra dans un commit frontend dédié.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 11:45:59 +02:00
parent 2f8467e2dd
commit e523f44425
4 changed files with 184 additions and 5 deletions

View File

@ -202,10 +202,37 @@ pub struct TicketListRequestDto {
pub assigned_agent_id: Option<String>,
pub sprint_id: Option<String>,
pub text: Option<String>,
pub sort: Option<TicketListSortDto>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
/// List sort request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketListSortDto {
pub field: TicketListSortFieldDto,
pub direction: TicketListSortDirectionDto,
}
/// List sort field.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TicketListSortFieldDto {
Number,
Priority,
Status,
Title,
}
/// List sort direction.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TicketListSortDirectionDto {
Asc,
Desc,
}
/// Update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -410,7 +437,7 @@ impl TicketToolProvider for AppTicketToolProvider {
}
"idea_ticket_list" => {
let req = mcp_list_request(project, arguments)?;
let rows = self
let mut rows = self
.list
.execute(ListIssuesInput {
project: project.clone(),
@ -419,6 +446,7 @@ impl TicketToolProvider for AppTicketToolProvider {
.await
.map_err(ticket_error)?
.issues;
sort_ticket_rows(&mut rows, req.sort);
let sprints = self
.list_sprints
.execute(ListSprintsInput {
@ -684,7 +712,7 @@ pub async fn ticket_list(
) -> Result<TicketListDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let page = TicketListPageInput::from_request(request)?;
let rows = state
let mut rows = state
.list_issues
.execute(ListIssuesInput {
project,
@ -693,6 +721,7 @@ pub async fn ticket_list(
.await
.map_err(ErrorDto::from)?
.issues;
sort_ticket_rows(&mut rows, page.sort);
Ok(paginate(rows, page.limit, page.cursor))
}
@ -1114,6 +1143,7 @@ impl From<IssueCarnet> for TicketCarnetDto {
#[derive(Debug)]
struct TicketListPageInput {
filter: IssueListFilter,
sort: Option<TicketListSortDto>,
limit: usize,
cursor: Option<String>,
}
@ -1136,12 +1166,59 @@ impl TicketListPageInput {
.transpose()?,
text: request.text,
},
sort: request.sort,
limit: request.limit.unwrap_or(100).clamp(1, 500),
cursor: request.cursor,
})
}
}
fn sort_ticket_rows(rows: &mut [IssueIndexEntry], sort: Option<TicketListSortDto>) {
let Some(sort) = sort else {
return;
};
rows.sort_by(|a, b| {
let field_order = match sort.field {
TicketListSortFieldDto::Number => {
a.issue_ref.number().get().cmp(&b.issue_ref.number().get())
}
TicketListSortFieldDto::Priority => {
priority_rank(a.priority).cmp(&priority_rank(b.priority))
}
TicketListSortFieldDto::Status => status_rank(a.status).cmp(&status_rank(b.status)),
TicketListSortFieldDto::Title => a
.title
.to_lowercase()
.cmp(&b.title.to_lowercase())
.then_with(|| a.title.cmp(&b.title)),
};
let directed = match sort.direction {
TicketListSortDirectionDto::Asc => field_order,
TicketListSortDirectionDto::Desc => field_order.reverse(),
};
directed.then_with(|| a.issue_ref.number().get().cmp(&b.issue_ref.number().get()))
});
}
fn priority_rank(priority: IssuePriority) -> u8 {
match priority {
IssuePriority::Low => 0,
IssuePriority::Medium => 1,
IssuePriority::High => 2,
IssuePriority::Critical => 3,
}
}
fn status_rank(status: IssueStatus) -> u8 {
match status {
IssueStatus::Open => 0,
IssueStatus::InProgress => 1,
IssueStatus::Qa => 2,
IssueStatus::Closed => 3,
}
}
async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
let id = ProjectId::from_uuid(
Uuid::parse_str(project_id)
@ -1556,6 +1633,7 @@ mod tests {
assigned_agent_id: None,
sprint_id: None,
text: None,
sort: None,
limit: None,
cursor: None,
})
@ -1580,6 +1658,7 @@ mod tests {
assigned_agent_id: None,
sprint_id: None,
text: None,
sort: None,
limit: None,
cursor: None,
})
@ -1598,6 +1677,7 @@ mod tests {
assigned_agent_id: None,
sprint_id: None,
text: None,
sort: None,
limit: Some(1),
cursor: Some("1".into()),
})
@ -1619,6 +1699,42 @@ mod tests {
assert_eq!(out.next_cursor, Some("2".to_owned()));
}
#[test]
fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() {
let page = TicketListPageInput::from_request(TicketListRequestDto {
project_id: String::new(),
statuses: Vec::new(),
priorities: Vec::new(),
assigned_agent_id: None,
sprint_id: None,
text: None,
sort: Some(TicketListSortDto {
field: TicketListSortFieldDto::Priority,
direction: TicketListSortDirectionDto::Desc,
}),
limit: None,
cursor: None,
})
.unwrap();
let mut rows = vec![
issue_row(4, IssueStatus::Open, IssuePriority::High, "Delta"),
issue_row(2, IssueStatus::Closed, IssuePriority::Critical, "Beta"),
issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"),
issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"),
];
sort_ticket_rows(&mut rows, page.sort);
let out = paginate(rows, page.limit, page.cursor);
assert_eq!(
out.items
.into_iter()
.map(|item| item.r#ref)
.collect::<Vec<_>>(),
vec!["#2", "#3", "#4", "#1"]
);
}
fn issue_row(
number: u64,
status: IssueStatus,

View File

@ -74,6 +74,15 @@ pub fn catalogue() -> Vec<ToolDef> {
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] });
let sort = json!({
"type": "object",
"properties": {
"field": { "type": "string", "enum": ["number", "priority", "status", "title"] },
"direction": { "type": "string", "enum": ["asc", "desc"] }
},
"required": ["field", "direction"],
"additionalProperties": false
});
let link_kind = json!({
"type": "string",
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
@ -137,6 +146,7 @@ pub fn catalogue() -> Vec<ToolDef> {
},
"assignedAgentId": { "type": "string", "format": "uuid" },
"text": { "type": "string" },
"sort": sort.clone(),
"limit": { "type": "integer", "minimum": 1 },
"cursor": { "type": "string" }
},

View File

@ -46,6 +46,8 @@ import type {
TicketLink,
TicketLinkKind,
TicketList,
TicketPriority,
TicketStatus,
TurnPage,
TurnView,
Unsubscribe,
@ -87,6 +89,44 @@ import type {
import { normalizeProjectWorkState } from "../workStateNormalization";
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
const TICKET_PRIORITY_RANK: Record<TicketPriority, number> = {
low: 0,
medium: 1,
high: 2,
critical: 3,
};
const TICKET_STATUS_RANK: Record<TicketStatus, number> = {
open: 0,
inProgress: 1,
QA: 2,
closed: 3,
};
function sortTickets(
rows: Ticket[],
sort: NonNullable<TicketListQuery["sort"]>,
): Ticket[] {
return rows.sort((a, b) => {
let fieldOrder = 0;
if (sort.field === "number") {
fieldOrder = a.number - b.number;
} else if (sort.field === "priority") {
fieldOrder =
TICKET_PRIORITY_RANK[a.priority] - TICKET_PRIORITY_RANK[b.priority];
} else if (sort.field === "status") {
fieldOrder = TICKET_STATUS_RANK[a.status] - TICKET_STATUS_RANK[b.status];
} else {
fieldOrder =
a.title.localeCompare(b.title, undefined, { sensitivity: "base" }) ||
a.title.localeCompare(b.title);
}
const directed = sort.direction === "desc" ? -fieldOrder : fieldOrder;
return directed || a.number - b.number;
});
}
export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>();
@ -1955,7 +1995,7 @@ export class MockTicketGateway implements TicketGateway {
// empty/absent set means "no constraint" (all values pass).
const statuses = q.statuses ?? [];
const priorities = q.priorities ?? [];
const rows = [...this.projectTickets(projectId).values()]
let rows = [...this.projectTickets(projectId).values()]
.filter((t) => statuses.length === 0 || statuses.includes(t.status))
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
.filter(
@ -1967,8 +2007,10 @@ export class MockTicketGateway implements TicketGateway {
!text ||
t.title.toLowerCase().includes(text) ||
t.description.toLowerCase().includes(text),
)
.sort((a, b) => b.number - a.number);
);
rows = q.sort
? sortTickets(rows, q.sort)
: rows.sort((a, b) => b.number - a.number);
const start = Number.parseInt(q.cursor ?? "0", 10) || 0;
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
const end = Math.min(rows.length, start + limit);

View File

@ -713,6 +713,7 @@ export interface ConversationGateway {
* Status and priority are **multi-select**: `statuses`/`priorities` are sets of
* accepted values with OR semantics *within* a facet and AND *across* facets. An
* empty or absent array means "no constraint on this facet" (all values pass).
* `sort` is optional; when absent, the backend preserves its historical order.
*/
export interface TicketListQuery {
statuses?: TicketStatus[];
@ -720,10 +721,20 @@ export interface TicketListQuery {
assignedAgentId?: string;
/** Free-text match over title/description. */
text?: string;
sort?: TicketListSort;
limit?: number;
cursor?: string;
}
export type TicketListSortField = "number" | "priority" | "status" | "title";
export type TicketListSortDirection = "asc" | "desc";
export interface TicketListSort {
field: TicketListSortField;
direction: TicketListSortDirection;
}
/** Input for {@link TicketGateway.create}. */
export interface CreateTicketInput {
title: string;