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,
|
||||
|
||||
Reference in New Issue
Block a user