Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le domaine (Issue, ports), l'application (usecases + assistant de ticket), les adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web- server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1570 lines
47 KiB
Rust
1570 lines
47 KiB
Rust
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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub sprint: Option<TicketSprintContextDto>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub carnet: Option<String>,
|
|
pub links: Vec<TicketLinkDto>,
|
|
pub assigned_agent_ids: Vec<String>,
|
|
pub attachments: Vec<TicketAttachmentDto>,
|
|
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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub sprint: Option<TicketSprintContextDto>,
|
|
pub assigned_agent_ids: Vec<String>,
|
|
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<TicketSummaryDto>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub next_cursor: Option<String>,
|
|
}
|
|
|
|
/// 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<TicketActorDto>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub summarized_at: Option<u64>,
|
|
}
|
|
|
|
/// 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<SprintDto>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub priority: Option<String>,
|
|
pub status: Option<String>,
|
|
pub assigned_agent_ids: Option<Vec<String>>,
|
|
pub links: Option<Vec<TicketLinkRequestDto>>,
|
|
}
|
|
|
|
/// 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<bool>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
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<String>,
|
|
#[serde(default)]
|
|
pub priorities: Vec<String>,
|
|
pub assigned_agent_id: Option<String>,
|
|
pub created_by: Option<TicketCreatorFilterDto>,
|
|
pub sprint_id: Option<String>,
|
|
pub text: Option<String>,
|
|
pub sort: Option<TicketListSortDto>,
|
|
pub limit: Option<usize>,
|
|
pub cursor: Option<String>,
|
|
}
|
|
|
|
/// Bulk status update request.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TicketBulkUpdateStatusRequestDto {
|
|
#[serde(default)]
|
|
pub project_id: String,
|
|
pub refs: Vec<String>,
|
|
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<String>,
|
|
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<String>,
|
|
}
|
|
|
|
/// Bulk operation response.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TicketBulkResultDto {
|
|
pub items: Vec<TicketBulkResultItemDto>,
|
|
}
|
|
|
|
/// 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<TicketDto>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<ErrorDto>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub description: Option<String>,
|
|
pub status: Option<String>,
|
|
pub priority: Option<String>,
|
|
pub assigned_agent_ids: Option<Vec<String>>,
|
|
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<String>,
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<String>) -> 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<String>,
|
|
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<IssueIndexEntry> 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<application::SprintListEntry> 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<Vec<application::SprintListEntry>> for SprintListDto {
|
|
fn from(items: Vec<application::SprintListEntry>) -> Self {
|
|
Self {
|
|
items: items.into_iter().map(SprintDto::from).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<IssueLink> for TicketLinkDto {
|
|
fn from(link: IssueLink) -> Self {
|
|
Self {
|
|
target_ref: link.target.to_string(),
|
|
kind: link_kind_wire(link.kind).to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<IssueActor> 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<IssueAttachment> 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<IssueAttachmentContent> for TicketAttachmentContentDto {
|
|
fn from(content: IssueAttachmentContent) -> Self {
|
|
Self {
|
|
attachment: TicketAttachmentDto::from(content.attachment),
|
|
content_base64: URL_SAFE_NO_PAD.encode(content.bytes),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<TicketCreatorFilterDto> for IssueActor {
|
|
type Error = ErrorDto;
|
|
|
|
fn try_from(actor: TicketCreatorFilterDto) -> Result<Self, Self::Error> {
|
|
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, ErrorDto> {
|
|
IssueAttachmentId::new(raw.to_owned()).map_err(|err| ErrorDto {
|
|
code: "INVALID".to_owned(),
|
|
message: err.to_string(),
|
|
})
|
|
}
|
|
|
|
impl From<IssueCarnet> 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<TicketListSortDto>,
|
|
pub limit: usize,
|
|
pub cursor: Option<String>,
|
|
}
|
|
|
|
impl TicketListPageInput {
|
|
pub fn from_request(request: TicketListRequestDto) -> Result<Self, ErrorDto> {
|
|
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<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,
|
|
}
|
|
}
|
|
|
|
#[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 },
|
|
}
|
|
|
|
pub fn create_input(
|
|
project: Project,
|
|
request: TicketCreateRequestDto,
|
|
actor: IssueActor,
|
|
) -> Result<CreateIssueInput, ErrorDto> {
|
|
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::<Result<Vec<_>, _>>()?,
|
|
assigned_agent_ids: request
|
|
.assigned_agent_ids
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|id| parse_agent_id_dto(id))
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
actor,
|
|
})
|
|
}
|
|
|
|
pub fn update_input(
|
|
project: Project,
|
|
request: TicketUpdateRequestDto,
|
|
actor: IssueActor,
|
|
) -> Result<UpdateIssueInput, ErrorDto> {
|
|
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<BulkUpdateIssueStatusInput, ErrorDto> {
|
|
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<BulkUpdateIssuePriorityInput, ErrorDto> {
|
|
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<BulkDeleteIssuesInput, ErrorDto> {
|
|
Ok(BulkDeleteIssuesInput {
|
|
project,
|
|
issue_refs: parse_bulk_refs(request.refs)?,
|
|
})
|
|
}
|
|
|
|
impl From<BulkIssueMutationOutput> 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<IssueLink, ErrorDto> {
|
|
Ok(IssueLink {
|
|
target: parse_ref_dto(&link.target_ref)?,
|
|
kind: parse_link_kind_dto(&link.kind)?,
|
|
})
|
|
}
|
|
|
|
fn parse_bulk_refs(raw: Vec<String>) -> Result<Vec<IssueRef>, 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<IssueIndexEntry>,
|
|
limit: usize,
|
|
cursor: Option<String>,
|
|
sort: Option<TicketListSortDto>,
|
|
) -> Result<TicketListDto, ErrorDto> {
|
|
paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from)
|
|
}
|
|
|
|
pub fn paginate_with_sprints(
|
|
rows: Vec<IssueIndexEntry>,
|
|
limit: usize,
|
|
cursor: Option<String>,
|
|
sort: Option<TicketListSortDto>,
|
|
sprints: &[application::SprintListEntry],
|
|
) -> 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));
|
|
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<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,
|
|
}
|
|
}
|
|
|
|
fn ticket_sprint_context(
|
|
sprint_id: SprintId,
|
|
sprints: &[application::SprintListEntry],
|
|
) -> Option<TicketSprintContextDto> {
|
|
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, ErrorDto> {
|
|
IssueRef::from_str(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
|
|
}
|
|
|
|
pub fn parse_agent_id_dto(raw: &str) -> Result<AgentId, ErrorDto> {
|
|
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<ProfileId, ErrorDto> {
|
|
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<SprintId, ErrorDto> {
|
|
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, ErrorDto> {
|
|
IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
|
|
}
|
|
|
|
pub fn sprint_version_dto(raw: u64) -> Result<SprintVersion, ErrorDto> {
|
|
SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
|
|
}
|
|
|
|
pub fn parse_status_dto(raw: &str) -> Result<IssueStatus, ErrorDto> {
|
|
parse_status(raw).map_err(|e| ErrorDto::invalid(e.message))
|
|
}
|
|
|
|
pub fn parse_priority_dto(raw: &str) -> Result<IssuePriority, ErrorDto> {
|
|
parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message))
|
|
}
|
|
|
|
fn parse_statuses_dto(raw: Vec<String>) -> Result<Vec<IssueStatus>, 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<String>) -> Result<Vec<IssuePriority>, 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<IssueLinkKind, ErrorDto> {
|
|
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
|
|
}
|
|
|
|
pub fn parse_sprint_status_dto(raw: &str) -> Result<SprintStatus, ErrorDto> {
|
|
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<IssueStatus, TicketToolError> {
|
|
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<IssuePriority, TicketToolError> {
|
|
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<IssueLinkKind, TicketToolError> {
|
|
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<_>>(),
|
|
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<_>>(),
|
|
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<String> {
|
|
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()
|
|
}
|
|
}
|