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>
507 lines
13 KiB
Rust
507 lines
13 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::ports::{
|
|
AgentContextStore, Clock, EventBus, EventStream, IdGenerator, IssueNumberAllocator, IssueStore,
|
|
IssueStoreError, StoreError,
|
|
};
|
|
use domain::{
|
|
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry,
|
|
IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
|
|
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
CreateIssue, CreateIssueInput, DeleteIssue, DeleteIssueInput, UpdateIssue, UpdateIssueInput,
|
|
};
|
|
|
|
#[derive(Default)]
|
|
struct FakeIssues {
|
|
issues: Mutex<HashMap<IssueRef, Issue>>,
|
|
conflict: Mutex<bool>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IssueStore for FakeIssues {
|
|
async fn create(&self, _root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
|
|
self.issues
|
|
.lock()
|
|
.unwrap()
|
|
.insert(issue.reference(), issue.clone());
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_by_ref(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
) -> Result<Issue, IssueStoreError> {
|
|
self.issues
|
|
.lock()
|
|
.unwrap()
|
|
.get(&issue_ref)
|
|
.cloned()
|
|
.ok_or(IssueStoreError::NotFound)
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_filter: IssueListFilter,
|
|
) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
|
|
Ok(self
|
|
.issues
|
|
.lock()
|
|
.unwrap()
|
|
.values()
|
|
.map(IssueIndexEntry::from)
|
|
.collect())
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
issue: &Issue,
|
|
expected_version: IssueVersion,
|
|
) -> Result<(), IssueStoreError> {
|
|
if *self.conflict.lock().unwrap() {
|
|
return Err(IssueStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: issue.version,
|
|
});
|
|
}
|
|
let current = self.get_by_ref(_root, issue.reference()).await?;
|
|
if current.version != expected_version {
|
|
return Err(IssueStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: current.version,
|
|
});
|
|
}
|
|
self.issues
|
|
.lock()
|
|
.unwrap()
|
|
.insert(issue.reference(), issue.clone());
|
|
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,
|
|
issue_ref: IssueRef,
|
|
) -> Result<IssueCarnet, IssueStoreError> {
|
|
let issue = self.get_by_ref(_root, issue_ref).await?;
|
|
Ok(IssueCarnet {
|
|
issue_ref,
|
|
carnet: issue.carnet,
|
|
version: issue.version,
|
|
updated_by: issue.updated_by,
|
|
updated_at: issue.updated_at,
|
|
})
|
|
}
|
|
|
|
async fn write_carnet(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
carnet: MarkdownDoc,
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
expected_version: IssueVersion,
|
|
) -> Result<IssueCarnet, IssueStoreError> {
|
|
let issue = self.get_by_ref(_root, issue_ref).await?;
|
|
if issue.version != expected_version {
|
|
return Err(IssueStoreError::VersionConflict {
|
|
expected: expected_version,
|
|
actual: issue.version,
|
|
});
|
|
}
|
|
let updated = issue
|
|
.mutate(actor, now_ms, |i| i.carnet = carnet)
|
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
|
self.update(_root, &updated, expected_version).await?;
|
|
self.read_carnet(_root, issue_ref).await
|
|
}
|
|
}
|
|
|
|
struct SeqAllocator(Mutex<u64>);
|
|
|
|
#[async_trait]
|
|
impl IssueNumberAllocator for SeqAllocator {
|
|
async fn allocate_next(&self, _root: &ProjectPath) -> Result<IssueNumber, IssueStoreError> {
|
|
let mut next = self.0.lock().unwrap();
|
|
let number = IssueNumber::new(*next).unwrap();
|
|
*next += 1;
|
|
Ok(number)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(AgentManifest);
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
_agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
Err(StoreError::NotFound)
|
|
}
|
|
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
_agent: &AgentId,
|
|
_md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.0.clone())
|
|
}
|
|
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
_manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
|
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
struct FixedClock;
|
|
|
|
impl Clock for FixedClock {
|
|
fn now_millis(&self) -> i64 {
|
|
1_234
|
|
}
|
|
}
|
|
|
|
struct FixedIds;
|
|
|
|
impl IdGenerator for FixedIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
Uuid::from_u128(42)
|
|
}
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::new_random(),
|
|
"IdeA",
|
|
ProjectPath::new("/tmp/idea").unwrap(),
|
|
RemoteRef::Local,
|
|
0,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn manifest(agent_id: AgentId) -> AgentManifest {
|
|
AgentManifest::new(
|
|
1,
|
|
vec![ManifestEntry::new(
|
|
agent_id,
|
|
"Dev",
|
|
"agents/dev.md",
|
|
ProfileId::new_random(),
|
|
None,
|
|
false,
|
|
None,
|
|
)
|
|
.unwrap()],
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_issue_allocates_number_validates_agent_and_emits_event() {
|
|
let agent_id = AgentId::new_random();
|
|
let issues = Arc::new(FakeIssues::default());
|
|
let bus = Arc::new(SpyBus::default());
|
|
let uc = CreateIssue::new(
|
|
issues.clone(),
|
|
Arc::new(SeqAllocator(Mutex::new(1))),
|
|
Arc::new(FakeContexts(manifest(agent_id))),
|
|
Arc::new(FixedIds),
|
|
Arc::new(FixedClock),
|
|
bus.clone(),
|
|
);
|
|
|
|
let out = uc
|
|
.execute(CreateIssueInput {
|
|
project: project(),
|
|
title: "Ship issues".into(),
|
|
description: "Body".into(),
|
|
priority: IssuePriority::Critical,
|
|
status: IssueStatus::Open,
|
|
links: Vec::new(),
|
|
assigned_agent_ids: vec![agent_id],
|
|
actor: IssueActor::User,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.issue.reference().to_string(), "#1");
|
|
assert_eq!(
|
|
out.issue.id,
|
|
domain::IssueId::from_uuid(Uuid::from_u128(42))
|
|
);
|
|
assert_eq!(out.issue.created_at, 1_234);
|
|
assert!(matches!(
|
|
bus.events().as_slice(),
|
|
[DomainEvent::IssueCreated { issue_ref, .. }] if issue_ref.to_string() == "#1"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_issue_rejects_unknown_assignee() {
|
|
let known = AgentId::new_random();
|
|
let unknown = AgentId::new_random();
|
|
let uc = CreateIssue::new(
|
|
Arc::new(FakeIssues::default()),
|
|
Arc::new(SeqAllocator(Mutex::new(1))),
|
|
Arc::new(FakeContexts(manifest(known))),
|
|
Arc::new(FixedIds),
|
|
Arc::new(FixedClock),
|
|
Arc::new(SpyBus::default()),
|
|
);
|
|
|
|
let err = uc
|
|
.execute(CreateIssueInput {
|
|
project: project(),
|
|
title: "Bad assignee".into(),
|
|
description: "Body".into(),
|
|
priority: IssuePriority::Medium,
|
|
status: IssueStatus::Open,
|
|
links: Vec::new(),
|
|
assigned_agent_ids: vec![unknown],
|
|
actor: IssueActor::User,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), "NOT_FOUND");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_issue_propagates_expected_version_and_emits_status_event() {
|
|
let agent_id = AgentId::new_random();
|
|
let issues = Arc::new(FakeIssues::default());
|
|
let initial = Issue::new(
|
|
domain::IssueId::new_random(),
|
|
IssueNumber::new(1).unwrap(),
|
|
"Initial",
|
|
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 = UpdateIssue::new(
|
|
issues,
|
|
Arc::new(FakeContexts(manifest(agent_id))),
|
|
Arc::new(FixedClock),
|
|
bus.clone(),
|
|
);
|
|
|
|
let out = uc
|
|
.execute(UpdateIssueInput {
|
|
project: project(),
|
|
issue_ref: initial.reference(),
|
|
expected_version: initial.version,
|
|
title: Some("Updated".into()),
|
|
description: None,
|
|
status: Some(IssueStatus::InProgress),
|
|
priority: None,
|
|
assigned_agent_ids: None,
|
|
actor: IssueActor::System,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.issue.version.get(), 2);
|
|
assert!(bus.events().iter().any(|event| matches!(
|
|
event,
|
|
DomainEvent::IssueStatusChanged {
|
|
status: IssueStatus::InProgress,
|
|
..
|
|
}
|
|
)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_issue_maps_version_conflict() {
|
|
let agent_id = AgentId::new_random();
|
|
let issues = Arc::new(FakeIssues::default());
|
|
let initial = Issue::new(
|
|
domain::IssueId::new_random(),
|
|
IssueNumber::new(1).unwrap(),
|
|
"Initial",
|
|
MarkdownDoc::new("Body"),
|
|
IssueStatus::Open,
|
|
IssuePriority::Medium,
|
|
MarkdownDoc::default(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
IssueActor::User,
|
|
1,
|
|
)
|
|
.unwrap();
|
|
issues.create(&project().root, &initial).await.unwrap();
|
|
*issues.conflict.lock().unwrap() = true;
|
|
let uc = UpdateIssue::new(
|
|
issues,
|
|
Arc::new(FakeContexts(manifest(agent_id))),
|
|
Arc::new(FixedClock),
|
|
Arc::new(SpyBus::default()),
|
|
);
|
|
|
|
let err = uc
|
|
.execute(UpdateIssueInput {
|
|
project: project(),
|
|
issue_ref: initial.reference(),
|
|
expected_version: initial.version,
|
|
title: Some("Updated".into()),
|
|
description: None,
|
|
status: None,
|
|
priority: None,
|
|
assigned_agent_ids: None,
|
|
actor: IssueActor::System,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
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()
|
|
));
|
|
}
|