diff --git a/Cargo.lock b/Cargo.lock index 953cadd..126522b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,6 +159,7 @@ version = "0.3.0" dependencies = [ "application", "async-trait", + "base64 0.22.1", "domain", "infrastructure", "interprocess", diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index bab1524..f60c3b7 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -4,375 +4,25 @@ #![allow(missing_docs)] -use std::cmp::Ordering; -use std::str::FromStr; use std::sync::Arc; use async_trait::async_trait; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tauri::State; -use uuid::Uuid; use application::{ AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput, - CreateIssueInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput, - ListIssuesInput, ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, - ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput, - UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, -}; -use domain::{ - AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, - IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, - Project, ProjectId, SprintId, SprintStatus, SprintVersion, + CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, + ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput, + ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput, + UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, }; +use domain::{IssueActor, IssueRef, Project}; use infrastructure::{TicketToolError, TicketToolProvider}; -use crate::dto::ErrorDto; +use crate::dto::*; use crate::state::AppState; -/// 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 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 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, -} - -/// Sprint context embedded in MCP 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, -} - -/// 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, -} - -/// 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 sprint_id: Option, - pub text: Option, - pub sort: Option, - pub limit: Option, - pub cursor: 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, -} - #[derive(Clone)] pub struct AppTicketToolProvider { pub create: Arc, @@ -1005,253 +655,8 @@ pub async fn ticket_assign( Ok(TicketDto::from_issue(issue, None)) } -impl TicketDto { - 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(), - 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(), - } - } - - 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(), - 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 { - 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 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)] -struct TicketListPageInput { - filter: IssueListFilter, - sort: Option, - limit: usize, - cursor: Option, -} - -impl TicketListPageInput { - 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()?, - 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, - }) - } -} - -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 }, -} - async fn resolve_project(state: &AppState, project_id: &str) -> Result { - let id = ProjectId::from_uuid( - Uuid::parse_str(project_id) - .map_err(|e| ErrorDto::invalid(format!("invalid project id: {e}")))?, - ); + let id = parse_project_id(project_id)?; state .open_project .execute(OpenProjectInput { project_id: id }) @@ -1260,238 +665,6 @@ async fn resolve_project(state: &AppState, project_id: &str) -> Result 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, - }) -} - -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, - }) -} - -fn parse_link_request(link: TicketLinkRequestDto) -> Result { - Ok(IssueLink { - target: parse_ref_dto(&link.target_ref)?, - kind: parse_link_kind_dto(&link.kind)?, - }) -} - -fn paginate( - rows: Vec, - limit: usize, - cursor: Option, - sort: Option, -) -> Result { - paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from) -} - -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(), - }) -} - async fn read_carnet_body( read_carnet: &application::ReadIssueCarnet, project: &Project, @@ -1507,164 +680,6 @@ async fn read_carnet_body( .map_err(ticket_error) } -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::System) -} - -fn parse_ref_dto(raw: &str) -> Result { - IssueRef::from_str(raw).map_err(|e| ErrorDto::invalid(e.to_string())) -} - -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}"))) -} - -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}"))) -} - -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}"))) -} - -fn version_dto(raw: u64) -> Result { - IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) -} - -fn sprint_version_dto(raw: u64) -> Result { - SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) -} - -fn parse_status_dto(raw: &str) -> Result { - parse_status(raw).map_err(|e| ErrorDto::invalid(e.message)) -} - -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) -} - -fn parse_link_kind_dto(raw: &str) -> Result { - parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message)) -} - -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}"))), - } -} - -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}"), - )), - } -} - -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}"), - )), - } -} - -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", - } -} - fn ticket_error(err: AppError) -> TicketToolError { let message = err.to_string(); let code = if message.contains("issue version conflict") { @@ -1723,17 +738,15 @@ fn mcp_list_request( } fn parse_json_ref(arguments: &Value, key: &str) -> Result { - required_str(arguments, key).and_then(|raw| { - IssueRef::from_str(raw).map_err(|e| TicketToolError::new("invalid", e.to_string())) - }) + required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error)) } -fn parse_json_version(arguments: &Value) -> Result { +fn parse_json_version(arguments: &Value) -> Result { let version = arguments .get("expectedVersion") .and_then(Value::as_u64) .ok_or_else(|| TicketToolError::new("invalid", "missing expectedVersion"))?; - IssueVersion::new(version).map_err(|e| TicketToolError::new("invalid", e.to_string())) + version_dto(version).map_err(dto_tool_error) } fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketToolError> { @@ -1742,221 +755,3 @@ fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketTo .and_then(Value::as_str) .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}"))) } - -#[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, - 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, - 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_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, - 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_list_sort_priority_is_semantic_with_number_tie_breaker() { - let page = TicketListPageInput::from_request(TicketListRequestDto { - project_id: String::new(), - statuses: Vec::new(), - priorities: Vec::new(), - assigned_agent_id: None, - sprint_id: None, - text: None, - sort: Some(TicketListSortDto { - field: TicketListSortFieldDto::Priority, - direction: TicketListSortDirectionDto::Desc, - }), - limit: None, - cursor: None, - }) - .unwrap(); - let mut rows = vec![ - issue_row(4, IssueStatus::Open, IssuePriority::High, "Delta"), - issue_row(2, IssueStatus::Closed, IssuePriority::Critical, "Beta"), - issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"), - issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"), - ]; - - sort_ticket_rows(&mut rows, page.sort); - let out = paginate(rows, page.limit, page.cursor, 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(), - updated_at: number, - } - } - - fn refs(out: &TicketListDto) -> Vec { - out.items.iter().map(|item| item.r#ref.clone()).collect() - } -} diff --git a/crates/backend/Cargo.toml b/crates/backend/Cargo.toml index 73e0889..5b0db16 100644 --- a/crates/backend/Cargo.toml +++ b/crates/backend/Cargo.toml @@ -16,6 +16,7 @@ serde_json = { workspace = true } uuid = { workspace = true } async-trait = { workspace = true } interprocess = { version = "2.4", features = ["tokio"] } +base64 = "0.22" [features] vector-http = ["infrastructure/vector-http"] diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index c438e1e..8a65b3e 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -18,6 +18,8 @@ use application::{ }; use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; +pub use crate::ticket_dto::*; + /// Request DTO for the `health` command. #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 2c5e8a0..0e8825e 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -94,6 +94,7 @@ pub mod events; pub mod mcp_endpoint; pub mod openai_tools; pub mod stream; +mod ticket_dto; use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; diff --git a/crates/backend/src/ticket_dto.rs b/crates/backend/src/ticket_dto.rs new file mode 100644 index 0000000..161773d --- /dev/null +++ b/crates/backend/src/ticket_dto.rs @@ -0,0 +1,1207 @@ +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::{CreateIssueInput, UpdateIssueInput}; +use domain::{ + AgentId, AgentIssueRole, Issue, IssueActor, 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 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 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, +} + +/// 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, +} + +/// 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, +} + +/// 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 sprint_id: Option, + pub text: Option, + pub sort: Option, + pub limit: Option, + pub cursor: 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(), + 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(), + 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 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()?, + 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, + }) +} + +fn parse_link_request(link: TicketLinkRequestDto) -> Result { + Ok(IssueLink { + target: parse_ref_dto(&link.target_ref)?, + kind: parse_link_kind_dto(&link.kind)?, + }) +} + +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::System) +} + +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, + 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, + 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_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, + 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_list_sort_priority_is_semantic_with_number_tie_breaker() { + let page = TicketListPageInput::from_request(TicketListRequestDto { + project_id: String::new(), + statuses: Vec::new(), + priorities: Vec::new(), + assigned_agent_id: None, + sprint_id: None, + text: None, + sort: Some(TicketListSortDto { + field: TicketListSortFieldDto::Priority, + direction: TicketListSortDirectionDto::Desc, + }), + limit: None, + cursor: None, + }) + .unwrap(); + let mut rows = vec![ + issue_row(4, IssueStatus::Open, IssuePriority::High, "Delta"), + issue_row(2, IssueStatus::Closed, IssuePriority::Critical, "Beta"), + issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"), + issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"), + ]; + + sort_ticket_rows(&mut rows, page.sort); + let out = paginate(rows, page.limit, page.cursor, 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(), + updated_at: number, + } + } + + fn refs(out: &TicketListDto) -> Vec { + out.items.iter().map(|item| item.r#ref.clone()).collect() + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 33772b1..99ab9d1 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -34,18 +34,31 @@ use tokio::task::JoinHandle; use uuid::Uuid; use application::{ - AuthenticateSessionInput, CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, - ListDevicesInput, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, - RateLimitKey, RenameDeviceInput, ResizeTerminalInput, RevokeDeviceInput, - RotateConversationLogInput, TouchDeviceInput, WriteToTerminalInput, + AssignIssueAgentInput, AssignTicketToSprintInput, AuthenticateSessionInput, CloseTerminalInput, + CreateSprintInput, DeleteIssueInput, DeleteSprintInput, GetProjectWorkStateInput, + LaunchAgentInput, LinkIssuesInput, ListDevicesInput, ListIssuesInput, ListSprintsInput, + McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, + ReadIssueCarnetInput, ReadIssueInput, RenameDeviceInput, RenameSprintInput, + ReorderSprintsInput, ResizeTerminalInput, RevokeDeviceInput, RotateConversationLogInput, + TouchDeviceInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, + WriteToTerminalInput, }; use domain::ports::PtyHandle; +use domain::IssueActor; use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash}; use backend::dto::{ - parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, - BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto, - OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, + create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id, + parse_project_id, parse_ref_dto, parse_session_id, parse_sprint_id_dto, + parse_sprint_status_dto, parse_task_id, sort_ticket_rows, sprint_version_dto, update_input, + version_dto, BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, + LaunchAgentRequestDto, OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, + SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, + SprintRenameRequestDto, SprintReorderRequestDto, TerminalSessionDto, TicketAssignRequestDto, + TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, + TicketLinkCommandRequestDto, TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, + TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, + TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, }; use backend::events::DomainEventDto; type PtyChunk = Vec; @@ -2296,6 +2309,23 @@ async fn invoke( "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, "retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await, + "ticket_create" => invoke_ticket_create(&request.args, &state.app).await, + "ticket_read" => invoke_ticket_read(&request.args, &state.app).await, + "ticket_list" => invoke_ticket_list(&request.args, &state.app).await, + "ticket_update" => invoke_ticket_update(&request.args, &state.app).await, + "ticket_delete" => invoke_ticket_delete(&request.args, &state.app).await, + "ticket_read_carnet" => invoke_ticket_read_carnet(&request.args, &state.app).await, + "ticket_update_carnet" => invoke_ticket_update_carnet(&request.args, &state.app).await, + "ticket_link" => invoke_ticket_link(&request.args, &state.app).await, + "ticket_unlink" => invoke_ticket_unlink(&request.args, &state.app).await, + "ticket_assign" => invoke_ticket_assign(&request.args, &state.app).await, + "ticket_assign_sprint" => invoke_ticket_assign_sprint(&request.args, &state.app).await, + "ticket_unassign_sprint" => invoke_ticket_unassign_sprint(&request.args, &state.app).await, + "sprint_create" => invoke_sprint_create(&request.args, &state.app).await, + "sprint_list" => invoke_sprint_list(&request.args, &state.app).await, + "sprint_rename" => invoke_sprint_rename(&request.args, &state.app).await, + "sprint_reorder" => invoke_sprint_reorder(&request.args, &state.app).await, + "sprint_delete" => invoke_sprint_delete(&request.args, &state.app).await, _ => Err(ErrorDto { code: "UNKNOWN_COMMAND".to_owned(), message: format!("unknown command: {}", request.command), @@ -2304,7 +2334,10 @@ async fn invoke( match result { Ok(value) => json_response(StatusCode::OK, &value, origin), - Err(error) => error_dto_response(status_for_error(&error), error, origin), + Err(error) => { + let error = normalize_invoke_error(error); + error_dto_response(status_for_error(&error), error, origin) + } } } @@ -2440,6 +2473,323 @@ async fn invoke_retry_background_task( serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error) } +async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_create", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue = state + .create_issue + .execute(create_input(project, request, IssueActor::User)?) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_ticket_read(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_read", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue_ref = parse_ref_dto(&request.r#ref)?; + let issue = state + .read_issue + .execute(ReadIssueInput { + project: project.clone(), + issue_ref, + }) + .await + .map_err(ErrorDto::from)? + .issue; + let carnet = if request.include_carnet.unwrap_or(false) { + Some( + state + .read_issue_carnet + .execute(ReadIssueCarnetInput { project, issue_ref }) + .await + .map_err(ErrorDto::from)? + .carnet + .carnet + .as_str() + .to_owned(), + ) + } else { + None + }; + serde_json::to_value(TicketDto::from_issue(issue, carnet)).map_err(serialization_error) +} + +async fn invoke_ticket_list(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_list", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let page = TicketListPageInput::from_request(request)?; + let mut rows = state + .list_issues + .execute(ListIssuesInput { + project, + filter: page.filter, + }) + .await + .map_err(ErrorDto::from)? + .issues; + sort_ticket_rows(&mut rows, page.sort); + let dto = paginate(rows, page.limit, page.cursor, page.sort)?; + serde_json::to_value(dto).map_err(serialization_error) +} + +async fn invoke_ticket_update(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_update", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue = state + .update_issue + .execute(update_input(project, request, IssueActor::User)?) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_ticket_delete(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_delete", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .delete_issue + .execute(DeleteIssueInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_ticket_read_carnet(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_read_carnet", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let carnet = state + .read_issue_carnet + .execute(ReadIssueCarnetInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + }) + .await + .map_err(ErrorDto::from)? + .carnet; + serde_json::to_value(TicketCarnetDto::from(carnet)).map_err(serialization_error) +} + +async fn invoke_ticket_update_carnet(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_update_carnet", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue_ref = parse_ref_dto(&request.r#ref)?; + state + .update_issue_carnet + .execute(UpdateIssueCarnetInput { + project: project.clone(), + issue_ref, + expected_version: version_dto(request.expected_version)?, + carnet: request.carnet, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)?; + let issue = state + .read_issue + .execute(ReadIssueInput { project, issue_ref }) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_ticket_link(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_link", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue = state + .link_issues + .execute(LinkIssuesInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + target: parse_ref_dto(&request.target_ref)?, + kind: parse_link_kind_dto(&request.kind)?, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_ticket_unlink(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_unlink", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue = state + .unlink_issues + .execute(UnlinkIssuesInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + target: parse_ref_dto(&request.target_ref)?, + kind: request + .kind + .as_deref() + .map(parse_link_kind_dto) + .transpose()?, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_ticket_assign(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_assign", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue = state + .assign_issue_agent + .execute(AssignIssueAgentInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + agent_id: parse_agent_id_dto(&request.agent_id)?, + assigned: request.assigned, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_ticket_assign_sprint(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("ticket_assign_sprint", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue = state + .assign_ticket_to_sprint + .execute(AssignTicketToSprintInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + sprint_id: parse_sprint_id_dto(&request.sprint_id)?, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_ticket_unassign_sprint( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("ticket_unassign_sprint", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let issue = state + .unassign_ticket_from_sprint + .execute(UnassignTicketFromSprintInput { + project, + issue_ref: parse_ref_dto(&request.r#ref)?, + expected_version: version_dto(request.expected_version)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .issue; + serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error) +} + +async fn invoke_sprint_create(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("sprint_create", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let sprint = state + .create_sprint + .execute(CreateSprintInput { + project, + name: request.name, + status: request + .status + .as_deref() + .map(parse_sprint_status_dto) + .transpose()?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .sprint; + serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error) +} + +async fn invoke_sprint_list(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("sprint_list", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let rows = state + .list_sprints + .execute(ListSprintsInput { project }) + .await + .map_err(ErrorDto::from)? + .sprints; + serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error) +} + +async fn invoke_sprint_rename(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("sprint_rename", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let sprint = state + .rename_sprint + .execute(RenameSprintInput { + project, + sprint_id: parse_sprint_id_dto(&request.sprint_id)?, + expected_version: sprint_version_dto(request.expected_version)?, + name: request.name, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)? + .sprint; + serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error) +} + +async fn invoke_sprint_reorder(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("sprint_reorder", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .reorder_sprints + .execute(ReorderSprintsInput { + project: project.clone(), + ordered_ids: request + .ordered_ids + .iter() + .map(|id| parse_sprint_id_dto(id)) + .collect::, _>>()?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)?; + let rows = state + .list_sprints + .execute(ListSprintsInput { project }) + .await + .map_err(ErrorDto::from)? + .sprints; + serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error) +} + +async fn invoke_sprint_delete(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("sprint_delete", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .delete_sprint + .execute(DeleteSprintInput { + project, + sprint_id: parse_sprint_id_dto(&request.sprint_id)?, + actor: IssueActor::User, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + fn task_id_arg(args: &Value, command: &str) -> Result { args.get("taskId") .and_then(Value::as_str) @@ -2492,6 +2842,16 @@ fn background_error(err: BackgroundTaskPortError) -> ErrorDto { } } +fn normalize_invoke_error(error: ErrorDto) -> ErrorDto { + if error.code == "INVALID" && error.message.contains("version conflict") { + return ErrorDto { + code: "CONFLICT".to_owned(), + message: error.message, + }; + } + error +} + async fn resolve_project_readonly( project_id: &str, state: &BackendCore, @@ -2527,6 +2887,16 @@ where } } +fn required_request(command: &str, args: &Value) -> Result +where + T: for<'de> Deserialize<'de>, +{ + optional_request(args)?.ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.request"), + }) +} + fn validate_request_origin( headers: &HeaderMap, config: &ServerConfig, @@ -2792,6 +3162,7 @@ fn status_for_error(error: &ErrorDto) -> StatusCode { "UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST, "INVALID" => StatusCode::BAD_REQUEST, "NOT_FOUND" => StatusCode::NOT_FOUND, + "CONFLICT" => StatusCode::CONFLICT, "FORBIDDEN" => StatusCode::FORBIDDEN, "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, _ => StatusCode::INTERNAL_SERVER_ERROR, @@ -3766,6 +4137,48 @@ mod tests { output.project.id.to_string() } + async fn create_agent_for_project_for_test( + state: &Arc, + project_id: &str, + name: &str, + ) -> String { + let project = resolve_project_readonly(project_id, &state.app) + .await + .expect("test project resolves"); + let profile_id = ProfileId::from_uuid(Uuid::new_v4()); + let profile = AgentProfile::new( + profile_id, + format!("{name} profile"), + "/bin/sh", + vec!["-c".to_owned(), "printf ready".to_owned()], + ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"), + None, + "{agentRunDir}", + None, + ) + .expect("valid profile"); + state + .app + .save_profile + .execute(SaveProfileInput { profile }) + .await + .expect("test profile saved"); + state + .app + .create_agent + .execute(CreateAgentInput { + project, + name: format!("{name} agent"), + profile_id, + initial_content: Some("Test agent context".to_owned()), + }) + .await + .expect("test agent created") + .agent + .id + .to_string() + } + async fn create_background_task_for_test( state: &Arc, project_id: &str, @@ -3795,6 +4208,24 @@ mod tests { (task_id, owner) } + async fn invoke_request_for_test( + state: Arc, + cookie: &str, + command: &str, + args: Value, + ) -> (StatusCode, Value) { + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": command, "args": args }), + &[("cookie", cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + (status, body) + } + async fn create_raw_cli_agent_for_test( state: &Arc, name: &str, @@ -5890,6 +6321,343 @@ mod tests { assert_eq!(tasks[0]["state"], "queued"); } + #[tokio::test] + async fn ticket_and_sprint_commands_require_authenticated_invoke() { + let state = state(); + + for command in [ + "ticket_list", + "ticket_create", + "sprint_list", + "sprint_create", + ] { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": command, "args": {} }), + &[], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + } + } + + #[tokio::test] + async fn authorized_ticket_invoke_routes_ticket_carnet_link_and_assign_commands() { + let state = state(); + let project_id = create_project_for_test(&state, "Web Tickets").await; + let agent_id = create_agent_for_project_for_test(&state, &project_id, "Ticket owner").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let (status, created) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_create", + json!({ + "request": { + "projectId": project_id, + "title": "Wire ticket", + "description": "Created over /api/invoke", + "priority": "high", + "status": "open" + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(created["title"], "Wire ticket"); + assert_eq!(created["priority"], "high"); + let ticket_ref = created["ref"].as_str().unwrap().to_owned(); + let mut ticket_version = created["version"].as_u64().unwrap(); + + let (status, read) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_read", + json!({ "request": { "projectId": project_id, "ref": ticket_ref, "includeCarnet": true } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(read["ref"], ticket_ref); + assert!(read["carnet"].is_string()); + + let (status, list) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_list", + json!({ "request": { "projectId": project_id, "statuses": ["open"] } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list["items"].as_array().unwrap().len(), 1); + assert_eq!(list["items"][0]["ref"], ticket_ref); + + let (status, updated) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_update", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "title": "Wire ticket updated", + "status": "inProgress", + "expectedVersion": ticket_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(updated["title"], "Wire ticket updated"); + assert_eq!(updated["status"], "inProgress"); + let stale_version = ticket_version; + + let (status, conflict) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_update", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "title": "Stale update", + "expectedVersion": stale_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(conflict["code"], "CONFLICT"); + + let (status, carnet) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_read_carnet", + json!({ "request": { "projectId": project_id, "ref": ticket_ref } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let carnet_version = carnet["version"].as_u64().unwrap(); + + let (status, after_carnet_update) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_update_carnet", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "carnet": "## QA\nCarnet updated through web transport.", + "expectedVersion": carnet_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + ticket_version = after_carnet_update["version"].as_u64().unwrap(); + + let (status, target) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_create", + json!({ "request": { "projectId": project_id, "title": "Linked target" } }), + ) + .await; + assert_eq!(status, StatusCode::OK, "target create failed: {target}"); + let target_ref = target["ref"].as_str().unwrap().to_owned(); + + let (status, linked) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_link", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "targetRef": target_ref, + "kind": "blocks", + "expectedVersion": ticket_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(linked["links"].as_array().unwrap().len(), 1); + ticket_version = linked["version"].as_u64().unwrap(); + + let (status, unlinked) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_unlink", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "targetRef": target_ref, + "kind": "blocks", + "expectedVersion": ticket_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(unlinked["links"].as_array().unwrap().is_empty()); + ticket_version = unlinked["version"].as_u64().unwrap(); + + let (status, assigned) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_assign", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "agentId": agent_id, + "assigned": true, + "expectedVersion": ticket_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(assigned["assignedAgentIds"][0], agent_id); + + let (status, deleted) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_delete", + json!({ "request": { "projectId": project_id, "ref": target_ref } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(deleted, Value::Null); + } + + #[tokio::test] + async fn authorized_sprint_invoke_routes_sprint_and_ticket_sprint_commands() { + let state = state(); + let project_id = create_project_for_test(&state, "Web Sprints").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let (status, ticket) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_create", + json!({ "request": { "projectId": project_id, "title": "Sprint ticket" } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let ticket_ref = ticket["ref"].as_str().unwrap().to_owned(); + let mut ticket_version = ticket["version"].as_u64().unwrap(); + + let (status, sprint_a) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "sprint_create", + json!({ "request": { "projectId": project_id, "name": "Alpha", "status": "planned" } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(sprint_a["name"], "Alpha"); + let sprint_a_id = sprint_a["id"].as_str().unwrap().to_owned(); + let sprint_a_version = sprint_a["version"].as_u64().unwrap(); + + let (status, sprint_b) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "sprint_create", + json!({ "request": { "projectId": project_id, "name": "Beta" } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let sprint_b_id = sprint_b["id"].as_str().unwrap().to_owned(); + + let (status, list) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "sprint_list", + json!({ "request": { "projectId": project_id } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list["items"].as_array().unwrap().len(), 2); + + let (status, renamed) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "sprint_rename", + json!({ + "request": { + "projectId": project_id, + "sprintId": sprint_a_id, + "name": "Alpha renamed", + "expectedVersion": sprint_a_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(renamed["name"], "Alpha renamed"); + + let (status, reordered) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "sprint_reorder", + json!({ "request": { "projectId": project_id, "orderedIds": [sprint_b_id, sprint_a_id] } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(reordered["items"][0]["id"], sprint_b_id); + + let (status, assigned) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_assign_sprint", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "sprintId": sprint_a_id, + "expectedVersion": ticket_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "sprint assign failed: {assigned}"); + assert_eq!(assigned["sprintId"], sprint_a_id); + ticket_version = assigned["version"].as_u64().unwrap(); + + let (status, unassigned) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_unassign_sprint", + json!({ + "request": { + "projectId": project_id, + "ref": ticket_ref, + "expectedVersion": ticket_version + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(unassigned["sprintId"].is_null()); + + let (status, deleted) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "sprint_delete", + json!({ "request": { "projectId": project_id, "sprintId": sprint_b_id } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(deleted, Value::Null); + } + #[tokio::test] async fn background_actions_are_allowlisted_with_auth_gate() { let state = state();