Ajoute le paramètre de tri dans TicketListQuery et l'expose via ticket_list (surface app-tauri + MCP). Ports et adaptateur mock frontend alignés sur le contrat de tri. La partie UI (contrôle de tri dans TicketFacetsBar) suivra dans un commit frontend dédié. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1756 lines
53 KiB
Rust
1756 lines
53 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 sort: Option<TicketListSortDto>,
|
|
pub limit: Option<usize>,
|
|
pub cursor: Option<String>,
|
|
}
|
|
|
|
/// 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<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 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<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 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<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,
|
|
sort: Option<TicketListSortDto>,
|
|
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,
|
|
},
|
|
sort: request.sort,
|
|
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
|
cursor: request.cursor,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn sort_ticket_rows(rows: &mut [IssueIndexEntry], sort: Option<TicketListSortDto>) {
|
|
let Some(sort) = sort else {
|
|
return;
|
|
};
|
|
|
|
rows.sort_by(|a, b| {
|
|
let field_order = match sort.field {
|
|
TicketListSortFieldDto::Number => {
|
|
a.issue_ref.number().get().cmp(&b.issue_ref.number().get())
|
|
}
|
|
TicketListSortFieldDto::Priority => {
|
|
priority_rank(a.priority).cmp(&priority_rank(b.priority))
|
|
}
|
|
TicketListSortFieldDto::Status => status_rank(a.status).cmp(&status_rank(b.status)),
|
|
TicketListSortFieldDto::Title => a
|
|
.title
|
|
.to_lowercase()
|
|
.cmp(&b.title.to_lowercase())
|
|
.then_with(|| a.title.cmp(&b.title)),
|
|
};
|
|
let directed = match sort.direction {
|
|
TicketListSortDirectionDto::Asc => field_order,
|
|
TicketListSortDirectionDto::Desc => field_order.reverse(),
|
|
};
|
|
directed.then_with(|| a.issue_ref.number().get().cmp(&b.issue_ref.number().get()))
|
|
});
|
|
}
|
|
|
|
fn priority_rank(priority: IssuePriority) -> u8 {
|
|
match priority {
|
|
IssuePriority::Low => 0,
|
|
IssuePriority::Medium => 1,
|
|
IssuePriority::High => 2,
|
|
IssuePriority::Critical => 3,
|
|
}
|
|
}
|
|
|
|
fn status_rank(status: IssueStatus) -> u8 {
|
|
match status {
|
|
IssueStatus::Open => 0,
|
|
IssueStatus::InProgress => 1,
|
|
IssueStatus::Qa => 2,
|
|
IssueStatus::Closed => 3,
|
|
}
|
|
}
|
|
|
|
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,
|
|
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<_>>(),
|
|
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,
|
|
}
|
|
}
|
|
}
|