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