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

@ -11,6 +11,35 @@ use thiserror::Error;
use crate::ids::{AgentId, IssueId, SprintId};
use crate::markdown::MarkdownDoc;
/// Stable ticket attachment identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct IssueAttachmentId(String);
impl IssueAttachmentId {
/// Builds an attachment id.
///
/// # Errors
/// [`IssueError::InvalidAttachmentId`] when the id is empty or not filename-safe.
pub fn new(value: impl Into<String>) -> Result<Self, IssueError> {
let value = value.into();
if value.is_empty()
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(IssueError::InvalidAttachmentId(value));
}
Ok(Self(value))
}
/// Returns the raw id.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Sequential per-project issue number.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
@ -239,6 +268,35 @@ pub enum IssueActor {
System,
}
/// Metadata for a file attached to a ticket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueAttachment {
/// Stable attachment id.
pub id: IssueAttachmentId,
/// Original display filename.
pub filename: String,
/// Stored relative path under the ticket directory.
pub path: String,
/// MIME type.
pub mime: String,
/// Size in bytes.
pub size_bytes: u64,
/// Actor that added the attachment.
pub added_by: IssueActor,
/// Add time, epoch milliseconds.
pub added_at: u64,
/// Whether an agent/user summarized the attachment into the ticket carnet.
#[serde(default)]
pub summarized_in_carnet: bool,
/// Actor that marked it summarized.
#[serde(default)]
pub summarized_by: Option<IssueActor>,
/// Summary mark time, epoch milliseconds.
#[serde(default)]
pub summarized_at: Option<u64>,
}
/// Issue aggregate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -263,6 +321,9 @@ pub struct Issue {
pub links: Vec<IssueLink>,
/// Agent references.
pub agent_refs: Vec<AgentIssueRef>,
/// Attached files.
#[serde(default)]
pub attachments: Vec<IssueAttachment>,
/// Creator.
pub created_by: IssueActor,
/// Last updater.
@ -305,6 +366,7 @@ impl Issue {
carnet,
links,
agent_refs,
attachments: Vec::new(),
created_by: actor.clone(),
updated_by: actor,
created_at: now_ms,
@ -344,6 +406,34 @@ impl Issue {
if self.links.iter().any(|link| link.target == own) {
return Err(IssueError::SelfLink { reference: own });
}
let mut attachment_ids = std::collections::HashSet::new();
for attachment in &self.attachments {
if !attachment_ids.insert(attachment.id.clone()) {
return Err(IssueError::DuplicateAttachmentId(
attachment.id.as_str().to_owned(),
));
}
if attachment.filename.trim().is_empty()
|| attachment.filename.contains('/')
|| attachment.filename.contains('\\')
|| attachment.filename == "."
|| attachment.filename == ".."
{
return Err(IssueError::InvalidAttachmentFilename(
attachment.filename.clone(),
));
}
if attachment.path.starts_with('/')
|| attachment.path.starts_with('\\')
|| attachment.path.contains("..")
|| !attachment.path.starts_with("attachments/")
{
return Err(IssueError::InvalidAttachmentPath(attachment.path.clone()));
}
if attachment.mime.trim().is_empty() {
return Err(IssueError::InvalidAttachmentMime(attachment.mime.clone()));
}
}
Ok(())
}
@ -469,4 +559,19 @@ pub enum IssueError {
/// Self reference.
reference: IssueRef,
},
/// Attachment id is not flat filename-safe.
#[error("invalid attachment id: {0}")]
InvalidAttachmentId(String),
/// Attachment filename is empty or not flat.
#[error("invalid attachment filename: {0}")]
InvalidAttachmentFilename(String),
/// Attachment stored path is invalid.
#[error("invalid attachment path: {0}")]
InvalidAttachmentPath(String),
/// Attachment MIME type is invalid.
#[error("invalid attachment mime: {0}")]
InvalidAttachmentMime(String),
/// Attachment ids must be unique per issue.
#[error("duplicate attachment id: {0}")]
DuplicateAttachmentId(String),
}