//! Public ticket adapters (Tauri commands + MCP provider). //! //! Public wire names use `ticket`; the application/domain model remains `Issue`. #![allow(missing_docs)] use std::sync::Arc; use async_trait::async_trait; use serde_json::{json, Value}; use tauri::State; use application::{ AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput, 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::*; use crate::state::AppState; #[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 fallback_carnet = issue.carnet.as_str().to_owned(); let carnet = if include_carnet { Some( read_ticket_carnet_body_best_effort( &*self.read_carnet, project, issue_ref, fallback_carnet, ) .await .map_err(ticket_error)?, ) } 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; let page = paginate_with_sprints(rows, req.limit, req.cursor, req.sort, &sprints) .map_err(|e| TicketToolError::new("invalid", e.message))?; json!(page) } "idea_sprint_list" => { 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 fallback_carnet = issue.carnet.as_str().to_owned(); let carnet = if request.include_carnet.unwrap_or(false) { Some( read_ticket_carnet_body_best_effort( &state.read_issue_carnet, &project, issue_ref, fallback_carnet, ) .await .map_err(ErrorDto::from)?, ) } 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); paginate(rows, page.limit, page.cursor, page.sort) } #[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)) } async fn resolve_project(state: &AppState, project_id: &str) -> Result { let id = parse_project_id(project_id)?; state .open_project .execute(OpenProjectInput { project_id: id }) .await .map(|out| out.project) .map_err(ErrorDto::from) } 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) } async fn read_ticket_carnet_body_best_effort( read_carnet: &application::ReadIssueCarnet, project: &Project, issue_ref: IssueRef, fallback_carnet: String, ) -> Result { match read_carnet .execute(ReadIssueCarnetInput { project: project.clone(), issue_ref, }) .await { Ok(out) => Ok(out.carnet.carnet.as_str().to_owned()), Err(AppError::NotFound(_)) => Ok(fallback_carnet), Err(err) => Err(err), } } 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| parse_ref_dto(raw).map_err(dto_tool_error)) } 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"))?; version_dto(version).map_err(dto_tool_error) } 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}"))) }