feat(sprints): modèle de sprints — domaine, use-cases, persistance et surfaces (backend)
Ticket #10 — introduction du modèle de sprints côté backend. - Domaine : nouvel agrégat Sprint (sprint.rs), IDs, événements et invariants ; rattachement des issues à un sprint (issue.rs) et ports associés. - Application : use-cases sprints (application/src/sprints) + erreurs dédiées. - Infrastructure : store de sprints (infrastructure/src/sprints.rs), adaptation du store d'issues et exposition MCP via orchestrator/mcp/tickets.rs. - app-tauri : commandes, state et events pour piloter les sprints depuis l'UI. Tests domaine/application/infra/app-tauri verts (sprint_usecases, sprint_store, issue_store, mcp_server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -299,6 +299,52 @@ pub enum DomainEventDto {
|
|||||||
/// New optimistic version.
|
/// New optimistic version.
|
||||||
version: u64,
|
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<String>,
|
||||||
|
/// New sprint id.
|
||||||
|
to: Option<String>,
|
||||||
|
/// New optimistic version.
|
||||||
|
version: u64,
|
||||||
|
},
|
||||||
/// An orchestrator request was processed on behalf of a requester agent.
|
/// An orchestrator request was processed on behalf of a requester agent.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
OrchestratorRequestProcessed {
|
OrchestratorRequestProcessed {
|
||||||
@ -773,6 +819,42 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
version: version.get(),
|
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 {
|
DomainEvent::OrchestratorRequestProcessed {
|
||||||
requester_id,
|
requester_id,
|
||||||
action,
|
action,
|
||||||
@ -981,4 +1063,22 @@ mod tests {
|
|||||||
assert_eq!(json["agentId"], agent(11).to_string());
|
assert_eq!(json["agentId"], agent(11).to_string());
|
||||||
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,6 +173,13 @@ pub fn run() {
|
|||||||
tickets::ticket_link,
|
tickets::ticket_link,
|
||||||
tickets::ticket_unlink,
|
tickets::ticket_unlink,
|
||||||
tickets::ticket_assign,
|
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::get_project_work_state,
|
||||||
commands::read_conversation_page,
|
commands::read_conversation_page,
|
||||||
commands::list_live_agents,
|
commands::list_live_agents,
|
||||||
|
|||||||
@ -13,30 +13,32 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile,
|
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
||||||
CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles,
|
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
||||||
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue,
|
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
||||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile,
|
||||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||||
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory,
|
||||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
|
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
||||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||||
ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||||
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, RetryBackgroundTask,
|
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||||
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||||
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredSessions,
|
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||||
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
||||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
||||||
|
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||||
|
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -45,7 +47,7 @@ use domain::ports::{
|
|||||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort,
|
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort,
|
||||||
IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore,
|
IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore,
|
||||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore,
|
||||||
TemplateStore, WakeError, WakeReason,
|
SprintStore, TemplateStore, WakeError, WakeReason,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -66,7 +68,7 @@ use infrastructure::{
|
|||||||
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
|
||||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||||
@ -804,6 +806,20 @@ pub struct AppState {
|
|||||||
pub unlink_issues: Arc<UnlinkIssues>,
|
pub unlink_issues: Arc<UnlinkIssues>,
|
||||||
/// Assign or unassign an agent on a public ticket.
|
/// Assign or unassign an agent on a public ticket.
|
||||||
pub assign_issue_agent: Arc<AssignIssueAgent>,
|
pub assign_issue_agent: Arc<AssignIssueAgent>,
|
||||||
|
/// Create a sprint.
|
||||||
|
pub create_sprint: Arc<CreateSprint>,
|
||||||
|
/// List sprints.
|
||||||
|
pub list_sprints: Arc<ListSprints>,
|
||||||
|
/// Rename a sprint.
|
||||||
|
pub rename_sprint: Arc<RenameSprint>,
|
||||||
|
/// Reorder sprints.
|
||||||
|
pub reorder_sprints: Arc<ReorderSprints>,
|
||||||
|
/// Delete a sprint.
|
||||||
|
pub delete_sprint: Arc<DeleteSprint>,
|
||||||
|
/// Assign a ticket to a sprint.
|
||||||
|
pub assign_ticket_to_sprint: Arc<AssignTicketToSprint>,
|
||||||
|
/// Unassign a ticket from its sprint.
|
||||||
|
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
|
||||||
/// MCP provider for the public `idea_ticket_*` tools.
|
/// MCP provider for the public `idea_ticket_*` tools.
|
||||||
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
||||||
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
||||||
@ -1213,6 +1229,45 @@ impl AppState {
|
|||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
|
let sprint_store = Arc::new(FsSprintStore::new());
|
||||||
|
let sprint_store_port = Arc::clone(&sprint_store) as Arc<dyn SprintStore>;
|
||||||
|
let create_sprint = Arc::new(CreateSprint::new(
|
||||||
|
Arc::clone(&sprint_store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
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<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let reorder_sprints = Arc::new(ReorderSprints::new(
|
||||||
|
Arc::clone(&sprint_store_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
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<dyn Clock>,
|
||||||
|
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<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let unassign_ticket_from_sprint = Arc::new(UnassignTicketFromSprint::new(
|
||||||
|
Arc::clone(&issue_store_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
let ticket_tool_provider: Arc<dyn TicketToolProvider> = Arc::new(AppTicketToolProvider {
|
let ticket_tool_provider: Arc<dyn TicketToolProvider> = Arc::new(AppTicketToolProvider {
|
||||||
create: Arc::clone(&create_issue),
|
create: Arc::clone(&create_issue),
|
||||||
read: Arc::clone(&read_issue),
|
read: Arc::clone(&read_issue),
|
||||||
@ -1222,6 +1277,7 @@ impl AppState {
|
|||||||
update_carnet: Arc::clone(&update_issue_carnet),
|
update_carnet: Arc::clone(&update_issue_carnet),
|
||||||
link: Arc::clone(&link_issues),
|
link: Arc::clone(&link_issues),
|
||||||
unlink: Arc::clone(&unlink_issues),
|
unlink: Arc::clone(&unlink_issues),
|
||||||
|
list_sprints: Arc::clone(&list_sprints),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Project permissions (LP1) ---
|
// --- Project permissions (LP1) ---
|
||||||
@ -2114,6 +2170,13 @@ impl AppState {
|
|||||||
link_issues,
|
link_issues,
|
||||||
unlink_issues,
|
unlink_issues,
|
||||||
assign_issue_agent,
|
assign_issue_agent,
|
||||||
|
create_sprint,
|
||||||
|
list_sprints,
|
||||||
|
rename_sprint,
|
||||||
|
reorder_sprints,
|
||||||
|
delete_sprint,
|
||||||
|
assign_ticket_to_sprint,
|
||||||
|
unassign_ticket_from_sprint,
|
||||||
ticket_tool_provider,
|
ticket_tool_provider,
|
||||||
pty_bridge,
|
pty_bridge,
|
||||||
structured_sessions,
|
structured_sessions,
|
||||||
@ -4194,6 +4257,7 @@ mod mcp_serve_peer_tests {
|
|||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
|
"idea_sprint_list",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -4203,8 +4267,8 @@ mod mcp_serve_peer_tests {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
24,
|
25,
|
||||||
"exactly the twenty-four exposed idea_* tools; got {names:?}"
|
"exactly the twenty-five exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(client); // EOF ⇒ serve loop ends
|
drop(client); // EOF ⇒ serve loop ends
|
||||||
|
|||||||
@ -14,14 +14,15 @@ use tauri::State;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AppError, AssignIssueAgentInput, CreateIssueInput, LinkIssuesInput, ListIssuesInput,
|
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CreateIssueInput,
|
||||||
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, UnlinkIssuesInput,
|
CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput,
|
||||||
UpdateIssueCarnetInput, UpdateIssueInput,
|
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
|
||||||
|
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
||||||
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project,
|
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project,
|
||||||
ProjectId,
|
ProjectId, SprintId, SprintStatus, SprintVersion,
|
||||||
};
|
};
|
||||||
use infrastructure::{TicketToolError, TicketToolProvider};
|
use infrastructure::{TicketToolError, TicketToolProvider};
|
||||||
|
|
||||||
@ -39,6 +40,9 @@ pub struct TicketDto {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
|
pub sprint_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sprint: Option<TicketSprintContextDto>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub carnet: Option<String>,
|
pub carnet: Option<String>,
|
||||||
pub links: Vec<TicketLinkDto>,
|
pub links: Vec<TicketLinkDto>,
|
||||||
@ -59,6 +63,9 @@ pub struct TicketSummaryDto {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
|
pub sprint_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sprint: Option<TicketSprintContextDto>,
|
||||||
pub assigned_agent_ids: Vec<String>,
|
pub assigned_agent_ids: Vec<String>,
|
||||||
pub updated_at: u64,
|
pub updated_at: u64,
|
||||||
}
|
}
|
||||||
@ -72,6 +79,33 @@ pub struct TicketListDto {
|
|||||||
pub next_cursor: Option<String>,
|
pub next_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sprint context embedded in MCP ticket read/list responses.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TicketSprintContextDto {
|
||||||
|
pub order: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public sprint DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SprintDto {
|
||||||
|
pub id: String,
|
||||||
|
pub order: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub status: String,
|
||||||
|
pub ticket_count: usize,
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public sprint list DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SprintListDto {
|
||||||
|
pub items: Vec<SprintDto>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Public link DTO.
|
/// Public link DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -131,6 +165,7 @@ pub struct TicketListRequestDto {
|
|||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
pub priority: Option<String>,
|
pub priority: Option<String>,
|
||||||
pub assigned_agent_id: Option<String>,
|
pub assigned_agent_id: Option<String>,
|
||||||
|
pub sprint_id: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
pub cursor: Option<String>,
|
pub cursor: Option<String>,
|
||||||
@ -206,6 +241,74 @@ pub struct TicketAssignRequestDto {
|
|||||||
pub expected_version: u64,
|
pub expected_version: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create sprint request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SprintCreateRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List sprint request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SprintListRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename sprint request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SprintRenameRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
pub sprint_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub expected_version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorder sprint request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SprintReorderRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
pub ordered_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete sprint request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SprintDeleteRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
pub sprint_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign ticket to sprint request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TicketSprintAssignRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
pub r#ref: String,
|
||||||
|
pub sprint_id: String,
|
||||||
|
pub expected_version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unassign ticket from sprint request.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TicketSprintUnassignRequestDto {
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_id: String,
|
||||||
|
pub r#ref: String,
|
||||||
|
pub expected_version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppTicketToolProvider {
|
pub struct AppTicketToolProvider {
|
||||||
pub create: Arc<application::CreateIssue>,
|
pub create: Arc<application::CreateIssue>,
|
||||||
@ -216,6 +319,7 @@ pub struct AppTicketToolProvider {
|
|||||||
pub update_carnet: Arc<application::UpdateIssueCarnet>,
|
pub update_carnet: Arc<application::UpdateIssueCarnet>,
|
||||||
pub link: Arc<application::LinkIssues>,
|
pub link: Arc<application::LinkIssues>,
|
||||||
pub unlink: Arc<application::UnlinkIssues>,
|
pub unlink: Arc<application::UnlinkIssues>,
|
||||||
|
pub list_sprints: Arc<application::ListSprints>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -259,7 +363,15 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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" => {
|
"idea_ticket_list" => {
|
||||||
let req = mcp_list_request(project, arguments)?;
|
let req = mcp_list_request(project, arguments)?;
|
||||||
@ -272,7 +384,26 @@ impl TicketToolProvider for AppTicketToolProvider {
|
|||||||
.await
|
.await
|
||||||
.map_err(ticket_error)?
|
.map_err(ticket_error)?
|
||||||
.issues;
|
.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" => {
|
"idea_ticket_update" => {
|
||||||
let req = mcp_update_request(project, arguments)?;
|
let req = mcp_update_request(project, arguments)?;
|
||||||
@ -476,6 +607,152 @@ pub async fn ticket_list(
|
|||||||
Ok(paginate(rows, page.limit, page.cursor))
|
Ok(paginate(rows, page.limit, page.cursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sprint_create(
|
||||||
|
request: SprintCreateRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SprintDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
|
let sprint = state
|
||||||
|
.create_sprint
|
||||||
|
.execute(CreateSprintInput {
|
||||||
|
project,
|
||||||
|
name: request.name,
|
||||||
|
status: request
|
||||||
|
.status
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_sprint_status_dto)
|
||||||
|
.transpose()?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.sprint;
|
||||||
|
Ok(SprintDto::from_sprint(sprint, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sprint_list(
|
||||||
|
request: SprintListRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SprintListDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
|
let rows = state
|
||||||
|
.list_sprints
|
||||||
|
.execute(ListSprintsInput { project })
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.sprints;
|
||||||
|
Ok(SprintListDto::from(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sprint_rename(
|
||||||
|
request: SprintRenameRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SprintDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
|
let sprint = state
|
||||||
|
.rename_sprint
|
||||||
|
.execute(RenameSprintInput {
|
||||||
|
project,
|
||||||
|
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
|
||||||
|
expected_version: sprint_version_dto(request.expected_version)?,
|
||||||
|
name: request.name,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.sprint;
|
||||||
|
Ok(SprintDto::from_sprint(sprint, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sprint_reorder(
|
||||||
|
request: SprintReorderRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SprintListDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
|
state
|
||||||
|
.reorder_sprints
|
||||||
|
.execute(ReorderSprintsInput {
|
||||||
|
project: project.clone(),
|
||||||
|
ordered_ids: request
|
||||||
|
.ordered_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| parse_sprint_id_dto(id))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
sprint_list(
|
||||||
|
SprintListRequestDto {
|
||||||
|
project_id: project.id.to_string(),
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sprint_delete(
|
||||||
|
request: SprintDeleteRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
|
state
|
||||||
|
.delete_sprint
|
||||||
|
.execute(DeleteSprintInput {
|
||||||
|
project,
|
||||||
|
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ticket_assign_sprint(
|
||||||
|
request: TicketSprintAssignRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<TicketDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
|
let issue = state
|
||||||
|
.assign_ticket_to_sprint
|
||||||
|
.execute(AssignTicketToSprintInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
Ok(TicketDto::from_issue(issue, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ticket_unassign_sprint(
|
||||||
|
request: TicketSprintUnassignRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<TicketDto, ErrorDto> {
|
||||||
|
let project = resolve_project(&state, &request.project_id).await?;
|
||||||
|
let issue = state
|
||||||
|
.unassign_ticket_from_sprint
|
||||||
|
.execute(UnassignTicketFromSprintInput {
|
||||||
|
project,
|
||||||
|
issue_ref: parse_ref_dto(&request.r#ref)?,
|
||||||
|
expected_version: version_dto(request.expected_version)?,
|
||||||
|
actor: IssueActor::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.issue;
|
||||||
|
Ok(TicketDto::from_issue(issue, None))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn ticket_update(
|
pub async fn ticket_update(
|
||||||
request: TicketUpdateRequestDto,
|
request: TicketUpdateRequestDto,
|
||||||
@ -616,6 +893,8 @@ impl TicketDto {
|
|||||||
description: issue.description.as_str().to_owned(),
|
description: issue.description.as_str().to_owned(),
|
||||||
status: status_wire(issue.status).to_owned(),
|
status: status_wire(issue.status).to_owned(),
|
||||||
priority: priority_wire(issue.priority).to_owned(),
|
priority: priority_wire(issue.priority).to_owned(),
|
||||||
|
sprint_id: issue.sprint.map(|id| id.to_string()),
|
||||||
|
sprint: None,
|
||||||
carnet,
|
carnet,
|
||||||
links: issue.links.into_iter().map(TicketLinkDto::from).collect(),
|
links: issue.links.into_iter().map(TicketLinkDto::from).collect(),
|
||||||
assigned_agent_ids: issue
|
assigned_agent_ids: issue
|
||||||
@ -631,6 +910,19 @@ impl TicketDto {
|
|||||||
version: issue.version.get(),
|
version: issue.version.get(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn from_issue_with_sprints(
|
||||||
|
issue: Issue,
|
||||||
|
carnet: Option<String>,
|
||||||
|
sprints: &[application::SprintListEntry],
|
||||||
|
) -> Self {
|
||||||
|
let sprint = issue
|
||||||
|
.sprint
|
||||||
|
.and_then(|id| ticket_sprint_context(id, sprints));
|
||||||
|
let mut dto = Self::from_issue(issue, carnet);
|
||||||
|
dto.sprint = sprint;
|
||||||
|
dto
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<IssueIndexEntry> for TicketSummaryDto {
|
impl From<IssueIndexEntry> for TicketSummaryDto {
|
||||||
@ -641,6 +933,8 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
status: status_wire(row.status).to_owned(),
|
status: status_wire(row.status).to_owned(),
|
||||||
priority: priority_wire(row.priority).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: row
|
||||||
.assigned_agent_ids
|
.assigned_agent_ids
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -651,6 +945,52 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TicketSummaryDto {
|
||||||
|
fn from_row_with_sprints(
|
||||||
|
row: IssueIndexEntry,
|
||||||
|
sprints: &[application::SprintListEntry],
|
||||||
|
) -> Self {
|
||||||
|
let sprint = row.sprint.and_then(|id| ticket_sprint_context(id, sprints));
|
||||||
|
let mut dto = Self::from(row);
|
||||||
|
dto.sprint = sprint;
|
||||||
|
dto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SprintDto {
|
||||||
|
fn from_sprint(sprint: domain::Sprint, ticket_count: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
id: sprint.id.to_string(),
|
||||||
|
order: sprint.order.get(),
|
||||||
|
name: sprint.name,
|
||||||
|
status: sprint_status_wire(sprint.status).to_owned(),
|
||||||
|
ticket_count,
|
||||||
|
version: sprint.version.get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::SprintListEntry> for SprintDto {
|
||||||
|
fn from(entry: application::SprintListEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: entry.sprint.id.to_string(),
|
||||||
|
order: entry.sprint.order.get(),
|
||||||
|
name: entry.sprint.name,
|
||||||
|
status: sprint_status_wire(entry.sprint.status).to_owned(),
|
||||||
|
ticket_count: entry.ticket_count,
|
||||||
|
version: entry.sprint.version.get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<application::SprintListEntry>> for SprintListDto {
|
||||||
|
fn from(items: Vec<application::SprintListEntry>) -> Self {
|
||||||
|
Self {
|
||||||
|
items: items.into_iter().map(SprintDto::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<IssueLink> for TicketLinkDto {
|
impl From<IssueLink> for TicketLinkDto {
|
||||||
fn from(link: IssueLink) -> Self {
|
fn from(link: IssueLink) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -707,6 +1047,11 @@ impl TicketListPageInput {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(parse_agent_id_dto)
|
.map(parse_agent_id_dto)
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
|
sprint: request
|
||||||
|
.sprint_id
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_sprint_id_dto)
|
||||||
|
.transpose()?,
|
||||||
text: request.text,
|
text: request.text,
|
||||||
},
|
},
|
||||||
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
||||||
@ -819,6 +1164,42 @@ fn paginate(rows: Vec<IssueIndexEntry>, limit: usize, cursor: Option<String>) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn paginate_with_sprints(
|
||||||
|
rows: Vec<IssueIndexEntry>,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<String>,
|
||||||
|
sprints: &[application::SprintListEntry],
|
||||||
|
) -> TicketListDto {
|
||||||
|
let start = cursor
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|raw| raw.parse::<usize>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let total = rows.len();
|
||||||
|
let end = total.min(start.saturating_add(limit));
|
||||||
|
TicketListDto {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.skip(start)
|
||||||
|
.take(limit)
|
||||||
|
.map(|row| TicketSummaryDto::from_row_with_sprints(row, sprints))
|
||||||
|
.collect(),
|
||||||
|
next_cursor: (end < total).then(|| end.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_sprint_context(
|
||||||
|
sprint_id: SprintId,
|
||||||
|
sprints: &[application::SprintListEntry],
|
||||||
|
) -> Option<TicketSprintContextDto> {
|
||||||
|
sprints
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.sprint.id == sprint_id)
|
||||||
|
.map(|entry| TicketSprintContextDto {
|
||||||
|
order: entry.sprint.order.get(),
|
||||||
|
name: entry.sprint.name.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn read_carnet_body(
|
async fn read_carnet_body(
|
||||||
read_carnet: &application::ReadIssueCarnet,
|
read_carnet: &application::ReadIssueCarnet,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
@ -853,10 +1234,20 @@ fn parse_agent_id_dto(raw: &str) -> Result<AgentId, ErrorDto> {
|
|||||||
.map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
|
.map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_sprint_id_dto(raw: &str) -> Result<SprintId, ErrorDto> {
|
||||||
|
Uuid::parse_str(raw)
|
||||||
|
.map(SprintId::from_uuid)
|
||||||
|
.map_err(|e| ErrorDto::invalid(format!("invalid sprint id: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
fn version_dto(raw: u64) -> Result<IssueVersion, ErrorDto> {
|
fn version_dto(raw: u64) -> Result<IssueVersion, ErrorDto> {
|
||||||
IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
|
IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sprint_version_dto(raw: u64) -> Result<SprintVersion, ErrorDto> {
|
||||||
|
SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_status_dto(raw: &str) -> Result<IssueStatus, ErrorDto> {
|
fn parse_status_dto(raw: &str) -> Result<IssueStatus, ErrorDto> {
|
||||||
parse_status(raw).map_err(|e| ErrorDto::invalid(e.message))
|
parse_status(raw).map_err(|e| ErrorDto::invalid(e.message))
|
||||||
}
|
}
|
||||||
@ -869,6 +1260,15 @@ fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
|
|||||||
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
|
parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_sprint_status_dto(raw: &str) -> Result<SprintStatus, ErrorDto> {
|
||||||
|
match raw {
|
||||||
|
"planned" => Ok(SprintStatus::Planned),
|
||||||
|
"active" => Ok(SprintStatus::Active),
|
||||||
|
"done" => Ok(SprintStatus::Done),
|
||||||
|
_ => Err(ErrorDto::invalid(format!("invalid sprint status: {raw}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_status(raw: &str) -> Result<IssueStatus, TicketToolError> {
|
fn parse_status(raw: &str) -> Result<IssueStatus, TicketToolError> {
|
||||||
match raw {
|
match raw {
|
||||||
"open" => Ok(IssueStatus::Open),
|
"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 {
|
fn ticket_error(err: AppError) -> TicketToolError {
|
||||||
let message = err.to_string();
|
let message = err.to_string();
|
||||||
let code = if message.contains("issue version conflict") {
|
let code = if message.contains("issue version conflict") {
|
||||||
|
|||||||
@ -9,8 +9,8 @@ use domain::ports::{
|
|||||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
||||||
RemoteError, RuntimeError, StoreError,
|
RemoteError, RuntimeError, StoreError,
|
||||||
};
|
};
|
||||||
use domain::IssueStoreError;
|
|
||||||
use domain::{AgentId, NodeId};
|
use domain::{AgentId, NodeId};
|
||||||
|
use domain::{IssueStoreError, SprintStoreError};
|
||||||
|
|
||||||
/// Errors surfaced by application use cases.
|
/// Errors surfaced by application use cases.
|
||||||
///
|
///
|
||||||
@ -140,6 +140,17 @@ impl From<IssueStoreError> for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<SprintStoreError> 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<MemoryError> for AppError {
|
impl From<MemoryError> for AppError {
|
||||||
fn from(e: MemoryError) -> Self {
|
fn from(e: MemoryError) -> Self {
|
||||||
match e {
|
match e {
|
||||||
|
|||||||
@ -27,6 +27,7 @@ pub mod permission;
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
pub mod sprints;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
@ -127,6 +128,13 @@ pub use skill::{
|
|||||||
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
|
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
|
||||||
UpdateSkillInput, UpdateSkillOutput,
|
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::{
|
pub use template::{
|
||||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||||
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
|
||||||
|
|||||||
615
crates/application/src/sprints/mod.rs
Normal file
615
crates/application/src/sprints/mod.rs
Normal file
@ -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<SprintStatus>,
|
||||||
|
/// 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<dyn SprintStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateSprint {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
sprints: Arc<dyn SprintStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
sprints,
|
||||||
|
ids,
|
||||||
|
clock,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes creation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on invariant or store failure.
|
||||||
|
pub async fn execute(&self, input: CreateSprintInput) -> Result<CreateSprintOutput, AppError> {
|
||||||
|
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<SprintListEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists sprints with derived ticket counts.
|
||||||
|
pub struct ListSprints {
|
||||||
|
sprints: Arc<dyn SprintStore>,
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListSprints {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(sprints: Arc<dyn SprintStore>, issues: Arc<dyn IssueStore>) -> Self {
|
||||||
|
Self { sprints, issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes list.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store failure.
|
||||||
|
pub async fn execute(&self, input: ListSprintsInput) -> Result<ListSprintsOutput, AppError> {
|
||||||
|
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<dyn SprintStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenameSprint {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
sprints: Arc<dyn SprintStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
sprints,
|
||||||
|
clock,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes rename.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] on store failure or conflict.
|
||||||
|
pub async fn execute(&self, input: RenameSprintInput) -> Result<SprintOutput, AppError> {
|
||||||
|
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<SprintId>,
|
||||||
|
/// 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<Sprint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorders all sprints and normalises orders to 1..n.
|
||||||
|
pub struct ReorderSprints {
|
||||||
|
sprints: Arc<dyn SprintStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReorderSprints {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
sprints: Arc<dyn SprintStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> 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<ReorderSprintsOutput, AppError> {
|
||||||
|
let rows = self.sprints.list(&input.project.root).await?;
|
||||||
|
let current_ids: HashSet<SprintId> = rows.iter().map(|row| row.id).collect();
|
||||||
|
let ordered_ids: HashSet<SprintId> = 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<dyn SprintStore>,
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteSprint {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
sprints: Arc<dyn SprintStore>,
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> 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<SprintId>,
|
||||||
|
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<dyn SprintStore>,
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssignTicketToSprint {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
sprints: Arc<dyn SprintStore>,
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> 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<AssignTicketToSprintOutput, AppError> {
|
||||||
|
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<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnassignTicketFromSprint {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
issues: Arc<dyn IssueStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> 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<AssignTicketToSprintOutput, AppError> {
|
||||||
|
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<dyn IssueStore>,
|
||||||
|
clock: &Arc<dyn Clock>,
|
||||||
|
events: &Arc<dyn EventBus>,
|
||||||
|
project: Project,
|
||||||
|
issue_ref: IssueRef,
|
||||||
|
sprint_id: Option<SprintId>,
|
||||||
|
expected_version: IssueVersion,
|
||||||
|
actor: IssueActor,
|
||||||
|
) -> Result<AssignTicketToSprintOutput, AppError> {
|
||||||
|
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<dyn EventBus>,
|
||||||
|
issue_ref: IssueRef,
|
||||||
|
from: Option<SprintId>,
|
||||||
|
to: Option<SprintId>,
|
||||||
|
version: IssueVersion,
|
||||||
|
) {
|
||||||
|
events.publish(DomainEvent::IssueUpdated { issue_ref, version });
|
||||||
|
events.publish(DomainEvent::IssueSprintChanged {
|
||||||
|
issue_ref,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now(clock: &Arc<dyn Clock>) -> 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<Vec<Sprint>, AppError> {
|
||||||
|
let by_id: HashMap<SprintId, Sprint> = 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)
|
||||||
|
}
|
||||||
513
crates/application/tests/sprint_usecases.rs
Normal file
513
crates/application/tests/sprint_usecases.rs
Normal file
@ -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<HashMap<SprintId, Sprint>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Sprint, SprintStoreError> {
|
||||||
|
self.sprints
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&sprint_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(SprintStoreError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, _root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, 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<HashMap<IssueRef, Issue>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Issue, IssueStoreError> {
|
||||||
|
self.issues
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&issue_ref)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(IssueStoreError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
filter: IssueListFilter,
|
||||||
|
) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
|
||||||
|
let mut rows: Vec<IssueIndexEntry> = 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<IssueCarnet, IssueStoreError> {
|
||||||
|
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<IssueCarnet, IssueStoreError> {
|
||||||
|
unimplemented!("not needed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeqIds(Mutex<u128>);
|
||||||
|
|
||||||
|
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<Vec<DomainEvent>>);
|
||||||
|
|
||||||
|
impl SpyBus {
|
||||||
|
fn events(&self) -> Vec<DomainEvent> {
|
||||||
|
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<SprintId>) -> 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<_>>(),
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -2,10 +2,13 @@
|
|||||||
//! presentation layer (ARCHITECTURE §3.2).
|
//! presentation layer (ARCHITECTURE §3.2).
|
||||||
|
|
||||||
use crate::conversation::ConversationParty;
|
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::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
use crate::memory::MemorySlug;
|
use crate::memory::MemorySlug;
|
||||||
|
use crate::sprint::{SprintOrder, SprintVersion};
|
||||||
use crate::template::TemplateVersion;
|
use crate::template::TemplateVersion;
|
||||||
|
|
||||||
/// Which entry door a processed orchestration request arrived through.
|
/// Which entry door a processed orchestration request arrived through.
|
||||||
@ -367,6 +370,47 @@ pub enum DomainEvent {
|
|||||||
/// New optimistic version.
|
/// New optimistic version.
|
||||||
version: IssueVersion,
|
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<SprintId>,
|
||||||
|
/// New sprint.
|
||||||
|
to: Option<SprintId>,
|
||||||
|
/// New optimistic version.
|
||||||
|
version: IssueVersion,
|
||||||
|
},
|
||||||
/// A project's memory grew past the recall budget while no embedder is
|
/// A project's memory grew past the recall budget while no embedder is
|
||||||
/// configured (strategy `none`): the first moment a semantic embedder would
|
/// configured (strategy `none`): the first moment a semantic embedder would
|
||||||
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
|
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
|
||||||
|
|||||||
@ -76,6 +76,10 @@ typed_id!(
|
|||||||
/// Identifies a [`crate::issue::Issue`].
|
/// Identifies a [`crate::issue::Issue`].
|
||||||
IssueId
|
IssueId
|
||||||
);
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifies a [`crate::sprint::Sprint`].
|
||||||
|
SprintId
|
||||||
|
);
|
||||||
typed_id!(
|
typed_id!(
|
||||||
/// Identifies a [`crate::terminal::TerminalSession`].
|
/// Identifies a [`crate::terminal::TerminalSession`].
|
||||||
SessionId
|
SessionId
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use std::str::FromStr;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::ids::{AgentId, IssueId};
|
use crate::ids::{AgentId, IssueId, SprintId};
|
||||||
use crate::markdown::MarkdownDoc;
|
use crate::markdown::MarkdownDoc;
|
||||||
|
|
||||||
/// Sequential per-project issue number.
|
/// Sequential per-project issue number.
|
||||||
@ -255,6 +255,8 @@ pub struct Issue {
|
|||||||
pub status: IssueStatus,
|
pub status: IssueStatus,
|
||||||
/// Priority.
|
/// Priority.
|
||||||
pub priority: IssuePriority,
|
pub priority: IssuePriority,
|
||||||
|
/// Sprint membership. `None` means backlog / no sprint.
|
||||||
|
pub sprint: Option<SprintId>,
|
||||||
/// Editable issue-local knowledge.
|
/// Editable issue-local knowledge.
|
||||||
pub carnet: MarkdownDoc,
|
pub carnet: MarkdownDoc,
|
||||||
/// Links to other issues.
|
/// Links to other issues.
|
||||||
@ -299,6 +301,7 @@ impl Issue {
|
|||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
|
sprint: None,
|
||||||
carnet,
|
carnet,
|
||||||
links,
|
links,
|
||||||
agent_refs,
|
agent_refs,
|
||||||
@ -392,6 +395,8 @@ pub struct IssueIndexEntry {
|
|||||||
pub status: IssueStatus,
|
pub status: IssueStatus,
|
||||||
/// Priority.
|
/// Priority.
|
||||||
pub priority: IssuePriority,
|
pub priority: IssuePriority,
|
||||||
|
/// Sprint membership.
|
||||||
|
pub sprint: Option<SprintId>,
|
||||||
/// Assigned agent ids.
|
/// Assigned agent ids.
|
||||||
pub assigned_agent_ids: Vec<AgentId>,
|
pub assigned_agent_ids: Vec<AgentId>,
|
||||||
/// Last update time.
|
/// Last update time.
|
||||||
@ -406,6 +411,7 @@ impl From<&Issue> for IssueIndexEntry {
|
|||||||
title: issue.title.clone(),
|
title: issue.title.clone(),
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
priority: issue.priority,
|
priority: issue.priority,
|
||||||
|
sprint: issue.sprint,
|
||||||
assigned_agent_ids: issue
|
assigned_agent_ids: issue
|
||||||
.agent_refs
|
.agent_refs
|
||||||
.iter()
|
.iter()
|
||||||
@ -426,6 +432,8 @@ pub struct IssueListFilter {
|
|||||||
pub priority: Option<IssuePriority>,
|
pub priority: Option<IssuePriority>,
|
||||||
/// Optional assigned agent filter.
|
/// Optional assigned agent filter.
|
||||||
pub assigned_agent_id: Option<AgentId>,
|
pub assigned_agent_id: Option<AgentId>,
|
||||||
|
/// Optional sprint membership filter.
|
||||||
|
pub sprint: Option<SprintId>,
|
||||||
/// Optional case-insensitive text query.
|
/// Optional case-insensitive text query.
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,7 @@ pub mod remote;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod session_limit;
|
pub mod session_limit;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
pub mod sprint;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|
||||||
@ -71,7 +72,7 @@ pub use error::DomainError;
|
|||||||
|
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
||||||
TabId, TaskId, TemplateId, WindowId,
|
SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use project::{Project, ProjectPath};
|
pub use project::{Project, ProjectPath};
|
||||||
@ -116,6 +117,11 @@ pub use issue::{
|
|||||||
IssueVersion,
|
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 live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
|
||||||
|
|
||||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||||
@ -183,5 +189,5 @@ pub use ports::{
|
|||||||
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
|
IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output,
|
||||||
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
|
||||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
|
||||||
ScheduledTask, Scheduler, SpawnSpec, StoreError, TemplateStore,
|
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -32,7 +32,7 @@ use crate::background_task::{
|
|||||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
};
|
};
|
||||||
use crate::events::DomainEvent;
|
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::{
|
use crate::issue::{
|
||||||
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||||
};
|
};
|
||||||
@ -43,6 +43,7 @@ use crate::profile::{AgentProfile, EmbedderProfile};
|
|||||||
use crate::project::{Project, ProjectPath};
|
use crate::project::{Project, ProjectPath};
|
||||||
use crate::remote::RemoteKind;
|
use crate::remote::RemoteKind;
|
||||||
use crate::skill::{Skill, SkillScope};
|
use crate::skill::{Skill, SkillScope};
|
||||||
|
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
|
||||||
use crate::template::AgentTemplate;
|
use crate::template::AgentTemplate;
|
||||||
use crate::terminal::PtySize;
|
use crate::terminal::PtySize;
|
||||||
|
|
||||||
@ -596,6 +597,28 @@ pub enum IssueStoreError {
|
|||||||
#[error("issue store failed: {0}")]
|
#[error("issue store failed: {0}")]
|
||||||
Store(String),
|
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
|
// Git port support types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1507,6 +1530,50 @@ pub trait IssueStore: Send + Sync {
|
|||||||
) -> Result<IssueCarnet, IssueStoreError>;
|
) -> Result<IssueCarnet, IssueStoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Sprint, SprintStoreError>;
|
||||||
|
|
||||||
|
/// Lists sprint index entries.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`SprintStoreError`] on persistence failure.
|
||||||
|
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, 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
|
/// Git operations for a project. Named `GitPort` to avoid clashing with the
|
||||||
/// [`crate::git::GitRepository`] *entity* (state image).
|
/// [`crate::git::GitRepository`] *entity* (state image).
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
350
crates/domain/src/sprint.rs
Normal file
350
crates/domain/src/sprint.rs
Normal file
@ -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<Self, SprintError> {
|
||||||
|
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<Self, SprintError> {
|
||||||
|
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<String>,
|
||||||
|
status: Option<SprintStatus>,
|
||||||
|
actor: IssueActor,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> Result<Self, SprintError> {
|
||||||
|
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<Self, SprintError> {
|
||||||
|
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<Self, SprintError> {
|
||||||
|
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<Item = &'a Sprint>,
|
||||||
|
) -> Result<(), SprintError> {
|
||||||
|
let mut orders: Vec<u32> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,7 @@ use tokio::io::AsyncWriteExt;
|
|||||||
use domain::{
|
use domain::{
|
||||||
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
|
AgentIssueRef, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink,
|
||||||
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
|
IssueListFilter, IssueNumber, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus,
|
||||||
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath,
|
IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, ProjectPath, SprintId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const IDEAI_DIR: &str = ".ideai";
|
const IDEAI_DIR: &str = ".ideai";
|
||||||
@ -211,6 +211,12 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if filter
|
||||||
|
.sprint
|
||||||
|
.is_some_and(|sprint| row.sprint != Some(sprint))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if let Some(text) = filter
|
if let Some(text) = filter
|
||||||
.text
|
.text
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -423,6 +429,7 @@ number: {}\n\
|
|||||||
title: {}\n\
|
title: {}\n\
|
||||||
status: {}\n\
|
status: {}\n\
|
||||||
priority: {}\n\
|
priority: {}\n\
|
||||||
|
sprint: {}\n\
|
||||||
links: {}\n\
|
links: {}\n\
|
||||||
agentRefs: {}\n\
|
agentRefs: {}\n\
|
||||||
createdBy: {}\n\
|
createdBy: {}\n\
|
||||||
@ -436,6 +443,7 @@ version: {}\n\
|
|||||||
json_string(&issue.title),
|
json_string(&issue.title),
|
||||||
json_value(&issue.status),
|
json_value(&issue.status),
|
||||||
json_value(&issue.priority),
|
json_value(&issue.priority),
|
||||||
|
json_value(&issue.sprint),
|
||||||
json_value(&issue.links),
|
json_value(&issue.links),
|
||||||
json_value(&issue.agent_refs),
|
json_value(&issue.agent_refs),
|
||||||
json_value(&issue.created_by),
|
json_value(&issue.created_by),
|
||||||
@ -475,6 +483,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
|||||||
let title: String = parse_json_field(&map, "title")?;
|
let title: String = parse_json_field(&map, "title")?;
|
||||||
let status: IssueStatus = parse_json_field(&map, "status")?;
|
let status: IssueStatus = parse_json_field(&map, "status")?;
|
||||||
let priority: IssuePriority = parse_json_field(&map, "priority")?;
|
let priority: IssuePriority = parse_json_field(&map, "priority")?;
|
||||||
|
let sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
|
||||||
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
|
let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
|
||||||
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
|
let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
|
||||||
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
||||||
@ -490,6 +499,7 @@ fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
|
|||||||
description: MarkdownDoc::new(body),
|
description: MarkdownDoc::new(body),
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
|
sprint,
|
||||||
carnet: MarkdownDoc::default(),
|
carnet: MarkdownDoc::default(),
|
||||||
links,
|
links,
|
||||||
agent_refs,
|
agent_refs,
|
||||||
@ -567,6 +577,18 @@ fn parse_json_field<T: serde::de::DeserializeOwned>(
|
|||||||
serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_optional_json_field<T: serde::de::DeserializeOwned>(
|
||||||
|
map: &BTreeMap<String, String>,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Option<T>, IssueStoreError> {
|
||||||
|
let Some(value) = map.get(key) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
serde_json::from_str(value)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|err| IssueStoreError::Invalid(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn json_value<T: Serialize>(value: &T) -> String {
|
fn json_value<T: Serialize>(value: &T) -> String {
|
||||||
serde_json::to_string(value).expect("serializing issue frontmatter cannot fail")
|
serde_json::to_string(value).expect("serializing issue frontmatter cannot fail")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ pub mod runtime;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod sprints;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod timeparse;
|
pub mod timeparse;
|
||||||
|
|
||||||
@ -79,6 +80,7 @@ pub use sandbox::LandlockSandbox;
|
|||||||
pub use sandbox::{default_enforcer, NoopSandbox};
|
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||||
pub use scheduler::TokioScheduler;
|
pub use scheduler::TokioScheduler;
|
||||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||||
|
pub use sprints::FsSprintStore;
|
||||||
#[cfg(feature = "vector-onnx")]
|
#[cfg(feature = "vector-onnx")]
|
||||||
pub use store::OnnxEmbedder;
|
pub use store::OnnxEmbedder;
|
||||||
#[cfg(feature = "vector-http")]
|
#[cfg(feature = "vector-http")]
|
||||||
|
|||||||
@ -39,7 +39,7 @@ impl TicketToolError {
|
|||||||
/// Provider injected by the composition root to execute public ticket tools.
|
/// Provider injected by the composition root to execute public ticket tools.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TicketToolProvider: Send + Sync {
|
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(
|
async fn handle_ticket_tool(
|
||||||
&self,
|
&self,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
@ -64,6 +64,7 @@ pub fn is_ticket_tool(name: &str) -> bool {
|
|||||||
| "idea_ticket_update_carnet"
|
| "idea_ticket_update_carnet"
|
||||||
| "idea_ticket_link"
|
| "idea_ticket_link"
|
||||||
| "idea_ticket_unlink"
|
| "idea_ticket_unlink"
|
||||||
|
| "idea_sprint_list"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,5 +235,14 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_sprint_list",
|
||||||
|
description: "List IdeA sprints in execution order. Read-only.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
342
crates/infrastructure/src/sprints.rs
Normal file
342
crates/infrastructure/src/sprints.rs
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
//! Filesystem sprint store.
|
||||||
|
//!
|
||||||
|
//! Project-scoped sprints live under `<root>/.ideai/sprints/`, one directory per
|
||||||
|
//! stable sprint id. The Markdown files are the source of truth; `index.json` is
|
||||||
|
//! a reconstructible projection kept in sync after writes.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
IssueActor, ProjectPath, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus,
|
||||||
|
SprintStore, SprintStoreError, SprintVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDEAI_DIR: &str = ".ideai";
|
||||||
|
const SPRINTS_DIR: &str = "sprints";
|
||||||
|
const SPRINT_FILE: &str = "sprint.md";
|
||||||
|
const INDEX_FILE: &str = "index.json";
|
||||||
|
const INDEX_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Filesystem-backed sprint store.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FsSprintStore;
|
||||||
|
|
||||||
|
impl FsSprintStore {
|
||||||
|
/// Builds a store.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct IndexDoc {
|
||||||
|
version: u32,
|
||||||
|
sprints: Vec<SprintIndexEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sprint_root(root: &ProjectPath) -> PathBuf {
|
||||||
|
PathBuf::from(root.as_str())
|
||||||
|
.join(IDEAI_DIR)
|
||||||
|
.join(SPRINTS_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sprint_dir(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
|
||||||
|
sprint_root(root).join(sprint_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sprint_path(root: &ProjectPath, sprint_id: SprintId) -> PathBuf {
|
||||||
|
sprint_dir(root, sprint_id).join(SPRINT_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_path(root: &ProjectPath) -> PathBuf {
|
||||||
|
sprint_root(root).join(INDEX_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), SprintStoreError> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| SprintStoreError::Store("path has no parent".to_owned()))?;
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
|
||||||
|
tokio::fs::rename(&tmp, path).await.map_err(io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_string(path: &Path) -> Result<String, SprintStoreError> {
|
||||||
|
let bytes = tokio::fs::read(path).await.map_err(|err| {
|
||||||
|
if err.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
SprintStoreError::NotFound
|
||||||
|
} else {
|
||||||
|
io_error(err)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
String::from_utf8(bytes).map_err(|err| SprintStoreError::Store(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_error(err: std::io::Error) -> SprintStoreError {
|
||||||
|
SprintStoreError::Store(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_sprint(root: &ProjectPath, sprint_id: SprintId) -> Result<Sprint, SprintStoreError> {
|
||||||
|
let text = read_string(&sprint_path(root, sprint_id)).await?;
|
||||||
|
parse_sprint_doc(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_sprint(root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
|
||||||
|
let dir = sprint_dir(root, sprint.id);
|
||||||
|
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
||||||
|
write_atomic(&dir.join(SPRINT_FILE), render_sprint_doc(sprint).as_bytes()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rebuild_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
|
||||||
|
let dir = sprint_root(root);
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
write_index(root, &rows).await?;
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
Err(err) => return Err(io_error(err)),
|
||||||
|
};
|
||||||
|
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
|
||||||
|
let Ok(file_type) = entry.file_type().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !file_type.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let Ok(id) = uuid::Uuid::parse_str(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(sprint) = load_sprint(root, SprintId::from_uuid(id)).await {
|
||||||
|
rows.push(SprintIndexEntry::from(&sprint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.sort_by_key(|row| row.order.get());
|
||||||
|
write_index(root, &rows).await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_index(root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
|
||||||
|
match tokio::fs::read(index_path(root)).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let doc: IndexDoc = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|err| SprintStoreError::Store(err.to_string()))?;
|
||||||
|
Ok(doc.sprints)
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await,
|
||||||
|
Err(err) => Err(io_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_index(
|
||||||
|
root: &ProjectPath,
|
||||||
|
rows: &[SprintIndexEntry],
|
||||||
|
) -> Result<(), SprintStoreError> {
|
||||||
|
let doc = IndexDoc {
|
||||||
|
version: INDEX_VERSION,
|
||||||
|
sprints: rows.to_vec(),
|
||||||
|
};
|
||||||
|
let bytes =
|
||||||
|
serde_json::to_vec_pretty(&doc).map_err(|err| SprintStoreError::Store(err.to_string()))?;
|
||||||
|
write_atomic(&index_path(root), &bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SprintStore for FsSprintStore {
|
||||||
|
async fn create(&self, root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> {
|
||||||
|
sprint
|
||||||
|
.validate()
|
||||||
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
||||||
|
if tokio::fs::try_exists(sprint_path(root, sprint.id))
|
||||||
|
.await
|
||||||
|
.map_err(io_error)?
|
||||||
|
{
|
||||||
|
return Err(SprintStoreError::Invalid(format!(
|
||||||
|
"sprint {} already exists",
|
||||||
|
sprint.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
save_sprint(root, sprint).await?;
|
||||||
|
rebuild_index(root).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
root: &ProjectPath,
|
||||||
|
sprint_id: SprintId,
|
||||||
|
) -> Result<Sprint, SprintStoreError> {
|
||||||
|
load_sprint(root, sprint_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, SprintStoreError> {
|
||||||
|
let mut rows = read_index(root).await?;
|
||||||
|
rows.sort_by_key(|row| row.order.get());
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
root: &ProjectPath,
|
||||||
|
sprint: &Sprint,
|
||||||
|
expected_version: SprintVersion,
|
||||||
|
) -> Result<(), SprintStoreError> {
|
||||||
|
sprint
|
||||||
|
.validate()
|
||||||
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
||||||
|
let current = load_sprint(root, sprint.id).await?;
|
||||||
|
if current.version != expected_version {
|
||||||
|
return Err(SprintStoreError::VersionConflict {
|
||||||
|
expected: expected_version,
|
||||||
|
actual: current.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
save_sprint(root, sprint).await?;
|
||||||
|
rebuild_index(root).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
root: &ProjectPath,
|
||||||
|
sprint_id: SprintId,
|
||||||
|
) -> Result<(), SprintStoreError> {
|
||||||
|
if !tokio::fs::try_exists(sprint_path(root, sprint_id))
|
||||||
|
.await
|
||||||
|
.map_err(io_error)?
|
||||||
|
{
|
||||||
|
return Err(SprintStoreError::NotFound);
|
||||||
|
}
|
||||||
|
tokio::fs::remove_dir_all(sprint_dir(root, sprint_id))
|
||||||
|
.await
|
||||||
|
.map_err(io_error)?;
|
||||||
|
rebuild_index(root).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sprint_doc(sprint: &Sprint) -> String {
|
||||||
|
format!(
|
||||||
|
"---\n\
|
||||||
|
id: {}\n\
|
||||||
|
order: {}\n\
|
||||||
|
name: {}\n\
|
||||||
|
status: {}\n\
|
||||||
|
createdBy: {}\n\
|
||||||
|
updatedBy: {}\n\
|
||||||
|
createdAt: {}\n\
|
||||||
|
updatedAt: {}\n\
|
||||||
|
version: {}\n\
|
||||||
|
---\n",
|
||||||
|
json_string(&sprint.id.to_string()),
|
||||||
|
sprint.order.get(),
|
||||||
|
json_string(&sprint.name),
|
||||||
|
json_value(&sprint.status),
|
||||||
|
json_value(&sprint.created_by),
|
||||||
|
json_value(&sprint.updated_by),
|
||||||
|
sprint.created_at,
|
||||||
|
sprint.updated_at,
|
||||||
|
sprint.version.get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sprint_doc(text: &str) -> Result<Sprint, SprintStoreError> {
|
||||||
|
let (fm, _body) = split_frontmatter(text)?;
|
||||||
|
let map = parse_frontmatter_map(fm)?;
|
||||||
|
let id_raw: String = parse_json_field(&map, "id")?;
|
||||||
|
let id = SprintId::from_uuid(
|
||||||
|
uuid::Uuid::parse_str(&id_raw).map_err(|err| SprintStoreError::Invalid(err.to_string()))?,
|
||||||
|
);
|
||||||
|
let order = SprintOrder::new(parse_plain_u32(&map, "order")?)
|
||||||
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
||||||
|
let name: String = parse_json_field(&map, "name")?;
|
||||||
|
let status: SprintStatus = parse_json_field(&map, "status")?;
|
||||||
|
let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
|
||||||
|
let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
|
||||||
|
let created_at = parse_plain_u64(&map, "createdAt")?;
|
||||||
|
let updated_at = parse_plain_u64(&map, "updatedAt")?;
|
||||||
|
let version = SprintVersion::new(parse_plain_u64(&map, "version")?)
|
||||||
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))?;
|
||||||
|
Sprint::rehydrate(Sprint {
|
||||||
|
id,
|
||||||
|
order,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_frontmatter(text: &str) -> Result<(&str, &str), SprintStoreError> {
|
||||||
|
let rest = text
|
||||||
|
.strip_prefix("---\n")
|
||||||
|
.or_else(|| text.strip_prefix("---\r\n"))
|
||||||
|
.ok_or_else(|| SprintStoreError::Invalid("missing opening frontmatter fence".to_owned()))?;
|
||||||
|
let mut offset = 0;
|
||||||
|
for line in rest.split_inclusive('\n') {
|
||||||
|
if line.trim_end_matches(['\r', '\n']) == "---" {
|
||||||
|
let body_start = offset + line.len();
|
||||||
|
return Ok((&rest[..offset], &rest[body_start..]));
|
||||||
|
}
|
||||||
|
offset += line.len();
|
||||||
|
}
|
||||||
|
Err(SprintStoreError::Invalid(
|
||||||
|
"missing closing frontmatter fence".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_frontmatter_map(block: &str) -> Result<BTreeMap<String, String>, SprintStoreError> {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
for line in block.lines().filter(|line| !line.trim().is_empty()) {
|
||||||
|
let (key, value) = line
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| SprintStoreError::Invalid("frontmatter line missing ':'".to_owned()))?;
|
||||||
|
map.insert(key.trim().to_owned(), value.trim().to_owned());
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_plain_u32(map: &BTreeMap<String, String>, key: &str) -> Result<u32, SprintStoreError> {
|
||||||
|
map.get(key)
|
||||||
|
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_plain_u64(map: &BTreeMap<String, String>, key: &str) -> Result<u64, SprintStoreError> {
|
||||||
|
map.get(key)
|
||||||
|
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json_field<T: serde::de::DeserializeOwned>(
|
||||||
|
map: &BTreeMap<String, String>,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<T, SprintStoreError> {
|
||||||
|
let value = map
|
||||||
|
.get(key)
|
||||||
|
.ok_or_else(|| SprintStoreError::Invalid(format!("missing frontmatter key `{key}`")))?;
|
||||||
|
serde_json::from_str(value).map_err(|err| SprintStoreError::Invalid(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_value<T: Serialize>(value: &T) -> String {
|
||||||
|
serde_json::to_string(value).expect("serializing sprint frontmatter cannot fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_string(value: &str) -> String {
|
||||||
|
serde_json::to_string(value).expect("serializing sprint string cannot fail")
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ use std::str::FromStr;
|
|||||||
use domain::{
|
use domain::{
|
||||||
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
||||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||||
MarkdownDoc, ProjectPath,
|
MarkdownDoc, ProjectPath, SprintId,
|
||||||
};
|
};
|
||||||
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
|
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -144,6 +144,44 @@ async fn issue_store_lists_by_index_filters() {
|
|||||||
assert_eq!(rows[0].title, "Beta");
|
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]
|
#[tokio::test]
|
||||||
async fn allocator_never_reuses_numbers() {
|
async fn allocator_never_reuses_numbers() {
|
||||||
let tmp = TempDir::new();
|
let tmp = TempDir::new();
|
||||||
|
|||||||
@ -52,6 +52,7 @@ use application::{
|
|||||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||||
};
|
};
|
||||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||||
|
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||||
SystemMillisClock,
|
SystemMillisClock,
|
||||||
@ -459,6 +460,59 @@ fn result_text(result: &Value) -> &str {
|
|||||||
.expect("text content block")
|
.expect("text content block")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeTicketTools {
|
||||||
|
calls: Arc<Mutex<Vec<String>>>,
|
||||||
|
mutation_attempts: Arc<Mutex<usize>>,
|
||||||
|
sprints: Arc<Mutex<Vec<Value>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeTicketTools {
|
||||||
|
fn seed_sprint(&self, order: u32, name: &str) {
|
||||||
|
self.sprints.lock().unwrap().push(json!({
|
||||||
|
"id": Uuid::new_v4().to_string(),
|
||||||
|
"order": order,
|
||||||
|
"name": name,
|
||||||
|
"status": "planned",
|
||||||
|
"ticketCount": 0,
|
||||||
|
"version": 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self) -> Vec<String> {
|
||||||
|
self.calls.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mutation_attempts(&self) -> usize {
|
||||||
|
*self.mutation_attempts.lock().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TicketToolProvider for FakeTicketTools {
|
||||||
|
async fn handle_ticket_tool(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_requester: &str,
|
||||||
|
name: &str,
|
||||||
|
_arguments: Value,
|
||||||
|
) -> Result<Value, TicketToolError> {
|
||||||
|
self.calls.lock().unwrap().push(name.to_owned());
|
||||||
|
match name {
|
||||||
|
"idea_sprint_list" => Ok(json!({
|
||||||
|
"items": self.sprints.lock().unwrap().clone()
|
||||||
|
})),
|
||||||
|
_ => {
|
||||||
|
*self.mutation_attempts.lock().unwrap() += 1;
|
||||||
|
Err(TicketToolError::new(
|
||||||
|
"unexpectedMutation",
|
||||||
|
format!("unexpected mutable ticket tool {name}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 1. tools/list catalogue
|
// 1. tools/list catalogue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -512,6 +566,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
"idea_ticket_update_carnet",
|
"idea_ticket_update_carnet",
|
||||||
"idea_ticket_link",
|
"idea_ticket_link",
|
||||||
"idea_ticket_unlink",
|
"idea_ticket_unlink",
|
||||||
|
"idea_sprint_list",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -521,8 +576,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
24,
|
25,
|
||||||
"exactly the twenty-four exposed idea_* tools; got {names:?}"
|
"exactly the twenty-five exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// 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"));
|
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)
|
// 5. A failed IdeA command ⇒ isError: true (not a transport error, no panic)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
127
crates/infrastructure/tests/sprint_store.rs
Normal file
127
crates/infrastructure/tests/sprint_store.rs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
IssueActor, ProjectPath, Sprint, SprintId, SprintOrder, SprintStatus, SprintStore,
|
||||||
|
SprintStoreError,
|
||||||
|
};
|
||||||
|
use infrastructure::FsSprintStore;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!("idea-sprints-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root(&self) -> ProjectPath {
|
||||||
|
ProjectPath::new(self.0.to_string_lossy().to_string()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child(&self, rel: &str) -> PathBuf {
|
||||||
|
self.0.join(rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sid(n: u128) -> SprintId {
|
||||||
|
SprintId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sprint(id: SprintId, order: u32, name: &str) -> Sprint {
|
||||||
|
Sprint::new(
|
||||||
|
id,
|
||||||
|
SprintOrder::new(order).unwrap(),
|
||||||
|
name,
|
||||||
|
None,
|
||||||
|
IssueActor::User,
|
||||||
|
1_000,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sprint_store_writes_markdown_doc_and_index() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let root = tmp.root();
|
||||||
|
let store = FsSprintStore::new();
|
||||||
|
let sprint = sprint(sid(1), 1, "Delivery");
|
||||||
|
|
||||||
|
store.create(&root, &sprint).await.unwrap();
|
||||||
|
|
||||||
|
let sprint_md =
|
||||||
|
std::fs::read_to_string(tmp.child(&format!(".ideai/sprints/{}/sprint.md", sprint.id)))
|
||||||
|
.unwrap();
|
||||||
|
let index = std::fs::read_to_string(tmp.child(".ideai/sprints/index.json")).unwrap();
|
||||||
|
|
||||||
|
assert!(sprint_md.starts_with("---\n"));
|
||||||
|
assert!(sprint_md.contains("name: \"Delivery\""));
|
||||||
|
assert!(index.contains("\"name\": \"Delivery\""));
|
||||||
|
|
||||||
|
let loaded = store.get(&root, sprint.id).await.unwrap();
|
||||||
|
assert_eq!(loaded.name, "Delivery");
|
||||||
|
assert_eq!(loaded.order.get(), 1);
|
||||||
|
assert_eq!(loaded.status, SprintStatus::Planned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sprint_store_update_checks_expected_version() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let root = tmp.root();
|
||||||
|
let store = FsSprintStore::new();
|
||||||
|
let original = sprint(sid(2), 1, "Plan");
|
||||||
|
store.create(&root, &original).await.unwrap();
|
||||||
|
let updated = original
|
||||||
|
.clone()
|
||||||
|
.mutate(IssueActor::System, 2_000, |sprint| {
|
||||||
|
sprint.name = "Build".to_owned();
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = store
|
||||||
|
.update(&root, &updated, updated.version)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, SprintStoreError::VersionConflict { .. }));
|
||||||
|
|
||||||
|
store
|
||||||
|
.update(&root, &updated, original.version)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let loaded = store.get(&root, updated.id).await.unwrap();
|
||||||
|
assert_eq!(loaded.name, "Build");
|
||||||
|
assert_eq!(loaded.version.get(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sprint_store_lists_by_order_and_deletes() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let root = tmp.root();
|
||||||
|
let store = FsSprintStore::new();
|
||||||
|
let first = sprint(sid(10), 2, "Second");
|
||||||
|
let second = sprint(sid(11), 1, "First");
|
||||||
|
store.create(&root, &first).await.unwrap();
|
||||||
|
store.create(&root, &second).await.unwrap();
|
||||||
|
|
||||||
|
let rows = store.list(&root).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["First", "Second"]
|
||||||
|
);
|
||||||
|
|
||||||
|
store.delete(&root, first.id).await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&root, first.id).await.unwrap_err(),
|
||||||
|
SprintStoreError::NotFound
|
||||||
|
));
|
||||||
|
let rows = store.list(&root).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, second.id);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user