Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le domaine (Issue, ports), l'application (usecases + assistant de ticket), les adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web- server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
522 lines
15 KiB
Rust
522 lines
15 KiB
Rust
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
|
|
use domain::{
|
|
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachmentId, IssueId,
|
|
IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
|
|
IssueStoreError, LocalPath, MarkdownDoc, ProjectPath, SprintId,
|
|
};
|
|
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
|
|
use uuid::Uuid;
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!("idea-issues-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
|
|
fn root(&self) -> ProjectPath {
|
|
ProjectPath::new(self.0.to_string_lossy().to_string()).unwrap()
|
|
}
|
|
|
|
fn child(&self, rel: &str) -> PathBuf {
|
|
self.0.join(rel)
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn issue(root: &ProjectPath, number: u64, title: &str) -> Issue {
|
|
let _ = root;
|
|
Issue::new(
|
|
IssueId::new_random(),
|
|
domain::IssueNumber::new(number).unwrap(),
|
|
title,
|
|
MarkdownDoc::new("Initial description"),
|
|
IssueStatus::Open,
|
|
IssuePriority::High,
|
|
MarkdownDoc::new("Initial carnet"),
|
|
Vec::new(),
|
|
vec![AgentIssueRef {
|
|
agent_id: domain::AgentId::new_random(),
|
|
role: AgentIssueRole::Assigned,
|
|
}],
|
|
IssueActor::User,
|
|
1_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_writes_markdown_docs_and_index() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
let issue = issue(&root, 1, "Wire issues");
|
|
|
|
store.create(&root, &issue).await.unwrap();
|
|
|
|
let issue_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/issue.md")).unwrap();
|
|
let carnet_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/carnet.md")).unwrap();
|
|
let index = std::fs::read_to_string(tmp.child(".ideai/tickets/index.json")).unwrap();
|
|
|
|
assert!(issue_md.starts_with("---\n"));
|
|
assert!(issue_md.contains("title: \"Wire issues\""));
|
|
assert!(issue_md.ends_with("Initial description"));
|
|
assert!(carnet_md.contains("issueRef: \"#1\""));
|
|
assert!(carnet_md.ends_with("Initial carnet"));
|
|
assert!(index.contains("\"issueRef\": \"#1\""));
|
|
|
|
let loaded = store
|
|
.get_by_ref(&root, IssueRef::from_str("#1").unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(loaded.title, "Wire issues");
|
|
assert_eq!(loaded.carnet.as_str(), "Initial carnet");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_update_checks_expected_version() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
let original = issue(&root, 2, "Conflict");
|
|
store.create(&root, &original).await.unwrap();
|
|
let updated = original
|
|
.clone()
|
|
.mutate(IssueActor::System, 2_000, |i| {
|
|
i.status = IssueStatus::InProgress;
|
|
})
|
|
.unwrap();
|
|
|
|
let err = store
|
|
.update(&root, &updated, updated.version)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, IssueStoreError::VersionConflict { .. }));
|
|
|
|
store
|
|
.update(&root, &updated, original.version)
|
|
.await
|
|
.unwrap();
|
|
let loaded = store.get_by_ref(&root, updated.reference()).await.unwrap();
|
|
assert_eq!(loaded.status, IssueStatus::InProgress);
|
|
assert_eq!(loaded.version.get(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_lists_by_index_filters_with_empty_or_and_and_semantics() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
store
|
|
.create(&root, &issue(&root, 1, "Alpha"))
|
|
.await
|
|
.unwrap();
|
|
let closed = issue(&root, 2, "Beta")
|
|
.mutate(IssueActor::System, 2_000, |i| {
|
|
i.status = IssueStatus::Closed;
|
|
i.priority = IssuePriority::Low;
|
|
})
|
|
.unwrap();
|
|
store.create(&root, &closed).await.unwrap();
|
|
let qa_high = issue(&root, 3, "Gamma")
|
|
.mutate(IssueActor::System, 3_000, |i| {
|
|
i.status = IssueStatus::Qa;
|
|
i.priority = IssuePriority::Critical;
|
|
})
|
|
.unwrap();
|
|
store.create(&root, &qa_high).await.unwrap();
|
|
|
|
let all = store.list(&root, IssueListFilter::default()).await.unwrap();
|
|
assert_eq!(all.len(), 3);
|
|
|
|
let by_status_or = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
statuses: vec![IssueStatus::Closed, IssueStatus::Qa],
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(by_status_or.len(), 2);
|
|
assert_eq!(by_status_or[0].title, "Beta");
|
|
assert_eq!(by_status_or[1].title, "Gamma");
|
|
|
|
let by_priority_or = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
priorities: vec![IssuePriority::Low, IssuePriority::Critical],
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(by_priority_or.len(), 2);
|
|
assert_eq!(by_priority_or[0].title, "Beta");
|
|
assert_eq!(by_priority_or[1].title, "Gamma");
|
|
|
|
let by_status_and_priority = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
statuses: vec![IssueStatus::Closed, IssueStatus::Qa],
|
|
priorities: vec![IssuePriority::Low],
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(by_status_and_priority.len(), 1);
|
|
assert_eq!(by_status_and_priority[0].title, "Beta");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_filters_by_created_by_from_index() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
let agent_id = AgentId::new_random();
|
|
store
|
|
.create(&root, &issue(&root, 1, "User ticket"))
|
|
.await
|
|
.unwrap();
|
|
let agent_ticket = Issue::new(
|
|
IssueId::new_random(),
|
|
domain::IssueNumber::new(2).unwrap(),
|
|
"Agent ticket",
|
|
MarkdownDoc::new("Initial description"),
|
|
IssueStatus::Open,
|
|
IssuePriority::High,
|
|
MarkdownDoc::new("Initial carnet"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
IssueActor::Agent { agent_id },
|
|
1_000,
|
|
)
|
|
.unwrap();
|
|
store.create(&root, &agent_ticket).await.unwrap();
|
|
|
|
let by_user = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
created_by: Some(IssueActor::User),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let by_agent = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
created_by: Some(IssueActor::Agent { agent_id }),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(by_user.len(), 1);
|
|
assert_eq!(by_user[0].title, "User ticket");
|
|
assert_eq!(by_agent.len(), 1);
|
|
assert_eq!(by_agent[0].title, "Agent ticket");
|
|
assert_eq!(by_agent[0].created_by, IssueActor::Agent { agent_id });
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_text_filter_matches_exact_ref_or_number_without_numeric_substring() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
store.create(&root, &issue(&root, 4, "Four")).await.unwrap();
|
|
store
|
|
.create(&root, &issue(&root, 40, "Forty"))
|
|
.await
|
|
.unwrap();
|
|
store
|
|
.create(&root, &issue(&root, 42, "Forty two"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let by_ref = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
text: Some("#42".to_owned()),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
by_ref.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
|
|
vec![IssueRef::from_str("#42").unwrap()]
|
|
);
|
|
|
|
let by_number = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
text: Some("42".to_owned()),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
by_number
|
|
.iter()
|
|
.map(|row| row.issue_ref)
|
|
.collect::<Vec<_>>(),
|
|
vec![IssueRef::from_str("#42").unwrap()]
|
|
);
|
|
|
|
let by_padded_number = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
text: Some("0042".to_owned()),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
by_padded_number
|
|
.iter()
|
|
.map(|row| row.issue_ref)
|
|
.collect::<Vec<_>>(),
|
|
vec![IssueRef::from_str("#42").unwrap()]
|
|
);
|
|
|
|
let by_single_digit = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
text: Some("4".to_owned()),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
by_single_digit
|
|
.iter()
|
|
.map(|row| row.issue_ref)
|
|
.collect::<Vec<_>>(),
|
|
vec![IssueRef::from_str("#4").unwrap()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_text_filter_keeps_classic_title_description_and_carnet_matching() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
let by_title = issue(&root, 1, "Needle in title");
|
|
let by_description = issue(&root, 2, "Plain title")
|
|
.mutate(IssueActor::System, 2_000, |i| {
|
|
i.description = MarkdownDoc::new("Needle in description");
|
|
})
|
|
.unwrap();
|
|
let by_carnet = issue(&root, 3, "Other title")
|
|
.mutate(IssueActor::System, 2_000, |i| {
|
|
i.carnet = MarkdownDoc::new("Needle in carnet");
|
|
})
|
|
.unwrap();
|
|
store.create(&root, &by_title).await.unwrap();
|
|
store.create(&root, &by_description).await.unwrap();
|
|
store.create(&root, &by_carnet).await.unwrap();
|
|
|
|
let rows = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
text: Some("needle".to_owned()),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
rows.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
|
|
vec![
|
|
IssueRef::from_str("#1").unwrap(),
|
|
IssueRef::from_str("#2").unwrap(),
|
|
IssueRef::from_str("#3").unwrap()
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_persists_and_filters_sprint_membership() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
let sprint_id = SprintId::from_uuid(Uuid::from_u128(42));
|
|
let assigned = issue(&root, 1, "Assigned")
|
|
.mutate(IssueActor::System, 2_000, |i| {
|
|
i.sprint = Some(sprint_id);
|
|
})
|
|
.unwrap();
|
|
store.create(&root, &assigned).await.unwrap();
|
|
store
|
|
.create(&root, &issue(&root, 2, "Backlog"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let loaded = store
|
|
.get_by_ref(&root, IssueRef::from_str("#1").unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(loaded.sprint, Some(sprint_id));
|
|
|
|
let rows = store
|
|
.list(
|
|
&root,
|
|
IssueListFilter {
|
|
sprint: Some(sprint_id),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].title, "Assigned");
|
|
assert_eq!(rows[0].sprint, Some(sprint_id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_store_adds_reads_and_marks_attachment_summarized() {
|
|
let tmp = TempDir::new();
|
|
let root = tmp.root();
|
|
let store = FsIssueStore::new();
|
|
let issue = issue(&root, 7, "Attachment ticket");
|
|
store.create(&root, &issue).await.unwrap();
|
|
let source = tmp.child("source-note.txt");
|
|
std::fs::write(&source, "attachment payload").unwrap();
|
|
let attachment_id = IssueAttachmentId::new("att_1".to_owned()).unwrap();
|
|
|
|
let updated = store
|
|
.add_attachment_from_path(
|
|
&root,
|
|
issue.reference(),
|
|
&LocalPath::new(source.to_string_lossy().to_string()),
|
|
attachment_id.clone(),
|
|
"source-note.txt".to_owned(),
|
|
"text/plain".to_owned(),
|
|
IssueActor::User,
|
|
2_000,
|
|
issue.version,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(updated.attachments.len(), 1);
|
|
assert_eq!(updated.attachments[0].filename, "source-note.txt");
|
|
assert_eq!(updated.attachments[0].mime, "text/plain");
|
|
assert_eq!(updated.attachments[0].size_bytes, 18);
|
|
assert!(!updated.attachments[0].summarized_in_carnet);
|
|
assert!(tmp
|
|
.child(".ideai/tickets/7/attachments/att_1-source-note.txt")
|
|
.exists());
|
|
|
|
let content = store
|
|
.read_attachment(&root, issue.reference(), &attachment_id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(content.attachment.id, attachment_id);
|
|
assert_eq!(content.bytes, b"attachment payload");
|
|
|
|
let summarized = store
|
|
.mark_attachment_summarized(
|
|
&root,
|
|
issue.reference(),
|
|
&attachment_id,
|
|
IssueActor::System,
|
|
3_000,
|
|
updated.version,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let attachment = &summarized.attachments[0];
|
|
assert!(attachment.summarized_in_carnet);
|
|
assert_eq!(attachment.summarized_by, Some(IssueActor::System));
|
|
assert_eq!(attachment.summarized_at, Some(3_000));
|
|
}
|
|
|
|
#[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();
|
|
let root = tmp.root();
|
|
let allocator = FsIssueNumberAllocator::new();
|
|
|
|
let first = allocator.allocate_next(&root).await.unwrap();
|
|
let second = allocator.allocate_next(&root).await.unwrap();
|
|
let third = FsIssueNumberAllocator::new()
|
|
.allocate_next(&root)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(first.get(), 1);
|
|
assert_eq!(second.get(), 2);
|
|
assert_eq!(third.get(), 3);
|
|
}
|