use std::cmp::Ordering; use std::str::FromStr; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use application::{ BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput, }; use domain::{ AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus, SprintVersion, }; use infrastructure::TicketToolError; use crate::dto::ErrorDto; /// Public full ticket DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketDto { pub id: String, pub r#ref: String, pub number: u64, pub title: String, pub description: String, pub status: String, pub priority: String, pub sprint_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sprint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub carnet: Option, pub links: Vec, pub assigned_agent_ids: Vec, pub attachments: Vec, pub created_by: TicketActorDto, pub updated_by: TicketActorDto, pub created_at: u64, pub updated_at: u64, pub version: u64, } /// Public ticket summary DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketSummaryDto { pub r#ref: String, pub path: String, pub title: String, pub status: String, pub priority: String, pub sprint_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sprint: Option, pub assigned_agent_ids: Vec, pub created_by: TicketActorDto, pub updated_at: u64, } /// Public list DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketListDto { pub items: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, } /// Public attachment metadata DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketAttachmentDto { pub id: String, pub filename: String, pub mime: String, pub size_bytes: u64, pub added_by: TicketActorDto, pub added_at: u64, pub summarized_in_carnet: bool, #[serde(skip_serializing_if = "Option::is_none")] pub summarized_by: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summarized_at: Option, } /// Raw attachment content DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketAttachmentContentDto { pub attachment: TicketAttachmentDto, pub content_base64: String, } /// Sprint context embedded in ticket read/list responses. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketSprintContextDto { pub order: u32, pub name: String, } /// Public sprint DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintDto { pub id: String, pub order: u32, pub name: String, pub status: String, pub ticket_count: usize, pub version: u64, } /// Public sprint list DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintListDto { pub items: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenTicketChatRequestDto { pub project_id: String, pub issue_ref: String, pub profile_id: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CloseTicketChatRequestDto { pub project_id: String, pub issue_ref: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketChatDto { pub session_id: String, pub requester: String, pub issue_ref: String, } /// Public link DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketLinkDto { pub target_ref: String, pub kind: String, } /// Public actor DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TicketActorDto { User, Agent { agent_id: String }, System, } /// Public creator filter DTO. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TicketCreatorFilterDto { User, Agent { agent_id: String }, } /// Carnet read DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketCarnetDto { pub r#ref: String, pub carnet: String, pub version: u64, } /// Create request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketCreateRequestDto { #[serde(default)] pub project_id: String, pub title: String, pub description: Option, pub priority: Option, pub status: Option, pub assigned_agent_ids: Option>, pub links: Option>, } /// Read request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketReadRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub include_carnet: Option, } /// Delete request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketDeleteRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, } /// Add attachment request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketAttachmentAddRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub path: String, pub mime: Option, pub expected_version: u64, } /// Read attachment request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketAttachmentReadRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub attachment_id: String, } /// Mark attachment summarized request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketAttachmentMarkSummarizedRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub attachment_id: String, pub expected_version: u64, } /// List request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketListRequestDto { #[serde(default)] pub project_id: String, #[serde(default)] pub statuses: Vec, #[serde(default)] pub priorities: Vec, pub assigned_agent_id: Option, pub created_by: Option, pub sprint_id: Option, pub text: Option, pub sort: Option, pub limit: Option, pub cursor: Option, } /// Bulk status update request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketBulkUpdateStatusRequestDto { #[serde(default)] pub project_id: String, pub refs: Vec, pub status: String, } /// Bulk priority update request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketBulkUpdatePriorityRequestDto { #[serde(default)] pub project_id: String, pub refs: Vec, pub priority: String, } /// Bulk delete request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketBulkDeleteRequestDto { #[serde(default)] pub project_id: String, pub refs: Vec, } /// Bulk operation response. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketBulkResultDto { pub items: Vec, } /// Per-ticket bulk operation result. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketBulkResultItemDto { pub r#ref: String, pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] pub ticket: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// List sort request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketListSortDto { pub field: TicketListSortFieldDto, pub direction: TicketListSortDirectionDto, } /// List sort field. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TicketListSortFieldDto { Number, Priority, Status, Title, } /// List sort direction. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TicketListSortDirectionDto { Asc, Desc, } /// Update request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketUpdateRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub title: Option, pub description: Option, pub status: Option, pub priority: Option, pub assigned_agent_ids: Option>, pub expected_version: u64, } /// Carnet update request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketUpdateCarnetRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub carnet: String, pub expected_version: u64, } /// Link mutation request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketLinkRequestDto { pub target_ref: String, pub kind: String, } /// Link command request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketLinkCommandRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub target_ref: String, pub kind: String, pub expected_version: u64, } /// Unlink command request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketUnlinkCommandRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub target_ref: String, pub kind: Option, pub expected_version: u64, } /// Assign command request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketAssignRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub agent_id: String, pub assigned: bool, pub expected_version: u64, } /// Create sprint request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintCreateRequestDto { #[serde(default)] pub project_id: String, pub name: String, pub status: Option, } /// List sprint request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintListRequestDto { #[serde(default)] pub project_id: String, } /// Rename sprint request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintRenameRequestDto { #[serde(default)] pub project_id: String, pub sprint_id: String, pub name: String, pub expected_version: u64, } /// Reorder sprint request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintReorderRequestDto { #[serde(default)] pub project_id: String, pub ordered_ids: Vec, } /// Delete sprint request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SprintDeleteRequestDto { #[serde(default)] pub project_id: String, pub sprint_id: String, } /// Assign ticket to sprint request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketSprintAssignRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub sprint_id: String, pub expected_version: u64, } /// Unassign ticket from sprint request. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketSprintUnassignRequestDto { #[serde(default)] pub project_id: String, pub r#ref: String, pub expected_version: u64, } impl TicketDto { pub fn from_issue(issue: Issue, carnet: Option) -> Self { Self { id: issue.id.to_string(), r#ref: issue.reference().to_string(), number: issue.number.get(), title: issue.title, description: issue.description.as_str().to_owned(), status: status_wire(issue.status).to_owned(), priority: priority_wire(issue.priority).to_owned(), sprint_id: issue.sprint.map(|id| id.to_string()), sprint: None, carnet, links: issue.links.into_iter().map(TicketLinkDto::from).collect(), assigned_agent_ids: issue .agent_refs .iter() .filter(|r| r.role == AgentIssueRole::Assigned) .map(|r| r.agent_id.to_string()) .collect(), attachments: issue .attachments .into_iter() .map(TicketAttachmentDto::from) .collect(), created_by: TicketActorDto::from(issue.created_by), updated_by: TicketActorDto::from(issue.updated_by), created_at: issue.created_at, updated_at: issue.updated_at, version: issue.version.get(), } } pub fn from_issue_with_sprints( issue: Issue, carnet: Option, sprints: &[application::SprintListEntry], ) -> Self { let sprint = issue .sprint .and_then(|id| ticket_sprint_context(id, sprints)); let mut dto = Self::from_issue(issue, carnet); dto.sprint = sprint; dto } } impl From for TicketSummaryDto { fn from(row: IssueIndexEntry) -> Self { Self { r#ref: row.issue_ref.to_string(), path: row.path, title: row.title, status: status_wire(row.status).to_owned(), priority: priority_wire(row.priority).to_owned(), sprint_id: row.sprint.map(|id| id.to_string()), sprint: None, assigned_agent_ids: row .assigned_agent_ids .into_iter() .map(|id| id.to_string()) .collect(), created_by: TicketActorDto::from(row.created_by), updated_at: row.updated_at, } } } impl TicketSummaryDto { fn from_row_with_sprints( row: IssueIndexEntry, sprints: &[application::SprintListEntry], ) -> Self { let sprint = row.sprint.and_then(|id| ticket_sprint_context(id, sprints)); let mut dto = Self::from(row); dto.sprint = sprint; dto } } impl SprintDto { pub fn from_sprint(sprint: domain::Sprint, ticket_count: usize) -> Self { Self { id: sprint.id.to_string(), order: sprint.order.get(), name: sprint.name, status: sprint_status_wire(sprint.status).to_owned(), ticket_count, version: sprint.version.get(), } } } impl From for SprintDto { fn from(entry: application::SprintListEntry) -> Self { Self { id: entry.sprint.id.to_string(), order: entry.sprint.order.get(), name: entry.sprint.name, status: sprint_status_wire(entry.sprint.status).to_owned(), ticket_count: entry.ticket_count, version: entry.sprint.version.get(), } } } impl From> for SprintListDto { fn from(items: Vec) -> Self { Self { items: items.into_iter().map(SprintDto::from).collect(), } } } impl From for TicketLinkDto { fn from(link: IssueLink) -> Self { Self { target_ref: link.target.to_string(), kind: link_kind_wire(link.kind).to_owned(), } } } impl From for TicketActorDto { fn from(actor: IssueActor) -> Self { match actor { IssueActor::User => Self::User, IssueActor::Agent { agent_id } => Self::Agent { agent_id: agent_id.to_string(), }, IssueActor::System => Self::System, } } } impl From for TicketAttachmentDto { fn from(attachment: IssueAttachment) -> Self { Self { id: attachment.id.as_str().to_owned(), filename: attachment.filename, mime: attachment.mime, size_bytes: attachment.size_bytes, added_by: TicketActorDto::from(attachment.added_by), added_at: attachment.added_at, summarized_in_carnet: attachment.summarized_in_carnet, summarized_by: attachment.summarized_by.map(TicketActorDto::from), summarized_at: attachment.summarized_at, } } } impl From for TicketAttachmentContentDto { fn from(content: IssueAttachmentContent) -> Self { Self { attachment: TicketAttachmentDto::from(content.attachment), content_base64: URL_SAFE_NO_PAD.encode(content.bytes), } } } impl TryFrom for IssueActor { type Error = ErrorDto; fn try_from(actor: TicketCreatorFilterDto) -> Result { match actor { TicketCreatorFilterDto::User => Ok(Self::User), TicketCreatorFilterDto::Agent { agent_id } => Ok(Self::Agent { agent_id: parse_agent_id_dto(&agent_id)?, }), } } } pub fn parse_attachment_id_dto(raw: &str) -> Result { IssueAttachmentId::new(raw.to_owned()).map_err(|err| ErrorDto { code: "INVALID".to_owned(), message: err.to_string(), }) } impl From for TicketCarnetDto { fn from(carnet: IssueCarnet) -> Self { Self { r#ref: carnet.issue_ref.to_string(), carnet: carnet.carnet.as_str().to_owned(), version: carnet.version.get(), } } } #[derive(Debug)] pub struct TicketListPageInput { pub filter: IssueListFilter, pub sort: Option, pub limit: usize, pub cursor: Option, } impl TicketListPageInput { pub fn from_request(request: TicketListRequestDto) -> Result { Ok(Self { filter: IssueListFilter { statuses: parse_statuses_dto(request.statuses)?, priorities: parse_priorities_dto(request.priorities)?, assigned_agent_id: request .assigned_agent_id .as_deref() .map(parse_agent_id_dto) .transpose()?, created_by: request.created_by.map(IssueActor::try_from).transpose()?, sprint: request .sprint_id .as_deref() .map(parse_sprint_id_dto) .transpose()?, text: request.text, }, sort: request.sort, limit: request.limit.unwrap_or(100).clamp(1, 500), cursor: request.cursor, }) } } pub fn sort_ticket_rows(rows: &mut [IssueIndexEntry], sort: Option) { 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, } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct TicketCursorToken { v: u8, sort: Option, 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 }, } pub fn create_input( project: Project, request: TicketCreateRequestDto, actor: IssueActor, ) -> Result { Ok(CreateIssueInput { project, title: request.title, description: request.description.unwrap_or_default(), priority: request .priority .as_deref() .map(parse_priority_dto) .transpose()? .unwrap_or(IssuePriority::Medium), status: request .status .as_deref() .map(parse_status_dto) .transpose()? .unwrap_or(IssueStatus::Open), links: request .links .unwrap_or_default() .into_iter() .map(parse_link_request) .collect::, _>>()?, assigned_agent_ids: request .assigned_agent_ids .unwrap_or_default() .iter() .map(|id| parse_agent_id_dto(id)) .collect::, _>>()?, actor, }) } pub fn update_input( project: Project, request: TicketUpdateRequestDto, actor: IssueActor, ) -> Result { Ok(UpdateIssueInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, expected_version: version_dto(request.expected_version)?, title: request.title, description: request.description, status: request .status .as_deref() .map(parse_status_dto) .transpose()?, priority: request .priority .as_deref() .map(parse_priority_dto) .transpose()?, assigned_agent_ids: request .assigned_agent_ids .map(|ids| ids.iter().map(|id| parse_agent_id_dto(id)).collect()) .transpose()?, actor, }) } pub fn bulk_update_status_input( project: Project, request: TicketBulkUpdateStatusRequestDto, actor: IssueActor, ) -> Result { Ok(BulkUpdateIssueStatusInput { project, issue_refs: parse_bulk_refs(request.refs)?, status: parse_status_dto(&request.status)?, actor, }) } pub fn bulk_update_priority_input( project: Project, request: TicketBulkUpdatePriorityRequestDto, actor: IssueActor, ) -> Result { Ok(BulkUpdateIssuePriorityInput { project, issue_refs: parse_bulk_refs(request.refs)?, priority: parse_priority_dto(&request.priority)?, actor, }) } pub fn bulk_delete_input( project: Project, request: TicketBulkDeleteRequestDto, ) -> Result { Ok(BulkDeleteIssuesInput { project, issue_refs: parse_bulk_refs(request.refs)?, }) } impl From for TicketBulkResultDto { fn from(output: BulkIssueMutationOutput) -> Self { Self { items: output .items .into_iter() .map(|item| TicketBulkResultItemDto { r#ref: item.issue_ref.to_string(), ok: item.error.is_none(), ticket: item.issue.map(|issue| TicketDto::from_issue(issue, None)), error: item.error.map(ErrorDto::from), }) .collect(), } } } fn parse_link_request(link: TicketLinkRequestDto) -> Result { Ok(IssueLink { target: parse_ref_dto(&link.target_ref)?, kind: parse_link_kind_dto(&link.kind)?, }) } fn parse_bulk_refs(raw: Vec) -> Result, ErrorDto> { if raw.is_empty() { return Err(ErrorDto::invalid("refs must not be empty")); } let mut refs = Vec::with_capacity(raw.len()); let mut seen = std::collections::HashSet::with_capacity(raw.len()); for item in raw { let issue_ref = parse_ref_dto(&item)?; if !seen.insert(issue_ref) { return Err(ErrorDto::invalid(format!( "duplicate ticket ref: {issue_ref}" ))); } refs.push(issue_ref); } Ok(refs) } pub fn paginate( rows: Vec, limit: usize, cursor: Option, sort: Option, ) -> Result { paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from) } pub fn paginate_with_sprints( rows: Vec, limit: usize, cursor: Option, sort: Option, sprints: &[application::SprintListEntry], ) -> Result { paginate_rows(rows, limit, cursor, sort, |row| { TicketSummaryDto::from_row_with_sprints(row, sprints) }) } fn paginate_rows( rows: Vec, limit: usize, cursor: Option, sort: Option, map_row: impl Fn(IssueIndexEntry) -> TicketSummaryDto, ) -> Result { let start = cursor_start(&rows, cursor.as_deref(), sort)?; let total = rows.len(); let end = total.min(start.saturating_add(limit)); 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(map_row) .collect(), next_cursor, }) } fn cursor_start( rows: &[IssueIndexEntry], cursor: Option<&str>, sort: Option, ) -> Result { 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, ) -> Result { 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 { 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) -> TicketCursorAnchor { TicketCursorAnchor { number: row.issue_ref.number().get(), sort_key: row_sort_key(row, sort), } } fn row_sort_key(row: &IssueIndexEntry, sort: Option) -> 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, ) -> 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, } } fn ticket_sprint_context( sprint_id: SprintId, sprints: &[application::SprintListEntry], ) -> Option { sprints .iter() .find(|entry| entry.sprint.id == sprint_id) .map(|entry| TicketSprintContextDto { order: entry.sprint.order.get(), name: entry.sprint.name.clone(), }) } pub fn actor_from_requester(requester: &str) -> IssueActor { Uuid::parse_str(requester) .ok() .map(|uuid| IssueActor::Agent { agent_id: AgentId::from_uuid(uuid), }) .unwrap_or(IssueActor::User) } pub fn parse_ref_dto(raw: &str) -> Result { IssueRef::from_str(raw).map_err(|e| ErrorDto::invalid(e.to_string())) } pub fn parse_agent_id_dto(raw: &str) -> Result { Uuid::parse_str(raw) .map(AgentId::from_uuid) .map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}"))) } pub fn parse_profile_id_dto(raw: &str) -> Result { Uuid::parse_str(raw) .map(ProfileId::from_uuid) .map_err(|e| ErrorDto::invalid(format!("invalid profile id: {e}"))) } pub fn parse_sprint_id_dto(raw: &str) -> Result { Uuid::parse_str(raw) .map(SprintId::from_uuid) .map_err(|e| ErrorDto::invalid(format!("invalid sprint id: {e}"))) } pub fn version_dto(raw: u64) -> Result { IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) } pub fn sprint_version_dto(raw: u64) -> Result { SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) } pub fn parse_status_dto(raw: &str) -> Result { parse_status(raw).map_err(|e| ErrorDto::invalid(e.message)) } pub fn parse_priority_dto(raw: &str) -> Result { parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message)) } fn parse_statuses_dto(raw: Vec) -> Result, ErrorDto> { let mut out = Vec::new(); for item in raw { let status = parse_status_dto(&item)?; if !out.contains(&status) { out.push(status); } } Ok(out) } fn parse_priorities_dto(raw: Vec) -> Result, ErrorDto> { let mut out = Vec::new(); for item in raw { let priority = parse_priority_dto(&item)?; if !out.contains(&priority) { out.push(priority); } } Ok(out) } pub fn parse_link_kind_dto(raw: &str) -> Result { parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message)) } pub fn parse_sprint_status_dto(raw: &str) -> Result { match raw { "planned" => Ok(SprintStatus::Planned), "active" => Ok(SprintStatus::Active), "done" => Ok(SprintStatus::Done), _ => Err(ErrorDto::invalid(format!("invalid sprint status: {raw}"))), } } pub fn parse_status(raw: &str) -> Result { match raw { "open" => Ok(IssueStatus::Open), "inProgress" => Ok(IssueStatus::InProgress), "QA" => Ok(IssueStatus::Qa), "closed" => Ok(IssueStatus::Closed), _ => Err(TicketToolError::new( "invalid", format!("invalid ticket status: {raw}"), )), } } pub fn parse_priority(raw: &str) -> Result { match raw { "low" => Ok(IssuePriority::Low), "medium" => Ok(IssuePriority::Medium), "high" => Ok(IssuePriority::High), "critical" => Ok(IssuePriority::Critical), _ => Err(TicketToolError::new( "invalid", format!("invalid ticket priority: {raw}"), )), } } pub fn parse_link_kind(raw: &str) -> Result { match raw { "relatesTo" => Ok(IssueLinkKind::RelatesTo), "blocks" => Ok(IssueLinkKind::Blocks), "blockedBy" => Ok(IssueLinkKind::BlockedBy), "duplicates" => Ok(IssueLinkKind::Duplicates), "dependsOn" => Ok(IssueLinkKind::DependsOn), _ => Err(TicketToolError::new( "invalid", format!("invalid ticket link kind: {raw}"), )), } } fn status_wire(status: IssueStatus) -> &'static str { match status { IssueStatus::Open => "open", IssueStatus::InProgress => "inProgress", IssueStatus::Qa => "QA", IssueStatus::Closed => "closed", } } fn priority_wire(priority: IssuePriority) -> &'static str { match priority { IssuePriority::Low => "low", IssuePriority::Medium => "medium", IssuePriority::High => "high", IssuePriority::Critical => "critical", } } fn link_kind_wire(kind: IssueLinkKind) -> &'static str { match kind { IssueLinkKind::RelatesTo => "relatesTo", IssueLinkKind::Blocks => "blocks", IssueLinkKind::BlockedBy => "blockedBy", IssueLinkKind::Duplicates => "duplicates", IssueLinkKind::DependsOn => "dependsOn", } } fn sprint_status_wire(status: SprintStatus) -> &'static str { match status { SprintStatus::Planned => "planned", SprintStatus::Active => "active", SprintStatus::Done => "done", } } #[cfg(test)] mod tests { use super::*; use domain::IssueNumber; #[test] fn ticket_list_request_deduplicates_multi_select_filters() { let page = TicketListPageInput::from_request(TicketListRequestDto { project_id: String::new(), statuses: vec!["open".into(), "closed".into(), "open".into()], priorities: vec!["high".into(), "low".into(), "high".into()], assigned_agent_id: None, created_by: None, sprint_id: None, text: None, sort: None, limit: None, cursor: None, }) .unwrap(); assert_eq!( page.filter.statuses, vec![IssueStatus::Open, IssueStatus::Closed] ); assert_eq!( page.filter.priorities, vec![IssuePriority::High, IssuePriority::Low] ); } #[test] fn ticket_list_request_rejects_invalid_multi_select_token() { let err = TicketListPageInput::from_request(TicketListRequestDto { project_id: String::new(), statuses: vec!["open".into(), "bad".into()], priorities: Vec::new(), assigned_agent_id: None, created_by: None, sprint_id: None, text: None, sort: None, limit: None, cursor: None, }) .unwrap_err(); assert_eq!(err.code, "INVALID"); assert!(err.message.contains("invalid ticket status: bad")); } #[test] fn ticket_list_request_parses_creator_filter() { let agent_id = AgentId::new_random(); let page = TicketListPageInput::from_request(TicketListRequestDto { project_id: String::new(), statuses: Vec::new(), priorities: Vec::new(), assigned_agent_id: None, created_by: Some(TicketCreatorFilterDto::Agent { agent_id: agent_id.to_string(), }), sprint_id: None, text: None, sort: None, limit: None, cursor: None, }) .unwrap(); assert_eq!(page.filter.created_by, Some(IssueActor::Agent { agent_id })); } #[test] fn ticket_list_pagination_preserves_multi_filter_request_shape() { let page = TicketListPageInput::from_request(TicketListRequestDto { project_id: String::new(), statuses: vec!["open".into(), "QA".into()], priorities: vec!["high".into()], assigned_agent_id: None, created_by: None, sprint_id: None, text: None, sort: None, limit: Some(1), cursor: None, }) .unwrap(); let rows = vec![ issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"), issue_row(2, IssueStatus::Qa, IssuePriority::High, "Beta"), issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"), ]; assert_eq!( page.filter.statuses, vec![IssueStatus::Open, IssueStatus::Qa] ); assert_eq!(page.filter.priorities, vec![IssuePriority::High]); let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap(); assert_eq!(out.items.len(), 1); assert_eq!(out.items[0].r#ref, "#1"); assert!(out .next_cursor .as_deref() .is_some_and(|cursor| cursor.starts_with("v1."))); } #[test] fn ticket_summary_exposes_created_by() { let agent_id = AgentId::new_random(); let mut row = issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"); row.created_by = IssueActor::Agent { agent_id }; let dto = TicketSummaryDto::from(row); assert!(matches!( dto.created_by, TicketActorDto::Agent { agent_id: id } if id == agent_id.to_string() )); } #[test] fn bulk_input_rejects_empty_refs() { let err = bulk_update_status_input( project_for_tests(), TicketBulkUpdateStatusRequestDto { project_id: String::new(), refs: Vec::new(), status: "closed".into(), }, IssueActor::User, ) .unwrap_err(); assert_eq!(err.code, "INVALID"); assert!(err.message.contains("refs must not be empty")); } #[test] fn bulk_input_rejects_duplicate_refs() { let err = bulk_update_status_input( project_for_tests(), TicketBulkUpdateStatusRequestDto { project_id: String::new(), refs: vec!["#1".into(), "#1".into()], status: "closed".into(), }, IssueActor::User, ) .unwrap_err(); assert_eq!(err.code, "INVALID"); assert!(err.message.contains("duplicate ticket ref: #1")); } #[test] fn bulk_delete_input_maps_refs_in_order() { let input = bulk_delete_input( project_for_tests(), TicketBulkDeleteRequestDto { project_id: String::new(), refs: vec!["#2".into(), "#1".into()], }, ) .unwrap(); assert_eq!( input .issue_refs .iter() .map(ToString::to_string) .collect::>(), vec!["#2", "#1"] ); } #[test] fn actor_from_requester_maps_agent_uuid_else_user() { let agent_id = AgentId::new_random(); assert_eq!( actor_from_requester(&agent_id.to_string()), IssueActor::Agent { agent_id } ); assert_eq!(actor_from_requester("mcp"), IssueActor::User); } #[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, created_by: 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, page.sort).unwrap(); assert_eq!( out.items .into_iter() .map(|item| item.r#ref) .collect::>(), vec!["#2", "#3", "#4", "#1"] ); } #[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, priority: IssuePriority, title: &str, ) -> IssueIndexEntry { IssueIndexEntry { issue_ref: IssueRef::from(IssueNumber::new(number).unwrap()), path: number.to_string(), title: title.to_owned(), status, priority, sprint: None, assigned_agent_ids: Vec::new(), created_by: IssueActor::User, updated_at: number, } } fn refs(out: &TicketListDto) -> Vec { out.items.iter().map(|item| item.r#ref.clone()).collect() } fn project_for_tests() -> Project { Project::new( domain::ProjectId::new_random(), "IdeA", domain::ProjectPath::new("/tmp/idea").unwrap(), domain::RemoteRef::Local, 0, ) .unwrap() } }