wip(tickets): backend createdBy (#5) + bulk status/priority/delete (#6)

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:
2026-07-28 18:28:39 +02:00
parent 1ef1fd9e40
commit 6da52c6a02
20 changed files with 1061 additions and 34 deletions

View File

@ -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<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.
pub struct UpdateIssue {
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(&current);
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 {

View File

@ -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,

View File

@ -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<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()
));
}

View File

@ -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;