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

@ -285,6 +285,9 @@ pub fn run() {
tickets::close_ticket_chat,
tickets::ticket_list,
tickets::ticket_update,
tickets::ticket_attachment_add,
tickets::ticket_attachment_read,
tickets::ticket_attachment_mark_summarized,
tickets::ticket_read_carnet,
tickets::ticket_update_carnet,
tickets::ticket_link,

View File

@ -336,6 +336,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

@ -71,6 +71,9 @@ impl AppState {
bulk_delete: Arc::clone(&core.bulk_delete_issues),
read_carnet: Arc::clone(&core.read_issue_carnet),
update_carnet: Arc::clone(&core.update_issue_carnet),
add_attachment: Arc::clone(&core.add_issue_attachment),
read_attachment: Arc::clone(&core.read_issue_attachment),
mark_attachment_summarized: Arc::clone(&core.mark_issue_attachment_summarized),
link: Arc::clone(&core.link_issues),
unlink: Arc::clone(&core.unlink_issues),
list_sprints: Arc::clone(&core.list_sprints),

View File

@ -11,9 +11,10 @@ use serde_json::{json, Value};
use tauri::State;
use application::{
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput,
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput,
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignTicketToSprintInput,
CloseTicketAssistantInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput,
LinkIssuesInput, ListIssuesInput, ListSprintsInput, MarkIssueAttachmentSummarizedInput,
OpenProjectInput, OpenTicketAssistantInput, ReadIssueAttachmentInput, ReadIssueCarnetInput,
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
};
@ -34,6 +35,9 @@ pub struct AppTicketToolProvider {
pub bulk_delete: Arc<application::BulkDeleteIssues>,
pub read_carnet: Arc<application::ReadIssueCarnet>,
pub update_carnet: Arc<application::UpdateIssueCarnet>,
pub add_attachment: Arc<application::AddIssueAttachment>,
pub read_attachment: Arc<application::ReadIssueAttachment>,
pub mark_attachment_summarized: Arc<application::MarkIssueAttachmentSummarized>,
pub link: Arc<application::LinkIssues>,
pub unlink: Arc<application::UnlinkIssues>,
pub list_sprints: Arc<application::ListSprints>,
@ -251,6 +255,57 @@ impl TicketToolProvider for AppTicketToolProvider {
Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?)
))
}
"idea_ticket_attachment_add" => {
let req = mcp_attachment_add_request(project, arguments)?;
let issue = self
.add_attachment
.execute(AddIssueAttachmentInput {
project: project.clone(),
issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
path: req.path,
mime: req.mime,
expected_version: domain::IssueVersion::new(req.expected_version)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?,
actor,
})
.await
.map_err(ticket_error)?
.issue;
json!(TicketDto::from_issue(issue, None))
}
"idea_ticket_attachment_read" => {
let req = mcp_attachment_read_request(project, arguments)?;
let content = self
.read_attachment
.execute(ReadIssueAttachmentInput {
project: project.clone(),
issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
attachment_id: parse_attachment_id_dto(&req.attachment_id)
.map_err(dto_tool_error)?,
})
.await
.map_err(ticket_error)?
.content;
json!(TicketAttachmentContentDto::from(content))
}
"idea_ticket_attachment_mark_summarized" => {
let req = mcp_attachment_mark_summarized_request(project, arguments)?;
let issue = self
.mark_attachment_summarized
.execute(MarkIssueAttachmentSummarizedInput {
project: project.clone(),
issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
attachment_id: parse_attachment_id_dto(&req.attachment_id)
.map_err(dto_tool_error)?,
expected_version: domain::IssueVersion::new(req.expected_version)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?,
actor,
})
.await
.map_err(ticket_error)?
.issue;
json!(TicketDto::from_issue(issue, None))
}
"idea_ticket_link" => {
let issue = self
.link
@ -348,6 +403,78 @@ pub async fn ticket_read(
Ok(TicketDto::from_issue(issue, carnet))
}
#[tauri::command]
pub async fn ticket_attachment_add(
request: TicketAttachmentAddRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.add_issue_attachment
.execute(AddIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
path: request.path,
mime: request.mime,
expected_version: domain::IssueVersion::new(request.expected_version).map_err(|e| {
ErrorDto {
code: "INVALID".to_owned(),
message: e.to_string(),
}
})?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_attachment_read(
request: TicketAttachmentReadRequestDto,
state: State<'_, AppState>,
) -> Result<TicketAttachmentContentDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let content = state
.read_issue_attachment
.execute(ReadIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
})
.await
.map_err(ErrorDto::from)?
.content;
Ok(TicketAttachmentContentDto::from(content))
}
#[tauri::command]
pub async fn ticket_attachment_mark_summarized(
request: TicketAttachmentMarkSummarizedRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.mark_issue_attachment_summarized
.execute(MarkIssueAttachmentSummarizedInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
expected_version: domain::IssueVersion::new(request.expected_version).map_err(|e| {
ErrorDto {
code: "INVALID".to_owned(),
message: e.to_string(),
}
})?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_delete(
request: TicketDeleteRequestDto,
@ -876,6 +1003,36 @@ fn mcp_bulk_delete_request(
Ok(req)
}
fn mcp_attachment_add_request(
project: &Project,
arguments: Value,
) -> Result<TicketAttachmentAddRequestDto, TicketToolError> {
let mut req: TicketAttachmentAddRequestDto = serde_json::from_value(arguments)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
req.project_id = project.id.to_string();
Ok(req)
}
fn mcp_attachment_read_request(
project: &Project,
arguments: Value,
) -> Result<TicketAttachmentReadRequestDto, TicketToolError> {
let mut req: TicketAttachmentReadRequestDto = serde_json::from_value(arguments)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
req.project_id = project.id.to_string();
Ok(req)
}
fn mcp_attachment_mark_summarized_request(
project: &Project,
arguments: Value,
) -> Result<TicketAttachmentMarkSummarizedRequestDto, TicketToolError> {
let mut req: TicketAttachmentMarkSummarizedRequestDto = serde_json::from_value(arguments)
.map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
req.project_id = project.id.to_string();
Ok(req)
}
fn parse_json_ref(arguments: &Value, key: &str) -> Result<IssueRef, TicketToolError> {
required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error))
}