//! Public ticket adapters (Tauri commands + MCP provider). //! //! Public wire names use `ticket`; the application/domain model remains `Issue`. #![allow(missing_docs)] use std::str::FromStr; use std::sync::Arc; use async_trait::async_trait; 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, }; use infrastructure::{TicketToolError, TicketToolProvider}; use crate::dto::ErrorDto; 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, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TicketListSortDto { pub field: TicketListSortFieldDto, pub direction: TicketListSortDirectionDto, } /// List sort field. #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TicketListSortFieldDto { Number, Priority, Status, Title, } /// List sort direction. #[derive(Debug, Clone, Copy, 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, pub read: Arc, pub list: Arc, pub update: Arc, pub read_carnet: Arc, pub update_carnet: Arc, pub link: Arc, pub unlink: Arc, pub list_sprints: Arc, } #[async_trait] impl TicketToolProvider for AppTicketToolProvider { async fn handle_ticket_tool( &self, project: &Project, requester: &str, name: &str, arguments: Value, ) -> Result { let actor = actor_from_requester(requester); let result = match name { "idea_ticket_create" => { let req = mcp_create_request(project, arguments)?; let issue = self .create .execute(create_input(project.clone(), req, actor).map_err(dto_tool_error)?) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_read" => { let issue_ref = parse_json_ref(&arguments, "ref")?; let include_carnet = arguments .get("includeCarnet") .and_then(Value::as_bool) .unwrap_or(false); let issue = self .read .execute(ReadIssueInput { project: project.clone(), issue_ref, }) .await .map_err(ticket_error)? .issue; let carnet = if include_carnet { Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) } else { None }; let sprints = self .list_sprints .execute(ListSprintsInput { project: project.clone(), }) .await .map_err(ticket_error)? .sprints; json!(TicketDto::from_issue_with_sprints(issue, carnet, &sprints)) } "idea_ticket_list" => { let req = mcp_list_request(project, arguments)?; let mut rows = self .list .execute(ListIssuesInput { project: project.clone(), filter: req.filter, }) .await .map_err(ticket_error)? .issues; sort_ticket_rows(&mut rows, req.sort); let sprints = self .list_sprints .execute(ListSprintsInput { project: project.clone(), }) .await .map_err(ticket_error)? .sprints; json!(paginate_with_sprints(rows, req.limit, req.cursor, &sprints)) } "idea_sprint_list" => { let rows = self .list_sprints .execute(ListSprintsInput { project: project.clone(), }) .await .map_err(ticket_error)? .sprints; json!(SprintListDto::from(rows)) } "idea_ticket_update" => { let req = mcp_update_request(project, arguments)?; let issue = self .update .execute(update_input(project.clone(), req, actor).map_err(dto_tool_error)?) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_update_status" => { let issue = self .update .execute(UpdateIssueInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, expected_version: parse_json_version(&arguments)?, title: None, description: None, status: Some(parse_status(required_str(&arguments, "status")?)?), priority: None, assigned_agent_ids: None, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_update_priority" => { let issue = self .update .execute(UpdateIssueInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, expected_version: parse_json_version(&arguments)?, title: None, description: None, status: None, priority: Some(parse_priority(required_str(&arguments, "priority")?)?), assigned_agent_ids: None, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_read_carnet" => { let carnet = self .read_carnet .execute(ReadIssueCarnetInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, }) .await .map_err(ticket_error)? .carnet; json!(TicketCarnetDto::from(carnet)) } "idea_ticket_update_carnet" => { let issue_ref = parse_json_ref(&arguments, "ref")?; self.update_carnet .execute(UpdateIssueCarnetInput { project: project.clone(), issue_ref, expected_version: parse_json_version(&arguments)?, carnet: required_str(&arguments, "carnet")?.to_owned(), actor, }) .await .map_err(ticket_error)?; let issue = self .read .execute(ReadIssueInput { project: project.clone(), issue_ref, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue( issue, Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) )) } "idea_ticket_link" => { let issue = self .link .execute(LinkIssuesInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, target: parse_json_ref(&arguments, "targetRef")?, kind: parse_link_kind(required_str(&arguments, "kind")?)?, expected_version: parse_json_version(&arguments)?, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } "idea_ticket_unlink" => { let kind = arguments .get("kind") .and_then(Value::as_str) .map(parse_link_kind) .transpose()?; let issue = self .unlink .execute(UnlinkIssuesInput { project: project.clone(), issue_ref: parse_json_ref(&arguments, "ref")?, target: parse_json_ref(&arguments, "targetRef")?, kind, expected_version: parse_json_version(&arguments)?, actor, }) .await .map_err(ticket_error)? .issue; json!(TicketDto::from_issue(issue, None)) } _ => { return Err(TicketToolError::new( "unknownTool", format!("unknown tool: {name}"), )) } }; Ok(result) } } #[tauri::command] pub async fn ticket_create( request: TicketCreateRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue = state .create_issue .execute(create_input(project, request, IssueActor::User)?) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_read( request: TicketReadRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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 }; Ok(TicketDto::from_issue(issue, carnet)) } #[tauri::command] pub async fn ticket_delete( request: TicketDeleteRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&state, &request.project_id).await?; state .delete_issue .execute(DeleteIssueInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, }) .await .map(|_| ()) .map_err(ErrorDto::from) } #[tauri::command] pub async fn open_ticket_chat( request: OpenTicketChatRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let output = state .open_ticket_assistant .execute(OpenTicketAssistantInput { project, issue_ref: parse_ref_dto(&request.issue_ref)?, profile_id: parse_profile_id_dto(&request.profile_id)?, }) .await .map_err(ErrorDto::from)?; Ok(TicketChatDto { session_id: output.session_id.to_string(), requester: output.requester, issue_ref: output.issue_ref.to_string(), }) } #[tauri::command] pub async fn close_ticket_chat( request: CloseTicketChatRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let _project = resolve_project(&state, &request.project_id).await?; state .close_ticket_assistant .execute(CloseTicketAssistantInput { issue_ref: parse_ref_dto(&request.issue_ref)?, }) .await .map_err(ErrorDto::from) } #[tauri::command] pub async fn ticket_list( request: TicketListRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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); Ok(paginate(rows, page.limit, page.cursor)) } #[tauri::command] pub async fn sprint_create( request: SprintCreateRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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; Ok(SprintDto::from_sprint(sprint, 0)) } #[tauri::command] pub async fn sprint_list( request: SprintListRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let rows = state .list_sprints .execute(ListSprintsInput { project }) .await .map_err(ErrorDto::from)? .sprints; Ok(SprintListDto::from(rows)) } #[tauri::command] pub async fn sprint_rename( request: SprintRenameRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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; Ok(SprintDto::from_sprint(sprint, 0)) } #[tauri::command] pub async fn sprint_reorder( request: SprintReorderRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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)?; sprint_list( SprintListRequestDto { project_id: project.id.to_string(), }, state, ) .await } #[tauri::command] pub async fn sprint_delete( request: SprintDeleteRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&state, &request.project_id).await?; state .delete_sprint .execute(DeleteSprintInput { project, sprint_id: parse_sprint_id_dto(&request.sprint_id)?, actor: IssueActor::User, }) .await .map_err(ErrorDto::from) } #[tauri::command] pub async fn ticket_assign_sprint( request: TicketSprintAssignRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_unassign_sprint( request: TicketSprintUnassignRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_update( request: TicketUpdateRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let issue = state .update_issue .execute(update_input(project, request, IssueActor::User)?) .await .map_err(ErrorDto::from)? .issue; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_read_carnet( request: TicketReadRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; state .read_issue_carnet .execute(ReadIssueCarnetInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, }) .await .map(|out| TicketCarnetDto::from(out.carnet)) .map_err(ErrorDto::from) } #[tauri::command] pub async fn ticket_update_carnet( request: TicketUpdateCarnetRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_link( request: TicketLinkCommandRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_unlink( request: TicketUnlinkCommandRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).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; Ok(TicketDto::from_issue(issue, None)) } #[tauri::command] pub async fn ticket_assign( request: TicketAssignRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&state, &request.project_id).await?; let agent_id = parse_agent_id_dto(&request.agent_id)?; let issue = state .assign_issue_agent .execute(AssignIssueAgentInput { project, issue_ref: parse_ref_dto(&request.r#ref)?, agent_id, assigned: request.assigned, expected_version: version_dto(request.expected_version)?, actor: IssueActor::User, }) .await .map_err(ErrorDto::from)? .issue; 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, } } 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}")))?, ); state .open_project .execute(OpenProjectInput { project_id: id }) .await .map(|out| out.project) .map_err(ErrorDto::from) } 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, }) } 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) -> TicketListDto { let start = cursor .as_deref() .and_then(|raw| raw.parse::().ok()) .unwrap_or(0); let total = rows.len(); let end = total.min(start.saturating_add(limit)); TicketListDto { items: rows .into_iter() .skip(start) .take(limit) .map(TicketSummaryDto::from) .collect(), next_cursor: (end < total).then(|| end.to_string()), } } fn paginate_with_sprints( rows: Vec, limit: usize, cursor: Option, sprints: &[application::SprintListEntry], ) -> TicketListDto { let start = cursor .as_deref() .and_then(|raw| raw.parse::().ok()) .unwrap_or(0); let total = rows.len(); let end = total.min(start.saturating_add(limit)); TicketListDto { items: rows .into_iter() .skip(start) .take(limit) .map(|row| TicketSummaryDto::from_row_with_sprints(row, sprints)) .collect(), next_cursor: (end < total).then(|| end.to_string()), } } 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, issue_ref: IssueRef, ) -> Result { read_carnet .execute(ReadIssueCarnetInput { project: project.clone(), issue_ref, }) .await .map(|out| out.carnet.carnet.as_str().to_owned()) .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") { "versionConflict" } else { match err.code() { "NOT_FOUND" => "notFound", "INVALID" => "invalid", "STORE" => "store", "FILESYSTEM" => "filesystem", _ => "internal", } }; TicketToolError::new(code, message) } fn dto_tool_error(err: ErrorDto) -> TicketToolError { let code = match err.code.as_str() { "NOT_FOUND" => "notFound", "INVALID" => "invalid", "STORE" => "store", "FILESYSTEM" => "filesystem", _ => "internal", }; TicketToolError::new(code, err.message) } fn mcp_create_request( project: &Project, arguments: Value, ) -> Result { let mut req: TicketCreateRequestDto = serde_json::from_value(arguments) .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; req.project_id = project.id.to_string(); Ok(req) } fn mcp_update_request( project: &Project, arguments: Value, ) -> Result { let mut req: TicketUpdateRequestDto = serde_json::from_value(arguments) .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; req.project_id = project.id.to_string(); Ok(req) } fn mcp_list_request( project: &Project, arguments: Value, ) -> Result { let mut req: TicketListRequestDto = serde_json::from_value(arguments) .map_err(|e| TicketToolError::new("invalid", e.to_string()))?; req.project_id = project.id.to_string(); TicketListPageInput::from_request(req).map_err(|e| TicketToolError::new("invalid", e.message)) } 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())) }) } 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())) } fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketToolError> { arguments .get(key) .and_then(Value::as_str) .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}"))) } impl ErrorDto { fn invalid(message: impl Into) -> Self { Self { code: "INVALID".to_owned(), message: message.into(), } } } #[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: Some("1".into()), }) .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); assert_eq!(out.items.len(), 1); assert_eq!(out.items[0].r#ref, "#2"); assert_eq!(out.next_cursor, Some("2".to_owned())); } #[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); assert_eq!( out.items .into_iter() .map(|item| item.r#ref) .collect::>(), vec!["#2", "#3", "#4", "#1"] ); } 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, } } }