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

@ -3,17 +3,22 @@
//! The domain/code term is `Issue` throughout this layer.
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use domain::{AgentContextStore, Clock, EventBus, IdGenerator};
use domain::{
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId,
IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator,
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project,
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueAttachmentContent,
IssueAttachmentId, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueLinkKind,
IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
IssueVersion, LocalPath, MarkdownDoc, Project,
};
use crate::error::AppError;
/// Maximum raw attachment size for the minimal ticket attachment lot.
pub const TICKET_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
/// Input for [`CreateIssue::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateIssueInput {
@ -720,6 +725,199 @@ impl UpdateIssueCarnet {
}
}
/// Input for [`AddIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddIssueAttachmentInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Local source file path.
pub path: String,
/// Optional MIME type supplied by the driving adapter.
pub mime: Option<String>,
/// Expected optimistic version.
pub expected_version: IssueVersion,
/// Actor adding the attachment.
pub actor: IssueActor,
}
/// Output of [`AddIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddIssueAttachmentOutput {
/// Updated issue.
pub issue: Issue,
}
/// Adds a local file as a ticket attachment.
pub struct AddIssueAttachment {
issues: Arc<dyn IssueStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl AddIssueAttachment {
/// Builds the use case.
#[must_use]
pub fn new(
issues: Arc<dyn IssueStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
issues,
ids,
clock,
events,
}
}
/// Executes attachment add.
pub async fn execute(
&self,
input: AddIssueAttachmentInput,
) -> Result<AddIssueAttachmentOutput, AppError> {
let filename = filename_from_path(&input.path)?;
validate_attachment_filename(&filename)?;
let mime = sanitize_mime(input.mime.as_deref(), &filename)?;
let attachment_id = IssueAttachmentId::new(self.ids.new_uuid().to_string())
.map_err(|err| AppError::Invalid(err.to_string()))?;
let issue = self
.issues
.add_attachment_from_path(
&input.project.root,
input.issue_ref,
&LocalPath::new(input.path),
attachment_id,
filename,
mime,
input.actor,
now(&self.clock),
input.expected_version,
)
.await?;
self.events.publish(DomainEvent::IssueUpdated {
issue_ref: input.issue_ref,
version: issue.version,
});
Ok(AddIssueAttachmentOutput { issue })
}
}
/// Input for [`ReadIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadIssueAttachmentInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Attachment id.
pub attachment_id: IssueAttachmentId,
}
/// Output of [`ReadIssueAttachment::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadIssueAttachmentOutput {
/// Attachment content.
pub content: IssueAttachmentContent,
}
/// Reads a ticket attachment.
pub struct ReadIssueAttachment {
issues: Arc<dyn IssueStore>,
}
impl ReadIssueAttachment {
/// Builds the use case.
#[must_use]
pub fn new(issues: Arc<dyn IssueStore>) -> Self {
Self { issues }
}
/// Executes attachment read.
pub async fn execute(
&self,
input: ReadIssueAttachmentInput,
) -> Result<ReadIssueAttachmentOutput, AppError> {
Ok(ReadIssueAttachmentOutput {
content: self
.issues
.read_attachment(&input.project.root, input.issue_ref, &input.attachment_id)
.await?,
})
}
}
/// Input for [`MarkIssueAttachmentSummarized::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkIssueAttachmentSummarizedInput {
/// Project owning the issue.
pub project: Project,
/// Issue reference.
pub issue_ref: IssueRef,
/// Attachment id.
pub attachment_id: IssueAttachmentId,
/// Expected optimistic version.
pub expected_version: IssueVersion,
/// Actor marking summary state.
pub actor: IssueActor,
}
/// Output of [`MarkIssueAttachmentSummarized::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkIssueAttachmentSummarizedOutput {
/// Updated issue.
pub issue: Issue,
}
/// Marks an attachment as summarized in the ticket carnet.
pub struct MarkIssueAttachmentSummarized {
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
}
impl MarkIssueAttachmentSummarized {
/// Builds the use case.
#[must_use]
pub fn new(
issues: Arc<dyn IssueStore>,
clock: Arc<dyn Clock>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
issues,
clock,
events,
}
}
/// Executes summary mark.
pub async fn execute(
&self,
input: MarkIssueAttachmentSummarizedInput,
) -> Result<MarkIssueAttachmentSummarizedOutput, AppError> {
let issue = self
.issues
.mark_attachment_summarized(
&input.project.root,
input.issue_ref,
&input.attachment_id,
input.actor,
now(&self.clock),
input.expected_version,
)
.await?;
self.events.publish(DomainEvent::IssueUpdated {
issue_ref: input.issue_ref,
version: issue.version,
});
Ok(MarkIssueAttachmentSummarizedOutput { issue })
}
}
/// Input for [`LinkIssues::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkIssuesInput {
@ -1030,6 +1228,75 @@ fn replace_assigned_agents(issue: &mut Issue, agent_ids: Vec<AgentId>) {
}
}
fn filename_from_path(path: &str) -> Result<String, AppError> {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
.ok_or_else(|| AppError::Invalid("attachment path has no filename".to_owned()))
}
fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
let lowered = filename.to_ascii_lowercase();
let blocked = [
"exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app",
"deb", "rpm",
];
if lowered.trim().is_empty()
|| lowered.contains('/')
|| lowered.contains('\\')
|| lowered == "."
|| lowered == ".."
{
return Err(AppError::Invalid("invalid attachment filename".to_owned()));
}
if lowered
.rsplit_once('.')
.is_some_and(|(_, ext)| blocked.contains(&ext))
{
return Err(AppError::Invalid(
"executable attachments are not allowed".to_owned(),
));
}
Ok(())
}
fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result<String, AppError> {
let mime = raw
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| infer_mime(filename).to_owned());
if mime.eq_ignore_ascii_case("application/x-msdownload")
|| mime.eq_ignore_ascii_case("application/x-sh")
|| mime.eq_ignore_ascii_case("application/x-executable")
{
return Err(AppError::Invalid(
"executable attachments are not allowed".to_owned(),
));
}
Ok(mime)
}
fn infer_mime(filename: &str) -> &'static str {
match filename
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.as_deref()
{
Some("txt" | "md" | "log") => "text/plain",
Some("json") => "application/json",
Some("xml") => "application/xml",
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("pdf") => "application/pdf",
Some("csv") => "text/csv",
_ => "application/octet-stream",
}
}
fn publish_update_events(
events: &Arc<dyn EventBus>,
issue: &Issue,