diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 1430a94..20a7734 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -299,6 +299,52 @@ pub enum DomainEventDto { /// New optimistic version. version: u64, }, + /// A sprint was created. + #[serde(rename_all = "camelCase")] + SprintCreated { + /// Stable sprint id. + sprint_id: String, + /// Reorderable order. + order: u32, + }, + /// A sprint was renamed. + #[serde(rename_all = "camelCase")] + SprintRenamed { + /// Stable sprint id. + sprint_id: String, + /// New name. + name: String, + /// New optimistic version. + version: u64, + }, + /// A sprint was reordered. + #[serde(rename_all = "camelCase")] + SprintReordered { + /// Stable sprint id. + sprint_id: String, + /// New order. + order: u32, + /// New optimistic version. + version: u64, + }, + /// A sprint was deleted. + #[serde(rename_all = "camelCase")] + SprintDeleted { + /// Stable sprint id. + sprint_id: String, + }, + /// A ticket changed sprint membership. + #[serde(rename_all = "camelCase")] + IssueSprintChanged { + /// Public ticket reference (`#N`). + issue_ref: String, + /// Previous sprint id. + from: Option, + /// New sprint id. + to: Option, + /// New optimistic version. + version: u64, + }, /// An orchestrator request was processed on behalf of a requester agent. #[serde(rename_all = "camelCase")] OrchestratorRequestProcessed { @@ -773,6 +819,42 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), version: version.get(), }, + DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated { + sprint_id: sprint_id.to_string(), + order: order.get(), + }, + DomainEvent::SprintRenamed { + sprint_id, + name, + version, + } => Self::SprintRenamed { + sprint_id: sprint_id.to_string(), + name: name.clone(), + version: version.get(), + }, + DomainEvent::SprintReordered { + sprint_id, + order, + version, + } => Self::SprintReordered { + sprint_id: sprint_id.to_string(), + order: order.get(), + version: version.get(), + }, + DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted { + sprint_id: sprint_id.to_string(), + }, + DomainEvent::IssueSprintChanged { + issue_ref, + from, + to, + version, + } => Self::IssueSprintChanged { + issue_ref: issue_ref.to_string(), + from: from.map(|id| id.to_string()), + to: to.map(|id| id.to_string()), + version: version.get(), + }, DomainEvent::OrchestratorRequestProcessed { requester_id, action, @@ -981,4 +1063,22 @@ mod tests { assert_eq!(json["agentId"], agent(11).to_string()); assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64); } + + #[test] + fn issue_sprint_changed_relays_to_dto_and_wire() { + let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41)); + let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42)); + let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged { + issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()), + from: Some(from), + to: Some(to), + version: domain::IssueVersion::new(3).unwrap(), + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "issueSprintChanged"); + assert_eq!(json["issueRef"], "#7"); + assert_eq!(json["from"], from.to_string()); + assert_eq!(json["to"], to.to_string()); + assert_eq!(json["version"], 3); + } } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 2d982b0..3323ac7 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -173,6 +173,13 @@ pub fn run() { tickets::ticket_link, tickets::ticket_unlink, tickets::ticket_assign, + tickets::sprint_create, + tickets::sprint_list, + tickets::sprint_rename, + tickets::sprint_reorder, + tickets::sprint_delete, + tickets::ticket_assign_sprint, + tickets::ticket_unassign_sprint, commands::get_project_work_state, commands::read_conversation_page, commands::list_live_agents, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index ace4b4b..2065036 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -13,30 +13,32 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, - AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile, - CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, - ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, - CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, - DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, - DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, - GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues, - ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills, - ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, - LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, - OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext, - ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, RetryBackgroundTask, - RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout, - SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, - UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, - UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, + ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, + ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, + CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, + CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, + DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, + DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory, + GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, + MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, + ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, + ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, + RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, + RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, + UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, + UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -45,7 +47,7 @@ use domain::ports::{ EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, - TemplateStore, WakeError, WakeReason, + SprintStore, TemplateStore, WakeError, WakeReason, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -66,7 +68,7 @@ use infrastructure::{ FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, + FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, @@ -804,6 +806,20 @@ pub struct AppState { pub unlink_issues: Arc, /// Assign or unassign an agent on a public ticket. pub assign_issue_agent: Arc, + /// Create a sprint. + pub create_sprint: Arc, + /// List sprints. + pub list_sprints: Arc, + /// Rename a sprint. + pub rename_sprint: Arc, + /// Reorder sprints. + pub reorder_sprints: Arc, + /// Delete a sprint. + pub delete_sprint: Arc, + /// Assign a ticket to a sprint. + pub assign_ticket_to_sprint: Arc, + /// Unassign a ticket from its sprint. + pub unassign_ticket_from_sprint: Arc, /// MCP provider for the public `idea_ticket_*` tools. pub(crate) ticket_tool_provider: Arc, /// Generic PTY↔Channel bridge registry (consumed by L3). @@ -1213,6 +1229,45 @@ impl AppState { Arc::clone(&clock) as Arc, Arc::clone(&events_port), )); + let sprint_store = Arc::new(FsSprintStore::new()); + let sprint_store_port = Arc::clone(&sprint_store) as Arc; + let create_sprint = Arc::new(CreateSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let list_sprints = Arc::new(ListSprints::new( + Arc::clone(&sprint_store_port), + Arc::clone(&issue_store_port), + )); + let rename_sprint = Arc::new(RenameSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let reorder_sprints = Arc::new(ReorderSprints::new( + Arc::clone(&sprint_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let delete_sprint = Arc::new(DeleteSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let assign_ticket_to_sprint = Arc::new(AssignTicketToSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let unassign_ticket_from_sprint = Arc::new(UnassignTicketFromSprint::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); let ticket_tool_provider: Arc = Arc::new(AppTicketToolProvider { create: Arc::clone(&create_issue), read: Arc::clone(&read_issue), @@ -1222,6 +1277,7 @@ impl AppState { update_carnet: Arc::clone(&update_issue_carnet), link: Arc::clone(&link_issues), unlink: Arc::clone(&unlink_issues), + list_sprints: Arc::clone(&list_sprints), }); // --- Project permissions (LP1) --- @@ -2114,6 +2170,13 @@ impl AppState { link_issues, unlink_issues, assign_issue_agent, + create_sprint, + list_sprints, + rename_sprint, + reorder_sprints, + delete_sprint, + assign_ticket_to_sprint, + unassign_ticket_from_sprint, ticket_tool_provider, pty_bridge, structured_sessions, @@ -4194,6 +4257,7 @@ mod mcp_serve_peer_tests { "idea_ticket_update_carnet", "idea_ticket_link", "idea_ticket_unlink", + "idea_sprint_list", ] { assert!( names.contains(&expected), @@ -4203,8 +4267,8 @@ mod mcp_serve_peer_tests { 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:?}" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index 42271fe..52194b3 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub sprint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub carnet: Option, pub links: Vec, @@ -59,6 +63,9 @@ pub struct TicketSummaryDto { pub title: String, pub status: String, pub priority: String, + pub sprint_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sprint: Option, pub assigned_agent_ids: Vec, pub updated_at: u64, } @@ -72,6 +79,33 @@ pub struct TicketListDto { pub next_cursor: Option, } +/// 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, +} + /// Public link DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -131,6 +165,7 @@ pub struct TicketListRequestDto { pub status: Option, pub priority: Option, pub assigned_agent_id: Option, + pub sprint_id: Option, pub text: Option, pub limit: Option, pub cursor: Option, @@ -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, +} + +/// 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, +} + +/// 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, @@ -216,6 +319,7 @@ pub struct AppTicketToolProvider { pub update_carnet: Arc, pub link: Arc, pub unlink: Arc, + pub list_sprints: Arc, } #[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 { + 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 { + 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 { + 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 { + 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::, _>>()?, + 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 { + 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 { + 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, + 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 for TicketSummaryDto { @@ -641,6 +933,8 @@ impl From 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 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 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> for SprintListDto { + fn from(items: Vec) -> Self { + Self { + items: items.into_iter().map(SprintDto::from).collect(), + } + } +} + impl From 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, limit: usize, cursor: Option) -> } } +fn paginate_with_sprints( + rows: Vec, + limit: usize, + cursor: Option, + sprints: &[application::SprintListEntry], +) -> TicketListDto { + let start = cursor + .as_deref() + .and_then(|raw| raw.parse::().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 { + 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 { .map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}"))) } +fn parse_sprint_id_dto(raw: &str) -> Result { + 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::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) } +fn sprint_version_dto(raw: u64) -> Result { + SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string())) +} + fn parse_status_dto(raw: &str) -> Result { parse_status(raw).map_err(|e| ErrorDto::invalid(e.message)) } @@ -869,6 +1260,15 @@ fn parse_link_kind_dto(raw: &str) -> Result { parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message)) } +fn parse_sprint_status_dto(raw: &str) -> Result { + 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 { 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") { diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index fb7205f..07a3be3 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -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 for AppError { } } +impl From 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 for AppError { fn from(e: MemoryError) -> Self { match e { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 08d0529..b7c0c33 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/sprints/mod.rs b/crates/application/src/sprints/mod.rs new file mode 100644 index 0000000..21ee580 --- /dev/null +++ b/crates/application/src/sprints/mod.rs @@ -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, + /// 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, + ids: Arc, + clock: Arc, + events: Arc, +} + +impl CreateSprint { + /// Builds the use case. + #[must_use] + pub fn new( + sprints: Arc, + ids: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + sprints, + ids, + clock, + events, + } + } + + /// Executes creation. + /// + /// # Errors + /// [`AppError`] on invariant or store failure. + pub async fn execute(&self, input: CreateSprintInput) -> Result { + 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, +} + +/// Lists sprints with derived ticket counts. +pub struct ListSprints { + sprints: Arc, + issues: Arc, +} + +impl ListSprints { + /// Builds the use case. + #[must_use] + pub fn new(sprints: Arc, issues: Arc) -> Self { + Self { sprints, issues } + } + + /// Executes list. + /// + /// # Errors + /// [`AppError`] on store failure. + pub async fn execute(&self, input: ListSprintsInput) -> Result { + 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, + clock: Arc, + events: Arc, +} + +impl RenameSprint { + /// Builds the use case. + #[must_use] + pub fn new( + sprints: Arc, + clock: Arc, + events: Arc, + ) -> Self { + Self { + sprints, + clock, + events, + } + } + + /// Executes rename. + /// + /// # Errors + /// [`AppError`] on store failure or conflict. + pub async fn execute(&self, input: RenameSprintInput) -> Result { + 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, + /// 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, +} + +/// Reorders all sprints and normalises orders to 1..n. +pub struct ReorderSprints { + sprints: Arc, + clock: Arc, + events: Arc, +} + +impl ReorderSprints { + /// Builds the use case. + #[must_use] + pub fn new( + sprints: Arc, + clock: Arc, + events: Arc, + ) -> 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 { + let rows = self.sprints.list(&input.project.root).await?; + let current_ids: HashSet = rows.iter().map(|row| row.id).collect(); + let ordered_ids: HashSet = 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, + issues: Arc, + clock: Arc, + events: Arc, +} + +impl DeleteSprint { + /// Builds the use case. + #[must_use] + pub fn new( + sprints: Arc, + issues: Arc, + clock: Arc, + events: Arc, + ) -> 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, + 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, + issues: Arc, + clock: Arc, + events: Arc, +} + +impl AssignTicketToSprint { + /// Builds the use case. + #[must_use] + pub fn new( + sprints: Arc, + issues: Arc, + clock: Arc, + events: Arc, + ) -> 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 { + 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, + clock: Arc, + events: Arc, +} + +impl UnassignTicketFromSprint { + /// Builds the use case. + #[must_use] + pub fn new( + issues: Arc, + clock: Arc, + events: Arc, + ) -> 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 { + 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, + clock: &Arc, + events: &Arc, + project: Project, + issue_ref: IssueRef, + sprint_id: Option, + expected_version: IssueVersion, + actor: IssueActor, +) -> Result { + 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, + issue_ref: IssueRef, + from: Option, + to: Option, + version: IssueVersion, +) { + events.publish(DomainEvent::IssueUpdated { issue_ref, version }); + events.publish(DomainEvent::IssueSprintChanged { + issue_ref, + from, + to, + version, + }); +} + +fn now(clock: &Arc) -> 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, AppError> { + let by_id: HashMap = 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) +} diff --git a/crates/application/tests/sprint_usecases.rs b/crates/application/tests/sprint_usecases.rs new file mode 100644 index 0000000..c138c5a --- /dev/null +++ b/crates/application/tests/sprint_usecases.rs @@ -0,0 +1,513 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError}; +use domain::{ + DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter, + IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project, + ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder, + SprintStatus, SprintStore, SprintStoreError, SprintVersion, +}; +use uuid::Uuid; + +use application::{ + normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput, CreateSprint, + CreateSprintInput, DeleteSprint, DeleteSprintInput, ListSprints, ListSprintsInput, + RenameSprint, RenameSprintInput, ReorderSprints, ReorderSprintsInput, UnassignTicketFromSprint, + UnassignTicketFromSprintInput, +}; + +#[derive(Default)] +struct FakeSprints { + sprints: Mutex>, +} + +#[async_trait] +impl SprintStore for FakeSprints { + async fn create(&self, _root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> { + self.sprints + .lock() + .unwrap() + .insert(sprint.id, sprint.clone()); + Ok(()) + } + + async fn get( + &self, + _root: &ProjectPath, + sprint_id: SprintId, + ) -> Result { + self.sprints + .lock() + .unwrap() + .get(&sprint_id) + .cloned() + .ok_or(SprintStoreError::NotFound) + } + + async fn list(&self, _root: &ProjectPath) -> Result, SprintStoreError> { + let mut rows: Vec<_> = self + .sprints + .lock() + .unwrap() + .values() + .map(SprintIndexEntry::from) + .collect(); + rows.sort_by_key(|row| row.order.get()); + Ok(rows) + } + + async fn update( + &self, + _root: &ProjectPath, + sprint: &Sprint, + expected_version: SprintVersion, + ) -> Result<(), SprintStoreError> { + let mut sprints = self.sprints.lock().unwrap(); + let current = sprints.get(&sprint.id).ok_or(SprintStoreError::NotFound)?; + if current.version != expected_version { + return Err(SprintStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + sprints.insert(sprint.id, sprint.clone()); + Ok(()) + } + + async fn delete( + &self, + _root: &ProjectPath, + sprint_id: SprintId, + ) -> Result<(), SprintStoreError> { + self.sprints + .lock() + .unwrap() + .remove(&sprint_id) + .map(|_| ()) + .ok_or(SprintStoreError::NotFound) + } +} + +#[derive(Default)] +struct FakeIssues { + issues: Mutex>, +} + +#[async_trait] +impl IssueStore for FakeIssues { + async fn create(&self, _root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { + self.issues + .lock() + .unwrap() + .insert(issue.reference(), issue.clone()); + Ok(()) + } + + async fn get_by_ref( + &self, + _root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result { + self.issues + .lock() + .unwrap() + .get(&issue_ref) + .cloned() + .ok_or(IssueStoreError::NotFound) + } + + async fn list( + &self, + _root: &ProjectPath, + filter: IssueListFilter, + ) -> Result, IssueStoreError> { + let mut rows: Vec = self + .issues + .lock() + .unwrap() + .values() + .filter(|issue| { + filter + .sprint + .map_or(true, |sprint| issue.sprint == Some(sprint)) + }) + .map(IssueIndexEntry::from) + .collect(); + rows.sort_by_key(|row| row.issue_ref.number().get()); + Ok(rows) + } + + async fn update( + &self, + _root: &ProjectPath, + issue: &Issue, + expected_version: IssueVersion, + ) -> Result<(), IssueStoreError> { + let mut issues = self.issues.lock().unwrap(); + let current = issues + .get(&issue.reference()) + .ok_or(IssueStoreError::NotFound)?; + if current.version != expected_version { + return Err(IssueStoreError::VersionConflict { + expected: expected_version, + actual: current.version, + }); + } + issues.insert(issue.reference(), issue.clone()); + Ok(()) + } + + async fn read_carnet( + &self, + _root: &ProjectPath, + issue_ref: IssueRef, + ) -> Result { + let issue = self.get_by_ref(_root, issue_ref).await?; + Ok(IssueCarnet { + issue_ref, + carnet: issue.carnet, + version: issue.version, + updated_by: issue.updated_by, + updated_at: issue.updated_at, + }) + } + + async fn write_carnet( + &self, + _root: &ProjectPath, + _issue_ref: IssueRef, + _carnet: MarkdownDoc, + _actor: IssueActor, + _now_ms: u64, + _expected_version: IssueVersion, + ) -> Result { + unimplemented!("not needed") + } +} + +struct SeqIds(Mutex); + +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut next = self.0.lock().unwrap(); + let id = Uuid::from_u128(*next); + *next += 1; + id + } +} + +struct FixedClock(i64); + +impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } +} + +#[derive(Default)] +struct SpyBus(Mutex>); + +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + + fn subscribe(&self) -> EventStream { + Box::new(Vec::new().into_iter()) + } +} + +fn project() -> Project { + Project { + id: ProjectId::from_uuid(Uuid::from_u128(900)), + name: "P".to_owned(), + root: ProjectPath::new("/tmp/idea-sprint-usecases").unwrap(), + remote: RemoteRef::Local, + created_at: 1_000, + } +} + +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() +} + +fn issue(number: u64, title: &str, sprint_id: Option) -> Issue { + Issue::new( + IssueId::from_uuid(Uuid::from_u128(number as u128)), + IssueNumber::new(number).unwrap(), + title, + MarkdownDoc::default(), + IssueStatus::Open, + IssuePriority::Medium, + MarkdownDoc::default(), + Vec::new(), + Vec::new(), + IssueActor::User, + 1_000, + ) + .unwrap() + .mutate(IssueActor::System, 1_000, |issue| issue.sprint = sprint_id) + .unwrap() +} + +#[tokio::test] +async fn create_sprint_uses_max_order_plus_one_and_publishes_event() { + let sprints = Arc::new(FakeSprints::default()); + sprints + .create(&project().root, &sprint(sid(1), 2, "Existing")) + .await + .unwrap(); + let bus = Arc::new(SpyBus::default()); + let usecase = CreateSprint::new( + sprints, + Arc::new(SeqIds(Mutex::new(10))), + Arc::new(FixedClock(2_000)), + bus.clone(), + ); + + let out = usecase + .execute(CreateSprintInput { + project: project(), + name: "Next".to_owned(), + status: Some(SprintStatus::Active), + actor: IssueActor::User, + }) + .await + .unwrap(); + + assert_eq!(out.sprint.id, sid(10)); + assert_eq!(out.sprint.order.get(), 3); + assert_eq!(out.sprint.status, SprintStatus::Active); + assert!(bus.events().contains(&DomainEvent::SprintCreated { + sprint_id: sid(10), + order: SprintOrder::new(3).unwrap(), + })); +} + +#[tokio::test] +async fn rename_and_reorder_publish_sprint_events() { + let sprints = Arc::new(FakeSprints::default()); + let first = sprint(sid(1), 1, "One"); + let second = sprint(sid(2), 2, "Two"); + sprints.create(&project().root, &first).await.unwrap(); + sprints.create(&project().root, &second).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + + let renamed = RenameSprint::new(sprints.clone(), Arc::new(FixedClock(2_000)), bus.clone()) + .execute(RenameSprintInput { + project: project(), + sprint_id: sid(1), + expected_version: first.version, + name: "Renamed".to_owned(), + actor: IssueActor::User, + }) + .await + .unwrap() + .sprint; + assert_eq!(renamed.name, "Renamed"); + + let reordered = ReorderSprints::new(sprints.clone(), Arc::new(FixedClock(3_000)), bus.clone()) + .execute(ReorderSprintsInput { + project: project(), + ordered_ids: vec![sid(2), sid(1)], + actor: IssueActor::System, + }) + .await + .unwrap(); + + assert_eq!( + reordered + .sprints + .iter() + .map(|sprint| (sprint.id, sprint.order.get())) + .collect::>(), + vec![(sid(2), 1), (sid(1), 2)] + ); + assert!(bus.events().iter().any(|event| { + matches!( + event, + DomainEvent::SprintRenamed { + sprint_id, + name, + .. + } if *sprint_id == sid(1) && name == "Renamed" + ) + })); + assert!(bus.events().iter().any(|event| { + matches!( + event, + DomainEvent::SprintReordered { sprint_id, order, .. } + if *sprint_id == sid(2) && order.get() == 1 + ) + })); +} + +#[test] +fn normalized_reorder_rejects_incomplete_or_duplicate_ids() { + let sprints = vec![sprint(sid(1), 1, "One"), sprint(sid(2), 2, "Two")]; + assert!(normalized_reorder(&sprints, &[sid(1)], IssueActor::User, 2_000).is_err()); + assert!(normalized_reorder(&sprints, &[sid(1), sid(1)], IssueActor::User, 2_000).is_err()); +} + +#[tokio::test] +async fn assign_and_unassign_ticket_to_sprint_use_issue_expected_version() { + let sprints = Arc::new(FakeSprints::default()); + sprints + .create(&project().root, &sprint(sid(1), 1, "One")) + .await + .unwrap(); + let issues = Arc::new(FakeIssues::default()); + let original = issue(1, "Ticket", None); + issues.create(&project().root, &original).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + let assign = AssignTicketToSprint::new( + sprints, + issues.clone(), + Arc::new(FixedClock(2_000)), + bus.clone(), + ); + + let conflict = assign + .execute(AssignTicketToSprintInput { + project: project(), + issue_ref: original.reference(), + sprint_id: sid(1), + expected_version: original.version.next(), + actor: IssueActor::User, + }) + .await + .unwrap_err(); + assert_eq!(conflict.code(), "INVALID"); + + let assigned = assign + .execute(AssignTicketToSprintInput { + project: project(), + issue_ref: original.reference(), + sprint_id: sid(1), + expected_version: original.version, + actor: IssueActor::User, + }) + .await + .unwrap() + .issue; + assert_eq!(assigned.sprint, Some(sid(1))); + + let unassigned = + UnassignTicketFromSprint::new(issues, Arc::new(FixedClock(3_000)), bus.clone()) + .execute(UnassignTicketFromSprintInput { + project: project(), + issue_ref: original.reference(), + expected_version: assigned.version, + actor: IssueActor::User, + }) + .await + .unwrap() + .issue; + assert_eq!(unassigned.sprint, None); + assert!(bus.events().iter().any(|event| { + matches!( + event, + DomainEvent::IssueSprintChanged { + issue_ref, + from: None, + to: Some(target), + .. + } if *issue_ref == original.reference() && *target == sid(1) + ) + })); +} + +#[tokio::test] +async fn delete_sprint_unassigns_tickets_without_deleting_them() { + let sprints = Arc::new(FakeSprints::default()); + sprints + .create(&project().root, &sprint(sid(1), 1, "One")) + .await + .unwrap(); + let issues = Arc::new(FakeIssues::default()); + let assigned = issue(1, "Assigned", Some(sid(1))); + let backlog = issue(2, "Backlog", None); + issues.create(&project().root, &assigned).await.unwrap(); + issues.create(&project().root, &backlog).await.unwrap(); + let bus = Arc::new(SpyBus::default()); + + DeleteSprint::new( + sprints.clone(), + issues.clone(), + Arc::new(FixedClock(2_000)), + bus.clone(), + ) + .execute(DeleteSprintInput { + project: project(), + sprint_id: sid(1), + actor: IssueActor::System, + }) + .await + .unwrap(); + + assert!(matches!( + sprints.get(&project().root, sid(1)).await.unwrap_err(), + SprintStoreError::NotFound + )); + assert_eq!( + issues + .get_by_ref(&project().root, assigned.reference()) + .await + .unwrap() + .sprint, + None + ); + assert!(issues + .get_by_ref(&project().root, backlog.reference()) + .await + .is_ok()); + assert!(bus + .events() + .contains(&DomainEvent::SprintDeleted { sprint_id: sid(1) })); +} + +#[tokio::test] +async fn list_sprints_returns_ticket_counts() { + let sprints = Arc::new(FakeSprints::default()); + sprints + .create(&project().root, &sprint(sid(1), 1, "One")) + .await + .unwrap(); + let issues = Arc::new(FakeIssues::default()); + issues + .create(&project().root, &issue(1, "A", Some(sid(1)))) + .await + .unwrap(); + issues + .create(&project().root, &issue(2, "B", Some(sid(1)))) + .await + .unwrap(); + + let out = ListSprints::new(sprints, issues) + .execute(ListSprintsInput { project: project() }) + .await + .unwrap(); + + assert_eq!(out.sprints.len(), 1); + assert_eq!(out.sprints[0].ticket_count, 2); +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index a25a0ee..70186e3 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -2,10 +2,13 @@ //! presentation layer (ARCHITECTURE §3.2). use crate::conversation::ConversationParty; -use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TaskId, TemplateId}; +use crate::ids::{ + AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId, +}; use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion}; use crate::mailbox::TicketId; use crate::memory::MemorySlug; +use crate::sprint::{SprintOrder, SprintVersion}; use crate::template::TemplateVersion; /// Which entry door a processed orchestration request arrived through. @@ -367,6 +370,47 @@ pub enum DomainEvent { /// New optimistic version. version: IssueVersion, }, + /// A sprint was created. + SprintCreated { + /// Stable sprint id. + sprint_id: SprintId, + /// Reorderable sprint order. + order: SprintOrder, + }, + /// A sprint was renamed. + SprintRenamed { + /// Stable sprint id. + sprint_id: SprintId, + /// New sprint name. + name: String, + /// New optimistic version. + version: SprintVersion, + }, + /// Sprint orders changed. + SprintReordered { + /// Stable sprint id. + sprint_id: SprintId, + /// New sprint order. + order: SprintOrder, + /// New optimistic version. + version: SprintVersion, + }, + /// A sprint was deleted. + SprintDeleted { + /// Stable sprint id. + sprint_id: SprintId, + }, + /// An issue was moved into, out of, or between sprints. + IssueSprintChanged { + /// Human-friendly issue reference. + issue_ref: IssueRef, + /// Previous sprint. + from: Option, + /// New sprint. + to: Option, + /// New optimistic version. + version: IssueVersion, + }, /// A project's memory grew past the recall budget while no embedder is /// configured (strategy `none`): the first moment a semantic embedder would /// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index 01f22a5..b65e945 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -76,6 +76,10 @@ typed_id!( /// Identifies a [`crate::issue::Issue`]. IssueId ); +typed_id!( + /// Identifies a [`crate::sprint::Sprint`]. + SprintId +); typed_id!( /// Identifies a [`crate::terminal::TerminalSession`]. SessionId diff --git a/crates/domain/src/issue.rs b/crates/domain/src/issue.rs index b25047a..75cd342 100644 --- a/crates/domain/src/issue.rs +++ b/crates/domain/src/issue.rs @@ -8,7 +8,7 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::ids::{AgentId, IssueId}; +use crate::ids::{AgentId, IssueId, SprintId}; use crate::markdown::MarkdownDoc; /// Sequential per-project issue number. @@ -255,6 +255,8 @@ pub struct Issue { pub status: IssueStatus, /// Priority. pub priority: IssuePriority, + /// Sprint membership. `None` means backlog / no sprint. + pub sprint: Option, /// Editable issue-local knowledge. pub carnet: MarkdownDoc, /// Links to other issues. @@ -299,6 +301,7 @@ impl Issue { description, status, priority, + sprint: None, carnet, links, agent_refs, @@ -392,6 +395,8 @@ pub struct IssueIndexEntry { pub status: IssueStatus, /// Priority. pub priority: IssuePriority, + /// Sprint membership. + pub sprint: Option, /// Assigned agent ids. pub assigned_agent_ids: Vec, /// Last update time. @@ -406,6 +411,7 @@ impl From<&Issue> for IssueIndexEntry { title: issue.title.clone(), status: issue.status, priority: issue.priority, + sprint: issue.sprint, assigned_agent_ids: issue .agent_refs .iter() @@ -426,6 +432,8 @@ pub struct IssueListFilter { pub priority: Option, /// Optional assigned agent filter. pub assigned_agent_id: Option, + /// Optional sprint membership filter. + pub sprint: Option, /// Optional case-insensitive text query. pub text: Option, } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 5823b0a..da988ea 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -58,6 +58,7 @@ pub mod remote; pub mod sandbox; pub mod session_limit; pub mod skill; +pub mod sprint; pub mod template; pub mod terminal; @@ -71,7 +72,7 @@ pub use error::DomainError; pub use ids::{ AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, - TabId, TaskId, TemplateId, WindowId, + SprintId, TabId, TaskId, TemplateId, WindowId, }; pub use project::{Project, ProjectPath}; @@ -116,6 +117,11 @@ pub use issue::{ IssueVersion, }; +pub use sprint::{ + validate_sprint_ordering, Sprint, SprintError, SprintIndexEntry, SprintOrder, SprintStatus, + SprintVersion, +}; + pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS}; pub use readiness::{ReadinessPolicy, ReadinessSignal}; @@ -183,5 +189,5 @@ pub use ports::{ IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, - ScheduledTask, Scheduler, SpawnSpec, StoreError, TemplateStore, + ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 6a588d5..4a9b9e4 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -32,7 +32,7 @@ use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; use crate::events::DomainEvent; -use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, TaskId}; +use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId}; use crate::issue::{ Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, }; @@ -43,6 +43,7 @@ use crate::profile::{AgentProfile, EmbedderProfile}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; use crate::skill::{Skill, SkillScope}; +use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion}; use crate::template::AgentTemplate; use crate::terminal::PtySize; @@ -596,6 +597,28 @@ pub enum IssueStoreError { #[error("issue store failed: {0}")] Store(String), } + +/// Errors from the sprint store. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SprintStoreError { + /// The requested sprint does not exist. + #[error("sprint not found")] + NotFound, + /// Optimistic concurrency conflict. + #[error("sprint version conflict: expected {expected}, actual {actual}")] + VersionConflict { + /// Expected version. + expected: SprintVersion, + /// Actual persisted version. + actual: SprintVersion, + }, + /// The sprint payload is invalid. + #[error("sprint invalid: {0}")] + Invalid(String), + /// Store I/O or serialization failed. + #[error("sprint store failed: {0}")] + Store(String), +} // --------------------------------------------------------------------------- // Git port support types // --------------------------------------------------------------------------- @@ -1507,6 +1530,50 @@ pub trait IssueStore: Send + Sync { ) -> Result; } +/// Persistence port for project-scoped sprints. +#[async_trait] +pub trait SprintStore: Send + Sync { + /// Creates a new sprint. + /// + /// # Errors + /// [`SprintStoreError`] when the sprint already exists or cannot be stored. + async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError>; + + /// Reads a sprint by stable id. + /// + /// # Errors + /// [`SprintStoreError::NotFound`] when absent. + async fn get( + &self, + root: &ProjectPath, + sprint_id: SprintId, + ) -> Result; + + /// Lists sprint index entries. + /// + /// # Errors + /// [`SprintStoreError`] on persistence failure. + async fn list(&self, root: &ProjectPath) -> Result, SprintStoreError>; + + /// Replaces a sprint after checking its expected version. + /// + /// # Errors + /// [`SprintStoreError::VersionConflict`] on optimistic-concurrency conflict. + async fn update( + &self, + root: &ProjectPath, + sprint: &Sprint, + expected_version: SprintVersion, + ) -> Result<(), SprintStoreError>; + + /// Deletes a sprint by id. + /// + /// # Errors + /// [`SprintStoreError::NotFound`] when absent. + async fn delete(&self, root: &ProjectPath, sprint_id: SprintId) + -> Result<(), SprintStoreError>; +} + /// Git operations for a project. Named `GitPort` to avoid clashing with the /// [`crate::git::GitRepository`] *entity* (state image). #[async_trait] diff --git a/crates/domain/src/sprint.rs b/crates/domain/src/sprint.rs new file mode 100644 index 0000000..623ebdf --- /dev/null +++ b/crates/domain/src/sprint.rs @@ -0,0 +1,350 @@ +//! Sprint domain model. +//! +//! A sprint is a project-scoped planning aggregate. Ticket membership remains +//! authoritative on [`crate::issue::Issue`]. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ids::SprintId; +use crate::issue::IssueActor; + +/// Reorderable execution position. Values start at 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SprintOrder(u32); + +impl SprintOrder { + /// Builds a sprint order. + /// + /// # Errors + /// [`SprintError::InvalidOrder`] when `value == 0`. + pub fn new(value: u32) -> Result { + if value == 0 { + return Err(SprintError::InvalidOrder); + } + Ok(Self(value)) + } + + /// Returns the raw order. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +impl std::fmt::Display for SprintOrder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Optimistic-concurrency version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SprintVersion(u64); + +impl SprintVersion { + /// Initial version assigned to a newly-created sprint. + pub const INITIAL: Self = Self(1); + + /// Builds a version. + /// + /// # Errors + /// [`SprintError::InvalidVersion`] when `value == 0`. + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(SprintError::InvalidVersion); + } + Ok(Self(value)) + } + + /// Returns the raw version. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + /// Returns the next optimistic version. + #[must_use] + pub const fn next(self) -> Self { + Self(self.0 + 1) + } +} + +impl std::fmt::Display for SprintVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Sprint lifecycle status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SprintStatus { + /// Planned and not active yet. + Planned, + /// Current sprint. + Active, + /// Completed sprint. + Done, +} + +impl Default for SprintStatus { + fn default() -> Self { + Self::Planned + } +} + +/// Sprint aggregate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Sprint { + /// Stable UUID. + pub id: SprintId, + /// Reorderable execution position. + pub order: SprintOrder, + /// Human-readable name. Empty means UI may display `Sprint {order}`. + pub name: String, + /// Lifecycle status. + pub status: SprintStatus, + /// Creator. + pub created_by: IssueActor, + /// Last updater. + pub updated_by: IssueActor, + /// Creation time, epoch milliseconds. + pub created_at: u64, + /// Last update time, epoch milliseconds. + pub updated_at: u64, + /// Optimistic-concurrency version. + pub version: SprintVersion, +} + +impl Sprint { + /// Builds a new sprint with version 1. + /// + /// # Errors + /// [`SprintError`] when invariants are violated. + pub fn new( + id: SprintId, + order: SprintOrder, + name: impl Into, + status: Option, + actor: IssueActor, + now_ms: u64, + ) -> Result { + let sprint = Self { + id, + order, + name: name.into(), + status: status.unwrap_or_default(), + created_by: actor.clone(), + updated_by: actor, + created_at: now_ms, + updated_at: now_ms, + version: SprintVersion::INITIAL, + }; + sprint.validate()?; + Ok(sprint) + } + + /// Rehydrates a persisted sprint. + /// + /// # Errors + /// [`SprintError`] when persisted data violates invariants. + pub fn rehydrate(sprint: Self) -> Result { + sprint.validate()?; + Ok(sprint) + } + + /// Validates invariants local to one sprint. + /// + /// # Errors + /// [`SprintError`] when an invariant is violated. + pub fn validate(&self) -> Result<(), SprintError> { + SprintOrder::new(self.order.get())?; + SprintVersion::new(self.version.get())?; + Ok(()) + } + + /// Applies a mutation and increments the version. + /// + /// # Errors + /// [`SprintError`] when the resulting sprint violates invariants. + pub fn mutate( + mut self, + actor: IssueActor, + now_ms: u64, + f: impl FnOnce(&mut Self), + ) -> Result { + f(&mut self); + self.updated_by = actor; + self.updated_at = now_ms; + self.version = self.version.next(); + self.validate()?; + Ok(self) + } +} + +/// Index row used by stores and list use cases. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SprintIndexEntry { + /// Stable sprint id. + pub id: SprintId, + /// Relative path to the sprint folder. + pub path: String, + /// Reorderable position. + pub order: SprintOrder, + /// Human-readable name. + pub name: String, + /// Lifecycle status. + pub status: SprintStatus, + /// Last update time. + pub updated_at: u64, + /// Current optimistic version. + pub version: SprintVersion, +} + +impl From<&Sprint> for SprintIndexEntry { + fn from(sprint: &Sprint) -> Self { + Self { + id: sprint.id, + path: sprint.id.to_string(), + order: sprint.order, + name: sprint.name.clone(), + status: sprint.status, + updated_at: sprint.updated_at, + version: sprint.version, + } + } +} + +/// Validates that sprint orders are unique and contiguous from 1. +/// +/// # Errors +/// [`SprintError::DuplicateOrder`] or [`SprintError::NonContiguousOrder`]. +pub fn validate_sprint_ordering<'a>( + sprints: impl IntoIterator, +) -> Result<(), SprintError> { + let mut orders: Vec = sprints + .into_iter() + .map(|sprint| sprint.order.get()) + .collect(); + orders.sort_unstable(); + let mut seen = HashSet::new(); + for order in &orders { + if !seen.insert(*order) { + return Err(SprintError::DuplicateOrder(*order)); + } + } + for (idx, order) in orders.into_iter().enumerate() { + let expected = (idx as u32) + 1; + if order != expected { + return Err(SprintError::NonContiguousOrder { + expected, + actual: order, + }); + } + } + Ok(()) +} + +/// Domain errors for sprint invariants and value objects. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SprintError { + /// Sprint order is strictly positive. + #[error("sprint order must be greater than zero")] + InvalidOrder, + /// Sprint versions are strictly positive. + #[error("sprint version must be greater than zero")] + InvalidVersion, + /// Two sprints share the same order. + #[error("duplicate sprint order {0}")] + DuplicateOrder(u32), + /// Sprint ordering must be contiguous from 1. + #[error("non-contiguous sprint order: expected {expected}, actual {actual}")] + NonContiguousOrder { + /// Expected contiguous value. + expected: u32, + /// Actual encountered value. + actual: u32, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ids::SprintId; + use uuid::Uuid; + + fn sid(n: u128) -> SprintId { + SprintId::from_uuid(Uuid::from_u128(n)) + } + + fn sprint(order: u32) -> Sprint { + Sprint::new( + sid(order as u128), + SprintOrder::new(order).unwrap(), + "", + None, + IssueActor::User, + 10, + ) + .unwrap() + } + + #[test] + fn sprint_defaults_to_planned_and_version_one() { + let sprint = sprint(1); + assert_eq!(sprint.status, SprintStatus::Planned); + assert_eq!(sprint.version, SprintVersion::INITIAL); + } + + #[test] + fn order_and_version_reject_zero() { + assert_eq!(SprintOrder::new(0), Err(SprintError::InvalidOrder)); + assert_eq!(SprintVersion::new(0), Err(SprintError::InvalidVersion)); + } + + #[test] + fn validate_ordering_accepts_contiguous_unique_orders() { + let a = sprint(1); + let b = sprint(2); + assert_eq!(validate_sprint_ordering([&b, &a]), Ok(())); + } + + #[test] + fn validate_ordering_rejects_duplicates_and_gaps() { + let a = sprint(1); + let duplicate = sprint(1); + assert_eq!( + validate_sprint_ordering([&a, &duplicate]), + Err(SprintError::DuplicateOrder(1)) + ); + + let c = sprint(3); + assert_eq!( + validate_sprint_ordering([&a, &c]), + Err(SprintError::NonContiguousOrder { + expected: 2, + actual: 3, + }) + ); + } + + #[test] + fn mutate_increments_version_and_tracks_actor() { + let updated = sprint(1) + .mutate(IssueActor::System, 20, |sprint| { + sprint.name = "Delivery".to_owned(); + }) + .unwrap(); + assert_eq!(updated.name, "Delivery"); + assert_eq!(updated.updated_by, IssueActor::System); + assert_eq!(updated.updated_at, 20); + assert_eq!(updated.version.get(), 2); + } +} diff --git a/crates/infrastructure/src/issues.rs b/crates/infrastructure/src/issues.rs index 5a909fc..0151bef 100644 --- a/crates/infrastructure/src/issues.rs +++ b/crates/infrastructure/src/issues.rs @@ -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 { 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 = parse_optional_json_field(&map, "sprint")?.unwrap_or(None); let links: Vec = parse_json_field(&map, "links")?; let agent_refs: Vec = 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 { description: MarkdownDoc::new(body), status, priority, + sprint, carnet: MarkdownDoc::default(), links, agent_refs, @@ -567,6 +577,18 @@ fn parse_json_field( serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string())) } +fn parse_optional_json_field( + map: &BTreeMap, + key: &str, +) -> Result, 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(value: &T) -> String { serde_json::to_string(value).expect("serializing issue frontmatter cannot fail") } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 1a08f0e..25d7ba5 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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")] diff --git a/crates/infrastructure/src/orchestrator/mcp/tickets.rs b/crates/infrastructure/src/orchestrator/mcp/tickets.rs index dd42037..d98e4bf 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tickets.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tickets.rs @@ -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 { "additionalProperties": false }), }, + ToolDef { + name: "idea_sprint_list", + description: "List IdeA sprints in execution order. Read-only.", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, ] } diff --git a/crates/infrastructure/src/sprints.rs b/crates/infrastructure/src/sprints.rs new file mode 100644 index 0000000..19e7f0d --- /dev/null +++ b/crates/infrastructure/src/sprints.rs @@ -0,0 +1,342 @@ +//! Filesystem sprint store. +//! +//! Project-scoped sprints live under `/.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, +} + +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 { + 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 { + 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, 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, 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 { + load_sprint(root, sprint_id).await + } + + async fn list(&self, root: &ProjectPath) -> Result, 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 { + 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, 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, key: &str) -> Result { + map.get(key) + .ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))? + .parse::() + .map_err(|err| SprintStoreError::Invalid(err.to_string())) +} + +fn parse_plain_u64(map: &BTreeMap, key: &str) -> Result { + map.get(key) + .ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))? + .parse::() + .map_err(|err| SprintStoreError::Invalid(err.to_string())) +} + +fn parse_json_field( + map: &BTreeMap, + key: &str, +) -> Result { + 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(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") +} diff --git a/crates/infrastructure/tests/issue_store.rs b/crates/infrastructure/tests/issue_store.rs index ec99746..14a0e5a 100644 --- a/crates/infrastructure/tests/issue_store.rs +++ b/crates/infrastructure/tests/issue_store.rs @@ -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(); diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 96a211b..cf4489c 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -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>>, + mutation_attempts: Arc>, + sprints: Arc>>, +} + +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 { + 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 { + 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) // --------------------------------------------------------------------------- diff --git a/crates/infrastructure/tests/sprint_store.rs b/crates/infrastructure/tests/sprint_store.rs new file mode 100644 index 0000000..b329128 --- /dev/null +++ b/crates/infrastructure/tests/sprint_store.rs @@ -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!["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); +}