diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 2b4a230..0013ecd 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -278,6 +278,8 @@ pub fn run() { tickets::ticket_create, tickets::ticket_read, tickets::ticket_delete, + tickets::ticket_bulk_update_status, + tickets::ticket_bulk_update_priority, tickets::open_ticket_chat, tickets::close_ticket_chat, tickets::ticket_list, diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index 761db73..da8545e 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -284,7 +284,31 @@ fn enforce_policy( "tool `{name}` is not permitted for requester {requester}" ))); } - if is_ticket_policy_mutation_tool(name) { + if is_ticket_policy_bulk_mutation_tool(name) { + let refs = arguments + .get("refs") + .and_then(Value::as_array) + .ok_or_else(|| { + ToolInvocationError::InvalidArguments(format!( + "tool `{name}` requires ticket refs under the active policy" + )) + })?; + for raw_ref in refs { + let raw_ref = raw_ref.as_str().ok_or_else(|| { + ToolInvocationError::InvalidArguments(format!( + "tool `{name}` requires string ticket refs under the active policy" + )) + })?; + let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| { + ToolInvocationError::InvalidArguments(format!("invalid ticket ref: {e}")) + })?; + if !policy.permits_ticket_mutation(name, issue_ref) { + return Err(ToolInvocationError::Rejected(format!( + "tool `{name}` is not permitted for ticket {issue_ref}" + ))); + } + } + } else if is_ticket_policy_mutation_tool(name) { let raw_ref = arguments .get("ref") .and_then(Value::as_str) @@ -317,6 +341,13 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { ) } +fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool { + matches!( + name, + "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" + ) +} + #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index fbc088f..7bb6880 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -66,6 +66,8 @@ impl AppState { read: Arc::clone(&core.read_issue), list: Arc::clone(&core.list_issues), update: Arc::clone(&core.update_issue), + bulk_update_status: Arc::clone(&core.bulk_update_issue_status), + bulk_update_priority: Arc::clone(&core.bulk_update_issue_priority), read_carnet: Arc::clone(&core.read_issue_carnet), update_carnet: Arc::clone(&core.update_issue_carnet), link: Arc::clone(&core.link_issues), diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index 57292d2..11a9d6d 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -29,6 +29,8 @@ pub struct AppTicketToolProvider { pub read: Arc, pub list: Arc, pub update: Arc, + pub bulk_update_status: Arc, + pub bulk_update_priority: Arc, pub read_carnet: Arc, pub update_carnet: Arc, pub link: Arc, @@ -180,6 +182,28 @@ impl TicketToolProvider for AppTicketToolProvider { .issue; json!(TicketDto::from_issue(issue, None)) } + "idea_ticket_bulk_update_status" => { + let req = mcp_bulk_update_status_request(project, arguments)?; + let out = self + .bulk_update_status + .execute( + bulk_update_status_input(project.clone(), req, actor) + .map_err(dto_tool_error)?, + ) + .await; + json!(TicketBulkResultDto::from(out)) + } + "idea_ticket_bulk_update_priority" => { + let req = mcp_bulk_update_priority_request(project, arguments)?; + let out = self + .bulk_update_priority + .execute( + bulk_update_priority_input(project.clone(), req, actor) + .map_err(dto_tool_error)?, + ) + .await; + json!(TicketBulkResultDto::from(out)) + } "idea_ticket_read_carnet" => { let carnet = self .read_carnet @@ -332,6 +356,40 @@ pub async fn ticket_delete( .map_err(ErrorDto::from) } +#[tauri::command] +pub async fn ticket_bulk_update_status( + request: TicketBulkUpdateStatusRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let out = state + .bulk_update_issue_status + .execute(bulk_update_status_input( + project, + request, + IssueActor::User, + )?) + .await; + Ok(TicketBulkResultDto::from(out)) +} + +#[tauri::command] +pub async fn ticket_bulk_update_priority( + request: TicketBulkUpdatePriorityRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&state, &request.project_id).await?; + let out = state + .bulk_update_issue_priority + .execute(bulk_update_priority_input( + project, + request, + IssueActor::User, + )?) + .await; + Ok(TicketBulkResultDto::from(out)) +} + #[tauri::command] pub async fn open_ticket_chat( request: OpenTicketChatRequestDto, @@ -766,6 +824,26 @@ fn mcp_list_request( TicketListPageInput::from_request(req).map_err(|e| TicketToolError::new("invalid", e.message)) } +fn mcp_bulk_update_status_request( + project: &Project, + arguments: Value, +) -> Result { + let mut req: TicketBulkUpdateStatusRequestDto = 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_bulk_update_priority_request( + project: &Project, + arguments: Value, +) -> Result { + let mut req: TicketBulkUpdatePriorityRequestDto = 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 { required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error)) } diff --git a/crates/application/src/issues/mod.rs b/crates/application/src/issues/mod.rs index 06cae97..988a1e3 100644 --- a/crates/application/src/issues/mod.rs +++ b/crates/application/src/issues/mod.rs @@ -328,6 +328,35 @@ pub struct UpdateIssueOutput { pub issue: Issue, } +/// Per-issue result for batch issue mutations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchIssueResult { + /// Requested issue reference. + pub issue_ref: IssueRef, + /// Updated issue for successful update operations. + pub issue: Option, + /// Error for failed items. + pub error: Option, +} + +impl BatchIssueResult { + fn ok(issue: Issue) -> Self { + Self { + issue_ref: issue.reference(), + issue: Some(issue), + error: None, + } + } + + fn err(issue_ref: IssueRef, error: AppError) -> Self { + Self { + issue_ref, + issue: None, + error: Some(error), + } + } +} + /// Updates issue fields with optimistic concurrency. pub struct UpdateIssue { issues: Arc, @@ -402,6 +431,220 @@ impl UpdateIssue { } } +/// Input for [`BulkUpdateIssueStatus::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BulkUpdateIssueStatusInput { + /// Project owning the issues. + pub project: Project, + /// Issue references in requested order. + pub issue_refs: Vec, + /// Replacement status. + pub status: IssueStatus, + /// Actor updating the issues. + pub actor: IssueActor, +} + +/// Output for batch issue mutations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BulkIssueMutationOutput { + /// Per-reference results in input order. + pub items: Vec, +} + +/// Updates several issue statuses, preserving input order and allowing partial success. +pub struct BulkUpdateIssueStatus { + issues: Arc, + clock: Arc, + events: Arc, +} + +impl BulkUpdateIssueStatus { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + clock, + events, + } + } + + /// Executes the batch update. + pub async fn execute(&self, input: BulkUpdateIssueStatusInput) -> BulkIssueMutationOutput { + let mut items = Vec::with_capacity(input.issue_refs.len()); + for issue_ref in input.issue_refs { + items.push( + update_issue_batch_item( + &self.issues, + &self.clock, + &self.events, + &input.project, + issue_ref, + input.actor.clone(), + |issue| issue.status = input.status, + ) + .await, + ); + } + BulkIssueMutationOutput { items } + } +} + +/// Input for [`BulkUpdateIssuePriority::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BulkUpdateIssuePriorityInput { + /// Project owning the issues. + pub project: Project, + /// Issue references in requested order. + pub issue_refs: Vec, + /// Replacement priority. + pub priority: IssuePriority, + /// Actor updating the issues. + pub actor: IssueActor, +} + +/// Updates several issue priorities, preserving input order and allowing partial success. +pub struct BulkUpdateIssuePriority { + issues: Arc, + clock: Arc, + events: Arc, +} + +impl BulkUpdateIssuePriority { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + issues, + clock, + events, + } + } + + /// Executes the batch update. + pub async fn execute(&self, input: BulkUpdateIssuePriorityInput) -> BulkIssueMutationOutput { + let mut items = Vec::with_capacity(input.issue_refs.len()); + for issue_ref in input.issue_refs { + items.push( + update_issue_batch_item( + &self.issues, + &self.clock, + &self.events, + &input.project, + issue_ref, + input.actor.clone(), + |issue| issue.priority = input.priority, + ) + .await, + ); + } + BulkIssueMutationOutput { items } + } +} + +/// Input for [`BulkDeleteIssues::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BulkDeleteIssuesInput { + /// Project owning the issues. + pub project: Project, + /// Issue references in requested order. + pub issue_refs: Vec, +} + +/// Deletes several issues, preserving input order and allowing partial success. +pub struct BulkDeleteIssues { + issues: Arc, + events: Arc, +} + +impl BulkDeleteIssues { + /// Builds the use case. + #[must_use] + pub fn new(issues: Arc, events: Arc) -> Self { + Self { issues, events } + } + + /// Executes the batch delete. + pub async fn execute(&self, input: BulkDeleteIssuesInput) -> BulkIssueMutationOutput { + let mut items = Vec::with_capacity(input.issue_refs.len()); + for issue_ref in input.issue_refs { + let item = match self + .issues + .get_by_ref(&input.project.root, issue_ref) + .await + .map_err(AppError::from) + { + Ok(issue) => { + let freed_sprint = issue.sprint; + match self.issues.delete(&input.project.root, issue_ref).await { + Ok(()) => { + self.events.publish(DomainEvent::IssueDeleted { + project_id: input.project.id, + issue_ref, + freed_sprint, + }); + BatchIssueResult::deleted(issue_ref) + } + Err(err) => BatchIssueResult::err(issue_ref, AppError::from(err)), + } + } + Err(err) => BatchIssueResult::err(issue_ref, err), + }; + items.push(item); + } + BulkIssueMutationOutput { items } + } +} + +async fn update_issue_batch_item( + issues: &Arc, + clock: &Arc, + events: &Arc, + project: &Project, + issue_ref: IssueRef, + actor: IssueActor, + mutate: impl FnOnce(&mut Issue), +) -> BatchIssueResult { + let current = match issues + .get_by_ref(&project.root, issue_ref) + .await + .map_err(AppError::from) + { + Ok(issue) => issue, + Err(err) => return BatchIssueResult::err(issue_ref, err), + }; + let old_status = current.status; + let old_priority = current.priority; + let old_assigned = assigned_set(¤t); + let expected_version = current.version; + let updated = match current + .mutate(actor, now(clock), mutate) + .map_err(|err| AppError::Invalid(err.to_string())) + { + Ok(issue) => issue, + Err(err) => return BatchIssueResult::err(issue_ref, err), + }; + match issues + .update(&project.root, &updated, expected_version) + .await + .map_err(AppError::from) + { + Ok(()) => { + publish_update_events(events, &updated, old_status, old_priority, old_assigned); + BatchIssueResult::ok(updated) + } + Err(err) => BatchIssueResult::err(issue_ref, err), + } +} + /// Input for [`UpdateIssueCarnet::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateIssueCarnetInput { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 426b1b6..fd35595 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -96,7 +96,9 @@ pub use git::{ }; pub use health::{HealthInput, HealthReport, HealthUseCase}; pub use issues::{ - AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput, + AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult, + BulkIssueMutationOutput, BulkUpdateIssuePriority, BulkUpdateIssuePriorityInput, + BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue, CreateIssueInput, CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput, ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput, ReadIssueOutput, diff --git a/crates/application/tests/issue_usecases.rs b/crates/application/tests/issue_usecases.rs index 8ff8bd4..2ecff5f 100644 --- a/crates/application/tests/issue_usecases.rs +++ b/crates/application/tests/issue_usecases.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::str::FromStr; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -14,7 +15,9 @@ use domain::{ use uuid::Uuid; use application::{ - CreateIssue, CreateIssueInput, DeleteIssue, DeleteIssueInput, UpdateIssue, UpdateIssueInput, + BulkDeleteIssues, BulkDeleteIssuesInput, BulkUpdateIssuePriority, BulkUpdateIssuePriorityInput, + BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue, CreateIssueInput, DeleteIssue, + DeleteIssueInput, UpdateIssue, UpdateIssueInput, }; #[derive(Default)] @@ -504,3 +507,163 @@ async fn delete_issue_emits_deleted_event_without_freed_sprint() { }] if *issue_ref == initial.reference() )); } + +#[tokio::test] +async fn bulk_update_status_preserves_order_and_allows_partial_success() { + let project = project(); + let issues = Arc::new(FakeIssues::default()); + for number in [1, 3] { + let issue = Issue::new( + domain::IssueId::new_random(), + IssueNumber::new(number).unwrap(), + format!("Ticket {number}"), + MarkdownDoc::new("Body"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap(); + issues.create(&project.root, &issue).await.unwrap(); + } + let bus = Arc::new(SpyBus::default()); + let uc = BulkUpdateIssueStatus::new( + issues.clone(), + Arc::new(FixedClock), + bus.clone(), + ); + + let out = uc + .execute(BulkUpdateIssueStatusInput { + project: project.clone(), + issue_refs: vec![ + IssueRef::from_str("#1").unwrap(), + IssueRef::from_str("#2").unwrap(), + IssueRef::from_str("#3").unwrap(), + ], + status: IssueStatus::Qa, + actor: IssueActor::System, + }) + .await; + + let refs: Vec = out + .items + .iter() + .map(|item| item.issue_ref.to_string()) + .collect(); + assert_eq!(refs, ["#1", "#2", "#3"]); + assert_eq!(out.items[0].issue.as_ref().unwrap().status, IssueStatus::Qa); + assert_eq!(out.items[1].error.as_ref().unwrap().code(), "NOT_FOUND"); + assert_eq!(out.items[2].issue.as_ref().unwrap().status, IssueStatus::Qa); + assert_eq!( + bus.events() + .iter() + .filter(|event| matches!(event, DomainEvent::IssueStatusChanged { .. })) + .count(), + 2 + ); +} + +#[tokio::test] +async fn bulk_update_priority_updates_each_existing_ticket() { + let project = project(); + let issues = Arc::new(FakeIssues::default()); + let initial = Issue::new( + domain::IssueId::new_random(), + IssueNumber::new(1).unwrap(), + "Initial", + MarkdownDoc::new("Body"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap(); + issues.create(&project.root, &initial).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + let uc = BulkUpdateIssuePriority::new( + issues.clone(), + Arc::new(FixedClock), + bus.clone(), + ); + + let out = uc + .execute(BulkUpdateIssuePriorityInput { + project, + issue_refs: vec![initial.reference()], + priority: IssuePriority::Critical, + actor: IssueActor::System, + }) + .await; + + assert_eq!(out.items.len(), 1); + assert!(out.items[0].error.is_none()); + assert_eq!( + out.items[0].issue.as_ref().unwrap().priority, + IssuePriority::Critical + ); + assert!(bus.events().iter().any(|event| matches!( + event, + DomainEvent::IssuePriorityChanged { + priority: IssuePriority::Critical, + .. + } + ))); +} + +#[tokio::test] +async fn bulk_delete_preserves_order_and_allows_partial_success() { + let project = project(); + let issues = Arc::new(FakeIssues::default()); + let initial = Issue::new( + domain::IssueId::new_random(), + IssueNumber::new(1).unwrap(), + "Initial", + MarkdownDoc::new("Body"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1, + ) + .unwrap(); + issues.create(&project.root, &initial).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + let uc = BulkDeleteIssues::new(issues.clone(), bus.clone()); + + let out = uc + .execute(BulkDeleteIssuesInput { + project: project.clone(), + issue_refs: vec![IssueRef::from_str("#2").unwrap(), initial.reference()], + }) + .await; + + let refs: Vec = out + .items + .iter() + .map(|item| item.issue_ref.to_string()) + .collect(); + assert_eq!(refs, ["#2", "#1"]); + assert_eq!(out.items[0].error.as_ref().unwrap().code(), "NOT_FOUND"); + assert!(out.items[1].error.is_none()); + assert!(matches!( + issues.get_by_ref(&project.root, initial.reference()).await, + Err(IssueStoreError::NotFound) + )); + assert!(matches!( + bus.events().as_slice(), + [DomainEvent::IssueDeleted { + issue_ref, + freed_sprint: None, + .. + }] if *issue_ref == initial.reference() + )); +} diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs index 38fb359..6386388 100644 --- a/crates/application/tests/model_server.rs +++ b/crates/application/tests/model_server.rs @@ -9,7 +9,8 @@ use async_trait::async_trait; use application::{ DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind, - LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy, TerminalSessions, + LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy, + TerminalSessions, }; use domain::events::DomainEvent; use domain::layout::Workspace; diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index ccc27eb..645e353 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -14,12 +14,13 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, - CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, - CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, - CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, - CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, - CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, - DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, + BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile, + CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, + CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, + ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, + CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, + CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, + DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, @@ -986,6 +987,10 @@ pub struct BackendCore { pub list_issues: Arc, /// Update an issue-backed public ticket. pub update_issue: Arc, + /// Update public ticket statuses in batch. + pub bulk_update_issue_status: Arc, + /// Update public ticket priorities in batch. + pub bulk_update_issue_priority: Arc, /// Read a ticket carnet. pub read_issue_carnet: Arc, /// Update a ticket carnet. @@ -1669,6 +1674,16 @@ impl BackendCore { Arc::clone(&clock) as Arc, Arc::clone(&events_port), )); + let bulk_update_issue_status = Arc::new(BulkUpdateIssueStatus::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let bulk_update_issue_priority = Arc::new(BulkUpdateIssuePriority::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port))); let update_issue_carnet = Arc::new(UpdateIssueCarnet::new( Arc::clone(&issue_store_port), @@ -2817,6 +2832,8 @@ impl BackendCore { delete_issue, list_issues, update_issue, + bulk_update_issue_status, + bulk_update_issue_priority, read_issue_carnet, update_issue_carnet, link_issues, @@ -4948,6 +4965,8 @@ mod mcp_serve_peer_tests { "idea_ticket_update", "idea_ticket_update_status", "idea_ticket_update_priority", + "idea_ticket_bulk_update_status", + "idea_ticket_bulk_update_priority", "idea_ticket_read_carnet", "idea_ticket_update_carnet", "idea_ticket_link", @@ -4968,8 +4987,8 @@ mod mcp_serve_peer_tests { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 31, - "exactly the thirty-one exposed idea_* tools; got {names:?}" + 33, + "exactly the thirty-three exposed idea_* tools; got {names:?}" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index 544233b..1a40edc 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -297,7 +297,31 @@ fn enforce_policy( "tool `{name}` is not permitted for requester {requester}" ))); } - if is_ticket_policy_mutation_tool(name) { + if is_ticket_policy_bulk_mutation_tool(name) { + let refs = arguments + .get("refs") + .and_then(Value::as_array) + .ok_or_else(|| { + ToolInvocationError::InvalidArguments(format!( + "tool `{name}` requires ticket refs under the active policy" + )) + })?; + for raw_ref in refs { + let raw_ref = raw_ref.as_str().ok_or_else(|| { + ToolInvocationError::InvalidArguments(format!( + "tool `{name}` requires string ticket refs under the active policy" + )) + })?; + let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| { + ToolInvocationError::InvalidArguments(format!("invalid ticket ref: {e}")) + })?; + if !policy.permits_ticket_mutation(name, issue_ref) { + return Err(ToolInvocationError::Rejected(format!( + "tool `{name}` is not permitted for ticket {issue_ref}" + ))); + } + } + } else if is_ticket_policy_mutation_tool(name) { let raw_ref = arguments .get("ref") .and_then(Value::as_str) @@ -330,6 +354,13 @@ fn is_ticket_policy_mutation_tool(name: &str) -> bool { ) } +fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool { + matches!( + name, + "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" + ) +} + #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/crates/backend/src/ticket_dto.rs b/crates/backend/src/ticket_dto.rs index 161773d..2ca6fe4 100644 --- a/crates/backend/src/ticket_dto.rs +++ b/crates/backend/src/ticket_dto.rs @@ -5,7 +5,10 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use application::{CreateIssueInput, UpdateIssueInput}; +use application::{ + BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatusInput, + CreateIssueInput, UpdateIssueInput, +}; use domain::{ AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, @@ -53,6 +56,7 @@ pub struct TicketSummaryDto { #[serde(skip_serializing_if = "Option::is_none")] pub sprint: Option, pub assigned_agent_ids: Vec, + pub created_by: TicketActorDto, pub updated_at: u64, } @@ -132,6 +136,14 @@ pub enum TicketActorDto { System, } +/// Public creator filter DTO. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TicketCreatorFilterDto { + User, + Agent { agent_id: String }, +} + /// Carnet read DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -185,6 +197,7 @@ pub struct TicketListRequestDto { #[serde(default)] pub priorities: Vec, pub assigned_agent_id: Option, + pub created_by: Option, pub sprint_id: Option, pub text: Option, pub sort: Option, @@ -192,6 +205,45 @@ pub struct TicketListRequestDto { pub cursor: Option, } +/// Bulk status update request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketBulkUpdateStatusRequestDto { + #[serde(default)] + pub project_id: String, + pub refs: Vec, + pub status: String, +} + +/// Bulk priority update request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketBulkUpdatePriorityRequestDto { + #[serde(default)] + pub project_id: String, + pub refs: Vec, + pub priority: String, +} + +/// Bulk operation response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketBulkResultDto { + pub items: Vec, +} + +/// Per-ticket bulk operation result. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketBulkResultItemDto { + pub r#ref: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub ticket: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// List sort request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -413,6 +465,7 @@ impl From for TicketSummaryDto { .into_iter() .map(|id| id.to_string()) .collect(), + created_by: TicketActorDto::from(row.created_by), updated_at: row.updated_at, } } @@ -485,6 +538,19 @@ impl From for TicketActorDto { } } +impl TryFrom for IssueActor { + type Error = ErrorDto; + + fn try_from(actor: TicketCreatorFilterDto) -> Result { + match actor { + TicketCreatorFilterDto::User => Ok(Self::User), + TicketCreatorFilterDto::Agent { agent_id } => Ok(Self::Agent { + agent_id: parse_agent_id_dto(&agent_id)?, + }), + } + } +} + impl From for TicketCarnetDto { fn from(carnet: IssueCarnet) -> Self { Self { @@ -514,6 +580,7 @@ impl TicketListPageInput { .as_deref() .map(parse_agent_id_dto) .transpose()?, + created_by: request.created_by.map(IssueActor::try_from).transpose()?, sprint: request .sprint_id .as_deref() @@ -664,6 +731,49 @@ pub fn update_input( }) } +pub fn bulk_update_status_input( + project: Project, + request: TicketBulkUpdateStatusRequestDto, + actor: IssueActor, +) -> Result { + Ok(BulkUpdateIssueStatusInput { + project, + issue_refs: parse_bulk_refs(request.refs)?, + status: parse_status_dto(&request.status)?, + actor, + }) +} + +pub fn bulk_update_priority_input( + project: Project, + request: TicketBulkUpdatePriorityRequestDto, + actor: IssueActor, +) -> Result { + Ok(BulkUpdateIssuePriorityInput { + project, + issue_refs: parse_bulk_refs(request.refs)?, + priority: parse_priority_dto(&request.priority)?, + actor, + }) +} + +impl From for TicketBulkResultDto { + fn from(output: BulkIssueMutationOutput) -> Self { + Self { + items: output + .items + .into_iter() + .map(|item| TicketBulkResultItemDto { + r#ref: item.issue_ref.to_string(), + ok: item.error.is_none(), + ticket: item.issue.map(|issue| TicketDto::from_issue(issue, None)), + error: item.error.map(ErrorDto::from), + }) + .collect(), + } + } +} + fn parse_link_request(link: TicketLinkRequestDto) -> Result { Ok(IssueLink { target: parse_ref_dto(&link.target_ref)?, @@ -671,6 +781,24 @@ fn parse_link_request(link: TicketLinkRequestDto) -> Result }) } +fn parse_bulk_refs(raw: Vec) -> Result, ErrorDto> { + if raw.is_empty() { + return Err(ErrorDto::invalid("refs must not be empty")); + } + let mut refs = Vec::with_capacity(raw.len()); + let mut seen = std::collections::HashSet::with_capacity(raw.len()); + for item in raw { + let issue_ref = parse_ref_dto(&item)?; + if !seen.insert(issue_ref) { + return Err(ErrorDto::invalid(format!( + "duplicate ticket ref: {issue_ref}" + ))); + } + refs.push(issue_ref); + } + Ok(refs) +} + pub fn paginate( rows: Vec, limit: usize, @@ -836,7 +964,7 @@ pub fn actor_from_requester(requester: &str) -> IssueActor { .map(|uuid| IssueActor::Agent { agent_id: AgentId::from_uuid(uuid), }) - .unwrap_or(IssueActor::System) + .unwrap_or(IssueActor::User) } pub fn parse_ref_dto(raw: &str) -> Result { @@ -1000,6 +1128,7 @@ mod tests { statuses: vec!["open".into(), "closed".into(), "open".into()], priorities: vec!["high".into(), "low".into(), "high".into()], assigned_agent_id: None, + created_by: None, sprint_id: None, text: None, sort: None, @@ -1025,6 +1154,7 @@ mod tests { statuses: vec!["open".into(), "bad".into()], priorities: Vec::new(), assigned_agent_id: None, + created_by: None, sprint_id: None, text: None, sort: None, @@ -1037,6 +1167,28 @@ mod tests { assert!(err.message.contains("invalid ticket status: bad")); } + #[test] + fn ticket_list_request_parses_creator_filter() { + let agent_id = AgentId::new_random(); + let page = TicketListPageInput::from_request(TicketListRequestDto { + project_id: String::new(), + statuses: Vec::new(), + priorities: Vec::new(), + assigned_agent_id: None, + created_by: Some(TicketCreatorFilterDto::Agent { + agent_id: agent_id.to_string(), + }), + sprint_id: None, + text: None, + sort: None, + limit: None, + cursor: None, + }) + .unwrap(); + + assert_eq!(page.filter.created_by, Some(IssueActor::Agent { agent_id })); + } + #[test] fn ticket_list_pagination_preserves_multi_filter_request_shape() { let page = TicketListPageInput::from_request(TicketListRequestDto { @@ -1044,6 +1196,7 @@ mod tests { statuses: vec!["open".into(), "QA".into()], priorities: vec!["high".into()], assigned_agent_id: None, + created_by: None, sprint_id: None, text: None, sort: None, @@ -1071,6 +1224,65 @@ mod tests { .is_some_and(|cursor| cursor.starts_with("v1."))); } + #[test] + fn ticket_summary_exposes_created_by() { + let agent_id = AgentId::new_random(); + let mut row = issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"); + row.created_by = IssueActor::Agent { agent_id }; + + let dto = TicketSummaryDto::from(row); + + assert!(matches!( + dto.created_by, + TicketActorDto::Agent { agent_id: id } if id == agent_id.to_string() + )); + } + + #[test] + fn bulk_input_rejects_empty_refs() { + let err = bulk_update_status_input( + project_for_tests(), + TicketBulkUpdateStatusRequestDto { + project_id: String::new(), + refs: Vec::new(), + status: "closed".into(), + }, + IssueActor::User, + ) + .unwrap_err(); + + assert_eq!(err.code, "INVALID"); + assert!(err.message.contains("refs must not be empty")); + } + + #[test] + fn bulk_input_rejects_duplicate_refs() { + let err = bulk_update_status_input( + project_for_tests(), + TicketBulkUpdateStatusRequestDto { + project_id: String::new(), + refs: vec!["#1".into(), "#1".into()], + status: "closed".into(), + }, + IssueActor::User, + ) + .unwrap_err(); + + assert_eq!(err.code, "INVALID"); + assert!(err.message.contains("duplicate ticket ref: #1")); + } + + #[test] + fn actor_from_requester_maps_agent_uuid_else_user() { + let agent_id = AgentId::new_random(); + + assert_eq!( + actor_from_requester(&agent_id.to_string()), + IssueActor::Agent { agent_id } + ); + assert_eq!(actor_from_requester("mcp"), IssueActor::User); + } + #[test] fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() { let page = TicketListPageInput::from_request(TicketListRequestDto { @@ -1078,6 +1290,7 @@ mod tests { statuses: Vec::new(), priorities: Vec::new(), assigned_agent_id: None, + created_by: None, sprint_id: None, text: None, sort: Some(TicketListSortDto { @@ -1197,6 +1410,7 @@ mod tests { priority, sprint: None, assigned_agent_ids: Vec::new(), + created_by: IssueActor::User, updated_at: number, } } @@ -1204,4 +1418,15 @@ mod tests { fn refs(out: &TicketListDto) -> Vec { out.items.iter().map(|item| item.r#ref.clone()).collect() } + + fn project_for_tests() -> Project { + Project::new( + domain::ProjectId::new_random(), + "IdeA", + domain::ProjectPath::new("/tmp/idea").unwrap(), + domain::RemoteRef::Local, + 0, + ) + .unwrap() + } } diff --git a/crates/domain/src/agent_tool_policy.rs b/crates/domain/src/agent_tool_policy.rs index 41bbbb3..ff89d8a 100644 --- a/crates/domain/src/agent_tool_policy.rs +++ b/crates/domain/src/agent_tool_policy.rs @@ -59,6 +59,9 @@ fn is_ticket_mutation_tool(tool: &str) -> bool { "idea_ticket_update" | "idea_ticket_update_status" | "idea_ticket_update_priority" + | "idea_ticket_bulk_update_status" + | "idea_ticket_bulk_update_priority" + | "idea_ticket_bulk_delete" | "idea_ticket_update_carnet" | "idea_ticket_link" | "idea_ticket_unlink" @@ -101,6 +104,17 @@ mod tests { assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8"))); } + #[test] + fn ticket_bulk_mutation_is_bound_to_the_configured_issue() { + let policy = AgentToolPolicy::new( + vec!["idea_ticket_bulk_delete".to_owned()], + Some(issue_ref("#7")), + true, + ); + assert!(policy.permits_ticket_mutation("idea_ticket_bulk_delete", issue_ref("#7"))); + assert!(!policy.permits_ticket_mutation("idea_ticket_bulk_delete", issue_ref("#8"))); + } + #[test] fn ticket_mutation_requires_the_tool_itself_to_be_allowed() { let policy = AgentToolPolicy::new( diff --git a/crates/domain/src/issue.rs b/crates/domain/src/issue.rs index c64c3a6..95ca3b1 100644 --- a/crates/domain/src/issue.rs +++ b/crates/domain/src/issue.rs @@ -399,6 +399,9 @@ pub struct IssueIndexEntry { pub sprint: Option, /// Assigned agent ids. pub assigned_agent_ids: Vec, + /// Actor that created the issue. + #[serde(default = "default_issue_actor_user")] + pub created_by: IssueActor, /// Last update time. pub updated_at: u64, } @@ -418,11 +421,16 @@ impl From<&Issue> for IssueIndexEntry { .filter(|r| r.role == AgentIssueRole::Assigned) .map(|r| r.agent_id) .collect(), + created_by: issue.created_by.clone(), updated_at: issue.updated_at, } } } +fn default_issue_actor_user() -> IssueActor { + IssueActor::User +} + /// Store-side list filter. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct IssueListFilter { @@ -432,6 +440,8 @@ pub struct IssueListFilter { pub priorities: Vec, /// Optional assigned agent filter. pub assigned_agent_id: Option, + /// Optional creator filter. + pub created_by: Option, /// Optional sprint membership filter. pub sprint: Option, /// Optional case-insensitive text query. diff --git a/crates/domain/tests/issue.rs b/crates/domain/tests/issue.rs index c5967ce..590fa13 100644 --- a/crates/domain/tests/issue.rs +++ b/crates/domain/tests/issue.rs @@ -1,8 +1,9 @@ use std::str::FromStr; use domain::{ - AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueError, IssueId, IssueLink, - IssueLinkKind, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, + AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueError, IssueId, IssueIndexEntry, + IssueLink, IssueLinkKind, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, + MarkdownDoc, }; fn issue_number(n: u64) -> IssueNumber { @@ -118,3 +119,26 @@ fn issue_mutation_increments_version_and_updates_actor_time() { assert_eq!(updated.updated_by, IssueActor::System); assert_eq!(updated.updated_at, 2_000); } + +#[test] +fn issue_index_entry_reuses_created_by_source_of_truth() { + let agent_id = domain::AgentId::new_random(); + let issue = Issue::new( + IssueId::new_random(), + issue_number(11), + "Agent-created", + MarkdownDoc::new("Description"), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::Agent { agent_id }, + 1, + ) + .unwrap(); + + let row = IssueIndexEntry::from(&issue); + + assert_eq!(row.created_by, IssueActor::Agent { agent_id }); +} diff --git a/crates/infrastructure/src/issues.rs b/crates/infrastructure/src/issues.rs index 9c40cea..f5fe02b 100644 --- a/crates/infrastructure/src/issues.rs +++ b/crates/infrastructure/src/issues.rs @@ -242,6 +242,11 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool { return false; } } + if let Some(created_by) = &filter.created_by { + if &row.created_by != created_by { + return false; + } + } if filter .sprint .is_some_and(|sprint| row.sprint != Some(sprint)) diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index ef791b7..7f49388 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -693,15 +693,39 @@ impl McpServer { ), )); } - if matches!( - name, - "idea_ticket_update" - | "idea_ticket_update_status" - | "idea_ticket_update_priority" - | "idea_ticket_update_carnet" - | "idea_ticket_link" - | "idea_ticket_unlink" - ) { + if is_ticket_policy_bulk_mutation_tool(name) { + let refs = arguments + .get("refs") + .and_then(Value::as_array) + .ok_or_else(|| { + JsonRpcError::new( + error_codes::INVALID_PARAMS, + format!("tool `{name}` requires ticket refs under the active policy"), + ) + })?; + for raw_ref in refs { + let raw_ref = raw_ref.as_str().ok_or_else(|| { + JsonRpcError::new( + error_codes::INVALID_PARAMS, + format!( + "tool `{name}` requires string ticket refs under the active policy" + ), + ) + })?; + let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| { + JsonRpcError::new( + error_codes::INVALID_PARAMS, + format!("invalid ticket ref: {e}"), + ) + })?; + if !policy.permits_ticket_mutation(name, issue_ref) { + return Err(JsonRpcError::new( + error_codes::INVALID_PARAMS, + format!("tool `{name}` is not permitted for ticket {issue_ref}"), + )); + } + } + } else if is_ticket_policy_mutation_tool(name) { let raw_ref = arguments .get("ref") .and_then(Value::as_str) @@ -880,6 +904,25 @@ fn parse_ask_agents_requests( Ok(out) } +fn is_ticket_policy_mutation_tool(name: &str) -> bool { + matches!( + name, + "idea_ticket_update" + | "idea_ticket_update_status" + | "idea_ticket_update_priority" + | "idea_ticket_update_carnet" + | "idea_ticket_link" + | "idea_ticket_unlink" + ) +} + +fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool { + matches!( + name, + "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" + ) +} + /// Maps a [`ToolMapError`] to the right JSON-RPC error code. fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { match err { diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs index 31d1073..3258b75 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tickets.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -60,6 +60,8 @@ pub fn is_ticket_tool(name: &str) -> bool { | "idea_ticket_update" | "idea_ticket_update_status" | "idea_ticket_update_priority" + | "idea_ticket_bulk_update_status" + | "idea_ticket_bulk_update_priority" | "idea_ticket_read_carnet" | "idea_ticket_update_carnet" | "idea_ticket_link" @@ -74,6 +76,31 @@ pub fn catalogue() -> Vec { let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" }); let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] }); let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] }); + let refs = json!({ + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": ticket_ref.clone() + }); + let created_by = json!({ + "oneOf": [ + { + "type": "object", + "properties": { "kind": { "const": "user" } }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "const": "agent" }, + "agentId": { "type": "string", "format": "uuid" } + }, + "required": ["kind", "agentId"], + "additionalProperties": false + } + ] + }); let sort = json!({ "type": "object", "properties": { @@ -145,6 +172,7 @@ pub fn catalogue() -> Vec { "uniqueItems": true }, "assignedAgentId": { "type": "string", "format": "uuid" }, + "createdBy": created_by, "text": { "type": "string" }, "sort": sort.clone(), "limit": { "type": "integer", "minimum": 1 }, @@ -199,6 +227,34 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_ticket_bulk_update_status", + description: + "Update the status of several IdeA tickets and return one result per ticket.", + input_schema: json!({ + "type": "object", + "properties": { + "refs": refs.clone(), + "status": status.clone() + }, + "required": ["refs", "status"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_ticket_bulk_update_priority", + description: + "Update the priority of several IdeA tickets and return one result per ticket.", + input_schema: json!({ + "type": "object", + "properties": { + "refs": refs.clone(), + "priority": priority.clone() + }, + "required": ["refs", "priority"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_ticket_read_carnet", description: "Read the editable carnet attached to a ticket.", diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 600b401..cb29f44 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -74,6 +74,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_ticket_update", "idea_ticket_update_status", "idea_ticket_update_priority", + "idea_ticket_bulk_update_status", + "idea_ticket_bulk_update_priority", "idea_ticket_update_carnet", "idea_ticket_link", "idea_ticket_unlink", diff --git a/crates/infrastructure/tests/issue_store.rs b/crates/infrastructure/tests/issue_store.rs index 6368d6e..8196999 100644 --- a/crates/infrastructure/tests/issue_store.rs +++ b/crates/infrastructure/tests/issue_store.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use std::str::FromStr; use domain::{ - AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter, + AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, MarkdownDoc, ProjectPath, SprintId, }; @@ -184,6 +184,60 @@ async fn issue_store_lists_by_index_filters_with_empty_or_and_and_semantics() { assert_eq!(by_status_and_priority[0].title, "Beta"); } +#[tokio::test] +async fn issue_store_filters_by_created_by_from_index() { + let tmp = TempDir::new(); + let root = tmp.root(); + let store = FsIssueStore::new(); + let agent_id = AgentId::new_random(); + store + .create(&root, &issue(&root, 1, "User ticket")) + .await + .unwrap(); + let agent_ticket = Issue::new( + IssueId::new_random(), + domain::IssueNumber::new(2).unwrap(), + "Agent ticket", + MarkdownDoc::new("Initial description"), + IssueStatus::Open, + IssuePriority::High, + MarkdownDoc::new("Initial carnet"), + Vec::new(), + Vec::new(), + IssueActor::Agent { agent_id }, + 1_000, + ) + .unwrap(); + store.create(&root, &agent_ticket).await.unwrap(); + + let by_user = store + .list( + &root, + IssueListFilter { + created_by: Some(IssueActor::User), + ..IssueListFilter::default() + }, + ) + .await + .unwrap(); + let by_agent = store + .list( + &root, + IssueListFilter { + created_by: Some(IssueActor::Agent { agent_id }), + ..IssueListFilter::default() + }, + ) + .await + .unwrap(); + + assert_eq!(by_user.len(), 1); + assert_eq!(by_user[0].title, "User ticket"); + assert_eq!(by_agent.len(), 1); + assert_eq!(by_agent[0].title, "Agent ticket"); + assert_eq!(by_agent[0].created_by, IssueActor::Agent { agent_id }); +} + #[tokio::test] async fn issue_store_text_filter_matches_exact_ref_or_number_without_numeric_substring() { let tmp = TempDir::new(); diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 2c30df1..48a88de 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -749,6 +749,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { "idea_ticket_update", "idea_ticket_update_status", "idea_ticket_update_priority", + "idea_ticket_bulk_update_status", + "idea_ticket_bulk_update_priority", "idea_ticket_read_carnet", "idea_ticket_update_carnet", "idea_ticket_link", @@ -767,11 +769,11 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { ); } assert!(!names.contains(&"idea_reply")); - assert_eq!( - tools.len(), - 31, - "exactly the thirty-one exposed idea_* tools; got {names:?}" - ); + assert_eq!( + tools.len(), + 33, + "exactly the thirty-three exposed idea_* tools; got {names:?}" + ); // Every tool advertises an object input schema. for t in tools { @@ -1361,11 +1363,21 @@ async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider ), ( 23, + "idea_ticket_bulk_update_status", + json!({ "refs": ["#7", "#8"], "status": "closed" }), + ), + ( + 24, + "idea_ticket_bulk_update_priority", + json!({ "refs": ["#8"], "priority": "high" }), + ), + ( + 25, "idea_ticket_link", json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ), ( - 24, + 26, "idea_ticket_unlink", json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ), @@ -1422,11 +1434,21 @@ async fn bounded_ticket_mutation_tools_allow_bound_issue_to_reach_ticket_provide ), ( 33, + "idea_ticket_bulk_update_status", + json!({ "refs": ["#7"], "status": "closed" }), + ), + ( + 34, + "idea_ticket_bulk_update_priority", + json!({ "refs": ["#7"], "priority": "high" }), + ), + ( + 35, "idea_ticket_link", json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ), ( - 34, + 36, "idea_ticket_unlink", json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), ),