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 assigned_agent_id: Option<String>,
|
||||||
pub sprint_id: Option<String>,
|
pub sprint_id: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
pub sort: Option<TicketListSortDto>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
pub cursor: Option<String>,
|
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.
|
/// Update request.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -410,7 +437,7 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
}
|
}
|
||||||
"idea_ticket_list" => {
|
"idea_ticket_list" => {
|
||||||
let req = mcp_list_request(project, arguments)?;
|
let req = mcp_list_request(project, arguments)?;
|
||||||
let rows = self
|
let mut rows = self
|
||||||
.list
|
.list
|
||||||
.execute(ListIssuesInput {
|
.execute(ListIssuesInput {
|
||||||
project: project.clone(),
|
project: project.clone(),
|
||||||
@ -419,6 +446,7 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
.await
|
.await
|
||||||
.map_err(ticket_error)?
|
.map_err(ticket_error)?
|
||||||
.issues;
|
.issues;
|
||||||
|
sort_ticket_rows(&mut rows, req.sort);
|
||||||
let sprints = self
|
let sprints = self
|
||||||
.list_sprints
|
.list_sprints
|
||||||
.execute(ListSprintsInput {
|
.execute(ListSprintsInput {
|
||||||
@ -684,7 +712,7 @@ pub async fn ticket_list(
|
|||||||
) -> Result<TicketListDto, ErrorDto> {
|
) -> Result<TicketListDto, ErrorDto> {
|
||||||
let project = resolve_project(&state, &request.project_id).await?;
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
let page = TicketListPageInput::from_request(request)?;
|
let page = TicketListPageInput::from_request(request)?;
|
||||||
let rows = state
|
let mut rows = state
|
||||||
.list_issues
|
.list_issues
|
||||||
.execute(ListIssuesInput {
|
.execute(ListIssuesInput {
|
||||||
project,
|
project,
|
||||||
@ -693,6 +721,7 @@ pub async fn ticket_list(
|
|||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?
|
.map_err(ErrorDto::from)?
|
||||||
.issues;
|
.issues;
|
||||||
|
sort_ticket_rows(&mut rows, page.sort);
|
||||||
Ok(paginate(rows, page.limit, page.cursor))
|
Ok(paginate(rows, page.limit, page.cursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1114,6 +1143,7 @@ impl From<IssueCarnet> for TicketCarnetDto {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TicketListPageInput {
|
struct TicketListPageInput {
|
||||||
filter: IssueListFilter,
|
filter: IssueListFilter,
|
||||||
|
sort: Option<TicketListSortDto>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
cursor: Option<String>,
|
cursor: Option<String>,
|
||||||
}
|
}
|
||||||
@ -1136,12 +1166,59 @@ impl TicketListPageInput {
|
|||||||
.transpose()?,
|
.transpose()?,
|
||||||
text: request.text,
|
text: request.text,
|
||||||
},
|
},
|
||||||
|
sort: request.sort,
|
||||||
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
||||||
cursor: request.cursor,
|
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> {
|
async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
|
||||||
let id = ProjectId::from_uuid(
|
let id = ProjectId::from_uuid(
|
||||||
Uuid::parse_str(project_id)
|
Uuid::parse_str(project_id)
|
||||||
@ -1556,6 +1633,7 @@ mod tests {
|
|||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
|
sort: None,
|
||||||
limit: None,
|
limit: None,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
})
|
})
|
||||||
@ -1580,6 +1658,7 @@ mod tests {
|
|||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
|
sort: None,
|
||||||
limit: None,
|
limit: None,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
})
|
})
|
||||||
@ -1598,6 +1677,7 @@ mod tests {
|
|||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
|
sort: None,
|
||||||
limit: Some(1),
|
limit: Some(1),
|
||||||
cursor: Some("1".into()),
|
cursor: Some("1".into()),
|
||||||
})
|
})
|
||||||
@ -1619,6 +1699,42 @@ mod tests {
|
|||||||
assert_eq!(out.next_cursor, Some("2".to_owned()));
|
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(
|
fn issue_row(
|
||||||
number: u64,
|
number: u64,
|
||||||
status: IssueStatus,
|
status: IssueStatus,
|
||||||
|
|||||||
@ -74,6 +74,15 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
|
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
|
||||||
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
|
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
|
||||||
let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] });
|
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!({
|
let link_kind = json!({
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
|
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
|
||||||
@ -137,6 +146,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
},
|
},
|
||||||
"assignedAgentId": { "type": "string", "format": "uuid" },
|
"assignedAgentId": { "type": "string", "format": "uuid" },
|
||||||
"text": { "type": "string" },
|
"text": { "type": "string" },
|
||||||
|
"sort": sort.clone(),
|
||||||
"limit": { "type": "integer", "minimum": 1 },
|
"limit": { "type": "integer", "minimum": 1 },
|
||||||
"cursor": { "type": "string" }
|
"cursor": { "type": "string" }
|
||||||
},
|
},
|
||||||
|
|||||||
@ -46,6 +46,8 @@ import type {
|
|||||||
TicketLink,
|
TicketLink,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
TicketList,
|
TicketList,
|
||||||
|
TicketPriority,
|
||||||
|
TicketStatus,
|
||||||
TurnPage,
|
TurnPage,
|
||||||
TurnView,
|
TurnView,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
@ -87,6 +89,44 @@ import type {
|
|||||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||||
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
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 {
|
export class MockSystemGateway implements SystemGateway {
|
||||||
private listeners = new Set<(e: DomainEvent) => void>();
|
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).
|
// empty/absent set means "no constraint" (all values pass).
|
||||||
const statuses = q.statuses ?? [];
|
const statuses = q.statuses ?? [];
|
||||||
const priorities = q.priorities ?? [];
|
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) => statuses.length === 0 || statuses.includes(t.status))
|
||||||
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
|
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
|
||||||
.filter(
|
.filter(
|
||||||
@ -1967,8 +2007,10 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
!text ||
|
!text ||
|
||||||
t.title.toLowerCase().includes(text) ||
|
t.title.toLowerCase().includes(text) ||
|
||||||
t.description.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 start = Number.parseInt(q.cursor ?? "0", 10) || 0;
|
||||||
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
|
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
|
||||||
const end = Math.min(rows.length, start + limit);
|
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
|
* Status and priority are **multi-select**: `statuses`/`priorities` are sets of
|
||||||
* accepted values with OR semantics *within* a facet and AND *across* facets. An
|
* 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).
|
* 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 {
|
export interface TicketListQuery {
|
||||||
statuses?: TicketStatus[];
|
statuses?: TicketStatus[];
|
||||||
@ -720,10 +721,20 @@ export interface TicketListQuery {
|
|||||||
assignedAgentId?: string;
|
assignedAgentId?: string;
|
||||||
/** Free-text match over title/description. */
|
/** Free-text match over title/description. */
|
||||||
text?: string;
|
text?: string;
|
||||||
|
sort?: TicketListSort;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
cursor?: string;
|
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}. */
|
/** Input for {@link TicketGateway.create}. */
|
||||||
export interface CreateTicketInput {
|
export interface CreateTicketInput {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user