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

@ -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`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadIssueCarnetInput {

View File

@ -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,

View File

@ -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()
));
}

View File

@ -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,

View File

@ -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,