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,163 @@
use std::path::PathBuf;
use std::str::FromStr;
use domain::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
MarkdownDoc, ProjectPath,
};
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-issues-{}", Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn root(&self) -> ProjectPath {
ProjectPath::new(self.0.to_string_lossy().to_string()).unwrap()
}
fn child(&self, rel: &str) -> PathBuf {
self.0.join(rel)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn issue(root: &ProjectPath, number: u64, title: &str) -> Issue {
let _ = root;
Issue::new(
IssueId::new_random(),
domain::IssueNumber::new(number).unwrap(),
title,
MarkdownDoc::new("Initial description"),
IssueStatus::Open,
IssuePriority::High,
MarkdownDoc::new("Initial carnet"),
Vec::new(),
vec![AgentIssueRef {
agent_id: domain::AgentId::new_random(),
role: AgentIssueRole::Assigned,
}],
IssueActor::User,
1_000,
)
.unwrap()
}
#[tokio::test]
async fn issue_store_writes_markdown_docs_and_index() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let issue = issue(&root, 1, "Wire issues");
store.create(&root, &issue).await.unwrap();
let issue_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/issue.md")).unwrap();
let carnet_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/carnet.md")).unwrap();
let index = std::fs::read_to_string(tmp.child(".ideai/tickets/index.json")).unwrap();
assert!(issue_md.starts_with("---\n"));
assert!(issue_md.contains("title: \"Wire issues\""));
assert!(issue_md.ends_with("Initial description"));
assert!(carnet_md.contains("issueRef: \"#1\""));
assert!(carnet_md.ends_with("Initial carnet"));
assert!(index.contains("\"issueRef\": \"#1\""));
let loaded = store
.get_by_ref(&root, IssueRef::from_str("#1").unwrap())
.await
.unwrap();
assert_eq!(loaded.title, "Wire issues");
assert_eq!(loaded.carnet.as_str(), "Initial carnet");
}
#[tokio::test]
async fn issue_store_update_checks_expected_version() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let original = issue(&root, 2, "Conflict");
store.create(&root, &original).await.unwrap();
let updated = original
.clone()
.mutate(IssueActor::System, 2_000, |i| {
i.status = IssueStatus::InProgress;
})
.unwrap();
let err = store
.update(&root, &updated, updated.version)
.await
.unwrap_err();
assert!(matches!(err, IssueStoreError::VersionConflict { .. }));
store
.update(&root, &updated, original.version)
.await
.unwrap();
let loaded = store.get_by_ref(&root, updated.reference()).await.unwrap();
assert_eq!(loaded.status, IssueStatus::InProgress);
assert_eq!(loaded.version.get(), 2);
}
#[tokio::test]
async fn issue_store_lists_by_index_filters() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
store
.create(&root, &issue(&root, 1, "Alpha"))
.await
.unwrap();
let closed = issue(&root, 2, "Beta")
.mutate(IssueActor::System, 2_000, |i| {
i.status = IssueStatus::Closed;
i.priority = IssuePriority::Low;
})
.unwrap();
store.create(&root, &closed).await.unwrap();
let rows = store
.list(
&root,
IssueListFilter {
status: Some(IssueStatus::Closed),
priority: Some(IssuePriority::Low),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Beta");
}
#[tokio::test]
async fn allocator_never_reuses_numbers() {
let tmp = TempDir::new();
let root = tmp.root();
let allocator = FsIssueNumberAllocator::new();
let first = allocator.allocate_next(&root).await.unwrap();
let second = allocator.allocate_next(&root).await.unwrap();
let third = FsIssueNumberAllocator::new()
.allocate_next(&root)
.await
.unwrap();
assert_eq!(first.get(), 1);
assert_eq!(second.get(), 2);
assert_eq!(third.get(), 3);
}

View File

@ -500,6 +500,17 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
// Live-state tools (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
// Public ticket tools (Issue domain).
"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",
] {
assert!(
names.contains(&expected),
@ -509,8 +520,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
13,
"exactly the thirteen exposed idea_* tools; got {names:?}"
23,
"exactly the twenty-three exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.