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:
@ -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")
|
||||
}
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
342
crates/infrastructure/src/sprints.rs
Normal file
342
crates/infrastructure/src/sprints.rs
Normal 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")
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
127
crates/infrastructure/tests/sprint_store.rs
Normal file
127
crates/infrastructure/tests/sprint_store.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user