Ajoute created_by sur IssueIndexEntry/TicketSummaryDto + filtre createdBy (#5), et les use cases BulkUpdateIssueStatus/Priority + BulkDeleteIssues avec les DTO/outils MCP idea_ticket_bulk_* associés (#6). NON VERT : BulkDeleteIssues::execute référence BatchIssueResult::deleted(), constructeur pas encore ajouté à BatchIssueResult (seuls ok/err existent) -> `cargo check` échoue (E0599 dans application::issues::mod). Reste à DevBackend avant toute QA/merge. Branché sur feature/sdk-integration (dépend du commit #2 dans orchestrator/mcp/server.rs et tools.rs) ; à rebaser sur develop une fois feature/sdk-integration mergé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -278,6 +278,8 @@ pub fn run() {
|
|||||||
tickets::ticket_create,
|
tickets::ticket_create,
|
||||||
tickets::ticket_read,
|
tickets::ticket_read,
|
||||||
tickets::ticket_delete,
|
tickets::ticket_delete,
|
||||||
|
tickets::ticket_bulk_update_status,
|
||||||
|
tickets::ticket_bulk_update_priority,
|
||||||
tickets::open_ticket_chat,
|
tickets::open_ticket_chat,
|
||||||
tickets::close_ticket_chat,
|
tickets::close_ticket_chat,
|
||||||
tickets::ticket_list,
|
tickets::ticket_list,
|
||||||
|
|||||||
@ -284,7 +284,31 @@ fn enforce_policy(
|
|||||||
"tool `{name}` is not permitted for requester {requester}"
|
"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
|
let raw_ref = arguments
|
||||||
.get("ref")
|
.get("ref")
|
||||||
.and_then(Value::as_str)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@ -66,6 +66,8 @@ impl AppState {
|
|||||||
read: Arc::clone(&core.read_issue),
|
read: Arc::clone(&core.read_issue),
|
||||||
list: Arc::clone(&core.list_issues),
|
list: Arc::clone(&core.list_issues),
|
||||||
update: Arc::clone(&core.update_issue),
|
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),
|
read_carnet: Arc::clone(&core.read_issue_carnet),
|
||||||
update_carnet: Arc::clone(&core.update_issue_carnet),
|
update_carnet: Arc::clone(&core.update_issue_carnet),
|
||||||
link: Arc::clone(&core.link_issues),
|
link: Arc::clone(&core.link_issues),
|
||||||
|
|||||||
@ -29,6 +29,8 @@ pub struct AppTicketToolProvider {
|
|||||||
pub read: Arc<application::ReadIssue>,
|
pub read: Arc<application::ReadIssue>,
|
||||||
pub list: Arc<application::ListIssues>,
|
pub list: Arc<application::ListIssues>,
|
||||||
pub update: Arc<application::UpdateIssue>,
|
pub update: Arc<application::UpdateIssue>,
|
||||||
|
pub bulk_update_status: Arc<application::BulkUpdateIssueStatus>,
|
||||||
|
pub bulk_update_priority: Arc<application::BulkUpdateIssuePriority>,
|
||||||
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 link: Arc<application::LinkIssues>,
|
pub link: Arc<application::LinkIssues>,
|
||||||
@ -180,6 +182,28 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
.issue;
|
.issue;
|
||||||
json!(TicketDto::from_issue(issue, None))
|
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" => {
|
"idea_ticket_read_carnet" => {
|
||||||
let carnet = self
|
let carnet = self
|
||||||
.read_carnet
|
.read_carnet
|
||||||
@ -332,6 +356,40 @@ pub async fn ticket_delete(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ticket_bulk_update_status(
|
||||||
|
request: TicketBulkUpdateStatusRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<TicketBulkResultDto, ErrorDto> {
|
||||||
|
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<TicketBulkResultDto, ErrorDto> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
pub async fn open_ticket_chat(
|
pub async fn open_ticket_chat(
|
||||||
request: OpenTicketChatRequestDto,
|
request: OpenTicketChatRequestDto,
|
||||||
@ -766,6 +824,26 @@ fn mcp_list_request(
|
|||||||
TicketListPageInput::from_request(req).map_err(|e| TicketToolError::new("invalid", e.message))
|
TicketListPageInput::from_request(req).map_err(|e| TicketToolError::new("invalid", e.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mcp_bulk_update_status_request(
|
||||||
|
project: &Project,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<TicketBulkUpdateStatusRequestDto, TicketToolError> {
|
||||||
|
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<TicketBulkUpdatePriorityRequestDto, TicketToolError> {
|
||||||
|
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<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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -328,6 +328,35 @@ pub struct UpdateIssueOutput {
|
|||||||
pub issue: Issue,
|
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<Issue>,
|
||||||
|
/// Error for failed items.
|
||||||
|
pub error: Option<AppError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// Updates issue fields with optimistic concurrency.
|
||||||
pub struct UpdateIssue {
|
pub struct UpdateIssue {
|
||||||
issues: Arc<dyn IssueStore>,
|
issues: Arc<dyn IssueStore>,
|
||||||
@ -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<IssueRef>,
|
||||||
|
/// 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<BatchIssueResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates several issue statuses, preserving input order and allowing partial success.
|
||||||
|
pub struct BulkUpdateIssueStatus {
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BulkUpdateIssueStatus {
|
||||||
|
/// 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 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<IssueRef>,
|
||||||
|
/// 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<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BulkUpdateIssuePriority {
|
||||||
|
/// 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 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<IssueRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes several issues, preserving input order and allowing partial success.
|
||||||
|
pub struct BulkDeleteIssues {
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BulkDeleteIssues {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(issues: Arc<dyn IssueStore>, events: Arc<dyn EventBus>) -> 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<dyn IssueStore>,
|
||||||
|
clock: &Arc<dyn Clock>,
|
||||||
|
events: &Arc<dyn EventBus>,
|
||||||
|
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`].
|
/// Input for [`UpdateIssueCarnet::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct UpdateIssueCarnetInput {
|
pub struct UpdateIssueCarnetInput {
|
||||||
|
|||||||
@ -96,7 +96,9 @@ pub use git::{
|
|||||||
};
|
};
|
||||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||||
pub use issues::{
|
pub use issues::{
|
||||||
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput,
|
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, BatchIssueResult,
|
||||||
|
BulkIssueMutationOutput, BulkUpdateIssuePriority, BulkUpdateIssuePriorityInput,
|
||||||
|
BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue, CreateIssueInput,
|
||||||
CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput, LinkIssues,
|
CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput, LinkIssues,
|
||||||
LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput, ReadIssue,
|
LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput, ReadIssue,
|
||||||
ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput, ReadIssueOutput,
|
ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput, ReadIssueOutput,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -14,7 +15,9 @@ use domain::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CreateIssue, CreateIssueInput, DeleteIssue, DeleteIssueInput, UpdateIssue, UpdateIssueInput,
|
BulkDeleteIssues, BulkDeleteIssuesInput, BulkUpdateIssuePriority, BulkUpdateIssuePriorityInput,
|
||||||
|
BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue, CreateIssueInput, DeleteIssue,
|
||||||
|
DeleteIssueInput, UpdateIssue, UpdateIssueInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@ -504,3 +507,163 @@ async fn delete_issue_emits_deleted_event_without_freed_sprint() {
|
|||||||
}] if *issue_ref == initial.reference()
|
}] 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<String> = 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<String> = 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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@ -9,7 +9,8 @@ use async_trait::async_trait;
|
|||||||
use application::{
|
use application::{
|
||||||
DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput,
|
DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput,
|
||||||
EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind,
|
EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind,
|
||||||
LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy, TerminalSessions,
|
LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy,
|
||||||
|
TerminalSessions,
|
||||||
};
|
};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::layout::Workspace;
|
use domain::layout::Workspace;
|
||||||
|
|||||||
@ -14,12 +14,13 @@ use std::sync::{Arc, Mutex};
|
|||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
||||||
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile,
|
||||||
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed,
|
||||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
|
||||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
||||||
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, 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,
|
||||||
@ -986,6 +987,10 @@ pub struct BackendCore {
|
|||||||
pub list_issues: Arc<ListIssues>,
|
pub list_issues: Arc<ListIssues>,
|
||||||
/// Update an issue-backed public ticket.
|
/// Update an issue-backed public ticket.
|
||||||
pub update_issue: Arc<UpdateIssue>,
|
pub update_issue: Arc<UpdateIssue>,
|
||||||
|
/// Update public ticket statuses in batch.
|
||||||
|
pub bulk_update_issue_status: Arc<BulkUpdateIssueStatus>,
|
||||||
|
/// Update public ticket priorities in batch.
|
||||||
|
pub bulk_update_issue_priority: Arc<BulkUpdateIssuePriority>,
|
||||||
/// Read a ticket carnet.
|
/// Read a ticket carnet.
|
||||||
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
||||||
/// Update a ticket carnet.
|
/// Update a ticket carnet.
|
||||||
@ -1669,6 +1674,16 @@ 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 bulk_update_issue_status = Arc::new(BulkUpdateIssueStatus::new(
|
||||||
|
Arc::clone(&issue_store_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let bulk_update_issue_priority = Arc::new(BulkUpdateIssuePriority::new(
|
||||||
|
Arc::clone(&issue_store_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port)));
|
let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port)));
|
||||||
let update_issue_carnet = Arc::new(UpdateIssueCarnet::new(
|
let update_issue_carnet = Arc::new(UpdateIssueCarnet::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
@ -2817,6 +2832,8 @@ impl BackendCore {
|
|||||||
delete_issue,
|
delete_issue,
|
||||||
list_issues,
|
list_issues,
|
||||||
update_issue,
|
update_issue,
|
||||||
|
bulk_update_issue_status,
|
||||||
|
bulk_update_issue_priority,
|
||||||
read_issue_carnet,
|
read_issue_carnet,
|
||||||
update_issue_carnet,
|
update_issue_carnet,
|
||||||
link_issues,
|
link_issues,
|
||||||
@ -4948,6 +4965,8 @@ mod mcp_serve_peer_tests {
|
|||||||
"idea_ticket_update",
|
"idea_ticket_update",
|
||||||
"idea_ticket_update_status",
|
"idea_ticket_update_status",
|
||||||
"idea_ticket_update_priority",
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_bulk_update_status",
|
||||||
|
"idea_ticket_bulk_update_priority",
|
||||||
"idea_ticket_read_carnet",
|
"idea_ticket_read_carnet",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
@ -4968,8 +4987,8 @@ mod mcp_serve_peer_tests {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
31,
|
33,
|
||||||
"exactly the thirty-one exposed idea_* tools; got {names:?}"
|
"exactly the thirty-three exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(client); // EOF ⇒ serve loop ends
|
drop(client); // EOF ⇒ serve loop ends
|
||||||
|
|||||||
@ -297,7 +297,31 @@ fn enforce_policy(
|
|||||||
"tool `{name}` is not permitted for requester {requester}"
|
"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
|
let raw_ref = arguments
|
||||||
.get("ref")
|
.get("ref")
|
||||||
.and_then(Value::as_str)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@ -5,7 +5,10 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{CreateIssueInput, UpdateIssueInput};
|
use application::{
|
||||||
|
BulkIssueMutationOutput, BulkUpdateIssuePriorityInput, BulkUpdateIssueStatusInput,
|
||||||
|
CreateIssueInput, UpdateIssueInput,
|
||||||
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
||||||
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
|
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
|
||||||
@ -53,6 +56,7 @@ pub struct TicketSummaryDto {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub sprint: Option<TicketSprintContextDto>,
|
pub sprint: Option<TicketSprintContextDto>,
|
||||||
pub assigned_agent_ids: Vec<String>,
|
pub assigned_agent_ids: Vec<String>,
|
||||||
|
pub created_by: TicketActorDto,
|
||||||
pub updated_at: u64,
|
pub updated_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +136,14 @@ pub enum TicketActorDto {
|
|||||||
System,
|
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.
|
/// Carnet read DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -185,6 +197,7 @@ pub struct TicketListRequestDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub priorities: Vec<String>,
|
pub priorities: Vec<String>,
|
||||||
pub assigned_agent_id: Option<String>,
|
pub assigned_agent_id: Option<String>,
|
||||||
|
pub created_by: Option<TicketCreatorFilterDto>,
|
||||||
pub sprint_id: Option<String>,
|
pub sprint_id: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
pub sort: Option<TicketListSortDto>,
|
pub sort: Option<TicketListSortDto>,
|
||||||
@ -192,6 +205,45 @@ pub struct TicketListRequestDto {
|
|||||||
pub cursor: Option<String>,
|
pub cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bulk status update request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TicketBulkUpdateStatusRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
pub refs: Vec<String>,
|
||||||
|
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<String>,
|
||||||
|
pub priority: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bulk operation response.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TicketBulkResultDto {
|
||||||
|
pub items: Vec<TicketBulkResultItemDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<TicketDto>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<ErrorDto>,
|
||||||
|
}
|
||||||
|
|
||||||
/// List sort request.
|
/// List sort request.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -413,6 +465,7 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|id| id.to_string())
|
.map(|id| id.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
created_by: TicketActorDto::from(row.created_by),
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -485,6 +538,19 @@ impl From<IssueActor> for TicketActorDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TryFrom<TicketCreatorFilterDto> for IssueActor {
|
||||||
|
type Error = ErrorDto;
|
||||||
|
|
||||||
|
fn try_from(actor: TicketCreatorFilterDto) -> Result<Self, Self::Error> {
|
||||||
|
match actor {
|
||||||
|
TicketCreatorFilterDto::User => Ok(Self::User),
|
||||||
|
TicketCreatorFilterDto::Agent { agent_id } => Ok(Self::Agent {
|
||||||
|
agent_id: parse_agent_id_dto(&agent_id)?,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<IssueCarnet> for TicketCarnetDto {
|
impl From<IssueCarnet> for TicketCarnetDto {
|
||||||
fn from(carnet: IssueCarnet) -> Self {
|
fn from(carnet: IssueCarnet) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -514,6 +580,7 @@ impl TicketListPageInput {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(parse_agent_id_dto)
|
.map(parse_agent_id_dto)
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
|
created_by: request.created_by.map(IssueActor::try_from).transpose()?,
|
||||||
sprint: request
|
sprint: request
|
||||||
.sprint_id
|
.sprint_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@ -664,6 +731,49 @@ pub fn update_input(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn bulk_update_status_input(
|
||||||
|
project: Project,
|
||||||
|
request: TicketBulkUpdateStatusRequestDto,
|
||||||
|
actor: IssueActor,
|
||||||
|
) -> Result<BulkUpdateIssueStatusInput, ErrorDto> {
|
||||||
|
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<BulkUpdateIssuePriorityInput, ErrorDto> {
|
||||||
|
Ok(BulkUpdateIssuePriorityInput {
|
||||||
|
project,
|
||||||
|
issue_refs: parse_bulk_refs(request.refs)?,
|
||||||
|
priority: parse_priority_dto(&request.priority)?,
|
||||||
|
actor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BulkIssueMutationOutput> 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<IssueLink, ErrorDto> {
|
fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto> {
|
||||||
Ok(IssueLink {
|
Ok(IssueLink {
|
||||||
target: parse_ref_dto(&link.target_ref)?,
|
target: parse_ref_dto(&link.target_ref)?,
|
||||||
@ -671,6 +781,24 @@ fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_bulk_refs(raw: Vec<String>) -> Result<Vec<IssueRef>, 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(
|
pub fn paginate(
|
||||||
rows: Vec<IssueIndexEntry>,
|
rows: Vec<IssueIndexEntry>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
@ -836,7 +964,7 @@ pub fn actor_from_requester(requester: &str) -> IssueActor {
|
|||||||
.map(|uuid| IssueActor::Agent {
|
.map(|uuid| IssueActor::Agent {
|
||||||
agent_id: AgentId::from_uuid(uuid),
|
agent_id: AgentId::from_uuid(uuid),
|
||||||
})
|
})
|
||||||
.unwrap_or(IssueActor::System)
|
.unwrap_or(IssueActor::User)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_ref_dto(raw: &str) -> Result<IssueRef, ErrorDto> {
|
pub fn parse_ref_dto(raw: &str) -> Result<IssueRef, ErrorDto> {
|
||||||
@ -1000,6 +1128,7 @@ mod tests {
|
|||||||
statuses: vec!["open".into(), "closed".into(), "open".into()],
|
statuses: vec!["open".into(), "closed".into(), "open".into()],
|
||||||
priorities: vec!["high".into(), "low".into(), "high".into()],
|
priorities: vec!["high".into(), "low".into(), "high".into()],
|
||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
|
created_by: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
sort: None,
|
sort: None,
|
||||||
@ -1025,6 +1154,7 @@ mod tests {
|
|||||||
statuses: vec!["open".into(), "bad".into()],
|
statuses: vec!["open".into(), "bad".into()],
|
||||||
priorities: Vec::new(),
|
priorities: Vec::new(),
|
||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
|
created_by: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
sort: None,
|
sort: None,
|
||||||
@ -1037,6 +1167,28 @@ mod tests {
|
|||||||
assert!(err.message.contains("invalid ticket status: bad"));
|
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]
|
#[test]
|
||||||
fn ticket_list_pagination_preserves_multi_filter_request_shape() {
|
fn ticket_list_pagination_preserves_multi_filter_request_shape() {
|
||||||
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
||||||
@ -1044,6 +1196,7 @@ mod tests {
|
|||||||
statuses: vec!["open".into(), "QA".into()],
|
statuses: vec!["open".into(), "QA".into()],
|
||||||
priorities: vec!["high".into()],
|
priorities: vec!["high".into()],
|
||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
|
created_by: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
sort: None,
|
sort: None,
|
||||||
@ -1071,6 +1224,65 @@ mod tests {
|
|||||||
.is_some_and(|cursor| cursor.starts_with("v1.")));
|
.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]
|
#[test]
|
||||||
fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() {
|
fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() {
|
||||||
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
let page = TicketListPageInput::from_request(TicketListRequestDto {
|
||||||
@ -1078,6 +1290,7 @@ mod tests {
|
|||||||
statuses: Vec::new(),
|
statuses: Vec::new(),
|
||||||
priorities: Vec::new(),
|
priorities: Vec::new(),
|
||||||
assigned_agent_id: None,
|
assigned_agent_id: None,
|
||||||
|
created_by: None,
|
||||||
sprint_id: None,
|
sprint_id: None,
|
||||||
text: None,
|
text: None,
|
||||||
sort: Some(TicketListSortDto {
|
sort: Some(TicketListSortDto {
|
||||||
@ -1197,6 +1410,7 @@ mod tests {
|
|||||||
priority,
|
priority,
|
||||||
sprint: None,
|
sprint: None,
|
||||||
assigned_agent_ids: Vec::new(),
|
assigned_agent_ids: Vec::new(),
|
||||||
|
created_by: IssueActor::User,
|
||||||
updated_at: number,
|
updated_at: number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1204,4 +1418,15 @@ mod tests {
|
|||||||
fn refs(out: &TicketListDto) -> Vec<String> {
|
fn refs(out: &TicketListDto) -> Vec<String> {
|
||||||
out.items.iter().map(|item| item.r#ref.clone()).collect()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,6 +59,9 @@ fn is_ticket_mutation_tool(tool: &str) -> bool {
|
|||||||
"idea_ticket_update"
|
"idea_ticket_update"
|
||||||
| "idea_ticket_update_status"
|
| "idea_ticket_update_status"
|
||||||
| "idea_ticket_update_priority"
|
| "idea_ticket_update_priority"
|
||||||
|
| "idea_ticket_bulk_update_status"
|
||||||
|
| "idea_ticket_bulk_update_priority"
|
||||||
|
| "idea_ticket_bulk_delete"
|
||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
| "idea_ticket_unlink"
|
| "idea_ticket_unlink"
|
||||||
@ -101,6 +104,17 @@ mod tests {
|
|||||||
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
|
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]
|
#[test]
|
||||||
fn ticket_mutation_requires_the_tool_itself_to_be_allowed() {
|
fn ticket_mutation_requires_the_tool_itself_to_be_allowed() {
|
||||||
let policy = AgentToolPolicy::new(
|
let policy = AgentToolPolicy::new(
|
||||||
|
|||||||
@ -399,6 +399,9 @@ pub struct IssueIndexEntry {
|
|||||||
pub sprint: Option<SprintId>,
|
pub sprint: Option<SprintId>,
|
||||||
/// Assigned agent ids.
|
/// Assigned agent ids.
|
||||||
pub assigned_agent_ids: Vec<AgentId>,
|
pub assigned_agent_ids: Vec<AgentId>,
|
||||||
|
/// Actor that created the issue.
|
||||||
|
#[serde(default = "default_issue_actor_user")]
|
||||||
|
pub created_by: IssueActor,
|
||||||
/// Last update time.
|
/// Last update time.
|
||||||
pub updated_at: u64,
|
pub updated_at: u64,
|
||||||
}
|
}
|
||||||
@ -418,11 +421,16 @@ impl From<&Issue> for IssueIndexEntry {
|
|||||||
.filter(|r| r.role == AgentIssueRole::Assigned)
|
.filter(|r| r.role == AgentIssueRole::Assigned)
|
||||||
.map(|r| r.agent_id)
|
.map(|r| r.agent_id)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
created_by: issue.created_by.clone(),
|
||||||
updated_at: issue.updated_at,
|
updated_at: issue.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_issue_actor_user() -> IssueActor {
|
||||||
|
IssueActor::User
|
||||||
|
}
|
||||||
|
|
||||||
/// Store-side list filter.
|
/// Store-side list filter.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct IssueListFilter {
|
pub struct IssueListFilter {
|
||||||
@ -432,6 +440,8 @@ pub struct IssueListFilter {
|
|||||||
pub priorities: Vec<IssuePriority>,
|
pub priorities: Vec<IssuePriority>,
|
||||||
/// Optional assigned agent filter.
|
/// Optional assigned agent filter.
|
||||||
pub assigned_agent_id: Option<AgentId>,
|
pub assigned_agent_id: Option<AgentId>,
|
||||||
|
/// Optional creator filter.
|
||||||
|
pub created_by: Option<IssueActor>,
|
||||||
/// Optional sprint membership filter.
|
/// Optional sprint membership filter.
|
||||||
pub sprint: Option<SprintId>,
|
pub sprint: Option<SprintId>,
|
||||||
/// Optional case-insensitive text query.
|
/// Optional case-insensitive text query.
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueError, IssueId, IssueLink,
|
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueError, IssueId, IssueIndexEntry,
|
||||||
IssueLinkKind, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc,
|
IssueLink, IssueLinkKind, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion,
|
||||||
|
MarkdownDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn issue_number(n: u64) -> IssueNumber {
|
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_by, IssueActor::System);
|
||||||
assert_eq!(updated.updated_at, 2_000);
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@ -242,6 +242,11 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(created_by) = &filter.created_by {
|
||||||
|
if &row.created_by != created_by {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if filter
|
if filter
|
||||||
.sprint
|
.sprint
|
||||||
.is_some_and(|sprint| row.sprint != Some(sprint))
|
.is_some_and(|sprint| row.sprint != Some(sprint))
|
||||||
|
|||||||
@ -693,15 +693,39 @@ impl McpServer {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if matches!(
|
if is_ticket_policy_bulk_mutation_tool(name) {
|
||||||
name,
|
let refs = arguments
|
||||||
"idea_ticket_update"
|
.get("refs")
|
||||||
| "idea_ticket_update_status"
|
.and_then(Value::as_array)
|
||||||
| "idea_ticket_update_priority"
|
.ok_or_else(|| {
|
||||||
| "idea_ticket_update_carnet"
|
JsonRpcError::new(
|
||||||
| "idea_ticket_link"
|
error_codes::INVALID_PARAMS,
|
||||||
| "idea_ticket_unlink"
|
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
|
let raw_ref = arguments
|
||||||
.get("ref")
|
.get("ref")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@ -880,6 +904,25 @@ fn parse_ask_agents_requests(
|
|||||||
Ok(out)
|
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.
|
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
|
||||||
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
||||||
match err {
|
match err {
|
||||||
|
|||||||
@ -60,6 +60,8 @@ pub fn is_ticket_tool(name: &str) -> bool {
|
|||||||
| "idea_ticket_update"
|
| "idea_ticket_update"
|
||||||
| "idea_ticket_update_status"
|
| "idea_ticket_update_status"
|
||||||
| "idea_ticket_update_priority"
|
| "idea_ticket_update_priority"
|
||||||
|
| "idea_ticket_bulk_update_status"
|
||||||
|
| "idea_ticket_bulk_update_priority"
|
||||||
| "idea_ticket_read_carnet"
|
| "idea_ticket_read_carnet"
|
||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
@ -74,6 +76,31 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
|
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
|
||||||
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
|
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
|
||||||
let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] });
|
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!({
|
let sort = json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -145,6 +172,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
},
|
},
|
||||||
"assignedAgentId": { "type": "string", "format": "uuid" },
|
"assignedAgentId": { "type": "string", "format": "uuid" },
|
||||||
|
"createdBy": created_by,
|
||||||
"text": { "type": "string" },
|
"text": { "type": "string" },
|
||||||
"sort": sort.clone(),
|
"sort": sort.clone(),
|
||||||
"limit": { "type": "integer", "minimum": 1 },
|
"limit": { "type": "integer", "minimum": 1 },
|
||||||
@ -199,6 +227,34 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"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 {
|
ToolDef {
|
||||||
name: "idea_ticket_read_carnet",
|
name: "idea_ticket_read_carnet",
|
||||||
description: "Read the editable carnet attached to a ticket.",
|
description: "Read the editable carnet attached to a ticket.",
|
||||||
|
|||||||
@ -74,6 +74,8 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_ticket_update",
|
"idea_ticket_update",
|
||||||
"idea_ticket_update_status",
|
"idea_ticket_update_status",
|
||||||
"idea_ticket_update_priority",
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_bulk_update_status",
|
||||||
|
"idea_ticket_bulk_update_priority",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
AgentId, AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
||||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||||
MarkdownDoc, ProjectPath, SprintId,
|
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");
|
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]
|
#[tokio::test]
|
||||||
async fn issue_store_text_filter_matches_exact_ref_or_number_without_numeric_substring() {
|
async fn issue_store_text_filter_matches_exact_ref_or_number_without_numeric_substring() {
|
||||||
let tmp = TempDir::new();
|
let tmp = TempDir::new();
|
||||||
|
|||||||
@ -749,6 +749,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
"idea_ticket_update",
|
"idea_ticket_update",
|
||||||
"idea_ticket_update_status",
|
"idea_ticket_update_status",
|
||||||
"idea_ticket_update_priority",
|
"idea_ticket_update_priority",
|
||||||
|
"idea_ticket_bulk_update_status",
|
||||||
|
"idea_ticket_bulk_update_priority",
|
||||||
"idea_ticket_read_carnet",
|
"idea_ticket_read_carnet",
|
||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
@ -767,11 +769,11 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
31,
|
33,
|
||||||
"exactly the thirty-one exposed idea_* tools; got {names:?}"
|
"exactly the thirty-three exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// Every tool advertises an object input schema.
|
||||||
for t in tools {
|
for t in tools {
|
||||||
@ -1361,11 +1363,21 @@ async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
23,
|
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",
|
"idea_ticket_link",
|
||||||
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
24,
|
26,
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
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,
|
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",
|
"idea_ticket_link",
|
||||||
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
34,
|
36,
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user