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:
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user