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,