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:
@ -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,
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -96,14 +96,18 @@ pub use git::{
|
||||
};
|
||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||
pub use issues::{
|
||||
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult,
|
||||
BulkDeleteIssues, BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority,
|
||||
AddIssueAttachment, AddIssueAttachmentInput, AddIssueAttachmentOutput, AssignIssueAgent,
|
||||
AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult, BulkDeleteIssues,
|
||||
BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority,
|
||||
BulkUpdateIssuePriorityInput, BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue,
|
||||
CreateIssueInput, CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput,
|
||||
LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput,
|
||||
ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput,
|
||||
ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, UpdateIssueOutput,
|
||||
MarkIssueAttachmentSummarized, MarkIssueAttachmentSummarizedInput,
|
||||
MarkIssueAttachmentSummarizedOutput, ReadIssue, ReadIssueAttachment, ReadIssueAttachmentInput,
|
||||
ReadIssueAttachmentOutput, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput,
|
||||
ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue,
|
||||
UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput,
|
||||
UpdateIssueOutput, TICKET_ATTACHMENT_MAX_BYTES,
|
||||
};
|
||||
pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
|
||||
@ -139,6 +139,8 @@ impl OpenTicketAssistant {
|
||||
"idea_ticket_update_priority".to_owned(),
|
||||
"idea_ticket_read_carnet".to_owned(),
|
||||
"idea_ticket_update_carnet".to_owned(),
|
||||
"idea_ticket_attachment_read".to_owned(),
|
||||
"idea_ticket_attachment_mark_summarized".to_owned(),
|
||||
"idea_ticket_link".to_owned(),
|
||||
"idea_ticket_unlink".to_owned(),
|
||||
],
|
||||
|
||||
@ -8,9 +8,10 @@ use domain::ports::{
|
||||
IssueStoreError, StoreError,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry,
|
||||
IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef,
|
||||
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueAttachmentContent,
|
||||
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority,
|
||||
IssueRef, IssueStatus, IssueVersion, LocalPath, ManifestEntry, MarkdownDoc, ProfileId, Project,
|
||||
ProjectId, ProjectPath, RemoteRef,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -139,6 +140,42 @@ impl IssueStore for FakeIssues {
|
||||
self.update(_root, &updated, expected_version).await?;
|
||||
self.read_carnet(_root, issue_ref).await
|
||||
}
|
||||
|
||||
async fn add_attachment_from_path(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_source: &LocalPath,
|
||||
_attachment_id: IssueAttachmentId,
|
||||
_filename: String,
|
||||
_mime: String,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
|
||||
async fn read_attachment(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_attachment_id: &IssueAttachmentId,
|
||||
) -> Result<IssueAttachmentContent, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
|
||||
async fn mark_attachment_summarized(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_attachment_id: &IssueAttachmentId,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqAllocator(Mutex<u64>);
|
||||
|
||||
@ -4,10 +4,11 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError};
|
||||
use domain::{
|
||||
DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter,
|
||||
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project,
|
||||
ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder,
|
||||
SprintStatus, SprintStore, SprintStoreError, SprintVersion,
|
||||
DomainEvent, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet,
|
||||
IssueId, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
|
||||
IssueVersion, LocalPath, MarkdownDoc, Project, ProjectId, ProjectPath, RemoteRef, Sprint,
|
||||
SprintId, SprintIndexEntry, SprintOrder, SprintStatus, SprintStore, SprintStoreError,
|
||||
SprintVersion,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -198,6 +199,42 @@ impl IssueStore for FakeIssues {
|
||||
) -> Result<IssueCarnet, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
|
||||
async fn add_attachment_from_path(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_source: &LocalPath,
|
||||
_attachment_id: IssueAttachmentId,
|
||||
_filename: String,
|
||||
_mime: String,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
|
||||
async fn read_attachment(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_attachment_id: &IssueAttachmentId,
|
||||
) -> Result<IssueAttachmentContent, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
|
||||
async fn mark_attachment_summarized(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_attachment_id: &IssueAttachmentId,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
|
||||
@ -17,11 +17,11 @@ use domain::profile::StructuredAdapter;
|
||||
use domain::{
|
||||
AgentId, AgentPermissionOverride, AgentProfile, AgentToolPolicy, AgentToolPolicyStore,
|
||||
AssistantContextError, AssistantContextProvider, ContextInjection, DomainEvent, EventBus,
|
||||
EventStream, Issue, IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber,
|
||||
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc,
|
||||
NetworkPolicy, PermissionSet, PreparedContext, ProfileId, ProfileStore, Project, ProjectId,
|
||||
ProjectPath, ProjectPermissions, ProjectSystemPermissions, RemoteRef, SessionId,
|
||||
SystemPermissionSet,
|
||||
EventStream, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet,
|
||||
IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueStore,
|
||||
IssueStoreError, IssueVersion, LocalPath, MarkdownDoc, NetworkPolicy, PermissionSet,
|
||||
PreparedContext, ProfileId, ProfileStore, Project, ProjectId, ProjectPath, ProjectPermissions,
|
||||
ProjectSystemPermissions, RemoteRef, SessionId, SystemPermissionSet,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -138,6 +138,42 @@ impl IssueStore for FakeIssues {
|
||||
) -> Result<IssueCarnet, IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn add_attachment_from_path(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_source: &LocalPath,
|
||||
_attachment_id: IssueAttachmentId,
|
||||
_filename: String,
|
||||
_mime: String,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn read_attachment(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_attachment_id: &IssueAttachmentId,
|
||||
) -> Result<IssueAttachmentContent, IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_attachment_summarized(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_attachment_id: &IssueAttachmentId,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeProfiles {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -14,20 +14,23 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use domain::{
|
||||
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
|
||||
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
|
||||
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId,
|
||||
AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId,
|
||||
IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber,
|
||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||
IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId,
|
||||
};
|
||||
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
const ISSUES_DIR: &str = "tickets";
|
||||
const ISSUE_FILE: &str = "issue.md";
|
||||
const CARNET_FILE: &str = "carnet.md";
|
||||
const ATTACHMENTS_DIR: &str = "attachments";
|
||||
const INDEX_FILE: &str = "index.json";
|
||||
const COUNTER_FILE: &str = "counter.json";
|
||||
const COUNTER_LOCK: &str = "counter.lock";
|
||||
const MUTATION_LOCK: &str = "mutation.lock";
|
||||
const INDEX_VERSION: u32 = 1;
|
||||
const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
/// Filesystem-backed issue store.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@ -84,6 +87,10 @@ fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
|
||||
issue_dir(root, number).join(CARNET_FILE)
|
||||
}
|
||||
|
||||
fn attachment_path(root: &ProjectPath, number: IssueNumber, relative: &str) -> PathBuf {
|
||||
issue_dir(root, number).join(relative)
|
||||
}
|
||||
|
||||
fn index_path(root: &ProjectPath) -> PathBuf {
|
||||
issue_root(root).join(INDEX_FILE)
|
||||
}
|
||||
@ -154,6 +161,18 @@ fn io_error(err: std::io::Error) -> IssueStoreError {
|
||||
IssueStoreError::Store(err.to_string())
|
||||
}
|
||||
|
||||
fn attachment_by_id(
|
||||
issue: &Issue,
|
||||
id: &IssueAttachmentId,
|
||||
) -> Result<IssueAttachment, IssueStoreError> {
|
||||
issue
|
||||
.attachments
|
||||
.iter()
|
||||
.find(|attachment| &attachment.id == id)
|
||||
.cloned()
|
||||
.ok_or(IssueStoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result<Issue, IssueStoreError> {
|
||||
let number = issue_ref.number();
|
||||
let issue_text = read_string(&issue_path(root, number)).await?;
|
||||
@ -451,6 +470,158 @@ impl IssueStore for FsIssueStore {
|
||||
let _ = tokio::fs::remove_file(&lock_path).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn add_attachment_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
source: &LocalPath,
|
||||
attachment_id: IssueAttachmentId,
|
||||
filename: String,
|
||||
mime: String,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
let source_path = PathBuf::from(source.as_str());
|
||||
let source_meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
|
||||
if !source_meta.is_file() {
|
||||
return Err(IssueStoreError::Invalid(
|
||||
"attachment source must be a file".to_owned(),
|
||||
));
|
||||
}
|
||||
let size_bytes = source_meta.len();
|
||||
if size_bytes > ATTACHMENT_MAX_BYTES {
|
||||
return Err(IssueStoreError::Invalid(format!(
|
||||
"attachment exceeds {} bytes",
|
||||
ATTACHMENT_MAX_BYTES
|
||||
)));
|
||||
}
|
||||
let stored_name = format!("{}-{}", attachment_id.as_str(), filename);
|
||||
let attachment = IssueAttachment {
|
||||
id: attachment_id,
|
||||
filename,
|
||||
path: format!("{ATTACHMENTS_DIR}/{stored_name}"),
|
||||
mime,
|
||||
size_bytes,
|
||||
added_by: actor,
|
||||
added_at: now_ms,
|
||||
summarized_in_carnet: false,
|
||||
summarized_by: None,
|
||||
summarized_at: None,
|
||||
};
|
||||
|
||||
let lock_path = mutation_lock_path(root);
|
||||
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
||||
let result = async {
|
||||
let current = load_issue(root, issue_ref).await?;
|
||||
if current.version != expected_version {
|
||||
return Err(IssueStoreError::VersionConflict {
|
||||
expected: expected_version,
|
||||
actual: current.version,
|
||||
});
|
||||
}
|
||||
if current
|
||||
.attachments
|
||||
.iter()
|
||||
.any(|existing| existing.id == attachment.id)
|
||||
{
|
||||
return Err(IssueStoreError::Invalid(format!(
|
||||
"attachment {} already exists",
|
||||
attachment.id.as_str()
|
||||
)));
|
||||
}
|
||||
|
||||
let dest = attachment_path(root, issue_ref.number(), &attachment.path);
|
||||
let parent = dest.parent().ok_or_else(|| {
|
||||
IssueStoreError::Store("attachment path has no parent".to_owned())
|
||||
})?;
|
||||
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
|
||||
tokio::fs::copy(&source_path, &dest)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
|
||||
let updated = current
|
||||
.mutate(attachment.added_by.clone(), attachment.added_at, |issue| {
|
||||
issue.attachments.push(attachment);
|
||||
})
|
||||
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
||||
save_issue(root, &updated).await?;
|
||||
rebuild_index(root).await?;
|
||||
Ok(updated)
|
||||
}
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&lock_path).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn read_attachment(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
attachment_id: &IssueAttachmentId,
|
||||
) -> Result<IssueAttachmentContent, IssueStoreError> {
|
||||
let issue = load_issue(root, issue_ref).await?;
|
||||
let attachment = attachment_by_id(&issue, attachment_id)?;
|
||||
let bytes = tokio::fs::read(attachment_path(root, issue.number, &attachment.path))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
IssueStoreError::NotFound
|
||||
} else {
|
||||
io_error(err)
|
||||
}
|
||||
})?;
|
||||
Ok(IssueAttachmentContent { attachment, bytes })
|
||||
}
|
||||
|
||||
async fn mark_attachment_summarized(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
attachment_id: &IssueAttachmentId,
|
||||
actor: IssueActor,
|
||||
now_ms: u64,
|
||||
expected_version: IssueVersion,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
let lock_path = mutation_lock_path(root);
|
||||
let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
|
||||
let result = async {
|
||||
let current = load_issue(root, issue_ref).await?;
|
||||
if current.version != expected_version {
|
||||
return Err(IssueStoreError::VersionConflict {
|
||||
expected: expected_version,
|
||||
actual: current.version,
|
||||
});
|
||||
}
|
||||
if !current
|
||||
.attachments
|
||||
.iter()
|
||||
.any(|attachment| &attachment.id == attachment_id)
|
||||
{
|
||||
return Err(IssueStoreError::NotFound);
|
||||
}
|
||||
let updated = current
|
||||
.mutate(actor.clone(), now_ms, |issue| {
|
||||
if let Some(attachment) = issue
|
||||
.attachments
|
||||
.iter_mut()
|
||||
.find(|attachment| &attachment.id == attachment_id)
|
||||
{
|
||||
attachment.summarized_in_carnet = true;
|
||||
attachment.summarized_by = Some(actor);
|
||||
attachment.summarized_at = Some(now_ms);
|
||||
}
|
||||
})
|
||||
.map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
|
||||
save_issue(root, &updated).await?;
|
||||
rebuild_index(root).await?;
|
||||
Ok(updated)
|
||||
}
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&lock_path).await;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -509,6 +680,7 @@ priority: {}\n\
|
||||
sprint: {}\n\
|
||||
links: {}\n\
|
||||
agentRefs: {}\n\
|
||||
attachments: {}\n\
|
||||
createdBy: {}\n\
|
||||
updatedBy: {}\n\
|
||||
createdAt: {}\n\
|
||||
@ -523,6 +695,7 @@ version: {}\n\
|
||||
json_value(&issue.sprint),
|
||||
json_value(&issue.links),
|
||||
json_value(&issue.agent_refs),
|
||||
json_value(&issue.attachments),
|
||||
json_value(&issue.created_by),
|
||||
json_value(&issue.updated_by),
|
||||
issue.created_at,
|
||||
@ -563,6 +736,8 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
||||
let sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
|
||||
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
|
||||
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
|
||||
let attachments: Vec<IssueAttachment> =
|
||||
parse_optional_json_field(&map, "attachments")?.unwrap_or_default();
|
||||
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
||||
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
||||
let created_at = parse_plain_u64(&map, "createdAt")?;
|
||||
@ -580,6 +755,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
||||
carnet: MarkdownDoc::default(),
|
||||
links,
|
||||
agent_refs,
|
||||
attachments,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_at,
|
||||
|
||||
@ -911,6 +911,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"
|
||||
)
|
||||
|
||||
@ -292,6 +292,48 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_attachment_add",
|
||||
description: "Attach a local file to a ticket using optimistic concurrency.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"path": { "type": "string" },
|
||||
"mime": { "type": "string" },
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "path", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_attachment_read",
|
||||
description: "Read one ticket attachment as base64 content.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"attachmentId": { "type": "string" }
|
||||
},
|
||||
"required": ["ref", "attachmentId"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_attachment_mark_summarized",
|
||||
description: "Mark a ticket attachment as summarized in the carnet.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": ticket_ref.clone(),
|
||||
"attachmentId": { "type": "string" },
|
||||
"expectedVersion": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["ref", "attachmentId", "expectedVersion"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ticket_link",
|
||||
description: "Add a link from one ticket to another using optimistic concurrency.",
|
||||
|
||||
@ -53,6 +53,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||
"idea_ticket_read",
|
||||
"idea_ticket_list",
|
||||
"idea_ticket_read_carnet",
|
||||
"idea_ticket_attachment_read",
|
||||
"idea_sprint_list",
|
||||
"idea_template_list",
|
||||
"idea_template_read",
|
||||
@ -78,6 +79,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"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_template_create",
|
||||
|
||||
@ -2,9 +2,9 @@ use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use domain::{
|
||||
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||
MarkdownDoc, ProjectPath, SprintId,
|
||||
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachmentId, IssueId,
|
||||
IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
|
||||
IssueStoreError, LocalPath, MarkdownDoc, ProjectPath, SprintId,
|
||||
};
|
||||
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
|
||||
use uuid::Uuid;
|
||||
@ -402,6 +402,66 @@ async fn issue_store_persists_and_filters_sprint_membership() {
|
||||
assert_eq!(rows[0].sprint, Some(sprint_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_adds_reads_and_marks_attachment_summarized() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let store = FsIssueStore::new();
|
||||
let issue = issue(&root, 7, "Attachment ticket");
|
||||
store.create(&root, &issue).await.unwrap();
|
||||
let source = tmp.child("source-note.txt");
|
||||
std::fs::write(&source, "attachment payload").unwrap();
|
||||
let attachment_id = IssueAttachmentId::new("att_1".to_owned()).unwrap();
|
||||
|
||||
let updated = store
|
||||
.add_attachment_from_path(
|
||||
&root,
|
||||
issue.reference(),
|
||||
&LocalPath::new(source.to_string_lossy().to_string()),
|
||||
attachment_id.clone(),
|
||||
"source-note.txt".to_owned(),
|
||||
"text/plain".to_owned(),
|
||||
IssueActor::User,
|
||||
2_000,
|
||||
issue.version,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.attachments.len(), 1);
|
||||
assert_eq!(updated.attachments[0].filename, "source-note.txt");
|
||||
assert_eq!(updated.attachments[0].mime, "text/plain");
|
||||
assert_eq!(updated.attachments[0].size_bytes, 18);
|
||||
assert!(!updated.attachments[0].summarized_in_carnet);
|
||||
assert!(tmp
|
||||
.child(".ideai/tickets/7/attachments/att_1-source-note.txt")
|
||||
.exists());
|
||||
|
||||
let content = store
|
||||
.read_attachment(&root, issue.reference(), &attachment_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content.attachment.id, attachment_id);
|
||||
assert_eq!(content.bytes, b"attachment payload");
|
||||
|
||||
let summarized = store
|
||||
.mark_attachment_summarized(
|
||||
&root,
|
||||
issue.reference(),
|
||||
&attachment_id,
|
||||
IssueActor::System,
|
||||
3_000,
|
||||
updated.version,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let attachment = &summarized.attachments[0];
|
||||
assert!(attachment.summarized_in_carnet);
|
||||
assert_eq!(attachment.summarized_by, Some(IssueActor::System));
|
||||
assert_eq!(attachment.summarized_at, Some(3_000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_delete_removes_files_and_index_entry() {
|
||||
let tmp = TempDir::new();
|
||||
|
||||
@ -34,20 +34,21 @@ use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput,
|
||||
AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput,
|
||||
CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
|
||||
DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput,
|
||||
DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
|
||||
GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput,
|
||||
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput,
|
||||
ListIssuesInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput,
|
||||
ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision,
|
||||
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
|
||||
AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput,
|
||||
ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput,
|
||||
CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput,
|
||||
DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput,
|
||||
DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput,
|
||||
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions,
|
||||
MarkIssueAttachmentSummarizedInput, McpRuntime, OpenProjectInput, PairAttemptDecision,
|
||||
PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput,
|
||||
ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
|
||||
ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameSprintInput,
|
||||
ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
||||
ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput,
|
||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
|
||||
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
||||
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
||||
@ -64,33 +65,36 @@ use domain::{
|
||||
};
|
||||
|
||||
use backend::dto::{
|
||||
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id,
|
||||
parse_profile_id, parse_project_id, parse_ref_dto, parse_session_id, parse_skill_id,
|
||||
parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_template_id,
|
||||
sort_ticket_rows, sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto,
|
||||
AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto,
|
||||
CreateSkillRequestDto, CreateTemplateRequestDto, DetectProfilesRequestDto,
|
||||
DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
||||
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
|
||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
|
||||
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
|
||||
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
|
||||
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
|
||||
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
|
||||
parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, parse_ref_dto,
|
||||
parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id,
|
||||
parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, version_dto,
|
||||
AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
||||
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
|
||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
||||
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
|
||||
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
||||
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
|
||||
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
||||
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
||||
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
@ -2478,6 +2482,11 @@ async fn invoke(
|
||||
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
|
||||
"ticket_list" => invoke_ticket_list(&request.args, &state.app).await,
|
||||
"ticket_update" => invoke_ticket_update(&request.args, &state.app).await,
|
||||
"ticket_attachment_add" => invoke_ticket_attachment_add(&request.args, &state.app).await,
|
||||
"ticket_attachment_read" => invoke_ticket_attachment_read(&request.args, &state.app).await,
|
||||
"ticket_attachment_mark_summarized" => {
|
||||
invoke_ticket_attachment_mark_summarized(&request.args, &state.app).await
|
||||
}
|
||||
"ticket_delete" => invoke_ticket_delete(&request.args, &state.app).await,
|
||||
"ticket_read_carnet" => invoke_ticket_read_carnet(&request.args, &state.app).await,
|
||||
"ticket_update_carnet" => invoke_ticket_update_carnet(&request.args, &state.app).await,
|
||||
@ -3050,6 +3059,72 @@ async fn invoke_ticket_update(args: &Value, state: &BackendCore) -> Result<Value
|
||||
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_ticket_attachment_add(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<TicketAttachmentAddRequestDto>("ticket_attachment_add", args)?;
|
||||
let project = resolve_project_readonly(&request.project_id, state).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: version_dto(request.expected_version)?,
|
||||
actor: IssueActor::User,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.issue;
|
||||
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_ticket_attachment_read(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let request =
|
||||
required_request::<TicketAttachmentReadRequestDto>("ticket_attachment_read", args)?;
|
||||
let project = resolve_project_readonly(&request.project_id, state).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;
|
||||
serde_json::to_value(TicketAttachmentContentDto::from(content)).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_ticket_attachment_mark_summarized(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<TicketAttachmentMarkSummarizedRequestDto>(
|
||||
"ticket_attachment_mark_summarized",
|
||||
args,
|
||||
)?;
|
||||
let project = resolve_project_readonly(&request.project_id, state).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: version_dto(request.expected_version)?,
|
||||
actor: IssueActor::User,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)?
|
||||
.issue;
|
||||
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_ticket_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<TicketDeleteRequestDto>("ticket_delete", args)?;
|
||||
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||
|
||||
@ -29,6 +29,7 @@ import type {
|
||||
Sprint,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketAttachmentContent,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
@ -122,6 +123,11 @@ export class HttpSystemGateway implements SystemGateway {
|
||||
return unsupportedOnWeb("Native file picker");
|
||||
}
|
||||
|
||||
pickFile(): Promise<string | null> {
|
||||
// Desktop-only, same rationale as `pickFolder`.
|
||||
return unsupportedOnWeb("Native file picker");
|
||||
}
|
||||
|
||||
onAppExitWorkGuard(
|
||||
_handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
@ -321,6 +327,36 @@ export class HttpTicketGateway implements TicketGateway {
|
||||
request: { projectId, ref, carnet, expectedVersion },
|
||||
});
|
||||
}
|
||||
addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_attachment_add", {
|
||||
request: { projectId, ref, path, expectedVersion, mime },
|
||||
});
|
||||
}
|
||||
readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent> {
|
||||
return this.http.invoke<TicketAttachmentContent>("ticket_attachment_read", {
|
||||
request: { projectId, ref, attachmentId },
|
||||
});
|
||||
}
|
||||
markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_attachment_mark_summarized", {
|
||||
request: { projectId, ref, attachmentId, expectedVersion },
|
||||
});
|
||||
}
|
||||
link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
|
||||
@ -66,6 +66,7 @@ import type {
|
||||
SystemPermissionSet,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketAttachmentContent,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
@ -179,6 +180,22 @@ function sameTicketCreator(
|
||||
return true;
|
||||
}
|
||||
|
||||
function filenameFromPath(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "attachment";
|
||||
}
|
||||
|
||||
function inferAttachmentMime(filename: string, supplied?: string | null): string {
|
||||
if (supplied?.trim()) return supplied.trim();
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
export class MockSystemGateway implements SystemGateway {
|
||||
private listeners = new Set<(e: DomainEvent) => void>();
|
||||
|
||||
@ -219,6 +236,11 @@ export class MockSystemGateway implements SystemGateway {
|
||||
return "/home/user/mock-plugin.ideaplug";
|
||||
}
|
||||
|
||||
/** Returns a deterministic fake path — never opens a native dialog. */
|
||||
async pickFile(): Promise<string | null> {
|
||||
return "/home/user/mock-attachment.txt";
|
||||
}
|
||||
|
||||
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
||||
/** Count of `confirmAppExit()` calls, for test assertions. */
|
||||
confirmAppExitCallCount = 0;
|
||||
@ -2710,6 +2732,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
|
||||
private chatSessions = new Map<string, string>();
|
||||
private chatCounter = 0;
|
||||
private attachmentCounter = 0;
|
||||
|
||||
constructor(private readonly system?: MockSystemGateway) {}
|
||||
|
||||
@ -2717,6 +2740,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
_seedTicket(projectId: string, ticket: Ticket): Ticket {
|
||||
const stored = structuredClone(ticket);
|
||||
if (stored.sprintId === undefined) stored.sprintId = null;
|
||||
stored.attachments ??= [];
|
||||
this.projectTickets(projectId).set(stored.ref, stored);
|
||||
this.counters.set(
|
||||
projectId,
|
||||
@ -2810,6 +2834,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
sprintId: null,
|
||||
links: [],
|
||||
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
|
||||
attachments: [],
|
||||
createdBy: { kind: "user" },
|
||||
updatedBy: { kind: "user" },
|
||||
createdAt: now,
|
||||
@ -3021,6 +3046,83 @@ export class MockTicketGateway implements TicketGateway {
|
||||
return structuredClone(ticket);
|
||||
}
|
||||
|
||||
async addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket> {
|
||||
const ticket = this.require(projectId, ref);
|
||||
this.guard(ticket, expectedVersion);
|
||||
const filename = filenameFromPath(path);
|
||||
this.attachmentCounter += 1;
|
||||
ticket.attachments.push({
|
||||
id: `att-${this.attachmentCounter}`,
|
||||
filename,
|
||||
mime: inferAttachmentMime(filename, mime),
|
||||
sizeBytes: Math.max(1, filename.length * 128),
|
||||
addedBy: { kind: "user" },
|
||||
addedAt: Date.now(),
|
||||
summarizedInCarnet: false,
|
||||
summarizedBy: null,
|
||||
summarizedAt: null,
|
||||
});
|
||||
this.bump(ticket);
|
||||
this.system?.emit({
|
||||
type: "issueUpdated",
|
||||
issueRef: ref,
|
||||
version: ticket.version,
|
||||
});
|
||||
return structuredClone(ticket);
|
||||
}
|
||||
|
||||
async readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent> {
|
||||
const ticket = this.require(projectId, ref);
|
||||
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
|
||||
if (!attachment) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `attachment ${attachmentId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
return {
|
||||
attachment: structuredClone(attachment),
|
||||
contentBase64: btoa(`mock content for ${attachment.filename}`),
|
||||
};
|
||||
}
|
||||
|
||||
async markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
const ticket = this.require(projectId, ref);
|
||||
this.guard(ticket, expectedVersion);
|
||||
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
|
||||
if (!attachment) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `attachment ${attachmentId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
attachment.summarizedInCarnet = true;
|
||||
attachment.summarizedBy = { kind: "user" };
|
||||
attachment.summarizedAt = Date.now();
|
||||
this.bump(ticket);
|
||||
this.system?.emit({
|
||||
type: "issueUpdated",
|
||||
issueRef: ref,
|
||||
version: ticket.version,
|
||||
});
|
||||
return structuredClone(ticket);
|
||||
}
|
||||
|
||||
async link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
|
||||
@ -50,6 +50,11 @@ export class TauriSystemGateway implements SystemGateway {
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async pickFile(): Promise<string | null> {
|
||||
const result = await open({ directory: false, multiple: false });
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async onAppExitWorkGuard(
|
||||
handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
|
||||
@ -54,4 +54,47 @@ describe("TauriTicketGateway invoke payloads", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps ticket attachment commands in the request DTO", async () => {
|
||||
const gateway = new TauriTicketGateway();
|
||||
|
||||
await gateway.addAttachment(
|
||||
"proj-1",
|
||||
"#12",
|
||||
"/tmp/note.txt",
|
||||
3,
|
||||
"text/plain",
|
||||
);
|
||||
await gateway.readAttachment("proj-1", "#12", "att-1");
|
||||
await gateway.markAttachmentSummarized("proj-1", "#12", "att-1", 4);
|
||||
|
||||
expect(invoke).toHaveBeenNthCalledWith(1, "ticket_attachment_add", {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
ref: "#12",
|
||||
path: "/tmp/note.txt",
|
||||
expectedVersion: 3,
|
||||
mime: "text/plain",
|
||||
},
|
||||
});
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "ticket_attachment_read", {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
ref: "#12",
|
||||
attachmentId: "att-1",
|
||||
},
|
||||
});
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"ticket_attachment_mark_summarized",
|
||||
{
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
ref: "#12",
|
||||
attachmentId: "att-1",
|
||||
expectedVersion: 4,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
ReplyChunk,
|
||||
Sprint,
|
||||
Ticket,
|
||||
TicketAttachmentContent,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
@ -134,6 +135,39 @@ export class TauriTicketGateway implements TicketGateway {
|
||||
});
|
||||
}
|
||||
|
||||
async addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket> {
|
||||
return invoke<Ticket>("ticket_attachment_add", {
|
||||
request: { projectId, ref, path, expectedVersion, mime },
|
||||
});
|
||||
}
|
||||
|
||||
async readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent> {
|
||||
return invoke<TicketAttachmentContent>("ticket_attachment_read", {
|
||||
request: { projectId, ref, attachmentId },
|
||||
});
|
||||
}
|
||||
|
||||
async markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return invoke<Ticket>("ticket_attachment_mark_summarized", {
|
||||
request: { projectId, ref, attachmentId, expectedVersion },
|
||||
});
|
||||
}
|
||||
|
||||
async link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
|
||||
@ -1473,6 +1473,25 @@ export type TicketActor =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "system" };
|
||||
|
||||
/** Metadata for a file attached to a ticket (mirror of `TicketAttachmentDto`). */
|
||||
export interface TicketAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime: string;
|
||||
sizeBytes: number;
|
||||
addedBy: TicketActor;
|
||||
addedAt: number;
|
||||
summarizedInCarnet: boolean;
|
||||
summarizedBy?: TicketActor | null;
|
||||
summarizedAt?: number | null;
|
||||
}
|
||||
|
||||
/** Raw ticket attachment content (mirror of `TicketAttachmentContentDto`). */
|
||||
export interface TicketAttachmentContent {
|
||||
attachment: TicketAttachment;
|
||||
contentBase64: string;
|
||||
}
|
||||
|
||||
/** One directed link from a ticket to another (mirror of `TicketLinkDto`). */
|
||||
export interface TicketLink {
|
||||
targetRef: TicketRef;
|
||||
@ -1510,6 +1529,7 @@ export interface Ticket {
|
||||
carnet?: string;
|
||||
links: TicketLink[];
|
||||
assignedAgentIds: string[];
|
||||
attachments: TicketAttachment[];
|
||||
createdBy: TicketActor;
|
||||
updatedBy: TicketActor;
|
||||
createdAt: number;
|
||||
|
||||
@ -76,6 +76,18 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function TicketDetail({
|
||||
projectId,
|
||||
ticketRef,
|
||||
@ -389,6 +401,72 @@ export function TicketDetail({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Attachments (#108) ── */}
|
||||
<Section title="Pièces jointes">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted">
|
||||
{t.attachments.length === 0
|
||||
? "Aucune pièce jointe."
|
||||
: `${t.attachments.length} pièce${
|
||||
t.attachments.length > 1 ? "s" : ""
|
||||
} jointe${t.attachments.length > 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={vm.busy}
|
||||
loading={vm.busy}
|
||||
onClick={() => void vm.attachFile()}
|
||||
>
|
||||
Joindre
|
||||
</Button>
|
||||
</div>
|
||||
{t.attachments.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{t.attachments.map((attachment) => (
|
||||
<li
|
||||
key={attachment.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-raised/30 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-content">
|
||||
{attachment.filename}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{formatBytes(attachment.sizeBytes)} ·{" "}
|
||||
{attachment.mime || "application/octet-stream"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
attachment.summarizedInCarnet
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-raised text-muted",
|
||||
)}
|
||||
>
|
||||
Résumé : {attachment.summarizedInCarnet ? "oui" : "non"}
|
||||
</span>
|
||||
{!attachment.summarizedInCarnet && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() =>
|
||||
void vm.markAttachmentSummarized(attachment.id)
|
||||
}
|
||||
aria-label={`marquer résumé ${attachment.filename}`}
|
||||
>
|
||||
Marquer résumé
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Links (F5) ── */}
|
||||
<Section title="Linked tickets">
|
||||
{t.links.length === 0 ? (
|
||||
|
||||
@ -172,6 +172,46 @@ describe("MockTicketGateway", () => {
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
||||
});
|
||||
|
||||
it("adds ticket attachments and marks them summarized (#108)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "With attachment" });
|
||||
|
||||
const attached = await ticket.addAttachment(
|
||||
PROJECT_ID,
|
||||
t.ref,
|
||||
"/tmp/spec.pdf",
|
||||
t.version,
|
||||
"application/pdf",
|
||||
);
|
||||
expect(attached.version).toBe(2);
|
||||
expect(attached.attachments).toMatchObject([
|
||||
{
|
||||
filename: "spec.pdf",
|
||||
mime: "application/pdf",
|
||||
summarizedInCarnet: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const content = await ticket.readAttachment(
|
||||
PROJECT_ID,
|
||||
t.ref,
|
||||
attached.attachments[0].id,
|
||||
);
|
||||
expect(content.attachment.filename).toBe("spec.pdf");
|
||||
expect(content.contentBase64).toBeTruthy();
|
||||
|
||||
const summarized = await ticket.markAttachmentSummarized(
|
||||
PROJECT_ID,
|
||||
t.ref,
|
||||
attached.attachments[0].id,
|
||||
attached.version,
|
||||
);
|
||||
expect(summarized.version).toBe(3);
|
||||
expect(summarized.attachments[0]).toMatchObject({
|
||||
summarizedInCarnet: true,
|
||||
summarizedBy: { kind: "user" },
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
|
||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
|
||||
@ -515,6 +555,36 @@ describe("TicketsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows, adds, and marks ticket attachments from the detail (#108)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Attachable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Attachable"));
|
||||
const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
||||
|
||||
expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
|
||||
expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByText("Joindre"));
|
||||
|
||||
expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
|
||||
expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
|
||||
expect(within(dialog).getByText("Résumé : non")).toBeTruthy();
|
||||
|
||||
const attached = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(attached.attachments).toHaveLength(1);
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByLabelText("marquer résumé mock-attachment.txt"),
|
||||
);
|
||||
|
||||
expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy();
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns only known project agents", async () => {
|
||||
const known = await seedAgent(agent, "Backend");
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
|
||||
|
||||
@ -45,6 +45,8 @@ export interface TicketDetailViewModel {
|
||||
priority?: Ticket["priority"];
|
||||
}) => Promise<boolean>;
|
||||
saveCarnet: (carnet: string) => Promise<boolean>;
|
||||
attachFile: () => Promise<boolean>;
|
||||
markAttachmentSummarized: (attachmentId: string) => Promise<boolean>;
|
||||
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
||||
@ -193,6 +195,26 @@ export function useTicketDetail(
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const attachFile: TicketDetailViewModel["attachFile"] = useCallback(async () => {
|
||||
if (!system) return false;
|
||||
try {
|
||||
const path = await system.pickFile();
|
||||
if (!path) return false;
|
||||
return run((version) => gateway.addAttachment(projectId, ref, path, version));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return false;
|
||||
}
|
||||
}, [system, run, gateway, projectId, ref]);
|
||||
|
||||
const markAttachmentSummarized: TicketDetailViewModel["markAttachmentSummarized"] = useCallback(
|
||||
(attachmentId) =>
|
||||
run((version) =>
|
||||
gateway.markAttachmentSummarized(projectId, ref, attachmentId, version),
|
||||
),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const link: TicketDetailViewModel["link"] = useCallback(
|
||||
(targetRef, kind) =>
|
||||
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
|
||||
@ -243,6 +265,8 @@ export function useTicketDetail(
|
||||
refresh,
|
||||
updateFields,
|
||||
saveCarnet,
|
||||
attachFile,
|
||||
markAttachmentSummarized,
|
||||
link,
|
||||
unlink,
|
||||
assign,
|
||||
|
||||
@ -28,6 +28,7 @@ function ticket(over: Partial<Ticket> = {}): Ticket {
|
||||
sprintId: null,
|
||||
links: [],
|
||||
assignedAgentIds: [],
|
||||
attachments: [],
|
||||
createdBy: { kind: "user" },
|
||||
updatedBy: { kind: "user" },
|
||||
createdAt: 1,
|
||||
|
||||
@ -68,6 +68,7 @@ import type {
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketBulkResult,
|
||||
TicketAttachmentContent,
|
||||
TicketChat,
|
||||
TicketCarnet,
|
||||
TicketLinkKind,
|
||||
@ -96,6 +97,12 @@ export interface SystemGateway {
|
||||
* cancelled. Same sanctioned-picker rule as {@link pickFolder}.
|
||||
*/
|
||||
pickArchiveFile(): Promise<string | null>;
|
||||
/**
|
||||
* Opens a native file picker for one local file and returns its path, or
|
||||
* `null` when cancelled. Used for ticket attachments; no file bytes cross the
|
||||
* frontend boundary.
|
||||
*/
|
||||
pickFile(): Promise<string | null>;
|
||||
/**
|
||||
* Subscribes to the app-exit work-in-progress guard (ticket #83): fired when
|
||||
* closing the main window is intercepted because it would interrupt active
|
||||
@ -1096,6 +1103,27 @@ export interface TicketGateway {
|
||||
carnet: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket>;
|
||||
/** Adds a local file path as a ticket attachment and returns the updated ticket. */
|
||||
addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket>;
|
||||
/** Reads raw attachment content as base64. Not used by the minimal v1 UI. */
|
||||
readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent>;
|
||||
/** Marks an attachment as summarized in the ticket carnet. */
|
||||
markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket>;
|
||||
/** Links this ticket to another `#id` with the given relationship kind. */
|
||||
link(
|
||||
projectId: string,
|
||||
|
||||
Reference in New Issue
Block a user