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

@ -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<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> {
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<IssueCarnet, IssueStoreError> {
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);