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

@ -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<boolean>;
unlink: (targetRef: string, kind?: TicketLinkKind) => 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 {
@ -69,6 +81,7 @@ export function useTicketDetail(
const [ticket, setTicket] = useState<Ticket | null>(null);
const [error, setError] = useState<string | null>(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,
};
}