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

@ -14,14 +14,15 @@ use tauri::State;
use uuid::Uuid;
use application::{
AppError, AssignIssueAgentInput, CreateIssueInput, LinkIssuesInput, ListIssuesInput,
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, UnlinkIssuesInput,
UpdateIssueCarnetInput, UpdateIssueInput,
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CreateIssueInput,
CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput,
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
};
use domain::{
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project,
ProjectId,
ProjectId, SprintId, SprintStatus, SprintVersion,
};
use infrastructure::{TicketToolError, TicketToolProvider};
@ -39,6 +40,9 @@ pub struct TicketDto {
pub description: String,
pub status: String,
pub priority: String,
pub sprint_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sprint: Option<TicketSprintContextDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub carnet: Option<String>,
pub links: Vec<TicketLinkDto>,
@ -59,6 +63,9 @@ pub struct TicketSummaryDto {
pub title: String,
pub status: String,
pub priority: String,
pub sprint_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sprint: Option<TicketSprintContextDto>,
pub assigned_agent_ids: Vec<String>,
pub updated_at: u64,
}
@ -72,6 +79,33 @@ pub struct TicketListDto {
pub next_cursor: Option<String>,
}
/// Sprint context embedded in MCP ticket read/list responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintContextDto {
pub order: u32,
pub name: String,
}
/// Public sprint DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintDto {
pub id: String,
pub order: u32,
pub name: String,
pub status: String,
pub ticket_count: usize,
pub version: u64,
}
/// Public sprint list DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintListDto {
pub items: Vec<SprintDto>,
}
/// Public link DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -131,6 +165,7 @@ pub struct TicketListRequestDto {
pub status: Option<String>,
pub priority: Option<String>,
pub assigned_agent_id: Option<String>,
pub sprint_id: Option<String>,
pub text: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
@ -206,6 +241,74 @@ pub struct TicketAssignRequestDto {
pub expected_version: u64,
}
/// Create sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintCreateRequestDto {
#[serde(default)]
pub project_id: String,
pub name: String,
pub status: Option<String>,
}
/// List sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintListRequestDto {
#[serde(default)]
pub project_id: String,
}
/// Rename sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintRenameRequestDto {
#[serde(default)]
pub project_id: String,
pub sprint_id: String,
pub name: String,
pub expected_version: u64,
}
/// Reorder sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintReorderRequestDto {
#[serde(default)]
pub project_id: String,
pub ordered_ids: Vec<String>,
}
/// Delete sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintDeleteRequestDto {
#[serde(default)]
pub project_id: String,
pub sprint_id: String,
}
/// Assign ticket to sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintAssignRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub sprint_id: String,
pub expected_version: u64,
}
/// Unassign ticket from sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintUnassignRequestDto {
#[serde(default)]
pub project_id: String,
pub r#ref: String,
pub expected_version: u64,
}
#[derive(Clone)]
pub struct AppTicketToolProvider {
pub create: Arc<application::CreateIssue>,
@ -216,6 +319,7 @@ pub struct AppTicketToolProvider {
pub update_carnet: Arc<application::UpdateIssueCarnet>,
pub link: Arc<application::LinkIssues>,
pub unlink: Arc<application::UnlinkIssues>,
pub list_sprints: Arc<application::ListSprints>,
}
#[async_trait]
@ -259,7 +363,15 @@ impl TicketToolProvider for AppTicketToolProvider {
} else {
None
};
json!(TicketDto::from_issue(issue, carnet))
let sprints = self
.list_sprints
.execute(ListSprintsInput {
project: project.clone(),
})
.await
.map_err(ticket_error)?
.sprints;
json!(TicketDto::from_issue_with_sprints(issue, carnet, &sprints))
}
"idea_ticket_list" => {
let req = mcp_list_request(project, arguments)?;
@ -272,7 +384,26 @@ impl TicketToolProvider for AppTicketToolProvider {
.await
.map_err(ticket_error)?
.issues;
json!(paginate(rows, req.limit, req.cursor))
let sprints = self
.list_sprints
.execute(ListSprintsInput {
project: project.clone(),
})
.await
.map_err(ticket_error)?
.sprints;
json!(paginate_with_sprints(rows, req.limit, req.cursor, &sprints))
}
"idea_sprint_list" => {
let rows = self
.list_sprints
.execute(ListSprintsInput {
project: project.clone(),
})
.await
.map_err(ticket_error)?
.sprints;
json!(SprintListDto::from(rows))
}
"idea_ticket_update" => {
let req = mcp_update_request(project, arguments)?;
@ -476,6 +607,152 @@ pub async fn ticket_list(
Ok(paginate(rows, page.limit, page.cursor))
}
#[tauri::command]
pub async fn sprint_create(
request: SprintCreateRequestDto,
state: State<'_, AppState>,
) -> Result<SprintDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let sprint = state
.create_sprint
.execute(CreateSprintInput {
project,
name: request.name,
status: request
.status
.as_deref()
.map(parse_sprint_status_dto)
.transpose()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
Ok(SprintDto::from_sprint(sprint, 0))
}
#[tauri::command]
pub async fn sprint_list(
request: SprintListRequestDto,
state: State<'_, AppState>,
) -> Result<SprintListDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let rows = state
.list_sprints
.execute(ListSprintsInput { project })
.await
.map_err(ErrorDto::from)?
.sprints;
Ok(SprintListDto::from(rows))
}
#[tauri::command]
pub async fn sprint_rename(
request: SprintRenameRequestDto,
state: State<'_, AppState>,
) -> Result<SprintDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let sprint = state
.rename_sprint
.execute(RenameSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: sprint_version_dto(request.expected_version)?,
name: request.name,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
Ok(SprintDto::from_sprint(sprint, 0))
}
#[tauri::command]
pub async fn sprint_reorder(
request: SprintReorderRequestDto,
state: State<'_, AppState>,
) -> Result<SprintListDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
state
.reorder_sprints
.execute(ReorderSprintsInput {
project: project.clone(),
ordered_ids: request
.ordered_ids
.iter()
.map(|id| parse_sprint_id_dto(id))
.collect::<Result<Vec<_>, _>>()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
sprint_list(
SprintListRequestDto {
project_id: project.id.to_string(),
},
state,
)
.await
}
#[tauri::command]
pub async fn sprint_delete(
request: SprintDeleteRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
state
.delete_sprint
.execute(DeleteSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)
}
#[tauri::command]
pub async fn ticket_assign_sprint(
request: TicketSprintAssignRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.assign_ticket_to_sprint
.execute(AssignTicketToSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_unassign_sprint(
request: TicketSprintUnassignRequestDto,
state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
let project = resolve_project(&state, &request.project_id).await?;
let issue = state
.unassign_ticket_from_sprint
.execute(UnassignTicketFromSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
Ok(TicketDto::from_issue(issue, None))
}
#[tauri::command]
pub async fn ticket_update(
request: TicketUpdateRequestDto,
@ -616,6 +893,8 @@ impl TicketDto {
description: issue.description.as_str().to_owned(),
status: status_wire(issue.status).to_owned(),
priority: priority_wire(issue.priority).to_owned(),
sprint_id: issue.sprint.map(|id| id.to_string()),
sprint: None,
carnet,
links: issue.links.into_iter().map(TicketLinkDto::from).collect(),
assigned_agent_ids: issue
@ -631,6 +910,19 @@ impl TicketDto {
version: issue.version.get(),
}
}
fn from_issue_with_sprints(
issue: Issue,
carnet: Option<String>,
sprints: &[application::SprintListEntry],
) -> Self {
let sprint = issue
.sprint
.and_then(|id| ticket_sprint_context(id, sprints));
let mut dto = Self::from_issue(issue, carnet);
dto.sprint = sprint;
dto
}
}
impl From<IssueIndexEntry> for TicketSummaryDto {
@ -641,6 +933,8 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
title: row.title,
status: status_wire(row.status).to_owned(),
priority: priority_wire(row.priority).to_owned(),
sprint_id: row.sprint.map(|id| id.to_string()),
sprint: None,
assigned_agent_ids: row
.assigned_agent_ids
.into_iter()
@ -651,6 +945,52 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
}
}
impl TicketSummaryDto {
fn from_row_with_sprints(
row: IssueIndexEntry,
sprints: &[application::SprintListEntry],
) -> Self {
let sprint = row.sprint.and_then(|id| ticket_sprint_context(id, sprints));
let mut dto = Self::from(row);
dto.sprint = sprint;
dto
}
}
impl SprintDto {
fn from_sprint(sprint: domain::Sprint, ticket_count: usize) -> Self {
Self {
id: sprint.id.to_string(),
order: sprint.order.get(),
name: sprint.name,
status: sprint_status_wire(sprint.status).to_owned(),
ticket_count,
version: sprint.version.get(),
}
}
}
impl From<application::SprintListEntry> for SprintDto {
fn from(entry: application::SprintListEntry) -> Self {
Self {
id: entry.sprint.id.to_string(),
order: entry.sprint.order.get(),
name: entry.sprint.name,
status: sprint_status_wire(entry.sprint.status).to_owned(),
ticket_count: entry.ticket_count,
version: entry.sprint.version.get(),
}
}
}
impl From<Vec<application::SprintListEntry>> for SprintListDto {
fn from(items: Vec<application::SprintListEntry>) -> Self {
Self {
items: items.into_iter().map(SprintDto::from).collect(),
}
}
}
impl From<IssueLink> for TicketLinkDto {
fn from(link: IssueLink) -> Self {
Self {
@ -707,6 +1047,11 @@ impl TicketListPageInput {
.as_deref()
.map(parse_agent_id_dto)
.transpose()?,
sprint: request
.sprint_id
.as_deref()
.map(parse_sprint_id_dto)
.transpose()?,
text: request.text,
},
limit: request.limit.unwrap_or(100).clamp(1, 500),
@ -819,6 +1164,42 @@ fn paginate(rows: Vec<IssueIndexEntry>, limit: usize, cursor: Option<String>) ->
}
}
fn paginate_with_sprints(
rows: Vec<IssueIndexEntry>,
limit: usize,
cursor: Option<String>,
sprints: &[application::SprintListEntry],
) -> TicketListDto {
let start = cursor
.as_deref()
.and_then(|raw| raw.parse::<usize>().ok())
.unwrap_or(0);
let total = rows.len();
let end = total.min(start.saturating_add(limit));
TicketListDto {
items: rows
.into_iter()
.skip(start)
.take(limit)
.map(|row| TicketSummaryDto::from_row_with_sprints(row, sprints))
.collect(),
next_cursor: (end < total).then(|| end.to_string()),
}
}
fn ticket_sprint_context(
sprint_id: SprintId,
sprints: &[application::SprintListEntry],
) -> Option<TicketSprintContextDto> {
sprints
.iter()
.find(|entry| entry.sprint.id == sprint_id)
.map(|entry| TicketSprintContextDto {
order: entry.sprint.order.get(),
name: entry.sprint.name.clone(),
})
}
async fn read_carnet_body(
read_carnet: &application::ReadIssueCarnet,
project: &Project,
@ -853,10 +1234,20 @@ fn parse_agent_id_dto(raw: &str) -> Result<AgentId, ErrorDto> {
.map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
}
fn parse_sprint_id_dto(raw: &str) -> Result<SprintId, ErrorDto> {
Uuid::parse_str(raw)
.map(SprintId::from_uuid)
.map_err(|e| ErrorDto::invalid(format!("invalid sprint id: {e}")))
}
fn version_dto(raw: u64) -> Result<IssueVersion, ErrorDto> {
IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}
fn sprint_version_dto(raw: u64) -> Result<SprintVersion, ErrorDto> {
SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}
fn parse_status_dto(raw: &str) -> Result<IssueStatus, ErrorDto> {
parse_status(raw).map_err(|e| ErrorDto::invalid(e.message))
}
@ -869,6 +1260,15 @@ fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
}
fn parse_sprint_status_dto(raw: &str) -> Result<SprintStatus, ErrorDto> {
match raw {
"planned" => Ok(SprintStatus::Planned),
"active" => Ok(SprintStatus::Active),
"done" => Ok(SprintStatus::Done),
_ => Err(ErrorDto::invalid(format!("invalid sprint status: {raw}"))),
}
}
fn parse_status(raw: &str) -> Result<IssueStatus, TicketToolError> {
match raw {
"open" => Ok(IssueStatus::Open),
@ -937,6 +1337,14 @@ fn link_kind_wire(kind: IssueLinkKind) -> &'static str {
}
}
fn sprint_status_wire(status: SprintStatus) -> &'static str {
match status {
SprintStatus::Planned => "planned",
SprintStatus::Active => "active",
SprintStatus::Done => "done",
}
}
fn ticket_error(err: AppError) -> TicketToolError {
let message = err.to_string();
let code = if message.contains("issue version conflict") {