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

@ -63,6 +63,8 @@ fn is_ticket_mutation_tool(tool: &str) -> bool {
| "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_ticket_create"

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),
}

View File

@ -127,9 +127,9 @@ pub use inbox::{
};
pub use issue::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry,
IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
IssueVersion,
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentId,
IssueCarnet, IssueError, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
};
pub use sprint::{
@ -237,15 +237,15 @@ pub use ports::{
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath,
McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError,
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError,
ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
TemplateStore, WindowStateStore,
IdGenerator, IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError,
LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall,
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
SprintStoreError, StoreError, StructuredSessionEnvironment,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
};

View File

@ -40,7 +40,8 @@ use crate::ids::{
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
};
use crate::issue::{
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
Issue, IssueAttachment, IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter,
IssueNumber, IssueRef, IssueVersion,
};
use crate::markdown::MarkdownDoc;
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
@ -980,6 +981,15 @@ pub enum IssueStoreError {
Store(String),
}
/// Raw ticket attachment content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueAttachmentContent {
/// Attachment metadata.
pub attachment: IssueAttachment,
/// Raw bytes.
pub bytes: Vec<u8>,
}
/// Errors from the sprint store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SprintStoreError {
@ -2360,6 +2370,40 @@ pub trait IssueStore: Send + Sync {
now_ms: u64,
expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError>;
/// Copies a local file into the ticket attachment folder and updates issue
/// metadata after checking the issue version.
async fn add_attachment_from_path(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
source: &LocalPath,
attachment_id: IssueAttachmentId,
filename: String,
mime: String,
actor: crate::issue::IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError>;
/// Reads an attachment's raw bytes.
async fn read_attachment(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
attachment_id: &IssueAttachmentId,
) -> Result<IssueAttachmentContent, IssueStoreError>;
/// Marks an attachment as summarized in the ticket carnet.
async fn mark_attachment_summarized(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
attachment_id: &IssueAttachmentId,
actor: crate::issue::IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError>;
}
/// Persistence port for project-scoped sprints.