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

@ -4,7 +4,7 @@ use std::str::FromStr;
use domain::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
MarkdownDoc, ProjectPath,
MarkdownDoc, ProjectPath, SprintId,
};
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
use uuid::Uuid;
@ -144,6 +144,44 @@ async fn issue_store_lists_by_index_filters() {
assert_eq!(rows[0].title, "Beta");
}
#[tokio::test]
async fn issue_store_persists_and_filters_sprint_membership() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsIssueStore::new();
let sprint_id = SprintId::from_uuid(Uuid::from_u128(42));
let assigned = issue(&root, 1, "Assigned")
.mutate(IssueActor::System, 2_000, |i| {
i.sprint = Some(sprint_id);
})
.unwrap();
store.create(&root, &assigned).await.unwrap();
store
.create(&root, &issue(&root, 2, "Backlog"))
.await
.unwrap();
let loaded = store
.get_by_ref(&root, IssueRef::from_str("#1").unwrap())
.await
.unwrap();
assert_eq!(loaded.sprint, Some(sprint_id));
let rows = store
.list(
&root,
IssueListFilter {
sprint: Some(sprint_id),
..IssueListFilter::default()
},
)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Assigned");
assert_eq!(rows[0].sprint, Some(sprint_id));
}
#[tokio::test]
async fn allocator_never_reuses_numbers() {
let tmp = TempDir::new();

View File

@ -52,6 +52,7 @@ use application::{
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock,
@ -459,6 +460,59 @@ fn result_text(result: &Value) -> &str {
.expect("text content block")
}
#[derive(Clone, Default)]
struct FakeTicketTools {
calls: Arc<Mutex<Vec<String>>>,
mutation_attempts: Arc<Mutex<usize>>,
sprints: Arc<Mutex<Vec<Value>>>,
}
impl FakeTicketTools {
fn seed_sprint(&self, order: u32, name: &str) {
self.sprints.lock().unwrap().push(json!({
"id": Uuid::new_v4().to_string(),
"order": order,
"name": name,
"status": "planned",
"ticketCount": 0,
"version": 1
}));
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn mutation_attempts(&self) -> usize {
*self.mutation_attempts.lock().unwrap()
}
}
#[async_trait]
impl TicketToolProvider for FakeTicketTools {
async fn handle_ticket_tool(
&self,
_project: &Project,
_requester: &str,
name: &str,
_arguments: Value,
) -> Result<Value, TicketToolError> {
self.calls.lock().unwrap().push(name.to_owned());
match name {
"idea_sprint_list" => Ok(json!({
"items": self.sprints.lock().unwrap().clone()
})),
_ => {
*self.mutation_attempts.lock().unwrap() += 1;
Err(TicketToolError::new(
"unexpectedMutation",
format!("unexpected mutable ticket tool {name}"),
))
}
}
}
}
// ---------------------------------------------------------------------------
// 1. tools/list catalogue
// ---------------------------------------------------------------------------
@ -512,6 +566,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_sprint_list",
] {
assert!(
names.contains(&expected),
@ -521,8 +576,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
24,
"exactly the twenty-four exposed idea_* tools; got {names:?}"
25,
"exactly the twenty-five exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -696,6 +751,56 @@ async fn list_agents_returns_the_agents_inline_as_json_array() {
assert!(names.contains(&"dev-backend"));
}
#[tokio::test]
async fn sprint_list_tool_call_returns_seeded_sprints_read_only() {
let (service, _s) = build_service(FakeContexts::new());
let ticket_tools = Arc::new(FakeTicketTools::default());
ticket_tools.seed_sprint(1, "Sprint Alpha");
ticket_tools.seed_sprint(2, "Sprint Beta");
let server = server(service).with_ticket_tools(ticket_tools.clone());
let raw = tools_call(41, "idea_sprint_list", json!({}));
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
let payload: Value =
serde_json::from_str(result_text(&result)).expect("sprint list payload is JSON");
let items = payload["items"].as_array().expect("SprintListDto.items");
assert_eq!(items.len(), 2, "got payload {payload}");
assert_eq!(items[0]["order"], json!(1));
assert_eq!(items[0]["name"], json!("Sprint Alpha"));
assert_eq!(items[1]["order"], json!(2));
assert_eq!(items[1]["name"], json!("Sprint Beta"));
assert_eq!(ticket_tools.calls(), vec!["idea_sprint_list".to_owned()]);
assert_eq!(
ticket_tools.mutation_attempts(),
0,
"idea_sprint_list must be a read-only provider call"
);
let raw = tools_call(42, "idea_sprint_create", json!({ "name": "Not exposed" }));
let response = server.handle_raw(&raw).await.expect("reply owed");
let error = response.error.expect("unknown sprint mutation expected");
assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
assert!(
error.message.contains("unknown tool"),
"message should name the cause; got {}",
error.message
);
assert_eq!(
ticket_tools.calls(),
vec!["idea_sprint_list".to_owned()],
"no sprint mutation tool should be routed to the provider"
);
assert_eq!(ticket_tools.mutation_attempts(), 0);
}
// ---------------------------------------------------------------------------
// 5. A failed IdeA command ⇒ isError: true (not a transport error, no panic)
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,127 @@
use std::path::PathBuf;
use domain::{
IssueActor, ProjectPath, Sprint, SprintId, SprintOrder, SprintStatus, SprintStore,
SprintStoreError,
};
use infrastructure::FsSprintStore;
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-sprints-{}", Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn root(&self) -> ProjectPath {
ProjectPath::new(self.0.to_string_lossy().to_string()).unwrap()
}
fn child(&self, rel: &str) -> PathBuf {
self.0.join(rel)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
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()
}
#[tokio::test]
async fn sprint_store_writes_markdown_doc_and_index() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsSprintStore::new();
let sprint = sprint(sid(1), 1, "Delivery");
store.create(&root, &sprint).await.unwrap();
let sprint_md =
std::fs::read_to_string(tmp.child(&format!(".ideai/sprints/{}/sprint.md", sprint.id)))
.unwrap();
let index = std::fs::read_to_string(tmp.child(".ideai/sprints/index.json")).unwrap();
assert!(sprint_md.starts_with("---\n"));
assert!(sprint_md.contains("name: \"Delivery\""));
assert!(index.contains("\"name\": \"Delivery\""));
let loaded = store.get(&root, sprint.id).await.unwrap();
assert_eq!(loaded.name, "Delivery");
assert_eq!(loaded.order.get(), 1);
assert_eq!(loaded.status, SprintStatus::Planned);
}
#[tokio::test]
async fn sprint_store_update_checks_expected_version() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsSprintStore::new();
let original = sprint(sid(2), 1, "Plan");
store.create(&root, &original).await.unwrap();
let updated = original
.clone()
.mutate(IssueActor::System, 2_000, |sprint| {
sprint.name = "Build".to_owned();
})
.unwrap();
let err = store
.update(&root, &updated, updated.version)
.await
.unwrap_err();
assert!(matches!(err, SprintStoreError::VersionConflict { .. }));
store
.update(&root, &updated, original.version)
.await
.unwrap();
let loaded = store.get(&root, updated.id).await.unwrap();
assert_eq!(loaded.name, "Build");
assert_eq!(loaded.version.get(), 2);
}
#[tokio::test]
async fn sprint_store_lists_by_order_and_deletes() {
let tmp = TempDir::new();
let root = tmp.root();
let store = FsSprintStore::new();
let first = sprint(sid(10), 2, "Second");
let second = sprint(sid(11), 1, "First");
store.create(&root, &first).await.unwrap();
store.create(&root, &second).await.unwrap();
let rows = store.list(&root).await.unwrap();
assert_eq!(
rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
vec!["First", "Second"]
);
store.delete(&root, first.id).await.unwrap();
assert!(matches!(
store.get(&root, first.id).await.unwrap_err(),
SprintStoreError::NotFound
));
let rows = store.list(&root).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, second.id);
}