From 5417bd756b8703c0a6ec966821ba97e472fb9c70 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 6 Jul 2026 06:27:09 +0200 Subject: [PATCH] feat(tickets): suppression d'un ticket (backend + frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la suppression complète d'un ticket (#6). Backend Rust : - port IssueStore::delete (NotFound si absent) + event IssueDeleted{freed_sprint} - FsIssueStore::delete : supprime .ideai/tickets// et l'index sous lock - use case DeleteIssue + adaptations des tests sprint/ticket_assistant au port - commande Tauri ticket_delete + câblage events/state/lib Frontend : - gateway delete (ports, adapter ticket + mock, domain) - useTicketDetail : retrait de la liste et fermeture du détail via event issueDeleted - intégration TicketDetail + tests Tests verts : application/issue_usecases (6), infrastructure/issue_store (7), app-tauri --lib (56), frontend vitest (503), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/events.rs | 35 ++++ crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/state.rs | 52 ++--- crates/app-tauri/src/tickets.rs | 34 +++- crates/application/src/issues/mod.rs | 53 ++++++ crates/application/src/lib.rs | 10 +- crates/application/tests/issue_usecases.rs | 106 ++++++++++- crates/application/tests/sprint_usecases.rs | 13 ++ crates/application/tests/ticket_assistant.rs | 8 + crates/domain/src/events.rs | 9 + crates/domain/src/ports.rs | 6 + crates/infrastructure/src/issues.rs | 177 ++++++++++++------ crates/infrastructure/tests/issue_store.rs | 40 ++++ frontend/src/adapters/mock/index.ts | 16 ++ frontend/src/adapters/ticket.ts | 9 + frontend/src/domain/index.ts | 13 ++ .../src/features/tickets/TicketDetail.tsx | 64 +++++++ .../src/features/tickets/tickets.test.tsx | 93 ++++++++- .../src/features/tickets/useTicketDetail.ts | 37 ++++ frontend/src/ports/index.ts | 8 + 20 files changed, 689 insertions(+), 95 deletions(-) diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 1af1e22..3d5f43d 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -241,6 +241,16 @@ pub enum DomainEventDto { /// New optimistic version. version: u64, }, + /// An issue-backed public ticket was deleted. + #[serde(rename_all = "camelCase")] + IssueDeleted { + /// Project id. + project_id: String, + /// Deleted public ticket reference (`#N`). + issue_ref: String, + /// Released sprint id, when any. + freed_sprint: Option, + }, /// A public ticket status changed. #[serde(rename_all = "camelCase")] IssueStatusChanged { @@ -781,6 +791,15 @@ impl From<&DomainEvent> for DomainEventDto { issue_ref: issue_ref.to_string(), version: version.get(), }, + DomainEvent::IssueDeleted { + project_id, + issue_ref, + freed_sprint, + } => Self::IssueDeleted { + project_id: project_id.to_string(), + issue_ref: issue_ref.to_string(), + freed_sprint: freed_sprint.map(|sprint| sprint.to_string()), + }, DomainEvent::IssueStatusChanged { issue_ref, status, @@ -1106,6 +1125,22 @@ mod tests { assert_eq!(json["version"], 3); } + #[test] + fn issue_deleted_relays_to_dto_and_wire() { + let project_id = domain::ProjectId::from_uuid(uuid::Uuid::from_u128(40)); + let sprint_id = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); + let dto = DomainEventDto::from(&DomainEvent::IssueDeleted { + project_id, + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + freed_sprint: Some(sprint_id), + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "issueDeleted"); + assert_eq!(json["projectId"], project_id.to_string()); + assert_eq!(json["issueRef"], "#7"); + assert_eq!(json["freedSprint"], sprint_id.to_string()); + } + #[test] fn ticket_assistant_events_relay_to_dto_and_wire() { let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9)); diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index bb77fe3..bd2ebe1 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -166,6 +166,7 @@ pub fn run() { commands::list_agents, tickets::ticket_create, tickets::ticket_read, + tickets::ticket_delete, tickets::open_ticket_chat, tickets::close_ticket_chat, tickets::ticket_list, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b72d1f1..90314e7 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -17,28 +17,29 @@ use application::{ ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, - CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, - DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, - DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, - GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, - GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, - HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, - ListAgentsInput, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, - ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, - LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, - McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, - ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, - SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, - UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, - UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, + DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, + DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, + FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, + GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, + GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, + LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues, + ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills, + ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, + LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, + MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, + OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, + ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, + ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, + RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, + RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, + UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, + UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -793,6 +794,8 @@ pub struct AppState { pub create_issue: Arc, /// Read an issue-backed public ticket. pub read_issue: Arc, + /// Delete an issue-backed public ticket. + pub delete_issue: Arc, /// List issue-backed public tickets. pub list_issues: Arc, /// Update an issue-backed public ticket. @@ -1207,6 +1210,10 @@ impl AppState { Arc::clone(&events_port), )); let read_issue = Arc::new(ReadIssue::new(Arc::clone(&issue_store_port))); + let delete_issue = Arc::new(DeleteIssue::new( + Arc::clone(&issue_store_port), + Arc::clone(&events_port), + )); let list_issues = Arc::new(ListIssues::new(Arc::clone(&issue_store_port))); let update_issue = Arc::new(UpdateIssue::new( Arc::clone(&issue_store_port), @@ -2190,6 +2197,7 @@ impl AppState { event_bus, create_issue, read_issue, + delete_issue, list_issues, update_issue, read_issue_carnet, diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index f1c73f3..eca279d 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -15,10 +15,10 @@ use uuid::Uuid; use application::{ AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput, - CreateIssueInput, CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, - ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput, - ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput, - UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, + 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, @@ -180,6 +180,15 @@ pub struct TicketReadRequestDto { pub include_carnet: Option, } +/// 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")] @@ -612,6 +621,23 @@ pub async fn ticket_read( 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, diff --git a/crates/application/src/issues/mod.rs b/crates/application/src/issues/mod.rs index b736dbb..06cae97 100644 --- a/crates/application/src/issues/mod.rs +++ b/crates/application/src/issues/mod.rs @@ -161,6 +161,59 @@ impl ReadIssue { } } +/// Input for [`DeleteIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteIssueInput { + /// Project owning the issue. + pub project: Project, + /// Issue reference. + pub issue_ref: IssueRef, +} + +/// Output of [`DeleteIssue::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteIssueOutput { + /// Deleted issue reference. + pub issue_ref: IssueRef, +} + +/// Deletes an issue. +pub struct DeleteIssue { + issues: Arc, + events: Arc, +} + +impl DeleteIssue { + /// Builds the use case. + #[must_use] + pub fn new(issues: Arc, events: Arc) -> Self { + Self { issues, events } + } + + /// Executes deletion. + /// + /// # Errors + /// [`AppError`] on missing issue or store failure. + pub async fn execute(&self, input: DeleteIssueInput) -> Result { + let issue = self + .issues + .get_by_ref(&input.project.root, input.issue_ref) + .await?; + let freed_sprint = issue.sprint; + self.issues + .delete(&input.project.root, input.issue_ref) + .await?; + self.events.publish(DomainEvent::IssueDeleted { + project_id: input.project.id, + issue_ref: input.issue_ref, + freed_sprint, + }); + Ok(DeleteIssueOutput { + issue_ref: input.issue_ref, + }) + } +} + /// Input for [`ReadIssueCarnet::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadIssueCarnetInput { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 59c0d20..1719ba5 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -79,11 +79,11 @@ pub use git::{ pub use health::{HealthInput, HealthReport, HealthUseCase}; pub use issues::{ AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput, - CreateIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, - ListIssuesOutput, ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, - ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, - UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, - UpdateIssueOutput, + CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput, LinkIssues, + LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput, ReadIssue, + ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput, ReadIssueOutput, + UnlinkIssues, UnlinkIssuesInput, UpdateIssue, UpdateIssueCarnet, UpdateIssueCarnetInput, + UpdateIssueCarnetOutput, UpdateIssueInput, UpdateIssueOutput, }; pub use layout::{ CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, diff --git a/crates/application/tests/issue_usecases.rs b/crates/application/tests/issue_usecases.rs index 3ac5103..8ff8bd4 100644 --- a/crates/application/tests/issue_usecases.rs +++ b/crates/application/tests/issue_usecases.rs @@ -13,7 +13,9 @@ use domain::{ }; use uuid::Uuid; -use application::{CreateIssue, CreateIssueInput, UpdateIssue, UpdateIssueInput}; +use application::{ + CreateIssue, CreateIssueInput, DeleteIssue, DeleteIssueInput, UpdateIssue, UpdateIssueInput, +}; #[derive(Default)] struct FakeIssues { @@ -84,6 +86,19 @@ impl IssueStore for FakeIssues { Ok(()) } + async fn delete( + &self, + _root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result<(), IssueStoreError> { + self.issues + .lock() + .unwrap() + .remove(&issue_ref) + .map(|_| ()) + .ok_or(IssueStoreError::NotFound) + } + async fn read_carnet( &self, _root: &ProjectPath, @@ -400,3 +415,92 @@ async fn update_issue_maps_version_conflict() { assert_eq!(err.code(), "INVALID"); assert!(err.to_string().contains("version conflict")); } + +#[tokio::test] +async fn delete_issue_emits_deleted_event_with_freed_sprint() { + let issues = Arc::new(FakeIssues::default()); + let sprint_id = domain::SprintId::from_uuid(Uuid::from_u128(7)); + let initial = Issue::new( + domain::IssueId::new_random(), + IssueNumber::new(1).unwrap(), + "In sprint", + MarkdownDoc::new("Body"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap() + .mutate(IssueActor::System, 2, |issue| { + issue.sprint = Some(sprint_id); + }) + .unwrap(); + issues.create(&project().root, &initial).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + let uc = DeleteIssue::new(issues.clone(), bus.clone()); + + let out = uc + .execute(DeleteIssueInput { + project: project(), + issue_ref: initial.reference(), + }) + .await + .unwrap(); + + assert_eq!(out.issue_ref, initial.reference()); + assert!(matches!( + issues + .get_by_ref(&project().root, initial.reference()) + .await, + Err(IssueStoreError::NotFound) + )); + assert!(matches!( + bus.events().as_slice(), + [DomainEvent::IssueDeleted { + issue_ref, + freed_sprint: Some(id), + .. + }] if *issue_ref == initial.reference() && *id == sprint_id + )); +} + +#[tokio::test] +async fn delete_issue_emits_deleted_event_without_freed_sprint() { + let issues = Arc::new(FakeIssues::default()); + let initial = Issue::new( + domain::IssueId::new_random(), + IssueNumber::new(2).unwrap(), + "Backlog", + MarkdownDoc::new("Body"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap(); + issues.create(&project().root, &initial).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + let uc = DeleteIssue::new(issues, bus.clone()); + + uc.execute(DeleteIssueInput { + project: project(), + issue_ref: initial.reference(), + }) + .await + .unwrap(); + + assert!(matches!( + bus.events().as_slice(), + [DomainEvent::IssueDeleted { + issue_ref, + freed_sprint: None, + .. + }] if *issue_ref == initial.reference() + )); +} diff --git a/crates/application/tests/sprint_usecases.rs b/crates/application/tests/sprint_usecases.rs index c138c5a..99e1444 100644 --- a/crates/application/tests/sprint_usecases.rs +++ b/crates/application/tests/sprint_usecases.rs @@ -159,6 +159,19 @@ impl IssueStore for FakeIssues { Ok(()) } + async fn delete( + &self, + _root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result<(), IssueStoreError> { + self.issues + .lock() + .unwrap() + .remove(&issue_ref) + .map(|_| ()) + .ok_or(IssueStoreError::NotFound) + } + async fn read_carnet( &self, _root: &ProjectPath, diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs index 45a11b6..58216f8 100644 --- a/crates/application/tests/ticket_assistant.rs +++ b/crates/application/tests/ticket_assistant.rs @@ -106,6 +106,14 @@ impl IssueStore for FakeIssues { unimplemented!() } + async fn delete( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + ) -> Result<(), IssueStoreError> { + unimplemented!() + } + async fn read_carnet( &self, _root: &ProjectPath, diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index d3e39cf..43b3fd5 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -317,6 +317,15 @@ pub enum DomainEvent { /// New optimistic version. version: IssueVersion, }, + /// An issue was deleted. + IssueDeleted { + /// Owning project. + project_id: ProjectId, + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// Sprint membership released by the deletion, when any. + freed_sprint: Option, + }, /// An issue status changed. IssueStatusChanged { /// Human-friendly issue reference. diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index d1b771c..8b988ec 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1543,6 +1543,12 @@ pub trait IssueStore: Send + Sync { expected_version: IssueVersion, ) -> Result<(), IssueStoreError>; + /// Deletes an issue by `#N` reference. + /// + /// # Errors + /// [`IssueStoreError::NotFound`] when absent. + async fn delete(&self, root: &ProjectPath, issue_ref: IssueRef) -> Result<(), IssueStoreError>; + /// Reads only the issue carnet projection. /// /// # Errors diff --git a/crates/infrastructure/src/issues.rs b/crates/infrastructure/src/issues.rs index 0151bef..6189a26 100644 --- a/crates/infrastructure/src/issues.rs +++ b/crates/infrastructure/src/issues.rs @@ -26,6 +26,7 @@ const CARNET_FILE: &str = "carnet.md"; const INDEX_FILE: &str = "index.json"; const COUNTER_FILE: &str = "counter.json"; const COUNTER_LOCK: &str = "counter.lock"; +const MUTATION_LOCK: &str = "mutation.lock"; const INDEX_VERSION: u32 = 1; /// Filesystem-backed issue store. @@ -95,6 +96,39 @@ fn counter_lock_path(root: &ProjectPath) -> PathBuf { issue_root(root).join(COUNTER_LOCK) } +fn mutation_lock_path(root: &ProjectPath) -> PathBuf { + issue_root(root).join(MUTATION_LOCK) +} + +async fn acquire_lock(path: &Path, name: &str) -> Result { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(io_error)?; + } + let mut lock = None; + for _ in 0..50 { + match tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .await + { + Ok(file) => { + lock = Some(file); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(err) => return Err(io_error(err)), + } + } + let Some(mut lock_file) = lock else { + return Err(IssueStoreError::Store(format!("{name} lock timed out"))); + }; + lock_file.write_all(b"locked\n").await.map_err(io_error)?; + Ok(lock_file) +} + async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), IssueStoreError> { let parent = path .parent() @@ -234,21 +268,28 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool { #[async_trait] impl IssueStore for FsIssueStore { async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { - issue - .validate() - .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; - if tokio::fs::try_exists(issue_path(root, issue.number)) - .await - .map_err(io_error)? - { - return Err(IssueStoreError::Invalid(format!( - "issue {} already exists", - issue.reference() - ))); + let lock_path = mutation_lock_path(root); + let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; + let result = async { + issue + .validate() + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + if tokio::fs::try_exists(issue_path(root, issue.number)) + .await + .map_err(io_error)? + { + return Err(IssueStoreError::Invalid(format!( + "issue {} already exists", + issue.reference() + ))); + } + save_issue(root, issue).await?; + rebuild_index(root).await?; + Ok(()) } - save_issue(root, issue).await?; - rebuild_index(root).await?; - Ok(()) + .await; + let _ = tokio::fs::remove_file(&lock_path).await; + result } async fn get_by_ref( @@ -304,19 +345,47 @@ impl IssueStore for FsIssueStore { issue: &Issue, expected_version: IssueVersion, ) -> Result<(), IssueStoreError> { - issue - .validate() - .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; - let current = load_issue(root, issue.reference()).await?; - if current.version != expected_version { - return Err(IssueStoreError::VersionConflict { - expected: expected_version, - actual: current.version, - }); + let lock_path = mutation_lock_path(root); + let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; + let result = async { + issue + .validate() + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + let current = load_issue(root, issue.reference()).await?; + if current.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + save_issue(root, issue).await?; + rebuild_index(root).await?; + Ok(()) } - save_issue(root, issue).await?; - rebuild_index(root).await?; - Ok(()) + .await; + let _ = tokio::fs::remove_file(&lock_path).await; + result + } + + async fn delete(&self, root: &ProjectPath, issue_ref: IssueRef) -> Result<(), IssueStoreError> { + let lock_path = mutation_lock_path(root); + let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; + let result = async { + if !tokio::fs::try_exists(issue_path(root, issue_ref.number())) + .await + .map_err(io_error)? + { + return Err(IssueStoreError::NotFound); + } + tokio::fs::remove_dir_all(issue_dir(root, issue_ref.number())) + .await + .map_err(io_error)?; + rebuild_index(root).await?; + Ok(()) + } + .await; + let _ = tokio::fs::remove_file(&lock_path).await; + result } async fn read_carnet( @@ -337,19 +406,26 @@ impl IssueStore for FsIssueStore { now_ms: u64, expected_version: IssueVersion, ) -> Result { - let current = load_issue(root, issue_ref).await?; - if current.version != expected_version { - return Err(IssueStoreError::VersionConflict { - expected: expected_version, - actual: current.version, - }); + let lock_path = mutation_lock_path(root); + let _lock_file = acquire_lock(&lock_path, "issue mutation").await?; + let result = async { + let current = load_issue(root, issue_ref).await?; + if current.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + let updated = current + .mutate(actor, now_ms, |issue| issue.carnet = carnet) + .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; + save_issue(root, &updated).await?; + rebuild_index(root).await?; + self.read_carnet(root, issue_ref).await } - let updated = current - .mutate(actor, now_ms, |issue| issue.carnet = carnet) - .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; - save_issue(root, &updated).await?; - rebuild_index(root).await?; - self.read_carnet(root, issue_ref).await + .await; + let _ = tokio::fs::remove_file(&lock_path).await; + result } } @@ -359,30 +435,7 @@ impl IssueNumberAllocator for FsIssueNumberAllocator { let dir = issue_root(root); tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; let lock_path = counter_lock_path(root); - let mut lock = None; - for _ in 0..50 { - match tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&lock_path) - .await - { - Ok(file) => { - lock = Some(file); - break; - } - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - tokio::time::sleep(Duration::from_millis(10)).await; - } - Err(err) => return Err(io_error(err)), - } - } - let Some(mut lock_file) = lock else { - return Err(IssueStoreError::Store( - "issue number allocator lock timed out".to_owned(), - )); - }; - lock_file.write_all(b"locked\n").await.map_err(io_error)?; + let _lock_file = acquire_lock(&lock_path, "issue number allocator").await?; let result = async { let path = counter_path(root); diff --git a/crates/infrastructure/tests/issue_store.rs b/crates/infrastructure/tests/issue_store.rs index 14a0e5a..1744f5e 100644 --- a/crates/infrastructure/tests/issue_store.rs +++ b/crates/infrastructure/tests/issue_store.rs @@ -182,6 +182,46 @@ async fn issue_store_persists_and_filters_sprint_membership() { assert_eq!(rows[0].sprint, Some(sprint_id)); } +#[tokio::test] +async fn issue_store_delete_removes_files_and_index_entry() { + let tmp = TempDir::new(); + let root = tmp.root(); + let store = FsIssueStore::new(); + store + .create(&root, &issue(&root, 1, "Delete me")) + .await + .unwrap(); + std::fs::write(tmp.child(".ideai/tickets/1/attachment.txt"), "payload").unwrap(); + + store + .delete(&root, IssueRef::from_str("#1").unwrap()) + .await + .unwrap(); + + assert!(!tmp.child(".ideai/tickets/1/issue.md").exists()); + assert!(!tmp.child(".ideai/tickets/1/carnet.md").exists()); + assert!(!tmp.child(".ideai/tickets/1/attachment.txt").exists()); + assert!(!tmp.child(".ideai/tickets/1").exists()); + let rows = store.list(&root, IssueListFilter::default()).await.unwrap(); + assert!(rows.is_empty()); + let index = std::fs::read_to_string(tmp.child(".ideai/tickets/index.json")).unwrap(); + assert!(!index.contains("\"issueRef\": \"#1\"")); +} + +#[tokio::test] +async fn issue_store_delete_returns_not_found_when_absent() { + let tmp = TempDir::new(); + let root = tmp.root(); + let store = FsIssueStore::new(); + + let err = store + .delete(&root, IssueRef::from_str("#404").unwrap()) + .await + .unwrap_err(); + + assert!(matches!(err, IssueStoreError::NotFound)); +} + #[tokio::test] async fn allocator_never_reuses_numbers() { let tmp = TempDir::new(); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index e65332e..eae1160 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -2012,6 +2012,22 @@ export class MockTicketGateway implements TicketGateway { return structuredClone(ticket); } + async delete(projectId: string, ref: string): Promise { + // NotFound if absent (mirrors the backend `AppError::NotFound`). + const ticket = this.require(projectId, ref); + const freedSprint = ticket.sprintId ?? null; + this.projectTickets(projectId).delete(ref); + this.projectCarnets(projectId).delete(ref); + // Releasing the ticket updates its former sprint's count. + if (freedSprint) this.recountSprints(projectId); + this.system?.emit({ + type: "issueDeleted", + projectId, + issueRef: ref, + freedSprint, + }); + } + async readCarnet(projectId: string, ref: string): Promise { const ticket = this.require(projectId, ref); return { diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index a3b7336..1357ac2 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -73,6 +73,15 @@ export class TauriTicketGateway implements TicketGateway { }); } + async delete(projectId: string, ref: string): Promise { + // `ticket_delete` takes the shared `{ request }` envelope like every other + // ticket command; a missing ref surfaces as a `NOT_FOUND` GatewayError from + // the backend, propagated as-is. + await invoke("ticket_delete", { + request: { projectId, ref }, + }); + } + async readCarnet(projectId: string, ref: string): Promise { return invoke("ticket_read_carnet", { request: { projectId, ref }, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 960e8bb..8db7be6 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -126,6 +126,19 @@ export type DomainEvent = } | { type: "issueCreated"; issueId: string; issueRef: string } | { type: "issueUpdated"; issueRef: string; version: number } + | { + /** + * A ticket was deleted (ticket #6). Mirror of the backend + * `DomainEventDto::IssueDeleted`. `issueRef` is the deleted `#N`; + * `freedSprint` is the sprint id it was released from, when any. Starts with + * `issue`, so {@link isTicketEvent} matches it and ticket lists refresh + * automatically; an open detail on the same `issueRef` closes. + */ + type: "issueDeleted"; + projectId: string; + issueRef: string; + freedSprint: string | null; + } | { type: "issueStatusChanged"; issueRef: string; diff --git a/frontend/src/features/tickets/TicketDetail.tsx b/frontend/src/features/tickets/TicketDetail.tsx index abbde66..398f091 100644 --- a/frontend/src/features/tickets/TicketDetail.tsx +++ b/frontend/src/features/tickets/TicketDetail.tsx @@ -104,6 +104,14 @@ export function TicketDetail({ const [copied, setCopied] = useState(false); // Confirmation shown when the user tries to close with unsaved changes. const [confirmClose, setConfirmClose] = useState(false); + // Confirmation shown before deleting the ticket (#6). + const [confirmDelete, setConfirmDelete] = useState(false); + + // The ticket was deleted (by this view or another actor): close the surface. + // The removal from lists flows from the same `issueDeleted` event (#6). + useEffect(() => { + if (vm.deleted) onClose(); + }, [vm.deleted, onClose]); const reloadCount = vm.reloadCount; const seededReload = useRef(-1); @@ -205,6 +213,16 @@ export function TicketDetail({ > {copied ? "Copied" : "Delegation context"} + @@ -559,6 +577,52 @@ export function TicketDetail({ )} + + {/* ── Delete confirmation (#6) ── */} + {confirmDelete && ( +
+
+

+ Supprimer le ticket +

+

+ Supprimer définitivement le ticket {ticketRef} ? Cette + action est irréversible. +

+
+ + +
+
+
+ )} ); } diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index a4b8e38..54cd40d 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -14,7 +14,7 @@ import { MockSystemGateway, MockTicketGateway, } from "@/adapters/mock"; -import type { Agent } from "@/domain"; +import type { Agent, DomainEvent } from "@/domain"; import type { Gateways } from "@/ports"; import { TicketsView } from "./TicketsView"; import { TicketDetail } from "./TicketDetail"; @@ -108,6 +108,34 @@ describe("MockTicketGateway", () => { ).rejects.toMatchObject({ message: expect.stringContaining("version conflict") }); }); + it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => { + ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" }); + const t = await ticket.create(PROJECT_ID, { title: "Doomed" }); + await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version); + const events: DomainEvent[] = []; + await system.onDomainEvent((e) => events.push(e)); + + await ticket.delete(PROJECT_ID, t.ref); + + // Removed from the store (NotFound afterwards)… + await expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + // …and the sprint it was released from is reported on the event. + expect(events).toContainEqual({ + type: "issueDeleted", + projectId: PROJECT_ID, + issueRef: t.ref, + freedSprint: "s1", + }); + }); + + it("rejects deleting an unknown ticket with NotFound (#6)", async () => { + await expect(ticket.delete(PROJECT_ID, "#404")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + it("links and unlinks tickets, bumping the version", async () => { const a = await ticket.create(PROJECT_ID, { title: "a" }); await ticket.create(PROJECT_ID, { title: "b" }); @@ -425,6 +453,69 @@ describe("TicketsView", () => { ).toBeNull(); }); + it("deletes a ticket from the detail: confirmation ⇒ row gone + detail closed (#6)", async () => { + const t = await ticket.create(PROJECT_ID, { title: "Deletable" }); + renderView(ticket, system, agent); + + fireEvent.click(await screen.findByText("Deletable")); + const detail = await screen.findByRole("dialog", { + name: `ticket ${t.ref}`, + }); + + // Open the confirmation, then confirm the deletion. + fireEvent.click(within(detail).getByText("Supprimer")); + const confirm = await screen.findByRole("dialog", { + name: "confirmer la suppression", + }); + fireEvent.click(within(confirm).getByLabelText("confirm delete")); + + // The gateway removed the ticket… + await waitFor(() => + expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({ + code: "NOT_FOUND", + }), + ); + // …the detail closed (via the `issueDeleted` event → `deleted` flag)… + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: `ticket ${t.ref}` }), + ).toBeNull(), + ); + // …and the row disappeared from the list (event-driven refresh). + await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull()); + expect(await screen.findByText("No tickets.")).toBeTruthy(); + }); + + it("cancelling the delete confirmation keeps the ticket (#6)", async () => { + const t = await ticket.create(PROJECT_ID, { title: "Keeper" }); + const delSpy = vi.spyOn(ticket, "delete"); + renderView(ticket, system, agent); + + fireEvent.click(await screen.findByText("Keeper")); + const detail = await screen.findByRole("dialog", { + name: `ticket ${t.ref}`, + }); + + fireEvent.click(within(detail).getByText("Supprimer")); + const confirm = await screen.findByRole("dialog", { + name: "confirmer la suppression", + }); + fireEvent.click(within(confirm).getByLabelText("cancel delete")); + + // The confirmation is dismissed, delete was never called, ticket stays. + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "confirmer la suppression" }), + ).toBeNull(), + ); + expect(delSpy).not.toHaveBeenCalled(); + expect( + screen.getByRole("dialog", { name: `ticket ${t.ref}` }), + ).toBeTruthy(); + const fresh = await ticket.read(PROJECT_ID, t.ref); + expect(fresh.title).toBe("Keeper"); + }); + it("copies a delegation context prompt (F7)", async () => { const writeText = stubClipboard(); const t = await ticket.create(PROJECT_ID, { title: "Delegate me" }); diff --git a/frontend/src/features/tickets/useTicketDetail.ts b/frontend/src/features/tickets/useTicketDetail.ts index 781bf23..e867e9b 100644 --- a/frontend/src/features/tickets/useTicketDetail.ts +++ b/frontend/src/features/tickets/useTicketDetail.ts @@ -22,6 +22,12 @@ export interface TicketDetailViewModel { error: string | null; /** Set when the last write lost an optimistic-concurrency race (F3). */ conflict: boolean; + /** + * Set once the open ticket has been deleted — by this view or any other actor + * (ticket #6). Driven by the `issueDeleted` domain event (single source of + * truth); the surface reacts by closing itself. + */ + deleted: boolean; busy: boolean; /** * Monotonic counter bumped **only** when the ticket is (re)loaded from the @@ -42,6 +48,12 @@ export interface TicketDetailViewModel { link: (targetRef: string, kind: TicketLinkKind) => Promise; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise; assign: (agentId: string, assigned: boolean) => Promise; + /** + * Deletes this ticket (ticket #6). Returns `true` on success. The removal from + * lists and the closing of this surface flow from the resulting `issueDeleted` + * event (see {@link TicketDetailViewModel.deleted}), not from local mutation. + */ + remove: () => Promise; } function describe(e: unknown): string { @@ -69,6 +81,7 @@ export function useTicketDetail( const [ticket, setTicket] = useState(null); const [error, setError] = useState(null); const [conflict, setConflict] = useState(false); + const [deleted, setDeleted] = useState(false); const [busy, setBusy] = useState(false); const [reloadCount, setReloadCount] = useState(0); @@ -101,6 +114,12 @@ export function useTicketDetail( let cancelled = false; void system .onDomainEvent((event) => { + if (event.type === "issueDeleted" && event.issueRef === ref) { + // The open ticket was deleted (here or elsewhere): don't refresh (it + // would 404) — flag it so the surface closes (#6). + setDeleted(true); + return; + } if (isTicketEvent(event) && event.issueRef === ref) void refresh(); }) .then((u) => { @@ -186,10 +205,27 @@ export function useTicketDetail( [run, gateway, projectId, ref], ); + const remove: TicketDetailViewModel["remove"] = useCallback(async () => { + setBusy(true); + setError(null); + try { + await gateway.delete(projectId, ref); + // Closing/removal flows from the `issueDeleted` event (single source of + // truth) — see the `deleted` flag set in the subscription above. + return true; + } catch (e) { + setError(describe(e)); + return false; + } finally { + setBusy(false); + } + }, [gateway, projectId, ref]); + return { ticket, error, conflict, + deleted, busy, reloadCount, refresh, @@ -198,5 +234,6 @@ export function useTicketDetail( link, unlink, assign, + remove, }; } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index a685147..9848d62 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -764,6 +764,14 @@ export interface TicketGateway { ref: string, input: UpdateTicketInput, ): Promise; + /** + * Deletes a ticket (ticket #6). Rejects with a `notFound` {@link GatewayError} + * when the ref does not exist. On success the backend emits an `issueDeleted` + * domain event (payload `{ projectId, issueRef, freedSprint }`) — the single + * source of truth that drives the ticket's removal from lists and the closing + * of an open detail view. Callers must not mutate local state imperatively. + */ + delete(projectId: string, ref: string): Promise; /** Reads the ticket-scoped Markdown carnet. */ readCarnet(projectId: string, ref: string): Promise; /** Replaces (not appends) the ticket-scoped carnet body. */