feat(sprints): modèle de sprints — domaine, use-cases, persistance et surfaces (backend)
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>
This commit is contained in:
@ -9,8 +9,8 @@ use domain::ports::{
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
||||
RemoteError, RuntimeError, StoreError,
|
||||
};
|
||||
use domain::IssueStoreError;
|
||||
use domain::{AgentId, NodeId};
|
||||
use domain::{IssueStoreError, SprintStoreError};
|
||||
|
||||
/// Errors surfaced by application use cases.
|
||||
///
|
||||
@ -140,6 +140,17 @@ impl From<IssueStoreError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SprintStoreError> for AppError {
|
||||
fn from(e: SprintStoreError) -> Self {
|
||||
match e {
|
||||
SprintStoreError::NotFound => Self::NotFound("sprint".to_owned()),
|
||||
SprintStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()),
|
||||
SprintStoreError::Invalid(message) => Self::Invalid(message),
|
||||
SprintStoreError::Store(message) => Self::Store(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
|
||||
@ -27,6 +27,7 @@ pub mod permission;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod sprints;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
pub mod window;
|
||||
@ -127,6 +128,13 @@ pub use skill::{
|
||||
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
|
||||
UpdateSkillInput, UpdateSkillOutput,
|
||||
};
|
||||
pub use sprints::{
|
||||
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
|
||||
AssignTicketToSprintOutput, CreateSprint, CreateSprintInput, CreateSprintOutput, DeleteSprint,
|
||||
DeleteSprintInput, ListSprints, ListSprintsInput, ListSprintsOutput, RenameSprint,
|
||||
RenameSprintInput, ReorderSprints, ReorderSprintsInput, ReorderSprintsOutput, SprintListEntry,
|
||||
SprintOutput, UnassignTicketFromSprint, UnassignTicketFromSprintInput,
|
||||
};
|
||||
pub use template::{
|
||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
||||
|
||||
615
crates/application/src/sprints/mod.rs
Normal file
615
crates/application/src/sprints/mod.rs
Normal file
@ -0,0 +1,615 @@
|
||||
//! 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)
|
||||
}
|
||||
513
crates/application/tests/sprint_usecases.rs
Normal file
513
crates/application/tests/sprint_usecases.rs
Normal file
@ -0,0 +1,513 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError};
|
||||
use domain::{
|
||||
DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter,
|
||||
IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project,
|
||||
ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder,
|
||||
SprintStatus, SprintStore, SprintStoreError, SprintVersion,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput, CreateSprint,
|
||||
CreateSprintInput, DeleteSprint, DeleteSprintInput, ListSprints, ListSprintsInput,
|
||||
RenameSprint, RenameSprintInput, ReorderSprints, ReorderSprintsInput, UnassignTicketFromSprint,
|
||||
UnassignTicketFromSprintInput,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeSprints {
|
||||
sprints: Mutex<HashMap<SprintId, Sprint>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SprintStore for FakeSprints {
|
||||
async fn create(&self, _root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
|
||||
self.sprints
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(sprint.id, sprint.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
sprint_id: SprintId,
|
||||
) -> Result<Sprint, SprintStoreError> {
|
||||
self.sprints
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&sprint_id)
|
||||
.cloned()
|
||||
.ok_or(SprintStoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
|
||||
let mut rows: Vec<_> = self
|
||||
.sprints
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(SprintIndexEntry::from)
|
||||
.collect();
|
||||
rows.sort_by_key(|row| row.order.get());
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
sprint: &Sprint,
|
||||
expected_version: SprintVersion,
|
||||
) -> Result<(), SprintStoreError> {
|
||||
let mut sprints = self.sprints.lock().unwrap();
|
||||
let current = sprints.get(&sprint.id).ok_or(SprintStoreError::NotFound)?;
|
||||
if current.version != expected_version {
|
||||
return Err(SprintStoreError::VersionConflict {
|
||||
expected: expected_version,
|
||||
actual: current.version,
|
||||
});
|
||||
}
|
||||
sprints.insert(sprint.id, sprint.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
sprint_id: SprintId,
|
||||
) -> Result<(), SprintStoreError> {
|
||||
self.sprints
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&sprint_id)
|
||||
.map(|_| ())
|
||||
.ok_or(SprintStoreError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeIssues {
|
||||
issues: Mutex<HashMap<IssueRef, Issue>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IssueStore for FakeIssues {
|
||||
async fn create(&self, _root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
|
||||
self.issues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(issue.reference(), issue.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_ref(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
) -> Result<Issue, IssueStoreError> {
|
||||
self.issues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&issue_ref)
|
||||
.cloned()
|
||||
.ok_or(IssueStoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
filter: IssueListFilter,
|
||||
) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
|
||||
let mut rows: Vec<IssueIndexEntry> = self
|
||||
.issues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|issue| {
|
||||
filter
|
||||
.sprint
|
||||
.map_or(true, |sprint| issue.sprint == Some(sprint))
|
||||
})
|
||||
.map(IssueIndexEntry::from)
|
||||
.collect();
|
||||
rows.sort_by_key(|row| row.issue_ref.number().get());
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
issue: &Issue,
|
||||
expected_version: IssueVersion,
|
||||
) -> Result<(), IssueStoreError> {
|
||||
let mut issues = self.issues.lock().unwrap();
|
||||
let current = issues
|
||||
.get(&issue.reference())
|
||||
.ok_or(IssueStoreError::NotFound)?;
|
||||
if current.version != expected_version {
|
||||
return Err(IssueStoreError::VersionConflict {
|
||||
expected: expected_version,
|
||||
actual: current.version,
|
||||
});
|
||||
}
|
||||
issues.insert(issue.reference(), issue.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_carnet(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
issue_ref: IssueRef,
|
||||
) -> Result<IssueCarnet, IssueStoreError> {
|
||||
let issue = self.get_by_ref(_root, issue_ref).await?;
|
||||
Ok(IssueCarnet {
|
||||
issue_ref,
|
||||
carnet: issue.carnet,
|
||||
version: issue.version,
|
||||
updated_by: issue.updated_by,
|
||||
updated_at: issue.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn write_carnet(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
_carnet: MarkdownDoc,
|
||||
_actor: IssueActor,
|
||||
_now_ms: u64,
|
||||
_expected_version: IssueVersion,
|
||||
) -> Result<IssueCarnet, IssueStoreError> {
|
||||
unimplemented!("not needed")
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
let mut next = self.0.lock().unwrap();
|
||||
let id = Uuid::from_u128(*next);
|
||||
*next += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock(i64);
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(Vec::new().into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project {
|
||||
id: ProjectId::from_uuid(Uuid::from_u128(900)),
|
||||
name: "P".to_owned(),
|
||||
root: ProjectPath::new("/tmp/idea-sprint-usecases").unwrap(),
|
||||
remote: RemoteRef::Local,
|
||||
created_at: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sid(n: u128) -> SprintId {
|
||||
SprintId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn sprint(id: SprintId, order: u32, name: &str) -> Sprint {
|
||||
Sprint::new(
|
||||
id,
|
||||
SprintOrder::new(order).unwrap(),
|
||||
name,
|
||||
None,
|
||||
IssueActor::User,
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn issue(number: u64, title: &str, sprint_id: Option<SprintId>) -> Issue {
|
||||
Issue::new(
|
||||
IssueId::from_uuid(Uuid::from_u128(number as u128)),
|
||||
IssueNumber::new(number).unwrap(),
|
||||
title,
|
||||
MarkdownDoc::default(),
|
||||
IssueStatus::Open,
|
||||
IssuePriority::Medium,
|
||||
MarkdownDoc::default(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
IssueActor::User,
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
.mutate(IssueActor::System, 1_000, |issue| issue.sprint = sprint_id)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_sprint_uses_max_order_plus_one_and_publishes_event() {
|
||||
let sprints = Arc::new(FakeSprints::default());
|
||||
sprints
|
||||
.create(&project().root, &sprint(sid(1), 2, "Existing"))
|
||||
.await
|
||||
.unwrap();
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let usecase = CreateSprint::new(
|
||||
sprints,
|
||||
Arc::new(SeqIds(Mutex::new(10))),
|
||||
Arc::new(FixedClock(2_000)),
|
||||
bus.clone(),
|
||||
);
|
||||
|
||||
let out = usecase
|
||||
.execute(CreateSprintInput {
|
||||
project: project(),
|
||||
name: "Next".to_owned(),
|
||||
status: Some(SprintStatus::Active),
|
||||
actor: IssueActor::User,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.sprint.id, sid(10));
|
||||
assert_eq!(out.sprint.order.get(), 3);
|
||||
assert_eq!(out.sprint.status, SprintStatus::Active);
|
||||
assert!(bus.events().contains(&DomainEvent::SprintCreated {
|
||||
sprint_id: sid(10),
|
||||
order: SprintOrder::new(3).unwrap(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_and_reorder_publish_sprint_events() {
|
||||
let sprints = Arc::new(FakeSprints::default());
|
||||
let first = sprint(sid(1), 1, "One");
|
||||
let second = sprint(sid(2), 2, "Two");
|
||||
sprints.create(&project().root, &first).await.unwrap();
|
||||
sprints.create(&project().root, &second).await.unwrap();
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
|
||||
let renamed = RenameSprint::new(sprints.clone(), Arc::new(FixedClock(2_000)), bus.clone())
|
||||
.execute(RenameSprintInput {
|
||||
project: project(),
|
||||
sprint_id: sid(1),
|
||||
expected_version: first.version,
|
||||
name: "Renamed".to_owned(),
|
||||
actor: IssueActor::User,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.sprint;
|
||||
assert_eq!(renamed.name, "Renamed");
|
||||
|
||||
let reordered = ReorderSprints::new(sprints.clone(), Arc::new(FixedClock(3_000)), bus.clone())
|
||||
.execute(ReorderSprintsInput {
|
||||
project: project(),
|
||||
ordered_ids: vec![sid(2), sid(1)],
|
||||
actor: IssueActor::System,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reordered
|
||||
.sprints
|
||||
.iter()
|
||||
.map(|sprint| (sprint.id, sprint.order.get()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(sid(2), 1), (sid(1), 2)]
|
||||
);
|
||||
assert!(bus.events().iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
DomainEvent::SprintRenamed {
|
||||
sprint_id,
|
||||
name,
|
||||
..
|
||||
} if *sprint_id == sid(1) && name == "Renamed"
|
||||
)
|
||||
}));
|
||||
assert!(bus.events().iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
DomainEvent::SprintReordered { sprint_id, order, .. }
|
||||
if *sprint_id == sid(2) && order.get() == 1
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_reorder_rejects_incomplete_or_duplicate_ids() {
|
||||
let sprints = vec![sprint(sid(1), 1, "One"), sprint(sid(2), 2, "Two")];
|
||||
assert!(normalized_reorder(&sprints, &[sid(1)], IssueActor::User, 2_000).is_err());
|
||||
assert!(normalized_reorder(&sprints, &[sid(1), sid(1)], IssueActor::User, 2_000).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assign_and_unassign_ticket_to_sprint_use_issue_expected_version() {
|
||||
let sprints = Arc::new(FakeSprints::default());
|
||||
sprints
|
||||
.create(&project().root, &sprint(sid(1), 1, "One"))
|
||||
.await
|
||||
.unwrap();
|
||||
let issues = Arc::new(FakeIssues::default());
|
||||
let original = issue(1, "Ticket", None);
|
||||
issues.create(&project().root, &original).await.unwrap();
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let assign = AssignTicketToSprint::new(
|
||||
sprints,
|
||||
issues.clone(),
|
||||
Arc::new(FixedClock(2_000)),
|
||||
bus.clone(),
|
||||
);
|
||||
|
||||
let conflict = assign
|
||||
.execute(AssignTicketToSprintInput {
|
||||
project: project(),
|
||||
issue_ref: original.reference(),
|
||||
sprint_id: sid(1),
|
||||
expected_version: original.version.next(),
|
||||
actor: IssueActor::User,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(conflict.code(), "INVALID");
|
||||
|
||||
let assigned = assign
|
||||
.execute(AssignTicketToSprintInput {
|
||||
project: project(),
|
||||
issue_ref: original.reference(),
|
||||
sprint_id: sid(1),
|
||||
expected_version: original.version,
|
||||
actor: IssueActor::User,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.issue;
|
||||
assert_eq!(assigned.sprint, Some(sid(1)));
|
||||
|
||||
let unassigned =
|
||||
UnassignTicketFromSprint::new(issues, Arc::new(FixedClock(3_000)), bus.clone())
|
||||
.execute(UnassignTicketFromSprintInput {
|
||||
project: project(),
|
||||
issue_ref: original.reference(),
|
||||
expected_version: assigned.version,
|
||||
actor: IssueActor::User,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.issue;
|
||||
assert_eq!(unassigned.sprint, None);
|
||||
assert!(bus.events().iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
DomainEvent::IssueSprintChanged {
|
||||
issue_ref,
|
||||
from: None,
|
||||
to: Some(target),
|
||||
..
|
||||
} if *issue_ref == original.reference() && *target == sid(1)
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_sprint_unassigns_tickets_without_deleting_them() {
|
||||
let sprints = Arc::new(FakeSprints::default());
|
||||
sprints
|
||||
.create(&project().root, &sprint(sid(1), 1, "One"))
|
||||
.await
|
||||
.unwrap();
|
||||
let issues = Arc::new(FakeIssues::default());
|
||||
let assigned = issue(1, "Assigned", Some(sid(1)));
|
||||
let backlog = issue(2, "Backlog", None);
|
||||
issues.create(&project().root, &assigned).await.unwrap();
|
||||
issues.create(&project().root, &backlog).await.unwrap();
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
|
||||
DeleteSprint::new(
|
||||
sprints.clone(),
|
||||
issues.clone(),
|
||||
Arc::new(FixedClock(2_000)),
|
||||
bus.clone(),
|
||||
)
|
||||
.execute(DeleteSprintInput {
|
||||
project: project(),
|
||||
sprint_id: sid(1),
|
||||
actor: IssueActor::System,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
sprints.get(&project().root, sid(1)).await.unwrap_err(),
|
||||
SprintStoreError::NotFound
|
||||
));
|
||||
assert_eq!(
|
||||
issues
|
||||
.get_by_ref(&project().root, assigned.reference())
|
||||
.await
|
||||
.unwrap()
|
||||
.sprint,
|
||||
None
|
||||
);
|
||||
assert!(issues
|
||||
.get_by_ref(&project().root, backlog.reference())
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(bus
|
||||
.events()
|
||||
.contains(&DomainEvent::SprintDeleted { sprint_id: sid(1) }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sprints_returns_ticket_counts() {
|
||||
let sprints = Arc::new(FakeSprints::default());
|
||||
sprints
|
||||
.create(&project().root, &sprint(sid(1), 1, "One"))
|
||||
.await
|
||||
.unwrap();
|
||||
let issues = Arc::new(FakeIssues::default());
|
||||
issues
|
||||
.create(&project().root, &issue(1, "A", Some(sid(1))))
|
||||
.await
|
||||
.unwrap();
|
||||
issues
|
||||
.create(&project().root, &issue(2, "B", Some(sid(1))))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let out = ListSprints::new(sprints, issues)
|
||||
.execute(ListSprintsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.sprints.len(), 1);
|
||||
assert_eq!(out.sprints[0].ticket_count, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user