feat(tickets): ajoute les pièces jointes sur tickets (#108)
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>
This commit is contained in:
@ -14,20 +14,23 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use domain::{
|
||||
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
|
||||
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
|
||||
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId,
|
||||
AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId,
|
||||
IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber,
|
||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||
IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId,
|
||||
};
|
||||
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
const ISSUES_DIR: &str = "tickets";
|
||||
const ISSUE_FILE: &str = "issue.md";
|
||||
const CARNET_FILE: &str = "carnet.md";
|
||||
const ATTACHMENTS_DIR: &str = "attachments";
|
||||
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;
|
||||
const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
/// Filesystem-backed issue store.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@ -84,6 +87,10 @@ fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
|
||||
issue_dir(root, number).join(CARNET_FILE)
|
||||
}
|
||||
|
||||
fn attachment_path(root: &ProjectPath, number: IssueNumber, relative: &str) -> PathBuf {
|
||||
issue_dir(root, number).join(relative)
|
||||
}
|
||||
|
||||
fn index_path(root: &ProjectPath) -> PathBuf {
|
||||
issue_root(root).join(INDEX_FILE)
|
||||
}
|
||||
@ -154,6 +161,18 @@ fn io_error(err: std::io::Error) -> IssueStoreError {
|
||||
IssueStoreError::Store(err.to_string())
|
||||
}
|
||||
|
||||
fn attachment_by_id(
|
||||
issue: &Issue,
|
||||
id: &IssueAttachmentId,
|
||||
) -> Result<IssueAttachment, IssueStoreError> {
|
||||
issue
|
||||
.attachments
|
||||
.iter()
|
||||
.find(|attachment| &attachment.id == id)
|
||||
.cloned()
|
||||
.ok_or(IssueStoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result<Issue, IssueStoreError> {
|
||||
let number = issue_ref.number();
|
||||
let issue_text = read_string(&issue_path(root, number)).await?;
|
||||
@ -451,6 +470,158 @@ impl IssueStore for FsIssueStore {
|
||||
let _ = tokio::fs::remove_file(&lock_path).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn add_attachment_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
source: &LocalPath,
|
||||
attachment_id: IssueAttachmentId,
|
||||
filename: String,
|
||||
mime: String,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
let source_path = PathBuf::from(source.as_str());
|
||||
let source_meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
|
||||
if !source_meta.is_file() {
|
||||
return Err(IssueStoreError::Invalid(
|
||||
"attachment source must be a file".to_owned(),
|
||||
));
|
||||
}
|
||||
let size_bytes = source_meta.len();
|
||||
if size_bytes > ATTACHMENT_MAX_BYTES {
|
||||
return Err(IssueStoreError::Invalid(format!(
|
||||
"attachment exceeds {} bytes",
|
||||
ATTACHMENT_MAX_BYTES
|
||||
)));
|
||||
}
|
||||
let stored_name = format!("{}-{}", attachment_id.as_str(), filename);
|
||||
let attachment = IssueAttachment {
|
||||
id: attachment_id,
|
||||
filename,
|
||||
path: format!("{ATTACHMENTS_DIR}/{stored_name}"),
|
||||
mime,
|
||||
size_bytes,
|
||||
added_by: actor,
|
||||
added_at: now_ms,
|
||||
summarized_in_carnet: false,
|
||||
summarized_by: None,
|
||||
summarized_at: None,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
if current
|
||||
.attachments
|
||||
.iter()
|
||||
.any(|existing| existing.id == attachment.id)
|
||||
{
|
||||
return Err(IssueStoreError::Invalid(format!(
|
||||
"attachment {} already exists",
|
||||
attachment.id.as_str()
|
||||
)));
|
||||
}
|
||||
|
||||
let dest = attachment_path(root, issue_ref.number(), &attachment.path);
|
||||
let parent = dest.parent().ok_or_else(|| {
|
||||
IssueStoreError::Store("attachment path has no parent".to_owned())
|
||||
})?;
|
||||
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
|
||||
tokio::fs::copy(&source_path, &dest)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
|
||||
let updated = current
|
||||
.mutate(attachment.added_by.clone(), attachment.added_at, |issue| {
|
||||
issue.attachments.push(attachment);
|
||||
})
|
||||
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
||||
save_issue(root, &updated).await?;
|
||||
rebuild_index(root).await?;
|
||||
Ok(updated)
|
||||
}
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&lock_path).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn read_attachment(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
attachment_id: &IssueAttachmentId,
|
||||
) -> Result<IssueAttachmentContent, IssueStoreError> {
|
||||
let issue = load_issue(root, issue_ref).await?;
|
||||
let attachment = attachment_by_id(&issue, attachment_id)?;
|
||||
let bytes = tokio::fs::read(attachment_path(root, issue.number, &attachment.path))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
IssueStoreError::NotFound
|
||||
} else {
|
||||
io_error(err)
|
||||
}
|
||||
})?;
|
||||
Ok(IssueAttachmentContent { attachment, bytes })
|
||||
}
|
||||
|
||||
async fn mark_attachment_summarized(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
attachment_id: &IssueAttachmentId,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
expected_version: IssueVersion,
|
||||
) -> Result<Issue, 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?;
|
||||
if current.version != expected_version {
|
||||
return Err(IssueStoreError::VersionConflict {
|
||||
expected: expected_version,
|
||||
actual: current.version,
|
||||
});
|
||||
}
|
||||
if !current
|
||||
.attachments
|
||||
.iter()
|
||||
.any(|attachment| &attachment.id == attachment_id)
|
||||
{
|
||||
return Err(IssueStoreError::NotFound);
|
||||
}
|
||||
let updated = current
|
||||
.mutate(actor.clone(), now_ms, |issue| {
|
||||
if let Some(attachment) = issue
|
||||
.attachments
|
||||
.iter_mut()
|
||||
.find(|attachment| &attachment.id == attachment_id)
|
||||
{
|
||||
attachment.summarized_in_carnet = true;
|
||||
attachment.summarized_by = Some(actor);
|
||||
attachment.summarized_at = Some(now_ms);
|
||||
}
|
||||
})
|
||||
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
||||
save_issue(root, &updated).await?;
|
||||
rebuild_index(root).await?;
|
||||
Ok(updated)
|
||||
}
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&lock_path).await;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -509,6 +680,7 @@ priority: {}\n\
|
||||
sprint: {}\n\
|
||||
links: {}\n\
|
||||
agentRefs: {}\n\
|
||||
attachments: {}\n\
|
||||
createdBy: {}\n\
|
||||
updatedBy: {}\n\
|
||||
createdAt: {}\n\
|
||||
@ -523,6 +695,7 @@ version: {}\n\
|
||||
json_value(&issue.sprint),
|
||||
json_value(&issue.links),
|
||||
json_value(&issue.agent_refs),
|
||||
json_value(&issue.attachments),
|
||||
json_value(&issue.created_by),
|
||||
json_value(&issue.updated_by),
|
||||
issue.created_at,
|
||||
@ -563,6 +736,8 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
||||
let sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
|
||||
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
|
||||
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
|
||||
let attachments: Vec<IssueAttachment> =
|
||||
parse_optional_json_field(&map, "attachments")?.unwrap_or_default();
|
||||
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
||||
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
||||
let created_at = parse_plain_u64(&map, "createdAt")?;
|
||||
@ -580,6 +755,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
||||
carnet: MarkdownDoc::default(),
|
||||
links,
|
||||
agent_refs,
|
||||
attachments,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_at,
|
||||
|
||||
@ -911,6 +911,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
||||
| "idea_ticket_update_status"
|
||||
| "idea_ticket_update_priority"
|
||||
| "idea_ticket_update_carnet"
|
||||
| "idea_ticket_attachment_add"
|
||||
| "idea_ticket_attachment_mark_summarized"
|
||||
| "idea_ticket_link"
|
||||
| "idea_ticket_unlink"
|
||||
)
|
||||
|
||||
@ -292,6 +292,48 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_attachment_add",
|
||||
description: "Attach a local file to a ticket using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"path": { "type": "string" },
|
||||
"mime": { "type": "string" },
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "path", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_attachment_read",
|
||||
description: "Read one ticket attachment as base64 content.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"attachmentId": { "type": "string" }
|
||||
},
|
||||
"required": ["ref", "attachmentId"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_attachment_mark_summarized",
|
||||
description: "Mark a ticket attachment as summarized in the carnet.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"attachmentId": { "type": "string" },
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "attachmentId", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_link",
|
||||
description: "Add a link from one ticket to another using optimistic concurrency.",
|
||||
|
||||
@ -53,6 +53,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||
"idea_ticket_read",
|
||||
"idea_ticket_list",
|
||||
"idea_ticket_read_carnet",
|
||||
"idea_ticket_attachment_read",
|
||||
"idea_sprint_list",
|
||||
"idea_template_list",
|
||||
"idea_template_read",
|
||||
@ -78,6 +79,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_ticket_bulk_update_priority",
|
||||
"idea_ticket_bulk_delete",
|
||||
"idea_ticket_update_carnet",
|
||||
"idea_ticket_attachment_add",
|
||||
"idea_ticket_attachment_mark_summarized",
|
||||
"idea_ticket_link",
|
||||
"idea_ticket_unlink",
|
||||
"idea_template_create",
|
||||
|
||||
@ -2,9 +2,9 @@ use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use domain::{
|
||||
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||
MarkdownDoc, ProjectPath, SprintId,
|
||||
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;
|
||||
@ -402,6 +402,66 @@ async fn issue_store_persists_and_filters_sprint_membership() {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user