Sort le serveur HTTP/WS de `app-tauri` vers un crate `web-server` autonome,
exposant le binaire `idea-serve` qui sert le client web sans aucune dépendance
GUI. Le cœur (use cases, DTO, events) devient partagé entre deux driving
adapters : le desktop Tauri et le serveur headless.
Structure :
- `crates/web-server` : lib + bin `idea-serve`, consomme `backend::{dto, events}`.
- `crates/backend` : `dto.rs` et `events.rs` deviennent le propriétaire canonique
des DTO/events transport-neutres (arbitrage Architect : pas de crate contrat
dédié, `backend` est déjà le composition root transport-neutre).
- `crates/app-tauri` : `dto.rs` réduit à un shim de ré-export, `events.rs` réduit
au relais Tauri (souscription au bus + emit), `server.rs` vidé au profit du
crate partagé.
- `Cargo.toml`/`Cargo.lock` : `web-server` ajouté aux membres du workspace.
Frontière respectée : les DTO de `backend` ne tirent ni Tauri, ni HTTP/WS, ni
Axum/Hyper, ni UI ; `domain`/`application` n'en dépendent pas. Le frontend est
inchangé (`git diff 506d589...HEAD -- frontend` vide).
`web_server::run_embedded(...)` est le point d'extension prévu pour #68 (le
desktop consommera `web-server` comme lib plutôt que d'en forker le serveur).
QA (exécution réelle, re-vérifiée avant merge) :
- `cargo test -p backend` 41 passed · `-p web-server` 55 passed · `-p app-tauri`
35 passed, 0 échec.
- Invariant headless : `ldd target/debug/idea-serve` ne tire aucun
webkit/javascriptcore/gtk/gdk/soup/tauri/wry, là où `app-tauri` les tire bien.
- Validation live utilisateur : `idea-serve` démarre, sert le SPA, le contrôle
d'origine strict tient, accès distant OK via reverse proxy.
- Les 10 échecs `openai_compat` de `cargo test --workspace` sont préexistants sur
`506d589` (bind réseau interdit par la sandbox), hors périmètre.
Squash de `82e8e77` (commit de sûreté « état intermédiaire non figé », qui
rapatriait le travail réalisé par erreur sur la branche de #69) et de `ddbea7b`
(déduplication des DTO events, comblant l'écart annoncé par le premier). Les deux
n'avaient de sens qu'ensemble : ce commit fige le contrat que le premier laissait
explicitement ouvert. Arbre identique bit pour bit à `ddbea7b` ; historique
d'origine conservé sur `backup/ticket65-pre-squash-ddbea7b`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1963 lines
61 KiB
Rust
1963 lines
61 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::cmp::Ordering;
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use tauri::State;
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
|
|
CreateIssueInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput,
|
|
ListIssuesInput, ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput,
|
|
ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
|
|
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
|
};
|
|
use domain::{
|
|
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
|
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
|
|
Project, ProjectId, SprintId, SprintStatus, SprintVersion,
|
|
};
|
|
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, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TicketListSortDto {
|
|
pub field: TicketListSortFieldDto,
|
|
pub direction: TicketListSortDirectionDto,
|
|
}
|
|
|
|
/// List sort field.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum TicketListSortFieldDto {
|
|
Number,
|
|
Priority,
|
|
Status,
|
|
Title,
|
|
}
|
|
|
|
/// List sort direction.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum TicketListSortDirectionDto {
|
|
Asc,
|
|
Desc,
|
|
}
|
|
|
|
/// Update request.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TicketUpdateRequestDto {
|
|
#[serde(default)]
|
|
pub project_id: String,
|
|
pub r#ref: String,
|
|
pub title: Option<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;
|
|
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<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);
|
|
paginate(rows, page.limit, page.cursor, page.sort)
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct TicketCursorToken {
|
|
v: u8,
|
|
sort: Option<TicketListSortDto>,
|
|
anchor: TicketCursorAnchor,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct TicketCursorAnchor {
|
|
number: u64,
|
|
sort_key: TicketCursorSortKey,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
|
enum TicketCursorSortKey {
|
|
Number(u64),
|
|
Priority(u8),
|
|
Status(u8),
|
|
Title { lower: String, raw: String },
|
|
}
|
|
|
|
async fn resolve_project(state: &AppState, project_id: &str) -> Result<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>,
|
|
sort: Option<TicketListSortDto>,
|
|
) -> Result<TicketListDto, ErrorDto> {
|
|
paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from)
|
|
}
|
|
|
|
fn paginate_with_sprints(
|
|
rows: Vec<IssueIndexEntry>,
|
|
limit: usize,
|
|
cursor: Option<String>,
|
|
sort: Option<TicketListSortDto>,
|
|
sprints: &[application::SprintListEntry],
|
|
) -> Result<TicketListDto, ErrorDto> {
|
|
paginate_rows(rows, limit, cursor, sort, |row| {
|
|
TicketSummaryDto::from_row_with_sprints(row, sprints)
|
|
})
|
|
}
|
|
|
|
fn paginate_rows(
|
|
rows: Vec<IssueIndexEntry>,
|
|
limit: usize,
|
|
cursor: Option<String>,
|
|
sort: Option<TicketListSortDto>,
|
|
map_row: impl Fn(IssueIndexEntry) -> TicketSummaryDto,
|
|
) -> Result<TicketListDto, ErrorDto> {
|
|
let start = cursor_start(&rows, cursor.as_deref(), sort)?;
|
|
let total = rows.len();
|
|
let end = total.min(start.saturating_add(limit));
|
|
let next_cursor = if end < total && end > start {
|
|
Some(encode_ticket_cursor(&rows[end - 1], sort)?)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(TicketListDto {
|
|
items: rows
|
|
.into_iter()
|
|
.skip(start)
|
|
.take(limit)
|
|
.map(map_row)
|
|
.collect(),
|
|
next_cursor,
|
|
})
|
|
}
|
|
|
|
fn cursor_start(
|
|
rows: &[IssueIndexEntry],
|
|
cursor: Option<&str>,
|
|
sort: Option<TicketListSortDto>,
|
|
) -> Result<usize, ErrorDto> {
|
|
let Some(raw) = cursor else {
|
|
return Ok(0);
|
|
};
|
|
let token = decode_ticket_cursor(raw)?;
|
|
if token.sort != sort {
|
|
return Err(ErrorDto::invalid("Invalid cursor: sort mismatch"));
|
|
}
|
|
Ok(rows
|
|
.iter()
|
|
.position(|row| compare_row_to_anchor(row, &token.anchor, sort) == Ordering::Greater)
|
|
.unwrap_or(rows.len()))
|
|
}
|
|
|
|
fn encode_ticket_cursor(
|
|
row: &IssueIndexEntry,
|
|
sort: Option<TicketListSortDto>,
|
|
) -> Result<String, ErrorDto> {
|
|
let token = TicketCursorToken {
|
|
v: 1,
|
|
sort,
|
|
anchor: row_cursor_anchor(row, sort),
|
|
};
|
|
let json = serde_json::to_vec(&token)
|
|
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
|
|
Ok(format!("v1.{}", URL_SAFE_NO_PAD.encode(json)))
|
|
}
|
|
|
|
fn decode_ticket_cursor(raw: &str) -> Result<TicketCursorToken, ErrorDto> {
|
|
let encoded = raw
|
|
.strip_prefix("v1.")
|
|
.ok_or_else(|| ErrorDto::invalid("Invalid cursor: unknown version"))?;
|
|
let bytes = URL_SAFE_NO_PAD
|
|
.decode(encoded)
|
|
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
|
|
let token: TicketCursorToken = serde_json::from_slice(&bytes)
|
|
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
|
|
if token.v != 1 {
|
|
return Err(ErrorDto::invalid("Invalid cursor: unknown version"));
|
|
}
|
|
Ok(token)
|
|
}
|
|
|
|
fn row_cursor_anchor(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorAnchor {
|
|
TicketCursorAnchor {
|
|
number: row.issue_ref.number().get(),
|
|
sort_key: row_sort_key(row, sort),
|
|
}
|
|
}
|
|
|
|
fn row_sort_key(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorSortKey {
|
|
match sort.map(|sort| sort.field) {
|
|
None | Some(TicketListSortFieldDto::Number) => {
|
|
TicketCursorSortKey::Number(row.issue_ref.number().get())
|
|
}
|
|
Some(TicketListSortFieldDto::Priority) => {
|
|
TicketCursorSortKey::Priority(priority_rank(row.priority))
|
|
}
|
|
Some(TicketListSortFieldDto::Status) => {
|
|
TicketCursorSortKey::Status(status_rank(row.status))
|
|
}
|
|
Some(TicketListSortFieldDto::Title) => TicketCursorSortKey::Title {
|
|
lower: row.title.to_lowercase(),
|
|
raw: row.title.clone(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn compare_row_to_anchor(
|
|
row: &IssueIndexEntry,
|
|
anchor: &TicketCursorAnchor,
|
|
sort: Option<TicketListSortDto>,
|
|
) -> Ordering {
|
|
let row_key = row_sort_key(row, sort);
|
|
let field_order = compare_sort_key(&row_key, &anchor.sort_key);
|
|
let directed = match sort.map(|sort| sort.direction) {
|
|
Some(TicketListSortDirectionDto::Desc) => field_order.reverse(),
|
|
None | Some(TicketListSortDirectionDto::Asc) => field_order,
|
|
};
|
|
directed.then_with(|| row.issue_ref.number().get().cmp(&anchor.number))
|
|
}
|
|
|
|
fn compare_sort_key(a: &TicketCursorSortKey, b: &TicketCursorSortKey) -> Ordering {
|
|
match (a, b) {
|
|
(TicketCursorSortKey::Number(a), TicketCursorSortKey::Number(b)) => a.cmp(b),
|
|
(TicketCursorSortKey::Priority(a), TicketCursorSortKey::Priority(b))
|
|
| (TicketCursorSortKey::Status(a), TicketCursorSortKey::Status(b)) => a.cmp(b),
|
|
(
|
|
TicketCursorSortKey::Title { lower: al, raw: ar },
|
|
TicketCursorSortKey::Title { lower: bl, raw: br },
|
|
) => al.cmp(bl).then_with(|| ar.cmp(br)),
|
|
_ => Ordering::Equal,
|
|
}
|
|
}
|
|
|
|
fn ticket_sprint_context(
|
|
sprint_id: SprintId,
|
|
sprints: &[application::SprintListEntry],
|
|
) -> Option<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}")))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use domain::IssueNumber;
|
|
|
|
#[test]
|
|
fn ticket_list_request_deduplicates_multi_select_filters() {
|
|
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
|
project_id: String::new(),
|
|
statuses: vec!["open".into(), "closed".into(), "open".into()],
|
|
priorities: vec!["high".into(), "low".into(), "high".into()],
|
|
assigned_agent_id: None,
|
|
sprint_id: None,
|
|
text: None,
|
|
sort: None,
|
|
limit: None,
|
|
cursor: None,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
page.filter.statuses,
|
|
vec![IssueStatus::Open, IssueStatus::Closed]
|
|
);
|
|
assert_eq!(
|
|
page.filter.priorities,
|
|
vec![IssuePriority::High, IssuePriority::Low]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_list_request_rejects_invalid_multi_select_token() {
|
|
let err = TicketListPageInput::from_request(TicketListRequestDto {
|
|
project_id: String::new(),
|
|
statuses: vec!["open".into(), "bad".into()],
|
|
priorities: Vec::new(),
|
|
assigned_agent_id: None,
|
|
sprint_id: None,
|
|
text: None,
|
|
sort: None,
|
|
limit: None,
|
|
cursor: None,
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code, "INVALID");
|
|
assert!(err.message.contains("invalid ticket status: bad"));
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_list_pagination_preserves_multi_filter_request_shape() {
|
|
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
|
project_id: String::new(),
|
|
statuses: vec!["open".into(), "QA".into()],
|
|
priorities: vec!["high".into()],
|
|
assigned_agent_id: None,
|
|
sprint_id: None,
|
|
text: None,
|
|
sort: None,
|
|
limit: Some(1),
|
|
cursor: None,
|
|
})
|
|
.unwrap();
|
|
let rows = vec![
|
|
issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
|
issue_row(2, IssueStatus::Qa, IssuePriority::High, "Beta"),
|
|
issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"),
|
|
];
|
|
|
|
assert_eq!(
|
|
page.filter.statuses,
|
|
vec![IssueStatus::Open, IssueStatus::Qa]
|
|
);
|
|
assert_eq!(page.filter.priorities, vec![IssuePriority::High]);
|
|
let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();
|
|
assert_eq!(out.items.len(), 1);
|
|
assert_eq!(out.items[0].r#ref, "#1");
|
|
assert!(out
|
|
.next_cursor
|
|
.as_deref()
|
|
.is_some_and(|cursor| cursor.starts_with("v1.")));
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() {
|
|
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
|
project_id: String::new(),
|
|
statuses: Vec::new(),
|
|
priorities: Vec::new(),
|
|
assigned_agent_id: None,
|
|
sprint_id: None,
|
|
text: None,
|
|
sort: Some(TicketListSortDto {
|
|
field: TicketListSortFieldDto::Priority,
|
|
direction: TicketListSortDirectionDto::Desc,
|
|
}),
|
|
limit: None,
|
|
cursor: None,
|
|
})
|
|
.unwrap();
|
|
let mut rows = vec![
|
|
issue_row(4, IssueStatus::Open, IssuePriority::High, "Delta"),
|
|
issue_row(2, IssueStatus::Closed, IssuePriority::Critical, "Beta"),
|
|
issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"),
|
|
issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"),
|
|
];
|
|
|
|
sort_ticket_rows(&mut rows, page.sort);
|
|
let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();
|
|
|
|
assert_eq!(
|
|
out.items
|
|
.into_iter()
|
|
.map(|item| item.r#ref)
|
|
.collect::<Vec<_>>(),
|
|
vec!["#2", "#3", "#4", "#1"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_list_cursor_is_anchor_based_when_items_are_inserted_or_removed_before_anchor() {
|
|
let rows = vec![
|
|
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
|
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
|
|
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
|
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
|
];
|
|
let first = paginate(rows, 2, None, None).unwrap();
|
|
let cursor = first.next_cursor.clone().expect("next cursor");
|
|
assert_eq!(refs(&first), vec!["#10", "#20"]);
|
|
|
|
let with_insert_before_anchor = vec![
|
|
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
|
issue_row(15, IssueStatus::Open, IssuePriority::High, "Inserted"),
|
|
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
|
|
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
|
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
|
];
|
|
let second = paginate(with_insert_before_anchor, 2, Some(cursor.clone()), None).unwrap();
|
|
assert_eq!(refs(&second), vec!["#30", "#40"]);
|
|
|
|
let with_removed_before_anchor = vec![
|
|
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
|
|
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
|
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
|
];
|
|
let second = paginate(with_removed_before_anchor, 2, Some(cursor.clone()), None).unwrap();
|
|
assert_eq!(refs(&second), vec!["#30", "#40"]);
|
|
|
|
let with_removed_anchor = vec![
|
|
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
|
|
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
|
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
|
|
];
|
|
let second = paginate(with_removed_anchor, 2, Some(cursor), None).unwrap();
|
|
assert_eq!(refs(&second), vec!["#30", "#40"]);
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_list_cursor_rejects_legacy_or_invalid_tokens() {
|
|
let rows = vec![issue_row(
|
|
1,
|
|
IssueStatus::Open,
|
|
IssuePriority::High,
|
|
"Alpha",
|
|
)];
|
|
|
|
for cursor in ["2", "v1.not-base64", "v2.abc"] {
|
|
let err = paginate(rows.clone(), 1, Some(cursor.to_owned()), None).unwrap_err();
|
|
assert_eq!(err.code, "INVALID");
|
|
assert!(err.message.contains("Invalid cursor"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_list_cursor_rejects_sort_mismatch() {
|
|
let mut rows = vec![
|
|
issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"),
|
|
issue_row(2, IssueStatus::Open, IssuePriority::Critical, "Beta"),
|
|
issue_row(3, IssueStatus::Open, IssuePriority::High, "Gamma"),
|
|
];
|
|
let priority_sort = Some(TicketListSortDto {
|
|
field: TicketListSortFieldDto::Priority,
|
|
direction: TicketListSortDirectionDto::Desc,
|
|
});
|
|
sort_ticket_rows(&mut rows, priority_sort);
|
|
let first = paginate(rows.clone(), 1, None, priority_sort).unwrap();
|
|
let cursor = first.next_cursor.expect("next cursor");
|
|
|
|
let err = paginate(rows, 1, Some(cursor), None).unwrap_err();
|
|
|
|
assert_eq!(err.code, "INVALID");
|
|
assert!(err.message.contains("sort mismatch"));
|
|
}
|
|
|
|
fn issue_row(
|
|
number: u64,
|
|
status: IssueStatus,
|
|
priority: IssuePriority,
|
|
title: &str,
|
|
) -> IssueIndexEntry {
|
|
IssueIndexEntry {
|
|
issue_ref: IssueRef::from(IssueNumber::new(number).unwrap()),
|
|
path: number.to_string(),
|
|
title: title.to_owned(),
|
|
status,
|
|
priority,
|
|
sprint: None,
|
|
assigned_agent_ids: Vec::new(),
|
|
updated_at: number,
|
|
}
|
|
}
|
|
|
|
fn refs(out: &TicketListDto) -> Vec<String> {
|
|
out.items.iter().map(|item| item.r#ref.clone()).collect()
|
|
}
|
|
}
|