feat(tickets): checkpoint backend V1 du système de tickets (T1-T5)

Checkpoint de travail — QA formelle encore à venir (après reset Codex).
`cargo build` workspace OK, tests ticket/issue verts (domaine, store,
use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé
« ticket » côté MCP/UI.

- T1 domaine : entité Issue (statut, priorité, liens, carnet),
  events, ids, ports.
- T2 infra : store FS Markdown + allocator de références #N.
- T3 application : use cases Issue (create/read/list/update/status/
  priority/carnet/link/unlink/assign) + erreurs dédiées.
- T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au
  total), mappés vers les use cases Issue.
- T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue +
  câblage state.rs.

Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri
mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets,
non traitée ici.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:34:04 +02:00
parent a9653bc417
commit 8de7be01a8
22 changed files with 4395 additions and 50 deletions

View File

@ -0,0 +1,238 @@
//! Public MCP ticket tools.
//!
//! The public surface says `ticket`, while the provider behind this trait maps
//! to the application/domain `Issue` use cases.
use async_trait::async_trait;
use domain::Project;
use serde_json::{json, Value};
use super::tools::ToolDef;
/// Error returned by an MCP ticket tool provider.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct TicketToolError {
/// Stable machine-readable code.
pub code: &'static str,
/// Human-readable message.
pub message: String,
}
impl TicketToolError {
/// Builds a typed ticket-tool error.
#[must_use]
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// Serialises the error as a camelCase JSON object.
#[must_use]
pub fn to_value(&self) -> Value {
json!({ "code": self.code, "message": self.message })
}
}
/// Provider injected by the composition root to execute public ticket tools.
#[async_trait]
pub trait TicketToolProvider: Send + Sync {
/// Executes one public `idea_ticket_*` tool for `project`.
async fn handle_ticket_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, TicketToolError>;
}
/// Returns true when `name` is one of the public ticket tools.
#[must_use]
pub fn is_ticket_tool(name: &str) -> bool {
matches!(
name,
"idea_ticket_create"
| "idea_ticket_read"
| "idea_ticket_list"
| "idea_ticket_update"
| "idea_ticket_update_status"
| "idea_ticket_update_priority"
| "idea_ticket_read_carnet"
| "idea_ticket_update_carnet"
| "idea_ticket_link"
| "idea_ticket_unlink"
)
}
/// Public ticket tool definitions advertised by `tools/list`.
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] });
let link_kind = json!({
"type": "string",
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
});
let link = json!({
"type": "object",
"properties": {
"targetRef": ticket_ref.clone(),
"kind": link_kind.clone()
},
"required": ["targetRef", "kind"],
"additionalProperties": false
});
vec![
ToolDef {
name: "idea_ticket_create",
description: "Create an IdeA ticket in the current project and return it as JSON.",
input_schema: json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"description": { "type": "string" },
"priority": priority.clone(),
"status": status.clone(),
"assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
"links": { "type": "array", "items": link.clone() }
},
"required": ["title"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_read",
description: "Read one IdeA ticket by public reference (#N).",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"includeCarnet": { "type": "boolean" }
},
"required": ["ref"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_list",
description: "List IdeA tickets in the current project.",
input_schema: json!({
"type": "object",
"properties": {
"status": status.clone(),
"priority": priority.clone(),
"assignedAgentId": { "type": "string", "format": "uuid" },
"text": { "type": "string" },
"limit": { "type": "integer", "minimum": 1 },
"cursor": { "type": "string" }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update",
description: "Update ticket fields using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"title": { "type": "string" },
"description": { "type": "string" },
"status": status.clone(),
"priority": priority.clone(),
"assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update_status",
description: "Update a ticket status using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"status": status.clone(),
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "status", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update_priority",
description: "Update a ticket priority using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"priority": priority.clone(),
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "priority", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_read_carnet",
description: "Read the editable carnet attached to a ticket.",
input_schema: json!({
"type": "object",
"properties": { "ref": ticket_ref.clone() },
"required": ["ref"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update_carnet",
description: "Update a ticket carnet using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"carnet": { "type": "string" },
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "carnet", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_link",
description: "Add a link from one ticket to another using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"targetRef": ticket_ref.clone(),
"kind": link_kind.clone(),
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "targetRef", "kind", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_unlink",
description: "Remove a link from one ticket to another using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"targetRef": ticket_ref,
"kind": link_kind,
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "targetRef", "expectedVersion"],
"additionalProperties": false
}),
},
]
}