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:
2026-07-29 11:08:45 +02:00
parent 2692b9cc03
commit 8158057b1d
34 changed files with 1722 additions and 102 deletions

View File

@ -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,