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:
@ -241,6 +241,16 @@ pub enum DomainEventDto {
|
||||
/// New optimistic version.
|
||||
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.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
IssueStatusChanged {
|
||||
@ -781,6 +791,15 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
issue_ref: issue_ref.to_string(),
|
||||
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 {
|
||||
issue_ref,
|
||||
status,
|
||||
@ -1106,6 +1125,22 @@ mod tests {
|
||||
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]
|
||||
fn ticket_assistant_events_relay_to_dto_and_wire() {
|
||||
let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9));
|
||||
|
||||
@ -166,6 +166,7 @@ pub fn run() {
|
||||
commands::list_agents,
|
||||
tickets::ticket_create,
|
||||
tickets::ticket_read,
|
||||
tickets::ticket_delete,
|
||||
tickets::open_ticket_chat,
|
||||
tickets::close_ticket_chat,
|
||||
tickets::ticket_list,
|
||||
|
||||
@ -17,28 +17,29 @@ use application::{
|
||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory,
|
||||
DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines,
|
||||
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean,
|
||||
GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit,
|
||||
GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
||||
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||
ListAgentsInput, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles,
|
||||
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry,
|
||||
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
|
||||
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||
DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
|
||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
||||
ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow,
|
||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
||||
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -793,6 +794,8 @@ pub struct AppState {
|
||||
pub create_issue: Arc<CreateIssue>,
|
||||
/// Read an issue-backed public ticket.
|
||||
pub read_issue: Arc<ReadIssue>,
|
||||
/// Delete an issue-backed public ticket.
|
||||
pub delete_issue: Arc<DeleteIssue>,
|
||||
/// List issue-backed public tickets.
|
||||
pub list_issues: Arc<ListIssues>,
|
||||
/// Update an issue-backed public ticket.
|
||||
@ -1207,6 +1210,10 @@ impl AppState {
|
||||
Arc::clone(&events_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 update_issue = Arc::new(UpdateIssue::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
@ -2190,6 +2197,7 @@ impl AppState {
|
||||
event_bus,
|
||||
create_issue,
|
||||
read_issue,
|
||||
delete_issue,
|
||||
list_issues,
|
||||
update_issue,
|
||||
read_issue_carnet,
|
||||
|
||||
@ -15,10 +15,10 @@ use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
|
||||
CreateIssueInput, CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput,
|
||||
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput,
|
||||
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
|
||||
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||
CreateIssueInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput,
|
||||
ListIssuesInput, ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput,
|
||||
ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
|
||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
||||
@ -180,6 +180,15 @@ pub struct TicketReadRequestDto {
|
||||
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.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -612,6 +621,23 @@ pub async fn ticket_read(
|
||||
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]
|
||||
pub async fn open_ticket_chat(
|
||||
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`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadIssueCarnetInput {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -317,6 +317,15 @@ pub enum DomainEvent {
|
||||
/// New optimistic version.
|
||||
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.
|
||||
IssueStatusChanged {
|
||||
/// Human-friendly issue reference.
|
||||
|
||||
@ -1543,6 +1543,12 @@ pub trait IssueStore: Send + Sync {
|
||||
expected_version: IssueVersion,
|
||||
) -> 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.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@ -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