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

@ -16,7 +16,7 @@ use tokio::io::AsyncWriteExt;
use domain::{
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath,
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId,
};
const IDEAI_DIR: &str = ".ideai";
@ -211,6 +211,12 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
return false;
}
}
if filter
.sprint
.is_some_and(|sprint| row.sprint != Some(sprint))
{
return false;
}
if let Some(text) = filter
.text
.as_ref()
@ -423,6 +429,7 @@ number: {}\n\
title: {}\n\
status: {}\n\
priority: {}\n\
sprint: {}\n\
links: {}\n\
agentRefs: {}\n\
createdBy: {}\n\
@ -436,6 +443,7 @@ version: {}\n\
json_string(&issue.title),
json_value(&issue.status),
json_value(&issue.priority),
json_value(&issue.sprint),
json_value(&issue.links),
json_value(&issue.agent_refs),
json_value(&issue.created_by),
@ -475,6 +483,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
let title: String = parse_json_field(&map, "title")?;
let status: IssueStatus = parse_json_field(&map, "status")?;
let priority: IssuePriority = parse_json_field(&map, "priority")?;
let sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
@ -490,6 +499,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
description: MarkdownDoc::new(body),
status,
priority,
sprint,
carnet: MarkdownDoc::default(),
links,
agent_refs,
@ -567,6 +577,18 @@ fn parse_json_field<T: serde::de::DeserializeOwned>(
serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string()))
}
fn parse_optional_json_field<T: serde::de::DeserializeOwned>(
map: &BTreeMap<String, String>,
key: &str,
) -> Result<Option<T>, IssueStoreError> {
let Some(value) = map.get(key) else {
return Ok(None);
};
serde_json::from_str(value)
.map(Some)
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
}
fn json_value<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("serializing issue frontmatter cannot fail")
}

View File

@ -35,6 +35,7 @@ pub mod runtime;
pub mod sandbox;
pub mod scheduler;
pub mod session;
pub mod sprints;
pub mod store;
pub mod timeparse;
@ -79,6 +80,7 @@ pub use sandbox::LandlockSandbox;
pub use sandbox::{default_enforcer, NoopSandbox};
pub use scheduler::TokioScheduler;
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
pub use sprints::FsSprintStore;
#[cfg(feature = "vector-onnx")]
pub use store::OnnxEmbedder;
#[cfg(feature = "vector-http")]

View File

@ -39,7 +39,7 @@ impl TicketToolError {
/// Provider injected by the composition root to execute public ticket tools.
#[async_trait]
pub trait TicketToolProvider: Send + Sync {
/// Executes one public `idea_ticket_*` tool for `project`.
/// Executes one public ticket/sprint planning tool for `project`.
async fn handle_ticket_tool(
&self,
project: &Project,
@ -64,6 +64,7 @@ pub fn is_ticket_tool(name: &str) -> bool {
| "idea_ticket_update_carnet"
| "idea_ticket_link"
| "idea_ticket_unlink"
| "idea_sprint_list"
)
}
@ -234,5 +235,14 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_sprint_list",
description: "List IdeA sprints in execution order. Read-only.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
]
}

View File

@ -0,0 +1,342 @@
//! Filesystem sprint store.
//!
//! Project-scoped sprints live under `<root>/.ideai/sprints/`, one directory per
//! stable sprint id. The Markdown files are the source of truth; `index.json` is
//! a reconstructible projection kept in sync after writes.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::{
IssueActor, ProjectPath, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus,
SprintStore, SprintStoreError, SprintVersion,
};
const IDEAI_DIR: &str = ".ideai";
const SPRINTS_DIR: &str = "sprints";
const SPRINT_FILE: &str = "sprint.md";
const INDEX_FILE: &str = "index.json";
const INDEX_VERSION: u32 = 1;
/// Filesystem-backed sprint store.
#[derive(Debug, Clone, Default)]
pub struct FsSprintStore;
impl FsSprintStore {
/// Builds a store.
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
version: u32,
sprints: Vec<SprintIndexEntry>,
}
fn sprint_root(root: &ProjectPath) -> PathBuf {
PathBuf::from(root.as_str())
.join(IDEAI_DIR)
.join(SPRINTS_DIR)
}
fn sprint_dir(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
sprint_root(root).join(sprint_id.to_string())
}
fn sprint_path(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
sprint_dir(root, sprint_id).join(SPRINT_FILE)
}
fn index_path(root: &ProjectPath) -> PathBuf {
sprint_root(root).join(INDEX_FILE)
}
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), SprintStoreError> {
let parent = path
.parent()
.ok_or_else(|| SprintStoreError::Store("path has no parent".to_owned()))?;
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
let tmp = path.with_extension("tmp");
tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
tokio::fs::rename(&tmp, path).await.map_err(io_error)
}
async fn read_string(path: &Path) -> Result<String, SprintStoreError> {
let bytes = tokio::fs::read(path).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
SprintStoreError::NotFound
} else {
io_error(err)
}
})?;
String::from_utf8(bytes).map_err(|err| SprintStoreError::Store(err.to_string()))
}
fn io_error(err: std::io::Error) -> SprintStoreError {
SprintStoreError::Store(err.to_string())
}
async fn load_sprint(root: &ProjectPath, sprint_id: SprintId) -> Result<Sprint, SprintStoreError> {
let text = read_string(&sprint_path(root, sprint_id)).await?;
parse_sprint_doc(&text)
}
async fn save_sprint(root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
let dir = sprint_dir(root, sprint.id);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
write_atomic(&dir.join(SPRINT_FILE), render_sprint_doc(sprint).as_bytes()).await
}
async fn rebuild_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
let dir = sprint_root(root);
let mut rows = Vec::new();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
write_index(root, &rows).await?;
return Ok(rows);
}
Err(err) => return Err(io_error(err)),
};
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
let Ok(file_type) = entry.file_type().await else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let Ok(id) = uuid::Uuid::parse_str(&name) else {
continue;
};
if let Ok(sprint) = load_sprint(root, SprintId::from_uuid(id)).await {
rows.push(SprintIndexEntry::from(&sprint));
}
}
rows.sort_by_key(|row| row.order.get());
write_index(root, &rows).await?;
Ok(rows)
}
async fn read_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
match tokio::fs::read(index_path(root)).await {
Ok(bytes) => {
let doc: IndexDoc = serde_json::from_slice(&bytes)
.map_err(|err| SprintStoreError::Store(err.to_string()))?;
Ok(doc.sprints)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await,
Err(err) => Err(io_error(err)),
}
}
async fn write_index(
root: &ProjectPath,
rows: &[SprintIndexEntry],
) -> Result<(), SprintStoreError> {
let doc = IndexDoc {
version: INDEX_VERSION,
sprints: rows.to_vec(),
};
let bytes =
serde_json::to_vec_pretty(&doc).map_err(|err| SprintStoreError::Store(err.to_string()))?;
write_atomic(&index_path(root), &bytes).await
}
#[async_trait]
impl SprintStore for FsSprintStore {
async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
sprint
.validate()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
if tokio::fs::try_exists(sprint_path(root, sprint.id))
.await
.map_err(io_error)?
{
return Err(SprintStoreError::Invalid(format!(
"sprint {} already exists",
sprint.id
)));
}
save_sprint(root, sprint).await?;
rebuild_index(root).await?;
Ok(())
}
async fn get(
&self,
root: &ProjectPath,
sprint_id: SprintId,
) -> Result<Sprint, SprintStoreError> {
load_sprint(root, sprint_id).await
}
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
let mut rows = read_index(root).await?;
rows.sort_by_key(|row| row.order.get());
Ok(rows)
}
async fn update(
&self,
root: &ProjectPath,
sprint: &Sprint,
expected_version: SprintVersion,
) -> Result<(), SprintStoreError> {
sprint
.validate()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
let current = load_sprint(root, sprint.id).await?;
if current.version != expected_version {
return Err(SprintStoreError::VersionConflict {
expected: expected_version,
actual: current.version,
});
}
save_sprint(root, sprint).await?;
rebuild_index(root).await?;
Ok(())
}
async fn delete(
&self,
root: &ProjectPath,
sprint_id: SprintId,
) -> Result<(), SprintStoreError> {
if !tokio::fs::try_exists(sprint_path(root, sprint_id))
.await
.map_err(io_error)?
{
return Err(SprintStoreError::NotFound);
}
tokio::fs::remove_dir_all(sprint_dir(root, sprint_id))
.await
.map_err(io_error)?;
rebuild_index(root).await?;
Ok(())
}
}
fn render_sprint_doc(sprint: &Sprint) -> String {
format!(
"---\n\
id: {}\n\
order: {}\n\
name: {}\n\
status: {}\n\
createdBy: {}\n\
updatedBy: {}\n\
createdAt: {}\n\
updatedAt: {}\n\
version: {}\n\
---\n",
json_string(&sprint.id.to_string()),
sprint.order.get(),
json_string(&sprint.name),
json_value(&sprint.status),
json_value(&sprint.created_by),
json_value(&sprint.updated_by),
sprint.created_at,
sprint.updated_at,
sprint.version.get(),
)
}
fn parse_sprint_doc(text: &str) -> Result<Sprint, SprintStoreError> {
let (fm, _body) = split_frontmatter(text)?;
let map = parse_frontmatter_map(fm)?;
let id_raw: String = parse_json_field(&map, "id")?;
let id = SprintId::from_uuid(
uuid::Uuid::parse_str(&id_raw).map_err(|err| SprintStoreError::Invalid(err.to_string()))?,
);
let order = SprintOrder::new(parse_plain_u32(&map, "order")?)
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
let name: String = parse_json_field(&map, "name")?;
let status: SprintStatus = parse_json_field(&map, "status")?;
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
let created_at = parse_plain_u64(&map, "createdAt")?;
let updated_at = parse_plain_u64(&map, "updatedAt")?;
let version = SprintVersion::new(parse_plain_u64(&map, "version")?)
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
Sprint::rehydrate(Sprint {
id,
order,
name,
status,
created_by,
updated_by,
created_at,
updated_at,
version,
})
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn split_frontmatter(text: &str) -> Result<(&str, &str), SprintStoreError> {
let rest = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))
.ok_or_else(|| SprintStoreError::Invalid("missing opening frontmatter fence".to_owned()))?;
let mut offset = 0;
for line in rest.split_inclusive('\n') {
if line.trim_end_matches(['\r', '\n']) == "---" {
let body_start = offset + line.len();
return Ok((&rest[..offset], &rest[body_start..]));
}
offset += line.len();
}
Err(SprintStoreError::Invalid(
"missing closing frontmatter fence".to_owned(),
))
}
fn parse_frontmatter_map(block: &str) -> Result<BTreeMap<String, String>, SprintStoreError> {
let mut map = BTreeMap::new();
for line in block.lines().filter(|line| !line.trim().is_empty()) {
let (key, value) = line
.split_once(':')
.ok_or_else(|| SprintStoreError::Invalid("frontmatter line missing ':'".to_owned()))?;
map.insert(key.trim().to_owned(), value.trim().to_owned());
}
Ok(map)
}
fn parse_plain_u32(map: &BTreeMap<String, String>, key: &str) -> Result<u32, SprintStoreError> {
map.get(key)
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
.parse::<u32>()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn parse_plain_u64(map: &BTreeMap<String, String>, key: &str) -> Result<u64, SprintStoreError> {
map.get(key)
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
.parse::<u64>()
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn parse_json_field<T: serde::de::DeserializeOwned>(
map: &BTreeMap<String, String>,
key: &str,
) -> Result<T, SprintStoreError> {
let value = map
.get(key)
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?;
serde_json::from_str(value).map_err(|err| SprintStoreError::Invalid(err.to_string()))
}
fn json_value<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("serializing sprint frontmatter cannot fail")
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).expect("serializing sprint string cannot fail")
}