merge feature/ticket108-attachments dans develop (pièces jointes sur tickets, #108)
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::close_ticket_chat,
|
||||||
tickets::ticket_list,
|
tickets::ticket_list,
|
||||||
tickets::ticket_update,
|
tickets::ticket_update,
|
||||||
|
tickets::ticket_attachment_add,
|
||||||
|
tickets::ticket_attachment_read,
|
||||||
|
tickets::ticket_attachment_mark_summarized,
|
||||||
tickets::ticket_read_carnet,
|
tickets::ticket_read_carnet,
|
||||||
tickets::ticket_update_carnet,
|
tickets::ticket_update_carnet,
|
||||||
tickets::ticket_link,
|
tickets::ticket_link,
|
||||||
|
|||||||
@ -336,6 +336,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
|||||||
| "idea_ticket_update_status"
|
| "idea_ticket_update_status"
|
||||||
| "idea_ticket_update_priority"
|
| "idea_ticket_update_priority"
|
||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
|
| "idea_ticket_attachment_add"
|
||||||
|
| "idea_ticket_attachment_mark_summarized"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
| "idea_ticket_unlink"
|
| "idea_ticket_unlink"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -71,6 +71,9 @@ impl AppState {
|
|||||||
bulk_delete: Arc::clone(&core.bulk_delete_issues),
|
bulk_delete: Arc::clone(&core.bulk_delete_issues),
|
||||||
read_carnet: Arc::clone(&core.read_issue_carnet),
|
read_carnet: Arc::clone(&core.read_issue_carnet),
|
||||||
update_carnet: Arc::clone(&core.update_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),
|
link: Arc::clone(&core.link_issues),
|
||||||
unlink: Arc::clone(&core.unlink_issues),
|
unlink: Arc::clone(&core.unlink_issues),
|
||||||
list_sprints: Arc::clone(&core.list_sprints),
|
list_sprints: Arc::clone(&core.list_sprints),
|
||||||
|
|||||||
@ -11,9 +11,10 @@ use serde_json::{json, Value};
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
|
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignTicketToSprintInput,
|
||||||
CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput,
|
CloseTicketAssistantInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput,
|
||||||
ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput, ReadIssueCarnetInput,
|
LinkIssuesInput, ListIssuesInput, ListSprintsInput, MarkIssueAttachmentSummarizedInput,
|
||||||
|
OpenProjectInput, OpenTicketAssistantInput, ReadIssueAttachmentInput, ReadIssueCarnetInput,
|
||||||
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
|
ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
|
||||||
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||||
};
|
};
|
||||||
@ -34,6 +35,9 @@ pub struct AppTicketToolProvider {
|
|||||||
pub bulk_delete: Arc<application::BulkDeleteIssues>,
|
pub bulk_delete: Arc<application::BulkDeleteIssues>,
|
||||||
pub read_carnet: Arc<application::ReadIssueCarnet>,
|
pub read_carnet: Arc<application::ReadIssueCarnet>,
|
||||||
pub update_carnet: Arc<application::UpdateIssueCarnet>,
|
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 link: Arc<application::LinkIssues>,
|
||||||
pub unlink: Arc<application::UnlinkIssues>,
|
pub unlink: Arc<application::UnlinkIssues>,
|
||||||
pub list_sprints: Arc<application::ListSprints>,
|
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?)
|
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" => {
|
"idea_ticket_link" => {
|
||||||
let issue = self
|
let issue = self
|
||||||
.link
|
.link
|
||||||
@ -348,6 +403,78 @@ pub async fn ticket_read(
|
|||||||
Ok(TicketDto::from_issue(issue, carnet))
|
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]
|
#[tauri::command]
|
||||||
pub async fn ticket_delete(
|
pub async fn ticket_delete(
|
||||||
request: TicketDeleteRequestDto,
|
request: TicketDeleteRequestDto,
|
||||||
@ -876,6 +1003,36 @@ fn mcp_bulk_delete_request(
|
|||||||
Ok(req)
|
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> {
|
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))
|
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.
|
//! The domain/code term is `Issue` throughout this layer.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::{AgentContextStore, Clock, EventBus, IdGenerator};
|
use domain::{AgentContextStore, Clock, EventBus, IdGenerator};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId,
|
AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueAttachmentContent,
|
||||||
IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator,
|
IssueAttachmentId, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueLinkKind,
|
||||||
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project,
|
IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
|
||||||
|
IssueVersion, LocalPath, MarkdownDoc, Project,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
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`].
|
/// Input for [`CreateIssue::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CreateIssueInput {
|
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`].
|
/// Input for [`LinkIssues::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LinkIssuesInput {
|
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(
|
fn publish_update_events(
|
||||||
events: &Arc<dyn EventBus>,
|
events: &Arc<dyn EventBus>,
|
||||||
issue: &Issue,
|
issue: &Issue,
|
||||||
|
|||||||
@ -96,14 +96,18 @@ pub use git::{
|
|||||||
};
|
};
|
||||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||||
pub use issues::{
|
pub use issues::{
|
||||||
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult,
|
AddIssueAttachment, AddIssueAttachmentInput, AddIssueAttachmentOutput, AssignIssueAgent,
|
||||||
BulkDeleteIssues, BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority,
|
AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult, BulkDeleteIssues,
|
||||||
|
BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriority,
|
||||||
BulkUpdateIssuePriorityInput, BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue,
|
BulkUpdateIssuePriorityInput, BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue,
|
||||||
CreateIssueInput, CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput,
|
CreateIssueInput, CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput,
|
||||||
LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput,
|
LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput,
|
||||||
ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput,
|
MarkIssueAttachmentSummarized, MarkIssueAttachmentSummarizedInput,
|
||||||
ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue, UpdateIssueCarnet,
|
MarkIssueAttachmentSummarizedOutput, ReadIssue, ReadIssueAttachment, ReadIssueAttachmentInput,
|
||||||
UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput, UpdateIssueOutput,
|
ReadIssueAttachmentOutput, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput,
|
||||||
|
ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue,
|
||||||
|
UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput,
|
||||||
|
UpdateIssueOutput, TICKET_ATTACHMENT_MAX_BYTES,
|
||||||
};
|
};
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||||
|
|||||||
@ -139,6 +139,8 @@ impl OpenTicketAssistant {
|
|||||||
"idea_ticket_update_priority".to_owned(),
|
"idea_ticket_update_priority".to_owned(),
|
||||||
"idea_ticket_read_carnet".to_owned(),
|
"idea_ticket_read_carnet".to_owned(),
|
||||||
"idea_ticket_update_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_link".to_owned(),
|
||||||
"idea_ticket_unlink".to_owned(),
|
"idea_ticket_unlink".to_owned(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -8,9 +8,10 @@ use domain::ports::{
|
|||||||
IssueStoreError, StoreError,
|
IssueStoreError, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry,
|
AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueAttachmentContent,
|
||||||
IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
|
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority,
|
||||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef,
|
IssueRef, IssueStatus, IssueVersion, LocalPath, ManifestEntry, MarkdownDoc, ProfileId, Project,
|
||||||
|
ProjectId, ProjectPath, RemoteRef,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -139,6 +140,42 @@ impl IssueStore for FakeIssues {
|
|||||||
self.update(_root, &updated, expected_version).await?;
|
self.update(_root, &updated, expected_version).await?;
|
||||||
self.read_carnet(_root, issue_ref).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>);
|
struct SeqAllocator(Mutex<u64>);
|
||||||
|
|||||||
@ -4,10 +4,11 @@ use std::sync::{Arc, Mutex};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError};
|
use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError};
|
||||||
use domain::{
|
use domain::{
|
||||||
DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter,
|
DomainEvent, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet,
|
||||||
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project,
|
IssueId, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
|
||||||
ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder,
|
IssueVersion, LocalPath, MarkdownDoc, Project, ProjectId, ProjectPath, RemoteRef, Sprint,
|
||||||
SprintStatus, SprintStore, SprintStoreError, SprintVersion,
|
SprintId, SprintIndexEntry, SprintOrder, SprintStatus, SprintStore, SprintStoreError,
|
||||||
|
SprintVersion,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -198,6 +199,42 @@ impl IssueStore for FakeIssues {
|
|||||||
) -> Result<IssueCarnet, IssueStoreError> {
|
) -> Result<IssueCarnet, IssueStoreError> {
|
||||||
unimplemented!("not needed")
|
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>);
|
struct SeqIds(Mutex<u128>);
|
||||||
|
|||||||
@ -17,11 +17,11 @@ use domain::profile::StructuredAdapter;
|
|||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentPermissionOverride, AgentProfile, AgentToolPolicy, AgentToolPolicyStore,
|
AgentId, AgentPermissionOverride, AgentProfile, AgentToolPolicy, AgentToolPolicyStore,
|
||||||
AssistantContextError, AssistantContextProvider, ContextInjection, DomainEvent, EventBus,
|
AssistantContextError, AssistantContextProvider, ContextInjection, DomainEvent, EventBus,
|
||||||
EventStream, Issue, IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber,
|
EventStream, Issue, IssueActor, IssueAttachmentContent, IssueAttachmentId, IssueCarnet,
|
||||||
IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc,
|
IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueStore,
|
||||||
NetworkPolicy, PermissionSet, PreparedContext, ProfileId, ProfileStore, Project, ProjectId,
|
IssueStoreError, IssueVersion, LocalPath, MarkdownDoc, NetworkPolicy, PermissionSet,
|
||||||
ProjectPath, ProjectPermissions, ProjectSystemPermissions, RemoteRef, SessionId,
|
PreparedContext, ProfileId, ProfileStore, Project, ProjectId, ProjectPath, ProjectPermissions,
|
||||||
SystemPermissionSet,
|
ProjectSystemPermissions, RemoteRef, SessionId, SystemPermissionSet,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -138,6 +138,42 @@ impl IssueStore for FakeIssues {
|
|||||||
) -> Result<IssueCarnet, IssueStoreError> {
|
) -> Result<IssueCarnet, IssueStoreError> {
|
||||||
unimplemented!()
|
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 {
|
struct FakeProfiles {
|
||||||
|
|||||||
@ -12,15 +12,15 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
||||||
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
||||||
BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask,
|
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
|
||||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed,
|
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||||
CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
|
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
||||||
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||||
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||||
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||||
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
||||||
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||||
@ -31,12 +31,13 @@ use application::{
|
|||||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||||
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
||||||
|
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||||
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||||
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||||
@ -997,6 +998,12 @@ pub struct BackendCore {
|
|||||||
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
||||||
/// Update a ticket carnet.
|
/// Update a ticket carnet.
|
||||||
pub update_issue_carnet: Arc<UpdateIssueCarnet>,
|
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.
|
/// Link two public tickets.
|
||||||
pub link_issues: Arc<LinkIssues>,
|
pub link_issues: Arc<LinkIssues>,
|
||||||
/// Unlink public tickets.
|
/// Unlink public tickets.
|
||||||
@ -1696,6 +1703,19 @@ impl BackendCore {
|
|||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
Arc::clone(&events_port),
|
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(
|
let link_issues = Arc::new(LinkIssues::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
@ -2843,6 +2863,9 @@ impl BackendCore {
|
|||||||
bulk_delete_issues,
|
bulk_delete_issues,
|
||||||
read_issue_carnet,
|
read_issue_carnet,
|
||||||
update_issue_carnet,
|
update_issue_carnet,
|
||||||
|
add_issue_attachment,
|
||||||
|
read_issue_attachment,
|
||||||
|
mark_issue_attachment_summarized,
|
||||||
link_issues,
|
link_issues,
|
||||||
unlink_issues,
|
unlink_issues,
|
||||||
assign_issue_agent,
|
assign_issue_agent,
|
||||||
|
|||||||
@ -349,6 +349,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
|||||||
| "idea_ticket_update_status"
|
| "idea_ticket_update_status"
|
||||||
| "idea_ticket_update_priority"
|
| "idea_ticket_update_priority"
|
||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
|
| "idea_ticket_attachment_add"
|
||||||
|
| "idea_ticket_attachment_mark_summarized"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
| "idea_ticket_unlink"
|
| "idea_ticket_unlink"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,9 +10,10 @@ use application::{
|
|||||||
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
|
BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent,
|
||||||
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
|
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
|
||||||
Project, SprintId, SprintStatus, SprintVersion,
|
IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus,
|
||||||
|
SprintVersion,
|
||||||
};
|
};
|
||||||
use infrastructure::TicketToolError;
|
use infrastructure::TicketToolError;
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ pub struct TicketDto {
|
|||||||
pub carnet: Option<String>,
|
pub carnet: Option<String>,
|
||||||
pub links: Vec<TicketLinkDto>,
|
pub links: Vec<TicketLinkDto>,
|
||||||
pub assigned_agent_ids: Vec<String>,
|
pub assigned_agent_ids: Vec<String>,
|
||||||
|
pub attachments: Vec<TicketAttachmentDto>,
|
||||||
pub created_by: TicketActorDto,
|
pub created_by: TicketActorDto,
|
||||||
pub updated_by: TicketActorDto,
|
pub updated_by: TicketActorDto,
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
@ -69,6 +71,31 @@ pub struct TicketListDto {
|
|||||||
pub next_cursor: Option<String>,
|
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.
|
/// Sprint context embedded in ticket read/list responses.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -186,6 +213,39 @@ pub struct TicketDeleteRequestDto {
|
|||||||
pub r#ref: String,
|
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.
|
/// List request.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -437,6 +497,11 @@ impl TicketDto {
|
|||||||
.filter(|r| r.role == AgentIssueRole::Assigned)
|
.filter(|r| r.role == AgentIssueRole::Assigned)
|
||||||
.map(|r| r.agent_id.to_string())
|
.map(|r| r.agent_id.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
attachments: issue
|
||||||
|
.attachments
|
||||||
|
.into_iter()
|
||||||
|
.map(TicketAttachmentDto::from)
|
||||||
|
.collect(),
|
||||||
created_by: TicketActorDto::from(issue.created_by),
|
created_by: TicketActorDto::from(issue.created_by),
|
||||||
updated_by: TicketActorDto::from(issue.updated_by),
|
updated_by: TicketActorDto::from(issue.updated_by),
|
||||||
created_at: issue.created_at,
|
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 {
|
impl TryFrom<TicketCreatorFilterDto> for IssueActor {
|
||||||
type Error = ErrorDto;
|
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 {
|
impl From<IssueCarnet> for TicketCarnetDto {
|
||||||
fn from(carnet: IssueCarnet) -> Self {
|
fn from(carnet: IssueCarnet) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@ -63,6 +63,8 @@ fn is_ticket_mutation_tool(tool: &str) -> bool {
|
|||||||
| "idea_ticket_bulk_update_priority"
|
| "idea_ticket_bulk_update_priority"
|
||||||
| "idea_ticket_bulk_delete"
|
| "idea_ticket_bulk_delete"
|
||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
|
| "idea_ticket_attachment_add"
|
||||||
|
| "idea_ticket_attachment_mark_summarized"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
| "idea_ticket_unlink"
|
| "idea_ticket_unlink"
|
||||||
| "idea_ticket_create"
|
| "idea_ticket_create"
|
||||||
|
|||||||
@ -11,6 +11,35 @@ use thiserror::Error;
|
|||||||
use crate::ids::{AgentId, IssueId, SprintId};
|
use crate::ids::{AgentId, IssueId, SprintId};
|
||||||
use crate::markdown::MarkdownDoc;
|
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.
|
/// Sequential per-project issue number.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
@ -239,6 +268,35 @@ pub enum IssueActor {
|
|||||||
System,
|
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.
|
/// Issue aggregate.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -263,6 +321,9 @@ pub struct Issue {
|
|||||||
pub links: Vec<IssueLink>,
|
pub links: Vec<IssueLink>,
|
||||||
/// Agent references.
|
/// Agent references.
|
||||||
pub agent_refs: Vec<AgentIssueRef>,
|
pub agent_refs: Vec<AgentIssueRef>,
|
||||||
|
/// Attached files.
|
||||||
|
#[serde(default)]
|
||||||
|
pub attachments: Vec<IssueAttachment>,
|
||||||
/// Creator.
|
/// Creator.
|
||||||
pub created_by: IssueActor,
|
pub created_by: IssueActor,
|
||||||
/// Last updater.
|
/// Last updater.
|
||||||
@ -305,6 +366,7 @@ impl Issue {
|
|||||||
carnet,
|
carnet,
|
||||||
links,
|
links,
|
||||||
agent_refs,
|
agent_refs,
|
||||||
|
attachments: Vec::new(),
|
||||||
created_by: actor.clone(),
|
created_by: actor.clone(),
|
||||||
updated_by: actor,
|
updated_by: actor,
|
||||||
created_at: now_ms,
|
created_at: now_ms,
|
||||||
@ -344,6 +406,34 @@ impl Issue {
|
|||||||
if self.links.iter().any(|link| link.target == own) {
|
if self.links.iter().any(|link| link.target == own) {
|
||||||
return Err(IssueError::SelfLink { reference: 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -469,4 +559,19 @@ pub enum IssueError {
|
|||||||
/// Self reference.
|
/// Self reference.
|
||||||
reference: IssueRef,
|
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::{
|
pub use issue::{
|
||||||
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry,
|
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentId,
|
||||||
IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
|
IssueCarnet, IssueError, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
|
||||||
IssueVersion,
|
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use sprint::{
|
pub use sprint::{
|
||||||
@ -237,15 +237,15 @@ pub use ports::{
|
|||||||
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
|
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
|
||||||
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
|
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
|
||||||
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||||
IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath,
|
IdGenerator, IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError,
|
||||||
McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
|
LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall,
|
||||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||||
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError,
|
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||||
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||||
PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError,
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle,
|
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
|
||||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
|
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
||||||
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
||||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||||
TemplateStore, WindowStateStore,
|
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -40,7 +40,8 @@ use crate::ids::{
|
|||||||
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
|
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
|
||||||
};
|
};
|
||||||
use crate::issue::{
|
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::markdown::MarkdownDoc;
|
||||||
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
||||||
@ -980,6 +981,15 @@ pub enum IssueStoreError {
|
|||||||
Store(String),
|
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.
|
/// Errors from the sprint store.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
pub enum SprintStoreError {
|
pub enum SprintStoreError {
|
||||||
@ -2360,6 +2370,40 @@ pub trait IssueStore: Send + Sync {
|
|||||||
now_ms: u64,
|
now_ms: u64,
|
||||||
expected_version: IssueVersion,
|
expected_version: IssueVersion,
|
||||||
) -> Result<IssueCarnet, IssueStoreError>;
|
) -> 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.
|
/// Persistence port for project-scoped sprints.
|
||||||
|
|||||||
@ -14,20 +14,23 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
|
AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId,
|
||||||
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
|
IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber,
|
||||||
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId,
|
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||||
|
IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const IDEAI_DIR: &str = ".ideai";
|
const IDEAI_DIR: &str = ".ideai";
|
||||||
const ISSUES_DIR: &str = "tickets";
|
const ISSUES_DIR: &str = "tickets";
|
||||||
const ISSUE_FILE: &str = "issue.md";
|
const ISSUE_FILE: &str = "issue.md";
|
||||||
const CARNET_FILE: &str = "carnet.md";
|
const CARNET_FILE: &str = "carnet.md";
|
||||||
|
const ATTACHMENTS_DIR: &str = "attachments";
|
||||||
const INDEX_FILE: &str = "index.json";
|
const INDEX_FILE: &str = "index.json";
|
||||||
const COUNTER_FILE: &str = "counter.json";
|
const COUNTER_FILE: &str = "counter.json";
|
||||||
const COUNTER_LOCK: &str = "counter.lock";
|
const COUNTER_LOCK: &str = "counter.lock";
|
||||||
const MUTATION_LOCK: &str = "mutation.lock";
|
const MUTATION_LOCK: &str = "mutation.lock";
|
||||||
const INDEX_VERSION: u32 = 1;
|
const INDEX_VERSION: u32 = 1;
|
||||||
|
const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
||||||
|
|
||||||
/// Filesystem-backed issue store.
|
/// Filesystem-backed issue store.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@ -84,6 +87,10 @@ fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
|
|||||||
issue_dir(root, number).join(CARNET_FILE)
|
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 {
|
fn index_path(root: &ProjectPath) -> PathBuf {
|
||||||
issue_root(root).join(INDEX_FILE)
|
issue_root(root).join(INDEX_FILE)
|
||||||
}
|
}
|
||||||
@ -154,6 +161,18 @@ fn io_error(err: std::io::Error) -> IssueStoreError {
|
|||||||
IssueStoreError::Store(err.to_string())
|
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> {
|
async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result<Issue, IssueStoreError> {
|
||||||
let number = issue_ref.number();
|
let number = issue_ref.number();
|
||||||
let issue_text = read_string(&issue_path(root, number)).await?;
|
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;
|
let _ = tokio::fs::remove_file(&lock_path).await;
|
||||||
result
|
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]
|
#[async_trait]
|
||||||
@ -509,6 +680,7 @@ priority: {}\n\
|
|||||||
sprint: {}\n\
|
sprint: {}\n\
|
||||||
links: {}\n\
|
links: {}\n\
|
||||||
agentRefs: {}\n\
|
agentRefs: {}\n\
|
||||||
|
attachments: {}\n\
|
||||||
createdBy: {}\n\
|
createdBy: {}\n\
|
||||||
updatedBy: {}\n\
|
updatedBy: {}\n\
|
||||||
createdAt: {}\n\
|
createdAt: {}\n\
|
||||||
@ -523,6 +695,7 @@ version: {}\n\
|
|||||||
json_value(&issue.sprint),
|
json_value(&issue.sprint),
|
||||||
json_value(&issue.links),
|
json_value(&issue.links),
|
||||||
json_value(&issue.agent_refs),
|
json_value(&issue.agent_refs),
|
||||||
|
json_value(&issue.attachments),
|
||||||
json_value(&issue.created_by),
|
json_value(&issue.created_by),
|
||||||
json_value(&issue.updated_by),
|
json_value(&issue.updated_by),
|
||||||
issue.created_at,
|
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 sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
|
||||||
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
|
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
|
||||||
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
|
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 created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
||||||
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
||||||
let created_at = parse_plain_u64(&map, "createdAt")?;
|
let created_at = parse_plain_u64(&map, "createdAt")?;
|
||||||
@ -580,6 +755,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
|||||||
carnet: MarkdownDoc::default(),
|
carnet: MarkdownDoc::default(),
|
||||||
links,
|
links,
|
||||||
agent_refs,
|
agent_refs,
|
||||||
|
attachments,
|
||||||
created_by,
|
created_by,
|
||||||
updated_by,
|
updated_by,
|
||||||
created_at,
|
created_at,
|
||||||
|
|||||||
@ -911,6 +911,8 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool {
|
|||||||
| "idea_ticket_update_status"
|
| "idea_ticket_update_status"
|
||||||
| "idea_ticket_update_priority"
|
| "idea_ticket_update_priority"
|
||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
|
| "idea_ticket_attachment_add"
|
||||||
|
| "idea_ticket_attachment_mark_summarized"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
| "idea_ticket_unlink"
|
| "idea_ticket_unlink"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -292,6 +292,48 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"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 {
|
ToolDef {
|
||||||
name: "idea_ticket_link",
|
name: "idea_ticket_link",
|
||||||
description: "Add a link from one ticket to another using optimistic concurrency.",
|
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_read",
|
||||||
"idea_ticket_list",
|
"idea_ticket_list",
|
||||||
"idea_ticket_read_carnet",
|
"idea_ticket_read_carnet",
|
||||||
|
"idea_ticket_attachment_read",
|
||||||
"idea_sprint_list",
|
"idea_sprint_list",
|
||||||
"idea_template_list",
|
"idea_template_list",
|
||||||
"idea_template_read",
|
"idea_template_read",
|
||||||
@ -78,6 +79,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_ticket_bulk_update_priority",
|
"idea_ticket_bulk_update_priority",
|
||||||
"idea_ticket_bulk_delete",
|
"idea_ticket_bulk_delete",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
|
"idea_ticket_attachment_add",
|
||||||
|
"idea_ticket_attachment_mark_summarized",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
"idea_template_create",
|
"idea_template_create",
|
||||||
|
|||||||
@ -2,9 +2,9 @@ use std::path::PathBuf;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueAttachmentId, IssueId,
|
||||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore,
|
||||||
MarkdownDoc, ProjectPath, SprintId,
|
IssueStoreError, LocalPath, MarkdownDoc, ProjectPath, SprintId,
|
||||||
};
|
};
|
||||||
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
|
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -402,6 +402,66 @@ async fn issue_store_persists_and_filters_sprint_membership() {
|
|||||||
assert_eq!(rows[0].sprint, Some(sprint_id));
|
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]
|
#[tokio::test]
|
||||||
async fn issue_store_delete_removes_files_and_index_entry() {
|
async fn issue_store_delete_removes_files_and_index_entry() {
|
||||||
let tmp = TempDir::new();
|
let tmp = TempDir::new();
|
||||||
|
|||||||
@ -34,20 +34,21 @@ use tokio::task::JoinHandle;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput,
|
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
|
||||||
AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput,
|
AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput,
|
||||||
CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
|
ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput,
|
||||||
DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput,
|
CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput,
|
||||||
DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
|
DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput,
|
||||||
GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput,
|
DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput,
|
||||||
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||||
InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput,
|
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||||
ListIssuesInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput,
|
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput,
|
||||||
ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision,
|
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions,
|
||||||
|
MarkIssueAttachmentSummarizedInput, McpRuntime, OpenProjectInput, PairAttemptDecision,
|
||||||
PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput,
|
PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput,
|
||||||
ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
|
ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput,
|
||||||
ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameSprintInput,
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
|
||||||
ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
||||||
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
||||||
@ -64,33 +65,36 @@ use domain::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use backend::dto::{
|
use backend::dto::{
|
||||||
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id,
|
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
|
||||||
parse_profile_id, parse_project_id, parse_ref_dto, parse_session_id, parse_skill_id,
|
parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, parse_ref_dto,
|
||||||
parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_template_id,
|
parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id,
|
||||||
sort_ticket_rows, sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto,
|
parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, version_dto,
|
||||||
AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
||||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
||||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto,
|
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||||
CreateSkillRequestDto, CreateTemplateRequestDto, DetectProfilesRequestDto,
|
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||||
DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
|
||||||
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
|
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
||||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
|
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||||
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
|
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
||||||
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
|
||||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
|
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
|
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
|
||||||
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
|
||||||
|
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
||||||
|
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
||||||
|
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
@ -2478,6 +2482,11 @@ async fn invoke(
|
|||||||
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
|
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
|
||||||
"ticket_list" => invoke_ticket_list(&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_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_delete" => invoke_ticket_delete(&request.args, &state.app).await,
|
||||||
"ticket_read_carnet" => invoke_ticket_read_carnet(&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,
|
"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)
|
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> {
|
async fn invoke_ticket_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
let request = required_request::<TicketDeleteRequestDto>("ticket_delete", args)?;
|
let request = required_request::<TicketDeleteRequestDto>("ticket_delete", args)?;
|
||||||
let project = resolve_project_readonly(&request.project_id, state).await?;
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import type {
|
|||||||
Sprint,
|
Sprint,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketAttachmentContent,
|
||||||
TicketBulkResult,
|
TicketBulkResult,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketChat,
|
TicketChat,
|
||||||
@ -122,6 +123,11 @@ export class HttpSystemGateway implements SystemGateway {
|
|||||||
return unsupportedOnWeb("Native file picker");
|
return unsupportedOnWeb("Native file picker");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pickFile(): Promise<string | null> {
|
||||||
|
// Desktop-only, same rationale as `pickFolder`.
|
||||||
|
return unsupportedOnWeb("Native file picker");
|
||||||
|
}
|
||||||
|
|
||||||
onAppExitWorkGuard(
|
onAppExitWorkGuard(
|
||||||
_handler: (state: AppExitWorkGuardState) => void,
|
_handler: (state: AppExitWorkGuardState) => void,
|
||||||
): Promise<Unsubscribe> {
|
): Promise<Unsubscribe> {
|
||||||
@ -321,6 +327,36 @@ export class HttpTicketGateway implements TicketGateway {
|
|||||||
request: { projectId, ref, carnet, expectedVersion },
|
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(
|
link(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
ref: string,
|
ref: string,
|
||||||
|
|||||||
@ -66,6 +66,7 @@ import type {
|
|||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketAttachmentContent,
|
||||||
TicketBulkResult,
|
TicketBulkResult,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketChat,
|
TicketChat,
|
||||||
@ -179,6 +180,22 @@ function sameTicketCreator(
|
|||||||
return true;
|
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 {
|
export class MockSystemGateway implements SystemGateway {
|
||||||
private listeners = new Set<(e: DomainEvent) => void>();
|
private listeners = new Set<(e: DomainEvent) => void>();
|
||||||
|
|
||||||
@ -219,6 +236,11 @@ export class MockSystemGateway implements SystemGateway {
|
|||||||
return "/home/user/mock-plugin.ideaplug";
|
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>();
|
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
||||||
/** Count of `confirmAppExit()` calls, for test assertions. */
|
/** Count of `confirmAppExit()` calls, for test assertions. */
|
||||||
confirmAppExitCallCount = 0;
|
confirmAppExitCallCount = 0;
|
||||||
@ -2710,6 +2732,7 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
|
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
|
||||||
private chatSessions = new Map<string, string>();
|
private chatSessions = new Map<string, string>();
|
||||||
private chatCounter = 0;
|
private chatCounter = 0;
|
||||||
|
private attachmentCounter = 0;
|
||||||
|
|
||||||
constructor(private readonly system?: MockSystemGateway) {}
|
constructor(private readonly system?: MockSystemGateway) {}
|
||||||
|
|
||||||
@ -2717,6 +2740,7 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
_seedTicket(projectId: string, ticket: Ticket): Ticket {
|
_seedTicket(projectId: string, ticket: Ticket): Ticket {
|
||||||
const stored = structuredClone(ticket);
|
const stored = structuredClone(ticket);
|
||||||
if (stored.sprintId === undefined) stored.sprintId = null;
|
if (stored.sprintId === undefined) stored.sprintId = null;
|
||||||
|
stored.attachments ??= [];
|
||||||
this.projectTickets(projectId).set(stored.ref, stored);
|
this.projectTickets(projectId).set(stored.ref, stored);
|
||||||
this.counters.set(
|
this.counters.set(
|
||||||
projectId,
|
projectId,
|
||||||
@ -2810,6 +2834,7 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
sprintId: null,
|
sprintId: null,
|
||||||
links: [],
|
links: [],
|
||||||
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
|
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
|
||||||
|
attachments: [],
|
||||||
createdBy: { kind: "user" },
|
createdBy: { kind: "user" },
|
||||||
updatedBy: { kind: "user" },
|
updatedBy: { kind: "user" },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@ -3021,6 +3046,83 @@ export class MockTicketGateway implements TicketGateway {
|
|||||||
return structuredClone(ticket);
|
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(
|
async link(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
ref: string,
|
ref: string,
|
||||||
|
|||||||
@ -50,6 +50,11 @@ export class TauriSystemGateway implements SystemGateway {
|
|||||||
return typeof result === "string" ? result : null;
|
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(
|
async onAppExitWorkGuard(
|
||||||
handler: (state: AppExitWorkGuardState) => void,
|
handler: (state: AppExitWorkGuardState) => void,
|
||||||
): Promise<Unsubscribe> {
|
): 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,
|
ReplyChunk,
|
||||||
Sprint,
|
Sprint,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketAttachmentContent,
|
||||||
TicketBulkResult,
|
TicketBulkResult,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketChat,
|
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(
|
async link(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
ref: string,
|
ref: string,
|
||||||
|
|||||||
@ -1473,6 +1473,25 @@ export type TicketActor =
|
|||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "system" };
|
| { 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`). */
|
/** One directed link from a ticket to another (mirror of `TicketLinkDto`). */
|
||||||
export interface TicketLink {
|
export interface TicketLink {
|
||||||
targetRef: TicketRef;
|
targetRef: TicketRef;
|
||||||
@ -1510,6 +1529,7 @@ export interface Ticket {
|
|||||||
carnet?: string;
|
carnet?: string;
|
||||||
links: TicketLink[];
|
links: TicketLink[];
|
||||||
assignedAgentIds: string[];
|
assignedAgentIds: string[];
|
||||||
|
attachments: TicketAttachment[];
|
||||||
createdBy: TicketActor;
|
createdBy: TicketActor;
|
||||||
updatedBy: TicketActor;
|
updatedBy: TicketActor;
|
||||||
createdAt: number;
|
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({
|
export function TicketDetail({
|
||||||
projectId,
|
projectId,
|
||||||
ticketRef,
|
ticketRef,
|
||||||
@ -389,6 +401,72 @@ export function TicketDetail({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</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) ── */}
|
{/* ── Links (F5) ── */}
|
||||||
<Section title="Linked tickets">
|
<Section title="Linked tickets">
|
||||||
{t.links.length === 0 ? (
|
{t.links.length === 0 ? (
|
||||||
|
|||||||
@ -172,6 +172,46 @@ describe("MockTicketGateway", () => {
|
|||||||
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
).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 () => {
|
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
|
||||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
|
||||||
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
|
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 () => {
|
it("assigns only known project agents", async () => {
|
||||||
const known = await seedAgent(agent, "Backend");
|
const known = await seedAgent(agent, "Backend");
|
||||||
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
|
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export interface TicketDetailViewModel {
|
|||||||
priority?: Ticket["priority"];
|
priority?: Ticket["priority"];
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
saveCarnet: (carnet: string) => Promise<boolean>;
|
saveCarnet: (carnet: string) => Promise<boolean>;
|
||||||
|
attachFile: () => Promise<boolean>;
|
||||||
|
markAttachmentSummarized: (attachmentId: string) => Promise<boolean>;
|
||||||
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||||
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||||
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
||||||
@ -193,6 +195,26 @@ export function useTicketDetail(
|
|||||||
[run, gateway, projectId, ref],
|
[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(
|
const link: TicketDetailViewModel["link"] = useCallback(
|
||||||
(targetRef, kind) =>
|
(targetRef, kind) =>
|
||||||
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
|
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
|
||||||
@ -243,6 +265,8 @@ export function useTicketDetail(
|
|||||||
refresh,
|
refresh,
|
||||||
updateFields,
|
updateFields,
|
||||||
saveCarnet,
|
saveCarnet,
|
||||||
|
attachFile,
|
||||||
|
markAttachmentSummarized,
|
||||||
link,
|
link,
|
||||||
unlink,
|
unlink,
|
||||||
assign,
|
assign,
|
||||||
|
|||||||
@ -28,6 +28,7 @@ function ticket(over: Partial<Ticket> = {}): Ticket {
|
|||||||
sprintId: null,
|
sprintId: null,
|
||||||
links: [],
|
links: [],
|
||||||
assignedAgentIds: [],
|
assignedAgentIds: [],
|
||||||
|
attachments: [],
|
||||||
createdBy: { kind: "user" },
|
createdBy: { kind: "user" },
|
||||||
updatedBy: { kind: "user" },
|
updatedBy: { kind: "user" },
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
|
|||||||
@ -68,6 +68,7 @@ import type {
|
|||||||
TerminalSession,
|
TerminalSession,
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketBulkResult,
|
TicketBulkResult,
|
||||||
|
TicketAttachmentContent,
|
||||||
TicketChat,
|
TicketChat,
|
||||||
TicketCarnet,
|
TicketCarnet,
|
||||||
TicketLinkKind,
|
TicketLinkKind,
|
||||||
@ -96,6 +97,12 @@ export interface SystemGateway {
|
|||||||
* cancelled. Same sanctioned-picker rule as {@link pickFolder}.
|
* cancelled. Same sanctioned-picker rule as {@link pickFolder}.
|
||||||
*/
|
*/
|
||||||
pickArchiveFile(): Promise<string | null>;
|
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
|
* Subscribes to the app-exit work-in-progress guard (ticket #83): fired when
|
||||||
* closing the main window is intercepted because it would interrupt active
|
* closing the main window is intercepted because it would interrupt active
|
||||||
@ -1096,6 +1103,27 @@ export interface TicketGateway {
|
|||||||
carnet: string,
|
carnet: string,
|
||||||
expectedVersion: number,
|
expectedVersion: number,
|
||||||
): Promise<Ticket>;
|
): 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. */
|
/** Links this ticket to another `#id` with the given relationship kind. */
|
||||||
link(
|
link(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user