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:
2026-07-05 10:57:33 +02:00
parent 9740149ebb
commit 48b652ff91
21 changed files with 2896 additions and 45 deletions

View File

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

View File

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

View 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)
}