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

@ -12,15 +12,15 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask,
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed,
CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
@ -31,12 +31,13 @@ use application::{
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
@ -997,6 +998,12 @@ pub struct BackendCore {
pub read_issue_carnet: Arc<ReadIssueCarnet>,
/// Update a ticket carnet.
pub update_issue_carnet: Arc<UpdateIssueCarnet>,
/// Attach a local file to a ticket.
pub add_issue_attachment: Arc<AddIssueAttachment>,
/// Read one ticket attachment.
pub read_issue_attachment: Arc<ReadIssueAttachment>,
/// Mark a ticket attachment as summarized in the carnet.
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
/// Link two public tickets.
pub link_issues: Arc<LinkIssues>,
/// Unlink public tickets.
@ -1696,6 +1703,19 @@ impl BackendCore {
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let add_issue_attachment = Arc::new(AddIssueAttachment::new(
Arc::clone(&issue_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let read_issue_attachment =
Arc::new(ReadIssueAttachment::new(Arc::clone(&issue_store_port)));
let mark_issue_attachment_summarized = Arc::new(MarkIssueAttachmentSummarized::new(
Arc::clone(&issue_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&events_port),
));
let link_issues = Arc::new(LinkIssues::new(
Arc::clone(&issue_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
@ -2843,6 +2863,9 @@ impl BackendCore {
bulk_delete_issues,
read_issue_carnet,
update_issue_carnet,
add_issue_attachment,
read_issue_attachment,
mark_issue_attachment_summarized,
link_issues,
unlink_issues,
assign_issue_agent,

View File

@ -349,6 +349,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
| "idea_ticket_update_status"
| "idea_ticket_update_priority"
| "idea_ticket_update_carnet"
| "idea_ticket_attachment_add"
| "idea_ticket_attachment_mark_summarized"
| "idea_ticket_link"
| "idea_ticket_unlink"
)

View File

@ -10,9 +10,10 @@ use application::{
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
};
use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
Project, SprintId, SprintStatus, SprintVersion,
AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent,
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus,
SprintVersion,
};
use infrastructure::TicketToolError;
@ -36,6 +37,7 @@ pub struct TicketDto {
pub carnet: Option<String>,
pub links: Vec<TicketLinkDto>,
pub assigned_agent_ids: Vec<String>,
pub attachments: Vec<TicketAttachmentDto>,
pub created_by: TicketActorDto,
pub updated_by: TicketActorDto,
pub created_at: u64,
@ -69,6 +71,31 @@ pub struct TicketListDto {
pub next_cursor: Option<String>,
}
/// Public attachment metadata DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentDto {
pub id: String,
pub filename: String,
pub mime: String,
pub size_bytes: u64,
pub added_by: TicketActorDto,
pub added_at: u64,
pub summarized_in_carnet: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summarized_by: Option<TicketActorDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summarized_at: Option<u64>,
}
/// Raw attachment content DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentContentDto {
pub attachment: TicketAttachmentDto,
pub content_base64: String,
}
/// Sprint context embedded in ticket read/list responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -186,6 +213,39 @@ pub struct TicketDeleteRequestDto {
pub r#ref: String,
}
/// Add attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentAddRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub path: String,
pub mime: Option<String>,
pub expected_version: u64,
}
/// Read attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentReadRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub attachment_id: String,
}
/// Mark attachment summarized request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentMarkSummarizedRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub attachment_id: String,
pub expected_version: u64,
}
/// List request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -437,6 +497,11 @@ impl TicketDto {
.filter(|r| r.role == AgentIssueRole::Assigned)
.map(|r| r.agent_id.to_string())
.collect(),
attachments: issue
.attachments
.into_iter()
.map(TicketAttachmentDto::from)
.collect(),
created_by: TicketActorDto::from(issue.created_by),
updated_by: TicketActorDto::from(issue.updated_by),
created_at: issue.created_at,
@ -547,6 +612,31 @@ impl From<IssueActor> for TicketActorDto {
}
}
impl From<IssueAttachment> for TicketAttachmentDto {
fn from(attachment: IssueAttachment) -> Self {
Self {
id: attachment.id.as_str().to_owned(),
filename: attachment.filename,
mime: attachment.mime,
size_bytes: attachment.size_bytes,
added_by: TicketActorDto::from(attachment.added_by),
added_at: attachment.added_at,
summarized_in_carnet: attachment.summarized_in_carnet,
summarized_by: attachment.summarized_by.map(TicketActorDto::from),
summarized_at: attachment.summarized_at,
}
}
}
impl From<IssueAttachmentContent> for TicketAttachmentContentDto {
fn from(content: IssueAttachmentContent) -> Self {
Self {
attachment: TicketAttachmentDto::from(content.attachment),
content_base64: URL_SAFE_NO_PAD.encode(content.bytes),
}
}
}
impl TryFrom<TicketCreatorFilterDto> for IssueActor {
type Error = ErrorDto;
@ -560,6 +650,13 @@ impl TryFrom<TicketCreatorFilterDto> for IssueActor {
}
}
pub fn parse_attachment_id_dto(raw: &str) -> Result<IssueAttachmentId, ErrorDto> {
IssueAttachmentId::new(raw.to_owned()).map_err(|err| ErrorDto {
code: "INVALID".to_owned(),
message: err.to_string(),
})
}
impl From<IssueCarnet> for TicketCarnetDto {
fn from(carnet: IssueCarnet) -> Self {
Self {