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:
@ -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"
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user