Merge feature/ticket-deletion into develop
Suppression d'un ticket (#6) : backend (IssueStore::delete, FsIssueStore::delete sous lock, use case DeleteIssue, event IssueDeleted, commande ticket_delete) et frontend (gateway delete, retrait de liste/fermeture détail via event). Tests verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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));
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {ticketRef} ? 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user