Merge feature/ticket20-ticketlist-ref-match-opaque-cursor into develop (#20)
ticket_list : matching exact du numéro/#ref dans la recherche texte + curseur de pagination opaque et stable (anchor-based v1, rejet explicite des curseurs invalides). DTO cursor inchangé (non-breaking UI). Tests #20 verts 7/7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -73,6 +73,7 @@ version = "0.3.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"domain",
|
"domain",
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
"interprocess",
|
"interprocess",
|
||||||
|
|||||||
@ -32,6 +32,7 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
base64 = "0.22"
|
||||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
||||||
|
|||||||
@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
#![allow(missing_docs)]
|
#![allow(missing_docs)]
|
||||||
|
|
||||||
|
use std::cmp::Ordering;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@ -208,7 +210,7 @@ pub struct TicketListRequestDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List sort request.
|
/// List sort request.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TicketListSortDto {
|
pub struct TicketListSortDto {
|
||||||
pub field: TicketListSortFieldDto,
|
pub field: TicketListSortFieldDto,
|
||||||
@ -216,7 +218,7 @@ pub struct TicketListSortDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List sort field.
|
/// List sort field.
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum TicketListSortFieldDto {
|
pub enum TicketListSortFieldDto {
|
||||||
Number,
|
Number,
|
||||||
@ -226,7 +228,7 @@ pub enum TicketListSortFieldDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List sort direction.
|
/// List sort direction.
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum TicketListSortDirectionDto {
|
pub enum TicketListSortDirectionDto {
|
||||||
Asc,
|
Asc,
|
||||||
@ -455,7 +457,9 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
.await
|
.await
|
||||||
.map_err(ticket_error)?
|
.map_err(ticket_error)?
|
||||||
.sprints;
|
.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" => {
|
"idea_sprint_list" => {
|
||||||
let rows = self
|
let rows = self
|
||||||
@ -722,7 +726,7 @@ pub async fn ticket_list(
|
|||||||
.map_err(ErrorDto::from)?
|
.map_err(ErrorDto::from)?
|
||||||
.issues;
|
.issues;
|
||||||
sort_ticket_rows(&mut rows, page.sort);
|
sort_ticket_rows(&mut rows, page.sort);
|
||||||
Ok(paginate(rows, page.limit, page.cursor))
|
paginate(rows, page.limit, page.cursor, page.sort)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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> {
|
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)
|
||||||
@ -1305,44 +1333,149 @@ fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paginate(rows: Vec<IssueIndexEntry>, limit: usize, cursor: Option<String>) -> TicketListDto {
|
fn paginate(
|
||||||
let start = cursor
|
rows: Vec<IssueIndexEntry>,
|
||||||
.as_deref()
|
limit: usize,
|
||||||
.and_then(|raw| raw.parse::<usize>().ok())
|
cursor: Option<String>,
|
||||||
.unwrap_or(0);
|
sort: Option<TicketListSortDto>,
|
||||||
let total = rows.len();
|
) -> Result<TicketListDto, ErrorDto> {
|
||||||
let end = total.min(start.saturating_add(limit));
|
paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from)
|
||||||
TicketListDto {
|
|
||||||
items: rows
|
|
||||||
.into_iter()
|
|
||||||
.skip(start)
|
|
||||||
.take(limit)
|
|
||||||
.map(TicketSummaryDto::from)
|
|
||||||
.collect(),
|
|
||||||
next_cursor: (end < total).then(|| end.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paginate_with_sprints(
|
fn paginate_with_sprints(
|
||||||
rows: Vec<IssueIndexEntry>,
|
rows: Vec<IssueIndexEntry>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
cursor: Option<String>,
|
cursor: Option<String>,
|
||||||
|
sort: Option<TicketListSortDto>,
|
||||||
sprints: &[application::SprintListEntry],
|
sprints: &[application::SprintListEntry],
|
||||||
) -> TicketListDto {
|
) -> Result<TicketListDto, ErrorDto> {
|
||||||
let start = cursor
|
paginate_rows(rows, limit, cursor, sort, |row| {
|
||||||
.as_deref()
|
TicketSummaryDto::from_row_with_sprints(row, sprints)
|
||||||
.and_then(|raw| raw.parse::<usize>().ok())
|
})
|
||||||
.unwrap_or(0);
|
}
|
||||||
|
|
||||||
|
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 total = rows.len();
|
||||||
let end = total.min(start.saturating_add(limit));
|
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
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(start)
|
.skip(start)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map(|row| TicketSummaryDto::from_row_with_sprints(row, sprints))
|
.map(map_row)
|
||||||
.collect(),
|
.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,
|
text: None,
|
||||||
sort: None,
|
sort: None,
|
||||||
limit: Some(1),
|
limit: Some(1),
|
||||||
cursor: Some("1".into()),
|
cursor: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let rows = vec![
|
let rows = vec![
|
||||||
@ -1693,10 +1826,13 @@ mod tests {
|
|||||||
vec![IssueStatus::Open, IssueStatus::Qa]
|
vec![IssueStatus::Open, IssueStatus::Qa]
|
||||||
);
|
);
|
||||||
assert_eq!(page.filter.priorities, vec![IssuePriority::High]);
|
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.len(), 1);
|
||||||
assert_eq!(out.items[0].r#ref, "#2");
|
assert_eq!(out.items[0].r#ref, "#1");
|
||||||
assert_eq!(out.next_cursor, Some("2".to_owned()));
|
assert!(out
|
||||||
|
.next_cursor
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|cursor| cursor.starts_with("v1.")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1724,7 +1860,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
sort_ticket_rows(&mut rows, page.sort);
|
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!(
|
assert_eq!(
|
||||||
out.items
|
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(
|
fn issue_row(
|
||||||
number: u64,
|
number: u64,
|
||||||
status: IssueStatus,
|
status: IssueStatus,
|
||||||
@ -1752,4 +1964,8 @@ mod tests {
|
|||||||
updated_at: number,
|
updated_at: number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn refs(out: &TicketListDto) -> Vec<String> {
|
||||||
|
out.items.iter().map(|item| item.r#ref.clone()).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -254,6 +254,9 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
|||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
{
|
{
|
||||||
|
if let Some(number) = parse_issue_search_ref(text) {
|
||||||
|
return row.issue_ref.number() == number;
|
||||||
|
}
|
||||||
row.title
|
row.title
|
||||||
.to_ascii_lowercase()
|
.to_ascii_lowercase()
|
||||||
.contains(&text.to_ascii_lowercase())
|
.contains(&text.to_ascii_lowercase())
|
||||||
@ -262,6 +265,17 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_issue_search_ref(needle: &str) -> Option<IssueNumber> {
|
||||||
|
let trimmed = needle.trim();
|
||||||
|
let raw = trimmed.strip_prefix('#').unwrap_or(trimmed);
|
||||||
|
if raw.is_empty() || !raw.chars().all(|ch| ch.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
raw.parse::<u64>()
|
||||||
|
.ok()
|
||||||
|
.and_then(|number| IssueNumber::new(number).ok())
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl IssueStore for FsIssueStore {
|
impl IssueStore for FsIssueStore {
|
||||||
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
|
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
|
||||||
@ -309,7 +323,9 @@ impl IssueStore for FsIssueStore {
|
|||||||
.is_some_and(|text| !text.trim().is_empty())
|
.is_some_and(|text| !text.trim().is_empty())
|
||||||
{
|
{
|
||||||
// Text search may need description/carnet, so fall back to source files.
|
// Text search may need description/carnet, so fall back to source files.
|
||||||
let needle = filter.text.as_ref().unwrap().trim().to_ascii_lowercase();
|
let raw_needle = filter.text.as_ref().unwrap().trim();
|
||||||
|
let ref_needle = parse_issue_search_ref(raw_needle);
|
||||||
|
let needle = raw_needle.to_ascii_lowercase();
|
||||||
let base_filter = IssueListFilter {
|
let base_filter = IssueListFilter {
|
||||||
text: None,
|
text: None,
|
||||||
..filter
|
..filter
|
||||||
@ -319,6 +335,12 @@ impl IssueStore for FsIssueStore {
|
|||||||
if !filter_matches(&row, &base_filter) {
|
if !filter_matches(&row, &base_filter) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(number) = ref_needle {
|
||||||
|
if row.issue_ref.number() == number {
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if row.title.to_ascii_lowercase().contains(&needle) {
|
if row.title.to_ascii_lowercase().contains(&needle) {
|
||||||
out.push(row);
|
out.push(row);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -184,6 +184,132 @@ async fn issue_store_lists_by_index_filters_with_empty_or_and_and_semantics() {
|
|||||||
assert_eq!(by_status_and_priority[0].title, "Beta");
|
assert_eq!(by_status_and_priority[0].title, "Beta");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn issue_store_text_filter_matches_exact_ref_or_number_without_numeric_substring() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let root = tmp.root();
|
||||||
|
let store = FsIssueStore::new();
|
||||||
|
store.create(&root, &issue(&root, 4, "Four")).await.unwrap();
|
||||||
|
store
|
||||||
|
.create(&root, &issue(&root, 40, "Forty"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.create(&root, &issue(&root, 42, "Forty two"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let by_ref = store
|
||||||
|
.list(
|
||||||
|
&root,
|
||||||
|
IssueListFilter {
|
||||||
|
text: Some("#42".to_owned()),
|
||||||
|
..IssueListFilter::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
by_ref.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
|
||||||
|
vec![IssueRef::from_str("#42").unwrap()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let by_number = store
|
||||||
|
.list(
|
||||||
|
&root,
|
||||||
|
IssueListFilter {
|
||||||
|
text: Some("42".to_owned()),
|
||||||
|
..IssueListFilter::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
by_number
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.issue_ref)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![IssueRef::from_str("#42").unwrap()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let by_padded_number = store
|
||||||
|
.list(
|
||||||
|
&root,
|
||||||
|
IssueListFilter {
|
||||||
|
text: Some("0042".to_owned()),
|
||||||
|
..IssueListFilter::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
by_padded_number
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.issue_ref)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![IssueRef::from_str("#42").unwrap()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let by_single_digit = store
|
||||||
|
.list(
|
||||||
|
&root,
|
||||||
|
IssueListFilter {
|
||||||
|
text: Some("4".to_owned()),
|
||||||
|
..IssueListFilter::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
by_single_digit
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.issue_ref)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![IssueRef::from_str("#4").unwrap()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn issue_store_text_filter_keeps_classic_title_description_and_carnet_matching() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let root = tmp.root();
|
||||||
|
let store = FsIssueStore::new();
|
||||||
|
let by_title = issue(&root, 1, "Needle in title");
|
||||||
|
let by_description = issue(&root, 2, "Plain title")
|
||||||
|
.mutate(IssueActor::System, 2_000, |i| {
|
||||||
|
i.description = MarkdownDoc::new("Needle in description");
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let by_carnet = issue(&root, 3, "Other title")
|
||||||
|
.mutate(IssueActor::System, 2_000, |i| {
|
||||||
|
i.carnet = MarkdownDoc::new("Needle in carnet");
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
store.create(&root, &by_title).await.unwrap();
|
||||||
|
store.create(&root, &by_description).await.unwrap();
|
||||||
|
store.create(&root, &by_carnet).await.unwrap();
|
||||||
|
|
||||||
|
let rows = store
|
||||||
|
.list(
|
||||||
|
&root,
|
||||||
|
IssueListFilter {
|
||||||
|
text: Some("needle".to_owned()),
|
||||||
|
..IssueListFilter::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rows.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
IssueRef::from_str("#1").unwrap(),
|
||||||
|
IssueRef::from_str("#2").unwrap(),
|
||||||
|
IssueRef::from_str("#3").unwrap()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn issue_store_persists_and_filters_sprint_membership() {
|
async fn issue_store_persists_and_filters_sprint_membership() {
|
||||||
let tmp = TempDir::new();
|
let tmp = TempDir::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user