feat(tickets): suppression d'un ticket (backend + frontend)

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/<N>/ 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 06:27:09 +02:00
parent d25e340b44
commit 5417bd756b
20 changed files with 689 additions and 95 deletions

View File

@ -241,6 +241,16 @@ pub enum DomainEventDto {
/// New optimistic version. /// New optimistic version.
version: u64, 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<String>,
},
/// A public ticket status changed. /// A public ticket status changed.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
IssueStatusChanged { IssueStatusChanged {
@ -781,6 +791,15 @@ impl From<&DomainEvent> for DomainEventDto {
issue_ref: issue_ref.to_string(), issue_ref: issue_ref.to_string(),
version: version.get(), 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 { DomainEvent::IssueStatusChanged {
issue_ref, issue_ref,
status, status,
@ -1106,6 +1125,22 @@ mod tests {
assert_eq!(json["version"], 3); 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] #[test]
fn ticket_assistant_events_relay_to_dto_and_wire() { fn ticket_assistant_events_relay_to_dto_and_wire() {
let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9)); let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9));

View File

@ -166,6 +166,7 @@ pub fn run() {
commands::list_agents, commands::list_agents,
tickets::ticket_create, tickets::ticket_create,
tickets::ticket_read, tickets::ticket_read,
tickets::ticket_delete,
tickets::open_ticket_chat, tickets::open_ticket_chat,
tickets::close_ticket_chat, tickets::close_ticket_chat,
tickets::ticket_list, tickets::ticket_list,

View File

@ -17,28 +17,29 @@ use application::{
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
ListAgentsInput, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow,
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{
@ -793,6 +794,8 @@ pub struct AppState {
pub create_issue: Arc<CreateIssue>, pub create_issue: Arc<CreateIssue>,
/// Read an issue-backed public ticket. /// Read an issue-backed public ticket.
pub read_issue: Arc<ReadIssue>, pub read_issue: Arc<ReadIssue>,
/// Delete an issue-backed public ticket.
pub delete_issue: Arc<DeleteIssue>,
/// List issue-backed public tickets. /// List issue-backed public tickets.
pub list_issues: Arc<ListIssues>, pub list_issues: Arc<ListIssues>,
/// Update an issue-backed public ticket. /// Update an issue-backed public ticket.
@ -1207,6 +1210,10 @@ impl AppState {
Arc::clone(&events_port), Arc::clone(&events_port),
)); ));
let read_issue = Arc::new(ReadIssue::new(Arc::clone(&issue_store_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 list_issues = Arc::new(ListIssues::new(Arc::clone(&issue_store_port)));
let update_issue = Arc::new(UpdateIssue::new( let update_issue = Arc::new(UpdateIssue::new(
Arc::clone(&issue_store_port), Arc::clone(&issue_store_port),
@ -2190,6 +2197,7 @@ impl AppState {
event_bus, event_bus,
create_issue, create_issue,
read_issue, read_issue,
delete_issue,
list_issues, list_issues,
update_issue, update_issue,
read_issue_carnet, read_issue_carnet,

View File

@ -15,10 +15,10 @@ use uuid::Uuid;
use application::{ use application::{
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput, AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
CreateIssueInput, CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, CreateIssueInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput,
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput, ListIssuesInput, ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput,
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
}; };
use domain::{ use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
@ -180,6 +180,15 @@ pub struct TicketReadRequestDto {
pub include_carnet: Option<bool>, 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. /// List request.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -612,6 +621,23 @@ pub async fn ticket_read(
Ok(TicketDto::from_issue(issue, carnet)) 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] #[tauri::command]
pub async fn open_ticket_chat( pub async fn open_ticket_chat(
request: OpenTicketChatRequestDto, request: OpenTicketChatRequestDto,

View File

@ -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<dyn IssueStore>,
events: Arc<dyn EventBus>,
}
impl DeleteIssue {
/// Builds the use case.
#[must_use]
pub fn new(issues: Arc<dyn IssueStore>, events: Arc<dyn EventBus>) -> Self {
Self { issues, events }
}
/// Executes deletion.
///
/// # Errors
/// [`AppError`] on missing issue or store failure.
pub async fn execute(&self, input: DeleteIssueInput) -> Result<DeleteIssueOutput, AppError> {
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`]. /// Input for [`ReadIssueCarnet::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadIssueCarnetInput { pub struct ReadIssueCarnetInput {

View File

@ -79,11 +79,11 @@ pub use git::{
pub use health::{HealthInput, HealthReport, HealthUseCase}; pub use health::{HealthInput, HealthReport, HealthUseCase};
pub use issues::{ pub use issues::{
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput, AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput,
CreateIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput, LinkIssues,
ListIssuesOutput, ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput, ReadIssue,
ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput, ReadIssueOutput,
UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, UpdateIssueCarnet, UpdateIssueCarnetInput,
UpdateIssueOutput, UpdateIssueCarnetOutput, UpdateIssueInput, UpdateIssueOutput,
}; };
pub use layout::{ pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,

View File

@ -13,7 +13,9 @@ use domain::{
}; };
use uuid::Uuid; use uuid::Uuid;
use application::{CreateIssue, CreateIssueInput, UpdateIssue, UpdateIssueInput}; use application::{
CreateIssue, CreateIssueInput, DeleteIssue, DeleteIssueInput, UpdateIssue, UpdateIssueInput,
};
#[derive(Default)] #[derive(Default)]
struct FakeIssues { struct FakeIssues {
@ -84,6 +86,19 @@ impl IssueStore for FakeIssues {
Ok(()) 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( async fn read_carnet(
&self, &self,
_root: &ProjectPath, _root: &ProjectPath,
@ -400,3 +415,92 @@ async fn update_issue_maps_version_conflict() {
assert_eq!(err.code(), "INVALID"); assert_eq!(err.code(), "INVALID");
assert!(err.to_string().contains("version conflict")); 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()
));
}

View File

@ -159,6 +159,19 @@ impl IssueStore for FakeIssues {
Ok(()) 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( async fn read_carnet(
&self, &self,
_root: &ProjectPath, _root: &ProjectPath,

View File

@ -106,6 +106,14 @@ impl IssueStore for FakeIssues {
unimplemented!() unimplemented!()
} }
async fn delete(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
) -> Result<(), IssueStoreError> {
unimplemented!()
}
async fn read_carnet( async fn read_carnet(
&self, &self,
_root: &ProjectPath, _root: &ProjectPath,

View File

@ -317,6 +317,15 @@ pub enum DomainEvent {
/// New optimistic version. /// New optimistic version.
version: IssueVersion, 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<SprintId>,
},
/// An issue status changed. /// An issue status changed.
IssueStatusChanged { IssueStatusChanged {
/// Human-friendly issue reference. /// Human-friendly issue reference.

View File

@ -1543,6 +1543,12 @@ pub trait IssueStore: Send + Sync {
expected_version: IssueVersion, expected_version: IssueVersion,
) -> Result<(), IssueStoreError>; ) -> 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. /// Reads only the issue carnet projection.
/// ///
/// # Errors /// # Errors

View File

@ -26,6 +26,7 @@ const CARNET_FILE: &str = "carnet.md";
const INDEX_FILE: &str = "index.json"; const INDEX_FILE: &str = "index.json";
const COUNTER_FILE: &str = "counter.json"; const COUNTER_FILE: &str = "counter.json";
const COUNTER_LOCK: &str = "counter.lock"; const COUNTER_LOCK: &str = "counter.lock";
const MUTATION_LOCK: &str = "mutation.lock";
const INDEX_VERSION: u32 = 1; const INDEX_VERSION: u32 = 1;
/// Filesystem-backed issue store. /// Filesystem-backed issue store.
@ -95,6 +96,39 @@ fn counter_lock_path(root: &ProjectPath) -> PathBuf {
issue_root(root).join(COUNTER_LOCK) 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<tokio::fs::File, IssueStoreError> {
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> { async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), IssueStoreError> {
let parent = path let parent = path
.parent() .parent()
@ -234,6 +268,9 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
#[async_trait] #[async_trait]
impl IssueStore for FsIssueStore { impl IssueStore for FsIssueStore {
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
let lock_path = mutation_lock_path(root);
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
let result = async {
issue issue
.validate() .validate()
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?; .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
@ -250,6 +287,10 @@ impl IssueStore for FsIssueStore {
rebuild_index(root).await?; rebuild_index(root).await?;
Ok(()) Ok(())
} }
.await;
let _ = tokio::fs::remove_file(&lock_path).await;
result
}
async fn get_by_ref( async fn get_by_ref(
&self, &self,
@ -304,6 +345,9 @@ impl IssueStore for FsIssueStore {
issue: &Issue, issue: &Issue,
expected_version: IssueVersion, expected_version: IssueVersion,
) -> Result<(), IssueStoreError> { ) -> Result<(), IssueStoreError> {
let lock_path = mutation_lock_path(root);
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
let result = async {
issue issue
.validate() .validate()
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?; .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
@ -318,6 +362,31 @@ impl IssueStore for FsIssueStore {
rebuild_index(root).await?; rebuild_index(root).await?;
Ok(()) 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( async fn read_carnet(
&self, &self,
@ -337,6 +406,9 @@ impl IssueStore for FsIssueStore {
now_ms: u64, now_ms: u64,
expected_version: IssueVersion, expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError> { ) -> Result<IssueCarnet, IssueStoreError> {
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?; let current = load_issue(root, issue_ref).await?;
if current.version != expected_version { if current.version != expected_version {
return Err(IssueStoreError::VersionConflict { return Err(IssueStoreError::VersionConflict {
@ -351,6 +423,10 @@ impl IssueStore for FsIssueStore {
rebuild_index(root).await?; rebuild_index(root).await?;
self.read_carnet(root, issue_ref).await self.read_carnet(root, issue_ref).await
} }
.await;
let _ = tokio::fs::remove_file(&lock_path).await;
result
}
} }
#[async_trait] #[async_trait]
@ -359,30 +435,7 @@ impl IssueNumberAllocator for FsIssueNumberAllocator {
let dir = issue_root(root); let dir = issue_root(root);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
let lock_path = counter_lock_path(root); let lock_path = counter_lock_path(root);
let mut lock = None; let _lock_file = acquire_lock(&lock_path, "issue number allocator").await?;
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 result = async { let result = async {
let path = counter_path(root); let path = counter_path(root);

View File

@ -182,6 +182,46 @@ async fn issue_store_persists_and_filters_sprint_membership() {
assert_eq!(rows[0].sprint, Some(sprint_id)); 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] #[tokio::test]
async fn allocator_never_reuses_numbers() { async fn allocator_never_reuses_numbers() {
let tmp = TempDir::new(); let tmp = TempDir::new();

View File

@ -2012,6 +2012,22 @@ export class MockTicketGateway implements TicketGateway {
return structuredClone(ticket); return structuredClone(ticket);
} }
async delete(projectId: string, ref: string): Promise<void> {
// 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<TicketCarnet> { async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
const ticket = this.require(projectId, ref); const ticket = this.require(projectId, ref);
return { return {

View File

@ -73,6 +73,15 @@ export class TauriTicketGateway implements TicketGateway {
}); });
} }
async delete(projectId: string, ref: string): Promise<void> {
// `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<void>("ticket_delete", {
request: { projectId, ref },
});
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> { async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return invoke<TicketCarnet>("ticket_read_carnet", { return invoke<TicketCarnet>("ticket_read_carnet", {
request: { projectId, ref }, request: { projectId, ref },

View File

@ -126,6 +126,19 @@ export type DomainEvent =
} }
| { type: "issueCreated"; issueId: string; issueRef: string } | { type: "issueCreated"; issueId: string; issueRef: string }
| { type: "issueUpdated"; issueRef: string; version: number } | { 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"; type: "issueStatusChanged";
issueRef: string; issueRef: string;

View File

@ -104,6 +104,14 @@ export function TicketDetail({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Confirmation shown when the user tries to close with unsaved changes. // Confirmation shown when the user tries to close with unsaved changes.
const [confirmClose, setConfirmClose] = useState(false); 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 reloadCount = vm.reloadCount;
const seededReload = useRef(-1); const seededReload = useRef(-1);
@ -205,6 +213,16 @@ export function TicketDetail({
> >
{copied ? "Copied" : "Delegation context"} {copied ? "Copied" : "Delegation context"}
</Button> </Button>
<Button
size="sm"
variant="ghost"
disabled={!t || vm.busy}
onClick={() => setConfirmDelete(true)}
className="text-danger hover:text-danger"
title="Supprimer définitivement ce ticket"
>
Supprimer
</Button>
<Button size="sm" variant="ghost" onClick={requestClose}> <Button size="sm" variant="ghost" onClick={requestClose}>
Close Close
</Button> </Button>
@ -559,6 +577,52 @@ export function TicketDetail({
</div> </div>
</div> </div>
)} )}
{/* ── Delete confirmation (#6) ── */}
{confirmDelete && (
<div
role="dialog"
aria-modal="true"
aria-label="confirmer la suppression"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Supprimer le ticket
</h4>
<p className="mt-2 text-sm text-muted">
Supprimer définitivement le ticket&nbsp;{ticketRef}&nbsp;? Cette
action est irréversible.
</p>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel delete"
disabled={vm.busy}
onClick={() => setConfirmDelete(false)}
>
Annuler
</Button>
<Button
size="sm"
variant="danger"
aria-label="confirm delete"
loading={vm.busy}
disabled={vm.busy}
onClick={async () => {
const ok = await vm.remove();
// On success the surface closes via the `issueDeleted` event
// (the `deleted` flag → onClose effect); just drop the dialog.
if (ok) setConfirmDelete(false);
}}
>
Supprimer
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -14,7 +14,7 @@ import {
MockSystemGateway, MockSystemGateway,
MockTicketGateway, MockTicketGateway,
} from "@/adapters/mock"; } from "@/adapters/mock";
import type { Agent } from "@/domain"; import type { Agent, DomainEvent } from "@/domain";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
import { TicketsView } from "./TicketsView"; import { TicketsView } from "./TicketsView";
import { TicketDetail } from "./TicketDetail"; import { TicketDetail } from "./TicketDetail";
@ -108,6 +108,34 @@ describe("MockTicketGateway", () => {
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") }); ).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 () => { it("links and unlinks tickets, bumping the version", async () => {
const a = await ticket.create(PROJECT_ID, { title: "a" }); const a = await ticket.create(PROJECT_ID, { title: "a" });
await ticket.create(PROJECT_ID, { title: "b" }); await ticket.create(PROJECT_ID, { title: "b" });
@ -425,6 +453,69 @@ describe("TicketsView", () => {
).toBeNull(); ).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 () => { it("copies a delegation context prompt (F7)", async () => {
const writeText = stubClipboard(); const writeText = stubClipboard();
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" }); const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });

View File

@ -22,6 +22,12 @@ export interface TicketDetailViewModel {
error: string | null; error: string | null;
/** Set when the last write lost an optimistic-concurrency race (F3). */ /** Set when the last write lost an optimistic-concurrency race (F3). */
conflict: boolean; 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; busy: boolean;
/** /**
* Monotonic counter bumped **only** when the ticket is (re)loaded from the * 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<boolean>; link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
assign: (agentId: string, assigned: boolean) => Promise<boolean>; assign: (agentId: string, assigned: boolean) => Promise<boolean>;
/**
* 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<boolean>;
} }
function describe(e: unknown): string { function describe(e: unknown): string {
@ -69,6 +81,7 @@ export function useTicketDetail(
const [ticket, setTicket] = useState<Ticket | null>(null); const [ticket, setTicket] = useState<Ticket | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState(false); const [conflict, setConflict] = useState(false);
const [deleted, setDeleted] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [reloadCount, setReloadCount] = useState(0); const [reloadCount, setReloadCount] = useState(0);
@ -101,6 +114,12 @@ export function useTicketDetail(
let cancelled = false; let cancelled = false;
void system void system
.onDomainEvent((event) => { .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(); if (isTicketEvent(event) && event.issueRef === ref) void refresh();
}) })
.then((u) => { .then((u) => {
@ -186,10 +205,27 @@ export function useTicketDetail(
[run, gateway, projectId, ref], [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 { return {
ticket, ticket,
error, error,
conflict, conflict,
deleted,
busy, busy,
reloadCount, reloadCount,
refresh, refresh,
@ -198,5 +234,6 @@ export function useTicketDetail(
link, link,
unlink, unlink,
assign, assign,
remove,
}; };
} }

View File

@ -764,6 +764,14 @@ export interface TicketGateway {
ref: string, ref: string,
input: UpdateTicketInput, input: UpdateTicketInput,
): Promise<Ticket>; ): Promise<Ticket>;
/**
* 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<void>;
/** Reads the ticket-scoped Markdown carnet. */ /** Reads the ticket-scoped Markdown carnet. */
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>; readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
/** Replaces (not appends) the ticket-scoped carnet body. */ /** Replaces (not appends) the ticket-scoped carnet body. */