From e523f444255500c26e03a7a437b1e0dac13c452c Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 7 Jul 2026 11:45:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20tri=20de=20la=20liste=20de=20t?= =?UTF-8?q?ickets=20c=C3=B4t=C3=A9=20backend=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/tickets.rs | 120 +++++++++++++++++- .../src/orchestrator/mcp/tickets.rs | 10 ++ frontend/src/adapters/mock/index.ts | 48 ++++++- frontend/src/ports/index.ts | 11 ++ 4 files changed, 184 insertions(+), 5 deletions(-) diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index ef813c3..c0ed986 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -202,10 +202,37 @@ pub struct TicketListRequestDto { pub assigned_agent_id: Option, pub sprint_id: Option, pub text: Option, + pub sort: Option, pub limit: Option, pub cursor: Option, } +/// 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 { 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 for TicketCarnetDto { #[derive(Debug)] struct TicketListPageInput { filter: IssueListFilter, + sort: Option, limit: usize, cursor: Option, } @@ -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) { + 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 { 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!["#2", "#3", "#4", "#1"] + ); + } + fn issue_row( number: u64, status: IssueStatus, diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs index c6f9e6b..31d1073 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tickets.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -74,6 +74,15 @@ pub fn catalogue() -> Vec { 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 { }, "assignedAgentId": { "type": "string", "format": "uuid" }, "text": { "type": "string" }, + "sort": sort.clone(), "limit": { "type": "integer", "minimum": 1 }, "cursor": { "type": "string" } }, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 4f9115f..dc04083 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 = { + low: 0, + medium: 1, + high: 2, + critical: 3, +}; + +const TICKET_STATUS_RANK: Record = { + open: 0, + inProgress: 1, + QA: 2, + closed: 3, +}; + +function sortTickets( + rows: Ticket[], + sort: NonNullable, +): 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); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 0c9d597..00fd5ef 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -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;