Files
IdeA/crates/app-tauri/src/tickets.rs
Blomios feeca3462c feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)
Passe les filtres de la liste des tickets en sélection multiple (#12).

Backend Rust :
- IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ
  et AND inter-champs
- TicketListRequestDto en tableaux + from_request (parse/déduplication)
- MCP idea_ticket_list aligné sur le nouveau contrat

Frontend :
- TicketListQuery.statuses/priorities
- UI de cases à cocher, toggle/clear des filtres

Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24),
issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:41:01 +02:00

1640 lines
50 KiB
Rust

//! 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sprint: Option<TicketSprintContextDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub carnet: Option<String>,
pub links: Vec<TicketLinkDto>,
pub assigned_agent_ids: Vec<String>,
pub created_by: TicketActorDto,
pub updated_by: TicketActorDto,
pub created_at: u64,
pub updated_at: u64,
pub version: u64,
}
/// Public ticket summary DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSummaryDto {
pub r#ref: String,
pub path: String,
pub title: String,
pub status: String,
pub priority: String,
pub sprint_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sprint: Option<TicketSprintContextDto>,
pub assigned_agent_ids: Vec<String>,
pub updated_at: u64,
}
/// Public list DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketListDto {
pub items: Vec<TicketSummaryDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
/// 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<SprintDto>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenTicketChatRequestDto {
pub project_id: String,
pub issue_ref: String,
pub profile_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseTicketChatRequestDto {
pub project_id: String,
pub issue_ref: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketChatDto {
pub session_id: String,
pub requester: String,
pub issue_ref: String,
}
/// Public link DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketLinkDto {
pub target_ref: String,
pub kind: String,
}
/// Public actor DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TicketActorDto {
User,
Agent { agent_id: String },
System,
}
/// Carnet read DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketCarnetDto {
pub r#ref: String,
pub carnet: String,
pub version: u64,
}
/// Create request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketCreateRequestDto {
#[serde(default)]
pub project_id: String,
pub title: String,
pub description: Option<String>,
pub priority: Option<String>,
pub status: Option<String>,
pub assigned_agent_ids: Option<Vec<String>>,
pub links: Option<Vec<TicketLinkRequestDto>>,
}
/// Read request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketReadRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub include_carnet: Option<bool>,
}
/// Delete request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketDeleteRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
}
/// List request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketListRequestDto {
#[serde(default)]
pub project_id: String,
#[serde(default)]
pub statuses: Vec<String>,
#[serde(default)]
pub priorities: Vec<String>,
pub assigned_agent_id: Option<String>,
pub sprint_id: Option<String>,
pub text: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
/// Update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketUpdateRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub title: Option<String>,
pub description: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub assigned_agent_ids: Option<Vec<String>>,
pub expected_version: u64,
}
/// Carnet update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketUpdateCarnetRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub carnet: String,
pub expected_version: u64,
}
/// Link mutation request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketLinkRequestDto {
pub target_ref: String,
pub kind: String,
}
/// Link command request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketLinkCommandRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub target_ref: String,
pub kind: String,
pub expected_version: u64,
}
/// Unlink command request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketUnlinkCommandRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub target_ref: String,
pub kind: Option<String>,
pub expected_version: u64,
}
/// Assign command request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAssignRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub agent_id: String,
pub assigned: bool,
pub expected_version: u64,
}
/// Create sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintCreateRequestDto {
#[serde(default)]
pub project_id: String,
pub name: String,
pub status: Option<String>,
}
/// List sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintListRequestDto {
#[serde(default)]
pub project_id: String,
}
/// Rename sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintRenameRequestDto {
#[serde(default)]
pub project_id: String,
pub sprint_id: String,
pub name: String,
pub expected_version: u64,
}
/// Reorder sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintReorderRequestDto {
#[serde(default)]
pub project_id: String,
pub ordered_ids: Vec<String>,
}
/// Delete sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintDeleteRequestDto {
#[serde(default)]
pub project_id: String,
pub sprint_id: String,
}
/// Assign ticket to sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintAssignRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub sprint_id: String,
pub expected_version: u64,
}
/// Unassign ticket from sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintUnassignRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub expected_version: u64,
}
#[derive(Clone)]
pub struct AppTicketToolProvider {
pub create: Arc<application::CreateIssue>,
pub read: Arc<application::ReadIssue>,
pub list: Arc<application::ListIssues>,
pub update: Arc<application::UpdateIssue>,
pub read_carnet: Arc<application::ReadIssueCarnet>,
pub update_carnet: Arc<application::UpdateIssueCarnet>,
pub link: Arc<application::LinkIssues>,
pub unlink: Arc<application::UnlinkIssues>,
pub list_sprints: Arc<application::ListSprints>,
}
#[async_trait]
impl TicketToolProvider for AppTicketToolProvider {
async fn handle_ticket_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, TicketToolError> {
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 rows = self
.list
.execute(ListIssuesInput {
project: project.clone(),
filter: req.filter,
})
.await
.map_err(ticket_error)?
.issues;
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<TicketDto, ErrorDto> {
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<TicketDto, ErrorDto> {
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<TicketChatDto, ErrorDto> {
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<TicketListDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let page = TicketListPageInput::from_request(request)?;
let rows = state
.list_issues
.execute(ListIssuesInput {
project,
filter: page.filter,
})
.await
.map_err(ErrorDto::from)?
.issues;
Ok(paginate(rows, page.limit, page.cursor))
}
#[tauri::command]
pub async fn sprint_create(
request: SprintCreateRequestDto,
state: State<'_, AppState>,
) -> Result<SprintDto, ErrorDto> {
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<SprintListDto, ErrorDto> {
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<SprintDto, ErrorDto> {
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<SprintListDto, ErrorDto> {
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::<Result<Vec<_>, _>>()?,
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<TicketDto, ErrorDto> {
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<TicketDto, ErrorDto> {
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<TicketDto, ErrorDto> {
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<TicketCarnetDto, ErrorDto> {
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<TicketDto, ErrorDto> {
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<TicketDto, ErrorDto> {
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<TicketDto, ErrorDto> {
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<TicketDto, ErrorDto> {
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<String>) -> Self {
Self {
id: issue.id.to_string(),
r#ref: issue.reference().to_string(),
number: issue.number.get(),
title: issue.title,
description: issue.description.as_str().to_owned(),
status: status_wire(issue.status).to_owned(),
priority: priority_wire(issue.priority).to_owned(),
sprint_id: issue.sprint.map(|id| id.to_string()),
sprint: None,
carnet,
links: issue.links.into_iter().map(TicketLinkDto::from).collect(),
assigned_agent_ids: issue
.agent_refs
.iter()
.filter(|r| r.role == AgentIssueRole::Assigned)
.map(|r| r.agent_id.to_string())
.collect(),
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<String>,
sprints: &[application::SprintListEntry],
) -> Self {
let sprint = issue
.sprint
.and_then(|id| ticket_sprint_context(id, sprints));
let mut dto = Self::from_issue(issue, carnet);
dto.sprint = sprint;
dto
}
}
impl From<IssueIndexEntry> for TicketSummaryDto {
fn from(row: IssueIndexEntry) -> Self {
Self {
r#ref: row.issue_ref.to_string(),
path: row.path,
title: row.title,
status: status_wire(row.status).to_owned(),
priority: priority_wire(row.priority).to_owned(),
sprint_id: row.sprint.map(|id| id.to_string()),
sprint: None,
assigned_agent_ids: row
.assigned_agent_ids
.into_iter()
.map(|id| id.to_string())
.collect(),
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<application::SprintListEntry> for SprintDto {
fn from(entry: application::SprintListEntry) -> Self {
Self {
id: entry.sprint.id.to_string(),
order: entry.sprint.order.get(),
name: entry.sprint.name,
status: sprint_status_wire(entry.sprint.status).to_owned(),
ticket_count: entry.ticket_count,
version: entry.sprint.version.get(),
}
}
}
impl From<Vec<application::SprintListEntry>> for SprintListDto {
fn from(items: Vec<application::SprintListEntry>) -> Self {
Self {
items: items.into_iter().map(SprintDto::from).collect(),
}
}
}
impl From<IssueLink> for TicketLinkDto {
fn from(link: IssueLink) -> Self {
Self {
target_ref: link.target.to_string(),
kind: link_kind_wire(link.kind).to_owned(),
}
}
}
impl From<IssueActor> for TicketActorDto {
fn from(actor: IssueActor) -> Self {
match actor {
IssueActor::User => Self::User,
IssueActor::Agent { agent_id } => Self::Agent {
agent_id: agent_id.to_string(),
},
IssueActor::System => Self::System,
}
}
}
impl From<IssueCarnet> for TicketCarnetDto {
fn from(carnet: IssueCarnet) -> Self {
Self {
r#ref: carnet.issue_ref.to_string(),
carnet: carnet.carnet.as_str().to_owned(),
version: carnet.version.get(),
}
}
}
#[derive(Debug)]
struct TicketListPageInput {
filter: IssueListFilter,
limit: usize,
cursor: Option<String>,
}
impl TicketListPageInput {
fn from_request(request: TicketListRequestDto) -> Result<Self, ErrorDto> {
Ok(Self {
filter: IssueListFilter {
statuses: parse_statuses_dto(request.statuses)?,
priorities: parse_priorities_dto(request.priorities)?,
assigned_agent_id: request
.assigned_agent_id
.as_deref()
.map(parse_agent_id_dto)
.transpose()?,
sprint: request
.sprint_id
.as_deref()
.map(parse_sprint_id_dto)
.transpose()?,
text: request.text,
},
limit: request.limit.unwrap_or(100).clamp(1, 500),
cursor: request.cursor,
})
}
}
async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
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<CreateIssueInput, ErrorDto> {
Ok(CreateIssueInput {
project,
title: request.title,
description: request.description.unwrap_or_default(),
priority: request
.priority
.as_deref()
.map(parse_priority_dto)
.transpose()?
.unwrap_or(IssuePriority::Medium),
status: request
.status
.as_deref()
.map(parse_status_dto)
.transpose()?
.unwrap_or(IssueStatus::Open),
links: request
.links
.unwrap_or_default()
.into_iter()
.map(parse_link_request)
.collect::<Result<Vec<_>, _>>()?,
assigned_agent_ids: request
.assigned_agent_ids
.unwrap_or_default()
.iter()
.map(|id| parse_agent_id_dto(id))
.collect::<Result<Vec<_>, _>>()?,
actor,
})
}
fn update_input(
project: Project,
request: TicketUpdateRequestDto,
actor: IssueActor,
) -> Result<UpdateIssueInput, ErrorDto> {
Ok(UpdateIssueInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
expected_version: version_dto(request.expected_version)?,
title: request.title,
description: request.description,
status: request
.status
.as_deref()
.map(parse_status_dto)
.transpose()?,
priority: request
.priority
.as_deref()
.map(parse_priority_dto)
.transpose()?,
assigned_agent_ids: request
.assigned_agent_ids
.map(|ids| ids.iter().map(|id| parse_agent_id_dto(id)).collect())
.transpose()?,
actor,
})
}
fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto> {
Ok(IssueLink {
target: parse_ref_dto(&link.target_ref)?,
kind: parse_link_kind_dto(&link.kind)?,
})
}
fn paginate(rows: Vec<IssueIndexEntry>, limit: usize, cursor: Option<String>) -> TicketListDto {
let start = cursor
.as_deref()
.and_then(|raw| raw.parse::<usize>().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<IssueIndexEntry>,
limit: usize,
cursor: Option<String>,
sprints: &[application::SprintListEntry],
) -> TicketListDto {
let start = cursor
.as_deref()
.and_then(|raw| raw.parse::<usize>().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<TicketSprintContextDto> {
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<String, TicketToolError> {
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, ErrorDto> {
IssueRef::from_str(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}
fn parse_agent_id_dto(raw: &str) -> Result<AgentId, ErrorDto> {
Uuid::parse_str(raw)
.map(AgentId::from_uuid)
.map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
}
fn parse_profile_id_dto(raw: &str) -> Result<ProfileId, ErrorDto> {
Uuid::parse_str(raw)
.map(ProfileId::from_uuid)
.map_err(|e| ErrorDto::invalid(format!("invalid profile id: {e}")))
}
fn parse_sprint_id_dto(raw: &str) -> Result<SprintId, ErrorDto> {
Uuid::parse_str(raw)
.map(SprintId::from_uuid)
.map_err(|e| ErrorDto::invalid(format!("invalid sprint id: {e}")))
}
fn version_dto(raw: u64) -> Result<IssueVersion, ErrorDto> {
IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}
fn sprint_version_dto(raw: u64) -> Result<SprintVersion, ErrorDto> {
SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}
fn parse_status_dto(raw: &str) -> Result<IssueStatus, ErrorDto> {
parse_status(raw).map_err(|e| ErrorDto::invalid(e.message))
}
fn parse_priority_dto(raw: &str) -> Result<IssuePriority, ErrorDto> {
parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message))
}
fn parse_statuses_dto(raw: Vec<String>) -> Result<Vec<IssueStatus>, ErrorDto> {
let mut out = Vec::new();
for item in raw {
let status = parse_status_dto(&item)?;
if !out.contains(&status) {
out.push(status);
}
}
Ok(out)
}
fn parse_priorities_dto(raw: Vec<String>) -> Result<Vec<IssuePriority>, ErrorDto> {
let mut out = Vec::new();
for item in raw {
let priority = parse_priority_dto(&item)?;
if !out.contains(&priority) {
out.push(priority);
}
}
Ok(out)
}
fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
}
fn parse_sprint_status_dto(raw: &str) -> Result<SprintStatus, ErrorDto> {
match raw {
"planned" => Ok(SprintStatus::Planned),
"active" => Ok(SprintStatus::Active),
"done" => Ok(SprintStatus::Done),
_ => Err(ErrorDto::invalid(format!("invalid sprint status: {raw}"))),
}
}
fn parse_status(raw: &str) -> Result<IssueStatus, TicketToolError> {
match raw {
"open" => Ok(IssueStatus::Open),
"inProgress" => Ok(IssueStatus::InProgress),
"QA" => Ok(IssueStatus::Qa),
"closed" => Ok(IssueStatus::Closed),
_ => Err(TicketToolError::new(
"invalid",
format!("invalid ticket status: {raw}"),
)),
}
}
fn parse_priority(raw: &str) -> Result<IssuePriority, TicketToolError> {
match raw {
"low" => Ok(IssuePriority::Low),
"medium" => Ok(IssuePriority::Medium),
"high" => Ok(IssuePriority::High),
"critical" => Ok(IssuePriority::Critical),
_ => Err(TicketToolError::new(
"invalid",
format!("invalid ticket priority: {raw}"),
)),
}
}
fn parse_link_kind(raw: &str) -> Result<IssueLinkKind, TicketToolError> {
match raw {
"relatesTo" => Ok(IssueLinkKind::RelatesTo),
"blocks" => Ok(IssueLinkKind::Blocks),
"blockedBy" => Ok(IssueLinkKind::BlockedBy),
"duplicates" => Ok(IssueLinkKind::Duplicates),
"dependsOn" => Ok(IssueLinkKind::DependsOn),
_ => Err(TicketToolError::new(
"invalid",
format!("invalid ticket link kind: {raw}"),
)),
}
}
fn status_wire(status: IssueStatus) -> &'static str {
match status {
IssueStatus::Open => "open",
IssueStatus::InProgress => "inProgress",
IssueStatus::Qa => "QA",
IssueStatus::Closed => "closed",
}
}
fn priority_wire(priority: IssuePriority) -> &'static str {
match priority {
IssuePriority::Low => "low",
IssuePriority::Medium => "medium",
IssuePriority::High => "high",
IssuePriority::Critical => "critical",
}
}
fn link_kind_wire(kind: IssueLinkKind) -> &'static str {
match kind {
IssueLinkKind::RelatesTo => "relatesTo",
IssueLinkKind::Blocks => "blocks",
IssueLinkKind::BlockedBy => "blockedBy",
IssueLinkKind::Duplicates => "duplicates",
IssueLinkKind::DependsOn => "dependsOn",
}
}
fn sprint_status_wire(status: SprintStatus) -> &'static str {
match status {
SprintStatus::Planned => "planned",
SprintStatus::Active => "active",
SprintStatus::Done => "done",
}
}
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<TicketCreateRequestDto, TicketToolError> {
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<TicketUpdateRequestDto, TicketToolError> {
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<TicketListPageInput, TicketToolError> {
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<IssueRef, TicketToolError> {
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<IssueVersion, TicketToolError> {
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<String>) -> 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,
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,
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,
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()));
}
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,
}
}
}