fix(tickets): matching exact du #ref et curseur de pagination opaque/stable pour ticket_list (#20)
Épurement de la dette de ticket_list sur deux axes :
Recherche texte — matching exact du numéro/#ref via parse_issue_search_ref,
court-circuité avant load_issue, sans matching par substring numérique
(« 1 » ne remonte plus « 12 », « 123 »…).
Pagination — curseur opaque et stable anchor-based au lieu d'un offset fragile :
token v1.<base64url-no-pad-json> encodant le tri + l'ancre {number, sortKey},
reprise strictement après l'ancre. Curseur legacy / invalide / de version
inconnue / avec sort divergent rejeté par une erreur explicite « Invalid
cursor ». Le DTO cursor reste String → non-breaking côté UI.
Fichiers : infrastructure/src/issues.rs, app-tauri/src/tickets.rs,
app-tauri/Cargo.toml (+ base64 0.22). Tests : issue_store_text_filter,
ticket_list_ 7/7 (anchor-based + rejets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -32,6 +32,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
base64 = "0.22"
|
||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||
async-trait = { workspace = true }
|
||||
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
||||
|
||||
@ -4,10 +4,12 @@
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
@ -208,7 +210,7 @@ pub struct TicketListRequestDto {
|
||||
}
|
||||
|
||||
/// List sort request.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TicketListSortDto {
|
||||
pub field: TicketListSortFieldDto,
|
||||
@ -216,7 +218,7 @@ pub struct TicketListSortDto {
|
||||
}
|
||||
|
||||
/// List sort field.
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TicketListSortFieldDto {
|
||||
Number,
|
||||
@ -226,7 +228,7 @@ pub enum TicketListSortFieldDto {
|
||||
}
|
||||
|
||||
/// List sort direction.
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TicketListSortDirectionDto {
|
||||
Asc,
|
||||
@ -455,7 +457,9 @@ impl TicketToolProvider for AppTicketToolProvider {
|
||||
.await
|
||||
.map_err(ticket_error)?
|
||||
.sprints;
|
||||
json!(paginate_with_sprints(rows, req.limit, req.cursor, &sprints))
|
||||
let page = paginate_with_sprints(rows, req.limit, req.cursor, req.sort, &sprints)
|
||||
.map_err(|e| TicketToolError::new("invalid", e.message))?;
|
||||
json!(page)
|
||||
}
|
||||
"idea_sprint_list" => {
|
||||
let rows = self
|
||||
@ -722,7 +726,7 @@ pub async fn ticket_list(
|
||||
.map_err(ErrorDto::from)?
|
||||
.issues;
|
||||
sort_ticket_rows(&mut rows, page.sort);
|
||||
Ok(paginate(rows, page.limit, page.cursor))
|
||||
paginate(rows, page.limit, page.cursor, page.sort)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@ -1219,6 +1223,30 @@ fn status_rank(status: IssueStatus) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TicketCursorToken {
|
||||
v: u8,
|
||||
sort: Option<TicketListSortDto>,
|
||||
anchor: TicketCursorAnchor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TicketCursorAnchor {
|
||||
number: u64,
|
||||
sort_key: TicketCursorSortKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
enum TicketCursorSortKey {
|
||||
Number(u64),
|
||||
Priority(u8),
|
||||
Status(u8),
|
||||
Title { lower: String, raw: String },
|
||||
}
|
||||
|
||||
async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
|
||||
let id = ProjectId::from_uuid(
|
||||
Uuid::parse_str(project_id)
|
||||
@ -1305,44 +1333,149 @@ fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto>
|
||||
})
|
||||
}
|
||||
|
||||
fn paginate(rows: Vec<IssueIndexEntry>, limit: usize, cursor: Option<String>) -> TicketListDto {
|
||||
let start = cursor
|
||||
.as_deref()
|
||||
.and_then(|raw| raw.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let total = rows.len();
|
||||
let end = total.min(start.saturating_add(limit));
|
||||
TicketListDto {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(limit)
|
||||
.map(TicketSummaryDto::from)
|
||||
.collect(),
|
||||
next_cursor: (end < total).then(|| end.to_string()),
|
||||
}
|
||||
fn paginate(
|
||||
rows: Vec<IssueIndexEntry>,
|
||||
limit: usize,
|
||||
cursor: Option<String>,
|
||||
sort: Option<TicketListSortDto>,
|
||||
) -> Result<TicketListDto, ErrorDto> {
|
||||
paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from)
|
||||
}
|
||||
|
||||
fn paginate_with_sprints(
|
||||
rows: Vec<IssueIndexEntry>,
|
||||
limit: usize,
|
||||
cursor: Option<String>,
|
||||
sort: Option<TicketListSortDto>,
|
||||
sprints: &[application::SprintListEntry],
|
||||
) -> TicketListDto {
|
||||
let start = cursor
|
||||
.as_deref()
|
||||
.and_then(|raw| raw.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
) -> Result<TicketListDto, ErrorDto> {
|
||||
paginate_rows(rows, limit, cursor, sort, |row| {
|
||||
TicketSummaryDto::from_row_with_sprints(row, sprints)
|
||||
})
|
||||
}
|
||||
|
||||
fn paginate_rows(
|
||||
rows: Vec<IssueIndexEntry>,
|
||||
limit: usize,
|
||||
cursor: Option<String>,
|
||||
sort: Option<TicketListSortDto>,
|
||||
map_row: impl Fn(IssueIndexEntry) -> TicketSummaryDto,
|
||||
) -> Result<TicketListDto, ErrorDto> {
|
||||
let start = cursor_start(&rows, cursor.as_deref(), sort)?;
|
||||
let total = rows.len();
|
||||
let end = total.min(start.saturating_add(limit));
|
||||
TicketListDto {
|
||||
let next_cursor = if end < total && end > start {
|
||||
Some(encode_ticket_cursor(&rows[end - 1], sort)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(TicketListDto {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(limit)
|
||||
.map(|row| TicketSummaryDto::from_row_with_sprints(row, sprints))
|
||||
.map(map_row)
|
||||
.collect(),
|
||||
next_cursor: (end < total).then(|| end.to_string()),
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
fn cursor_start(
|
||||
rows: &[IssueIndexEntry],
|
||||
cursor: Option<&str>,
|
||||
sort: Option<TicketListSortDto>,
|
||||
) -> Result<usize, ErrorDto> {
|
||||
let Some(raw) = cursor else {
|
||||
return Ok(0);
|
||||
};
|
||||
let token = decode_ticket_cursor(raw)?;
|
||||
if token.sort != sort {
|
||||
return Err(ErrorDto::invalid("Invalid cursor: sort mismatch"));
|
||||
}
|
||||
Ok(rows
|
||||
.iter()
|
||||
.position(|row| compare_row_to_anchor(row, &token.anchor, sort) == Ordering::Greater)
|
||||
.unwrap_or(rows.len()))
|
||||
}
|
||||
|
||||
fn encode_ticket_cursor(
|
||||
row: &IssueIndexEntry,
|
||||
sort: Option<TicketListSortDto>,
|
||||
) -> Result<String, ErrorDto> {
|
||||
let token = TicketCursorToken {
|
||||
v: 1,
|
||||
sort,
|
||||
anchor: row_cursor_anchor(row, sort),
|
||||
};
|
||||
let json = serde_json::to_vec(&token)
|
||||
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
|
||||
Ok(format!("v1.{}", URL_SAFE_NO_PAD.encode(json)))
|
||||
}
|
||||
|
||||
fn decode_ticket_cursor(raw: &str) -> Result<TicketCursorToken, ErrorDto> {
|
||||
let encoded = raw
|
||||
.strip_prefix("v1.")
|
||||
.ok_or_else(|| ErrorDto::invalid("Invalid cursor: unknown version"))?;
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
|
||||
let token: TicketCursorToken = serde_json::from_slice(&bytes)
|
||||
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
|
||||
if token.v != 1 {
|
||||
return Err(ErrorDto::invalid("Invalid cursor: unknown version"));
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn row_cursor_anchor(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorAnchor {
|
||||
TicketCursorAnchor {
|
||||
number: row.issue_ref.number().get(),
|
||||
sort_key: row_sort_key(row, sort),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_sort_key(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorSortKey {
|
||||
match sort.map(|sort| sort.field) {
|
||||
None | Some(TicketListSortFieldDto::Number) => {
|
||||
TicketCursorSortKey::Number(row.issue_ref.number().get())
|
||||
}
|
||||
Some(TicketListSortFieldDto::Priority) => {
|
||||
TicketCursorSortKey::Priority(priority_rank(row.priority))
|
||||
}
|
||||
Some(TicketListSortFieldDto::Status) => {
|
||||
TicketCursorSortKey::Status(status_rank(row.status))
|
||||
}
|
||||
Some(TicketListSortFieldDto::Title) => TicketCursorSortKey::Title {
|
||||
lower: row.title.to_lowercase(),
|
||||
raw: row.title.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_row_to_anchor(
|
||||
row: &IssueIndexEntry,
|
||||
anchor: &TicketCursorAnchor,
|
||||
sort: Option<TicketListSortDto>,
|
||||
) -> Ordering {
|
||||
let row_key = row_sort_key(row, sort);
|
||||
let field_order = compare_sort_key(&row_key, &anchor.sort_key);
|
||||
let directed = match sort.map(|sort| sort.direction) {
|
||||
Some(TicketListSortDirectionDto::Desc) => field_order.reverse(),
|
||||
None | Some(TicketListSortDirectionDto::Asc) => field_order,
|
||||
};
|
||||
directed.then_with(|| row.issue_ref.number().get().cmp(&anchor.number))
|
||||
}
|
||||
|
||||
fn compare_sort_key(a: &TicketCursorSortKey, b: &TicketCursorSortKey) -> Ordering {
|
||||
match (a, b) {
|
||||
(TicketCursorSortKey::Number(a), TicketCursorSortKey::Number(b)) => a.cmp(b),
|
||||
(TicketCursorSortKey::Priority(a), TicketCursorSortKey::Priority(b))
|
||||
| (TicketCursorSortKey::Status(a), TicketCursorSortKey::Status(b)) => a.cmp(b),
|
||||
(
|
||||
TicketCursorSortKey::Title { lower: al, raw: ar },
|
||||
TicketCursorSortKey::Title { lower: bl, raw: br },
|
||||
) => al.cmp(bl).then_with(|| ar.cmp(br)),
|
||||
_ => Ordering::Equal,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1679,7 +1812,7 @@ mod tests {
|
||||
text: None,
|
||||
sort: None,
|
||||
limit: Some(1),
|
||||
cursor: Some("1".into()),
|
||||
cursor: None,
|
||||
})
|
||||
.unwrap();
|
||||
let rows = vec![
|
||||
@ -1693,10 +1826,13 @@ mod tests {
|
||||
vec![IssueStatus::Open, IssueStatus::Qa]
|
||||
);
|
||||
assert_eq!(page.filter.priorities, vec![IssuePriority::High]);
|
||||
let out = paginate(rows, page.limit, page.cursor);
|
||||
let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();
|
||||
assert_eq!(out.items.len(), 1);
|
||||
assert_eq!(out.items[0].r#ref, "#2");
|
||||
assert_eq!(out.next_cursor, Some("2".to_owned()));
|
||||
assert_eq!(out.items[0].r#ref, "#1");
|
||||
assert!(out
|
||||
.next_cursor
|
||||
.as_deref()
|
||||
.is_some_and(|cursor| cursor.starts_with("v1.")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1724,7 +1860,7 @@ mod tests {
|
||||
];
|
||||
|
||||
sort_ticket_rows(&mut rows, page.sort);
|
||||
let out = paginate(rows, page.limit, page.cursor);
|
||||
let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.items
|
||||
@ -1735,6 +1871,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_list_cursor_is_anchor_based_when_items_are_inserted_or_removed_before_anchor() {
|
||||
let rows = vec![
|
||||
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
||||
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
|
||||
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
||||
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
||||
];
|
||||
let first = paginate(rows, 2, None, None).unwrap();
|
||||
let cursor = first.next_cursor.clone().expect("next cursor");
|
||||
assert_eq!(refs(&first), vec!["#10", "#20"]);
|
||||
|
||||
let with_insert_before_anchor = vec![
|
||||
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
||||
issue_row(15, IssueStatus::Open, IssuePriority::High, "Inserted"),
|
||||
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
|
||||
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
||||
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
||||
];
|
||||
let second = paginate(with_insert_before_anchor, 2, Some(cursor.clone()), None).unwrap();
|
||||
assert_eq!(refs(&second), vec!["#30", "#40"]);
|
||||
|
||||
let with_removed_before_anchor = vec![
|
||||
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
|
||||
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
||||
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
||||
];
|
||||
let second = paginate(with_removed_before_anchor, 2, Some(cursor.clone()), None).unwrap();
|
||||
assert_eq!(refs(&second), vec!["#30", "#40"]);
|
||||
|
||||
let with_removed_anchor = vec![
|
||||
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
||||
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
||||
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
||||
];
|
||||
let second = paginate(with_removed_anchor, 2, Some(cursor), None).unwrap();
|
||||
assert_eq!(refs(&second), vec!["#30", "#40"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_list_cursor_rejects_legacy_or_invalid_tokens() {
|
||||
let rows = vec![issue_row(
|
||||
1,
|
||||
IssueStatus::Open,
|
||||
IssuePriority::High,
|
||||
"Alpha",
|
||||
)];
|
||||
|
||||
for cursor in ["2", "v1.not-base64", "v2.abc"] {
|
||||
let err = paginate(rows.clone(), 1, Some(cursor.to_owned()), None).unwrap_err();
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(err.message.contains("Invalid cursor"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_list_cursor_rejects_sort_mismatch() {
|
||||
let mut rows = vec![
|
||||
issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"),
|
||||
issue_row(2, IssueStatus::Open, IssuePriority::Critical, "Beta"),
|
||||
issue_row(3, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
||||
];
|
||||
let priority_sort = Some(TicketListSortDto {
|
||||
field: TicketListSortFieldDto::Priority,
|
||||
direction: TicketListSortDirectionDto::Desc,
|
||||
});
|
||||
sort_ticket_rows(&mut rows, priority_sort);
|
||||
let first = paginate(rows.clone(), 1, None, priority_sort).unwrap();
|
||||
let cursor = first.next_cursor.expect("next cursor");
|
||||
|
||||
let err = paginate(rows, 1, Some(cursor), None).unwrap_err();
|
||||
|
||||
assert_eq!(err.code, "INVALID");
|
||||
assert!(err.message.contains("sort mismatch"));
|
||||
}
|
||||
|
||||
fn issue_row(
|
||||
number: u64,
|
||||
status: IssueStatus,
|
||||
@ -1752,4 +1964,8 @@ mod tests {
|
||||
updated_at: number,
|
||||
}
|
||||
}
|
||||
|
||||
fn refs(out: &TicketListDto) -> Vec<String> {
|
||||
out.items.iter().map(|item| item.r#ref.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user