Ticket #10 — introduction du modèle de sprints côté backend. - Domaine : nouvel agrégat Sprint (sprint.rs), IDs, événements et invariants ; rattachement des issues à un sprint (issue.rs) et ports associés. - Application : use-cases sprints (application/src/sprints) + erreurs dédiées. - Infrastructure : store de sprints (infrastructure/src/sprints.rs), adaptation du store d'issues et exposition MCP via orchestrator/mcp/tickets.rs. - app-tauri : commandes, state et events pour piloter les sprints depuis l'UI. Tests domaine/application/infra/app-tauri verts (sprint_usecases, sprint_store, issue_store, mcp_server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
616 lines
18 KiB
Rust
616 lines
18 KiB
Rust
//! Sprint use cases.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
|
|
use domain::{
|
|
Clock, DomainEvent, EventBus, IdGenerator, IssueActor, IssueListFilter, IssueRef, IssueStore,
|
|
IssueVersion, Project, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus,
|
|
SprintStore, SprintVersion,
|
|
};
|
|
|
|
use crate::error::AppError;
|
|
|
|
/// Input for [`CreateSprint::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateSprintInput {
|
|
/// Project owning the sprint.
|
|
pub project: Project,
|
|
/// Sprint name.
|
|
pub name: String,
|
|
/// Initial status. Defaults to planned.
|
|
pub status: Option<SprintStatus>,
|
|
/// Actor creating the sprint.
|
|
pub actor: IssueActor,
|
|
}
|
|
|
|
/// Output of [`CreateSprint::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CreateSprintOutput {
|
|
/// Created sprint.
|
|
pub sprint: Sprint,
|
|
}
|
|
|
|
/// Creates a sprint at `max(order)+1`.
|
|
pub struct CreateSprint {
|
|
sprints: Arc<dyn SprintStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl CreateSprint {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
sprints: Arc<dyn SprintStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
sprints,
|
|
ids,
|
|
clock,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes creation.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] on invariant or store failure.
|
|
pub async fn execute(&self, input: CreateSprintInput) -> Result<CreateSprintOutput, AppError> {
|
|
let rows = self.sprints.list(&input.project.root).await?;
|
|
let next_order = rows.iter().map(|row| row.order.get()).max().unwrap_or(0) + 1;
|
|
let sprint = Sprint::new(
|
|
SprintId::from_uuid(self.ids.new_uuid()),
|
|
SprintOrder::new(next_order).map_err(|err| AppError::Invalid(err.to_string()))?,
|
|
input.name,
|
|
input.status,
|
|
input.actor,
|
|
now(&self.clock),
|
|
)
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
self.sprints.create(&input.project.root, &sprint).await?;
|
|
self.events.publish(DomainEvent::SprintCreated {
|
|
sprint_id: sprint.id,
|
|
order: sprint.order,
|
|
});
|
|
Ok(CreateSprintOutput { sprint })
|
|
}
|
|
}
|
|
|
|
/// Input for [`ListSprints::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListSprintsInput {
|
|
/// Project owning the sprints.
|
|
pub project: Project,
|
|
}
|
|
|
|
/// Sprint list row enriched with ticket count.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SprintListEntry {
|
|
/// Sprint index projection.
|
|
pub sprint: SprintIndexEntry,
|
|
/// Number of tickets assigned to the sprint.
|
|
pub ticket_count: usize,
|
|
}
|
|
|
|
/// Output of [`ListSprints::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListSprintsOutput {
|
|
/// Sprints sorted by order.
|
|
pub sprints: Vec<SprintListEntry>,
|
|
}
|
|
|
|
/// Lists sprints with derived ticket counts.
|
|
pub struct ListSprints {
|
|
sprints: Arc<dyn SprintStore>,
|
|
issues: Arc<dyn IssueStore>,
|
|
}
|
|
|
|
impl ListSprints {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(sprints: Arc<dyn SprintStore>, issues: Arc<dyn IssueStore>) -> Self {
|
|
Self { sprints, issues }
|
|
}
|
|
|
|
/// Executes list.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] on store failure.
|
|
pub async fn execute(&self, input: ListSprintsInput) -> Result<ListSprintsOutput, AppError> {
|
|
let rows = self.sprints.list(&input.project.root).await?;
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for row in rows {
|
|
let ticket_count = self
|
|
.issues
|
|
.list(
|
|
&input.project.root,
|
|
IssueListFilter {
|
|
sprint: Some(row.id),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await?
|
|
.len();
|
|
out.push(SprintListEntry {
|
|
sprint: row,
|
|
ticket_count,
|
|
});
|
|
}
|
|
Ok(ListSprintsOutput { sprints: out })
|
|
}
|
|
}
|
|
|
|
/// Input for [`RenameSprint::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RenameSprintInput {
|
|
/// Project owning the sprint.
|
|
pub project: Project,
|
|
/// Sprint id.
|
|
pub sprint_id: SprintId,
|
|
/// Expected optimistic version.
|
|
pub expected_version: SprintVersion,
|
|
/// New name.
|
|
pub name: String,
|
|
/// Actor updating the sprint.
|
|
pub actor: IssueActor,
|
|
}
|
|
|
|
/// Output for sprint mutations.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SprintOutput {
|
|
/// Updated sprint.
|
|
pub sprint: Sprint,
|
|
}
|
|
|
|
/// Renames a sprint.
|
|
pub struct RenameSprint {
|
|
sprints: Arc<dyn SprintStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl RenameSprint {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
sprints: Arc<dyn SprintStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
sprints,
|
|
clock,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes rename.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] on store failure or conflict.
|
|
pub async fn execute(&self, input: RenameSprintInput) -> Result<SprintOutput, AppError> {
|
|
let current = self
|
|
.sprints
|
|
.get(&input.project.root, input.sprint_id)
|
|
.await?;
|
|
let updated = current
|
|
.mutate(input.actor, now(&self.clock), |sprint| {
|
|
sprint.name = input.name;
|
|
})
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
self.sprints
|
|
.update(&input.project.root, &updated, input.expected_version)
|
|
.await?;
|
|
self.events.publish(DomainEvent::SprintRenamed {
|
|
sprint_id: updated.id,
|
|
name: updated.name.clone(),
|
|
version: updated.version,
|
|
});
|
|
Ok(SprintOutput { sprint: updated })
|
|
}
|
|
}
|
|
|
|
/// Input for [`ReorderSprints::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReorderSprintsInput {
|
|
/// Project owning the sprints.
|
|
pub project: Project,
|
|
/// Complete ordered list of sprint ids.
|
|
pub ordered_ids: Vec<SprintId>,
|
|
/// Actor updating the sprints.
|
|
pub actor: IssueActor,
|
|
}
|
|
|
|
/// Output of [`ReorderSprints::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReorderSprintsOutput {
|
|
/// Updated sprints sorted by order.
|
|
pub sprints: Vec<Sprint>,
|
|
}
|
|
|
|
/// Reorders all sprints and normalises orders to 1..n.
|
|
pub struct ReorderSprints {
|
|
sprints: Arc<dyn SprintStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl ReorderSprints {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
sprints: Arc<dyn SprintStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
sprints,
|
|
clock,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes reorder.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] when ids are incomplete/duplicated or persistence fails.
|
|
pub async fn execute(
|
|
&self,
|
|
input: ReorderSprintsInput,
|
|
) -> Result<ReorderSprintsOutput, AppError> {
|
|
let rows = self.sprints.list(&input.project.root).await?;
|
|
let current_ids: HashSet<SprintId> = rows.iter().map(|row| row.id).collect();
|
|
let ordered_ids: HashSet<SprintId> = input.ordered_ids.iter().copied().collect();
|
|
if ordered_ids.len() != input.ordered_ids.len() || ordered_ids != current_ids {
|
|
return Err(AppError::Invalid(
|
|
"ordered sprint ids must contain every sprint exactly once".to_owned(),
|
|
));
|
|
}
|
|
|
|
let mut updated = Vec::with_capacity(input.ordered_ids.len());
|
|
for (idx, sprint_id) in input.ordered_ids.into_iter().enumerate() {
|
|
let current = self.sprints.get(&input.project.root, sprint_id).await?;
|
|
let expected_version = current.version;
|
|
let order = SprintOrder::new((idx as u32) + 1)
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
let next = current
|
|
.mutate(input.actor.clone(), now(&self.clock), |sprint| {
|
|
sprint.order = order;
|
|
})
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
self.sprints
|
|
.update(&input.project.root, &next, expected_version)
|
|
.await?;
|
|
self.events.publish(DomainEvent::SprintReordered {
|
|
sprint_id: next.id,
|
|
order: next.order,
|
|
version: next.version,
|
|
});
|
|
updated.push(next);
|
|
}
|
|
updated.sort_by_key(|sprint| sprint.order.get());
|
|
Ok(ReorderSprintsOutput { sprints: updated })
|
|
}
|
|
}
|
|
|
|
/// Input for [`DeleteSprint::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeleteSprintInput {
|
|
/// Project owning the sprint.
|
|
pub project: Project,
|
|
/// Sprint id.
|
|
pub sprint_id: SprintId,
|
|
/// Actor deleting the sprint and unassigning tickets.
|
|
pub actor: IssueActor,
|
|
}
|
|
|
|
/// Deletes a sprint and unassigns its tickets.
|
|
pub struct DeleteSprint {
|
|
sprints: Arc<dyn SprintStore>,
|
|
issues: Arc<dyn IssueStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl DeleteSprint {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
sprints: Arc<dyn SprintStore>,
|
|
issues: Arc<dyn IssueStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
sprints,
|
|
issues,
|
|
clock,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes deletion.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] on store failure or issue conflict.
|
|
pub async fn execute(&self, input: DeleteSprintInput) -> Result<(), AppError> {
|
|
self.sprints
|
|
.get(&input.project.root, input.sprint_id)
|
|
.await?;
|
|
let assigned = self
|
|
.issues
|
|
.list(
|
|
&input.project.root,
|
|
IssueListFilter {
|
|
sprint: Some(input.sprint_id),
|
|
..IssueListFilter::default()
|
|
},
|
|
)
|
|
.await?;
|
|
for row in assigned {
|
|
self.assign_issue(
|
|
&input.project,
|
|
row.issue_ref,
|
|
None,
|
|
input.actor.clone(),
|
|
now(&self.clock),
|
|
)
|
|
.await?;
|
|
}
|
|
self.sprints
|
|
.delete(&input.project.root, input.sprint_id)
|
|
.await?;
|
|
self.events.publish(DomainEvent::SprintDeleted {
|
|
sprint_id: input.sprint_id,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn assign_issue(
|
|
&self,
|
|
project: &Project,
|
|
issue_ref: IssueRef,
|
|
sprint_id: Option<SprintId>,
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
) -> Result<(), AppError> {
|
|
let current = self.issues.get_by_ref(&project.root, issue_ref).await?;
|
|
let expected_version = current.version;
|
|
let from = current.sprint;
|
|
if from == sprint_id {
|
|
return Ok(());
|
|
}
|
|
let updated = current
|
|
.mutate(actor, now_ms, |issue| issue.sprint = sprint_id)
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
self.issues
|
|
.update(&project.root, &updated, expected_version)
|
|
.await?;
|
|
publish_issue_sprint_changed(&self.events, issue_ref, from, sprint_id, updated.version);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Input for [`AssignTicketToSprint::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AssignTicketToSprintInput {
|
|
/// Project owning the issue.
|
|
pub project: Project,
|
|
/// Issue reference.
|
|
pub issue_ref: IssueRef,
|
|
/// Target sprint.
|
|
pub sprint_id: SprintId,
|
|
/// Expected issue optimistic version.
|
|
pub expected_version: IssueVersion,
|
|
/// Actor updating the issue.
|
|
pub actor: IssueActor,
|
|
}
|
|
|
|
/// Input for [`UnassignTicketFromSprint::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UnassignTicketFromSprintInput {
|
|
/// Project owning the issue.
|
|
pub project: Project,
|
|
/// Issue reference.
|
|
pub issue_ref: IssueRef,
|
|
/// Expected issue optimistic version.
|
|
pub expected_version: IssueVersion,
|
|
/// Actor updating the issue.
|
|
pub actor: IssueActor,
|
|
}
|
|
|
|
/// Output of ticket/sprint assignment use cases.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AssignTicketToSprintOutput {
|
|
/// Updated issue.
|
|
pub issue: domain::Issue,
|
|
}
|
|
|
|
/// Assigns a ticket to a sprint via `IssueStore.update`.
|
|
pub struct AssignTicketToSprint {
|
|
sprints: Arc<dyn SprintStore>,
|
|
issues: Arc<dyn IssueStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl AssignTicketToSprint {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
sprints: Arc<dyn SprintStore>,
|
|
issues: Arc<dyn IssueStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self {
|
|
sprints,
|
|
issues,
|
|
clock,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Executes assignment.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] when the sprint/issue is missing or the issue version conflicts.
|
|
pub async fn execute(
|
|
&self,
|
|
input: AssignTicketToSprintInput,
|
|
) -> Result<AssignTicketToSprintOutput, AppError> {
|
|
self.sprints
|
|
.get(&input.project.root, input.sprint_id)
|
|
.await?;
|
|
assign_ticket_sprint(
|
|
&self.issues,
|
|
&self.clock,
|
|
&self.events,
|
|
input.project,
|
|
input.issue_ref,
|
|
Some(input.sprint_id),
|
|
input.expected_version,
|
|
input.actor,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
/// Unassigns a ticket from its sprint via `IssueStore.update`.
|
|
pub struct UnassignTicketFromSprint {
|
|
issues: Arc<dyn IssueStore>,
|
|
clock: Arc<dyn Clock>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl UnassignTicketFromSprint {
|
|
/// 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 unassignment.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] when the issue is missing or its version conflicts.
|
|
pub async fn execute(
|
|
&self,
|
|
input: UnassignTicketFromSprintInput,
|
|
) -> Result<AssignTicketToSprintOutput, AppError> {
|
|
assign_ticket_sprint(
|
|
&self.issues,
|
|
&self.clock,
|
|
&self.events,
|
|
input.project,
|
|
input.issue_ref,
|
|
None,
|
|
input.expected_version,
|
|
input.actor,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
async fn assign_ticket_sprint(
|
|
issues: &Arc<dyn IssueStore>,
|
|
clock: &Arc<dyn Clock>,
|
|
events: &Arc<dyn EventBus>,
|
|
project: Project,
|
|
issue_ref: IssueRef,
|
|
sprint_id: Option<SprintId>,
|
|
expected_version: IssueVersion,
|
|
actor: IssueActor,
|
|
) -> Result<AssignTicketToSprintOutput, AppError> {
|
|
let current = issues.get_by_ref(&project.root, issue_ref).await?;
|
|
let from = current.sprint;
|
|
let updated = current
|
|
.mutate(actor, now(clock), |issue| issue.sprint = sprint_id)
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
issues
|
|
.update(&project.root, &updated, expected_version)
|
|
.await?;
|
|
if from != sprint_id {
|
|
publish_issue_sprint_changed(events, issue_ref, from, sprint_id, updated.version);
|
|
}
|
|
Ok(AssignTicketToSprintOutput { issue: updated })
|
|
}
|
|
|
|
fn publish_issue_sprint_changed(
|
|
events: &Arc<dyn EventBus>,
|
|
issue_ref: IssueRef,
|
|
from: Option<SprintId>,
|
|
to: Option<SprintId>,
|
|
version: IssueVersion,
|
|
) {
|
|
events.publish(DomainEvent::IssueUpdated { issue_ref, version });
|
|
events.publish(DomainEvent::IssueSprintChanged {
|
|
issue_ref,
|
|
from,
|
|
to,
|
|
version,
|
|
});
|
|
}
|
|
|
|
fn now(clock: &Arc<dyn Clock>) -> u64 {
|
|
clock.now_millis().max(0) as u64
|
|
}
|
|
|
|
/// Returns sprints in the requested order with contiguous order values.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError::Invalid`] when the requested ids are not a complete permutation.
|
|
pub fn normalized_reorder(
|
|
sprints: &[Sprint],
|
|
ordered_ids: &[SprintId],
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
) -> Result<Vec<Sprint>, AppError> {
|
|
let by_id: HashMap<SprintId, Sprint> = sprints
|
|
.iter()
|
|
.cloned()
|
|
.map(|sprint| (sprint.id, sprint))
|
|
.collect();
|
|
if by_id.len() != sprints.len() || ordered_ids.len() != sprints.len() {
|
|
return Err(AppError::Invalid(
|
|
"ordered sprint ids must contain every sprint exactly once".to_owned(),
|
|
));
|
|
}
|
|
let mut seen = HashSet::new();
|
|
let mut out = Vec::with_capacity(ordered_ids.len());
|
|
for (idx, sprint_id) in ordered_ids.iter().copied().enumerate() {
|
|
if !seen.insert(sprint_id) {
|
|
return Err(AppError::Invalid(
|
|
"ordered sprint ids must contain every sprint exactly once".to_owned(),
|
|
));
|
|
}
|
|
let Some(current) = by_id.get(&sprint_id) else {
|
|
return Err(AppError::Invalid(
|
|
"ordered sprint ids must contain every sprint exactly once".to_owned(),
|
|
));
|
|
};
|
|
let order =
|
|
SprintOrder::new((idx as u32) + 1).map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
out.push(
|
|
current
|
|
.clone()
|
|
.mutate(actor.clone(), now_ms, |sprint| sprint.order = order)
|
|
.map_err(|err| AppError::Invalid(err.to_string()))?,
|
|
);
|
|
}
|
|
Ok(out)
|
|
}
|