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.
|
||||
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.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OrchestratorRequestProcessed {
|
||||
@ -773,6 +819,42 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
version: version.get(),
|
||||
},
|
||||
DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated {
|
||||
sprint_id: sprint_id.to_string(),
|
||||
order: order.get(),
|
||||
},
|
||||
DomainEvent::SprintRenamed {
|
||||
sprint_id,
|
||||
name,
|
||||
version,
|
||||
} => Self::SprintRenamed {
|
||||
sprint_id: sprint_id.to_string(),
|
||||
name: name.clone(),
|
||||
version: version.get(),
|
||||
},
|
||||
DomainEvent::SprintReordered {
|
||||
sprint_id,
|
||||
order,
|
||||
version,
|
||||
} => Self::SprintReordered {
|
||||
sprint_id: sprint_id.to_string(),
|
||||
order: order.get(),
|
||||
version: version.get(),
|
||||
},
|
||||
DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted {
|
||||
sprint_id: sprint_id.to_string(),
|
||||
},
|
||||
DomainEvent::IssueSprintChanged {
|
||||
issue_ref,
|
||||
from,
|
||||
to,
|
||||
version,
|
||||
} => Self::IssueSprintChanged {
|
||||
issue_ref: issue_ref.to_string(),
|
||||
from: from.map(|id| id.to_string()),
|
||||
to: to.map(|id| id.to_string()),
|
||||
version: version.get(),
|
||||
},
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
requester_id,
|
||||
action,
|
||||
@ -981,4 +1063,22 @@ mod tests {
|
||||
assert_eq!(json["agentId"], agent(11).to_string());
|
||||
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_sprint_changed_relays_to_dto_and_wire() {
|
||||
let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41));
|
||||
let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
|
||||
let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged {
|
||||
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
|
||||
from: Some(from),
|
||||
to: Some(to),
|
||||
version: domain::IssueVersion::new(3).unwrap(),
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "issueSprintChanged");
|
||||
assert_eq!(json["issueRef"], "#7");
|
||||
assert_eq!(json["from"], from.to_string());
|
||||
assert_eq!(json["to"], to.to_string());
|
||||
assert_eq!(json["version"], 3);
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,6 +173,13 @@ pub fn run() {
|
||||
tickets::ticket_link,
|
||||
tickets::ticket_unlink,
|
||||
tickets::ticket_assign,
|
||||
tickets::sprint_create,
|
||||
tickets::sprint_list,
|
||||
tickets::sprint_rename,
|
||||
tickets::sprint_reorder,
|
||||
tickets::sprint_delete,
|
||||
tickets::ticket_assign_sprint,
|
||||
tickets::ticket_unassign_sprint,
|
||||
commands::get_project_work_state,
|
||||
commands::read_conversation_page,
|
||||
commands::list_live_agents,
|
||||
|
||||
@ -13,30 +13,32 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||
AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile,
|
||||
CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, ConfigureProfiles,
|
||||
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue,
|
||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
|
||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
||||
ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
||||
LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
||||
OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, RetryBackgroundTask,
|
||||
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
||||
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
||||
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
||||
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
||||
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile,
|
||||
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
||||
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -45,7 +47,7 @@ use domain::ports::{
|
||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort,
|
||||
IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore,
|
||||
TemplateStore, WakeError, WakeReason,
|
||||
SprintStore, TemplateStore, WakeError, WakeReason,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -66,7 +68,7 @@ use infrastructure::{
|
||||
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||
@ -804,6 +806,20 @@ pub struct AppState {
|
||||
pub unlink_issues: Arc<UnlinkIssues>,
|
||||
/// Assign or unassign an agent on a public ticket.
|
||||
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.
|
||||
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
||||
/// Generic PTY↔Channel bridge registry (consumed by L3).
|
||||
@ -1213,6 +1229,45 @@ impl AppState {
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
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 {
|
||||
create: Arc::clone(&create_issue),
|
||||
read: Arc::clone(&read_issue),
|
||||
@ -1222,6 +1277,7 @@ impl AppState {
|
||||
update_carnet: Arc::clone(&update_issue_carnet),
|
||||
link: Arc::clone(&link_issues),
|
||||
unlink: Arc::clone(&unlink_issues),
|
||||
list_sprints: Arc::clone(&list_sprints),
|
||||
});
|
||||
|
||||
// --- Project permissions (LP1) ---
|
||||
@ -2114,6 +2170,13 @@ impl AppState {
|
||||
link_issues,
|
||||
unlink_issues,
|
||||
assign_issue_agent,
|
||||
create_sprint,
|
||||
list_sprints,
|
||||
rename_sprint,
|
||||
reorder_sprints,
|
||||
delete_sprint,
|
||||
assign_ticket_to_sprint,
|
||||
unassign_ticket_from_sprint,
|
||||
ticket_tool_provider,
|
||||
pty_bridge,
|
||||
structured_sessions,
|
||||
@ -4194,6 +4257,7 @@ mod mcp_serve_peer_tests {
|
||||
"idea_ticket_update_carnet",
|
||||
"idea_ticket_link",
|
||||
"idea_ticket_unlink",
|
||||
"idea_sprint_list",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
@ -4203,8 +4267,8 @@ mod mcp_serve_peer_tests {
|
||||
assert!(!names.contains(&"idea_reply"));
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
24,
|
||||
"exactly the twenty-four exposed idea_* tools; got {names:?}"
|
||||
25,
|
||||
"exactly the twenty-five exposed idea_* tools; got {names:?}"
|
||||
);
|
||||
|
||||
drop(client); // EOF ⇒ serve loop ends
|
||||
|
||||
@ -14,14 +14,15 @@ use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
AppError, AssignIssueAgentInput, CreateIssueInput, LinkIssuesInput, ListIssuesInput,
|
||||
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, UnlinkIssuesInput,
|
||||
UpdateIssueCarnetInput, UpdateIssueInput,
|
||||
AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CreateIssueInput,
|
||||
CreateSprintInput, DeleteSprintInput, LinkIssuesInput, ListIssuesInput, ListSprintsInput,
|
||||
OpenProjectInput, ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
|
||||
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
|
||||
IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, Project,
|
||||
ProjectId,
|
||||
ProjectId, SprintId, SprintStatus, SprintVersion,
|
||||
};
|
||||
use infrastructure::{TicketToolError, TicketToolProvider};
|
||||
|
||||
@ -39,6 +40,9 @@ pub struct TicketDto {
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub priority: String,
|
||||
pub sprint_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sprint: Option<TicketSprintContextDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub carnet: Option<String>,
|
||||
pub links: Vec<TicketLinkDto>,
|
||||
@ -59,6 +63,9 @@ pub struct TicketSummaryDto {
|
||||
pub title: String,
|
||||
pub status: 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 updated_at: u64,
|
||||
}
|
||||
@ -72,6 +79,33 @@ pub struct TicketListDto {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -131,6 +165,7 @@ pub struct TicketListRequestDto {
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub assigned_agent_id: Option<String>,
|
||||
pub sprint_id: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub cursor: Option<String>,
|
||||
@ -206,6 +241,74 @@ pub struct TicketAssignRequestDto {
|
||||
pub expected_version: u64,
|
||||
}
|
||||
|
||||
/// Create sprint request.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SprintCreateRequestDto {
|
||||
#[serde(default)]
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub status: Option<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)]
|
||||
pub struct AppTicketToolProvider {
|
||||
pub create: Arc<application::CreateIssue>,
|
||||
@ -216,6 +319,7 @@ pub struct AppTicketToolProvider {
|
||||
pub update_carnet: Arc<application::UpdateIssueCarnet>,
|
||||
pub link: Arc<application::LinkIssues>,
|
||||
pub unlink: Arc<application::UnlinkIssues>,
|
||||
pub list_sprints: Arc<application::ListSprints>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -259,7 +363,15 @@ impl TicketToolProvider for AppTicketToolProvider {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
json!(TicketDto::from_issue(issue, carnet))
|
||||
let sprints = self
|
||||
.list_sprints
|
||||
.execute(ListSprintsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(ticket_error)?
|
||||
.sprints;
|
||||
json!(TicketDto::from_issue_with_sprints(issue, carnet, &sprints))
|
||||
}
|
||||
"idea_ticket_list" => {
|
||||
let req = mcp_list_request(project, arguments)?;
|
||||
@ -272,7 +384,26 @@ impl TicketToolProvider for AppTicketToolProvider {
|
||||
.await
|
||||
.map_err(ticket_error)?
|
||||
.issues;
|
||||
json!(paginate(rows, req.limit, req.cursor))
|
||||
let sprints = self
|
||||
.list_sprints
|
||||
.execute(ListSprintsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(ticket_error)?
|
||||
.sprints;
|
||||
json!(paginate_with_sprints(rows, req.limit, req.cursor, &sprints))
|
||||
}
|
||||
"idea_sprint_list" => {
|
||||
let rows = self
|
||||
.list_sprints
|
||||
.execute(ListSprintsInput {
|
||||
project: project.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(ticket_error)?
|
||||
.sprints;
|
||||
json!(SprintListDto::from(rows))
|
||||
}
|
||||
"idea_ticket_update" => {
|
||||
let req = mcp_update_request(project, arguments)?;
|
||||
@ -476,6 +607,152 @@ pub async fn ticket_list(
|
||||
Ok(paginate(rows, page.limit, page.cursor))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sprint_create(
|
||||
request: SprintCreateRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<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]
|
||||
pub async fn ticket_update(
|
||||
request: TicketUpdateRequestDto,
|
||||
@ -616,6 +893,8 @@ impl TicketDto {
|
||||
description: issue.description.as_str().to_owned(),
|
||||
status: status_wire(issue.status).to_owned(),
|
||||
priority: priority_wire(issue.priority).to_owned(),
|
||||
sprint_id: issue.sprint.map(|id| id.to_string()),
|
||||
sprint: None,
|
||||
carnet,
|
||||
links: issue.links.into_iter().map(TicketLinkDto::from).collect(),
|
||||
assigned_agent_ids: issue
|
||||
@ -631,6 +910,19 @@ impl TicketDto {
|
||||
version: issue.version.get(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_issue_with_sprints(
|
||||
issue: Issue,
|
||||
carnet: Option<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 {
|
||||
@ -641,6 +933,8 @@ impl From<IssueIndexEntry> for TicketSummaryDto {
|
||||
title: row.title,
|
||||
status: status_wire(row.status).to_owned(),
|
||||
priority: priority_wire(row.priority).to_owned(),
|
||||
sprint_id: row.sprint.map(|id| id.to_string()),
|
||||
sprint: None,
|
||||
assigned_agent_ids: row
|
||||
.assigned_agent_ids
|
||||
.into_iter()
|
||||
@ -651,6 +945,52 @@ impl From<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 {
|
||||
fn from(link: IssueLink) -> Self {
|
||||
Self {
|
||||
@ -707,6 +1047,11 @@ impl TicketListPageInput {
|
||||
.as_deref()
|
||||
.map(parse_agent_id_dto)
|
||||
.transpose()?,
|
||||
sprint: request
|
||||
.sprint_id
|
||||
.as_deref()
|
||||
.map(parse_sprint_id_dto)
|
||||
.transpose()?,
|
||||
text: request.text,
|
||||
},
|
||||
limit: request.limit.unwrap_or(100).clamp(1, 500),
|
||||
@ -819,6 +1164,42 @@ fn paginate(rows: Vec<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(
|
||||
read_carnet: &application::ReadIssueCarnet,
|
||||
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}")))
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
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))
|
||||
}
|
||||
|
||||
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> {
|
||||
match raw {
|
||||
"open" => Ok(IssueStatus::Open),
|
||||
@ -937,6 +1337,14 @@ fn link_kind_wire(kind: IssueLinkKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn sprint_status_wire(status: SprintStatus) -> &'static str {
|
||||
match status {
|
||||
SprintStatus::Planned => "planned",
|
||||
SprintStatus::Active => "active",
|
||||
SprintStatus::Done => "done",
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_error(err: AppError) -> TicketToolError {
|
||||
let message = err.to_string();
|
||||
let code = if message.contains("issue version conflict") {
|
||||
|
||||
Reference in New Issue
Block a user