//! Issue use cases. //! //! The domain/code term is `Issue` throughout this layer. use std::collections::HashSet; use std::sync::Arc; use domain::{AgentContextStore, Clock, EventBus, IdGenerator}; use domain::{ AgentId, AgentIssueRef, AgentIssueRole, DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueVersion, MarkdownDoc, Project, }; use crate::error::AppError; /// Input for [`CreateIssue::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateIssueInput { /// Project owning the issue. pub project: Project, /// Issue title. pub title: String, /// Issue description Markdown. pub description: String, /// Initial priority. pub priority: IssuePriority, /// Initial status. pub status: IssueStatus, /// Initial issue links. pub links: Vec, /// Initially assigned agents. pub assigned_agent_ids: Vec, /// Actor creating the issue. pub actor: IssueActor, } /// Output of [`CreateIssue::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateIssueOutput { /// Created issue. pub issue: Issue, } /// Creates an issue, allocating a non-reused project-local number. pub struct CreateIssue { issues: Arc, allocator: Arc, contexts: Arc, ids: Arc, clock: Arc, events: Arc, } impl CreateIssue { /// Builds the use case. #[must_use] pub fn new( issues: Arc, allocator: Arc, contexts: Arc, ids: Arc, clock: Arc, events: Arc, ) -> Self { Self { issues, allocator, contexts, ids, clock, events, } } /// Executes creation. /// /// # Errors /// [`AppError`] on invariant, manifest validation or store failure. pub async fn execute(&self, input: CreateIssueInput) -> Result { validate_agents(&self.contexts, &input.project, &input.assigned_agent_ids).await?; let number = self.allocator.allocate_next(&input.project.root).await?; let issue_ref = IssueRef::from(number); for link in &input.links { if link.target != issue_ref { self.issues .get_by_ref(&input.project.root, link.target) .await?; } } let agent_refs = input .assigned_agent_ids .into_iter() .map(|agent_id| AgentIssueRef { agent_id, role: AgentIssueRole::Assigned, }) .collect(); let issue = Issue::new( IssueId::from_uuid(self.ids.new_uuid()), number, input.title, MarkdownDoc::new(input.description), input.status, input.priority, MarkdownDoc::default(), input.links, agent_refs, input.actor, now(&self.clock), ) .map_err(|err| AppError::Invalid(err.to_string()))?; self.issues.create(&input.project.root, &issue).await?; self.events.publish(DomainEvent::IssueCreated { issue_id: issue.id, issue_ref: issue.reference(), }); Ok(CreateIssueOutput { issue }) } } /// Input for [`ReadIssue::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadIssueInput { /// Project owning the issue. pub project: Project, /// Issue reference. pub issue_ref: IssueRef, } /// Output of [`ReadIssue::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadIssueOutput { /// Issue. pub issue: Issue, } /// Reads an issue. pub struct ReadIssue { issues: Arc, } impl ReadIssue { /// Builds the use case. #[must_use] pub fn new(issues: Arc) -> Self { Self { issues } } /// Executes read. /// /// # Errors /// [`AppError`] on store failure. pub async fn execute(&self, input: ReadIssueInput) -> Result { Ok(ReadIssueOutput { issue: self .issues .get_by_ref(&input.project.root, input.issue_ref) .await?, }) } } /// Input for [`ReadIssueCarnet::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadIssueCarnetInput { /// Project owning the issue. pub project: Project, /// Issue reference. pub issue_ref: IssueRef, } /// Output of [`ReadIssueCarnet::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadIssueCarnetOutput { /// Carnet projection. pub carnet: IssueCarnet, } /// Reads an issue carnet. pub struct ReadIssueCarnet { issues: Arc, } impl ReadIssueCarnet { /// Builds the use case. #[must_use] pub fn new(issues: Arc) -> Self { Self { issues } } /// Executes carnet read. /// /// # Errors /// [`AppError`] on store failure. pub async fn execute( &self, input: ReadIssueCarnetInput, ) -> Result { Ok(ReadIssueCarnetOutput { carnet: self .issues .read_carnet(&input.project.root, input.issue_ref) .await?, }) } } /// Input for [`ListIssues::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListIssuesInput { /// Project owning the issues. pub project: Project, /// Filter. pub filter: IssueListFilter, } /// Output of [`ListIssues::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListIssuesOutput { /// Matching issue index entries. pub issues: Vec, } /// Lists issues. pub struct ListIssues { issues: Arc, } impl ListIssues { /// Builds the use case. #[must_use] pub fn new(issues: Arc) -> Self { Self { issues } } /// Executes list. /// /// # Errors /// [`AppError`] on store failure. pub async fn execute(&self, input: ListIssuesInput) -> Result { Ok(ListIssuesOutput { issues: self.issues.list(&input.project.root, input.filter).await?, }) } } /// Input for [`UpdateIssue::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateIssueInput { /// Project owning the issue. pub project: Project, /// Issue reference. pub issue_ref: IssueRef, /// Expected optimistic version. pub expected_version: IssueVersion, /// Optional title replacement. pub title: Option, /// Optional description replacement. pub description: Option, /// Optional status replacement. pub status: Option, /// Optional priority replacement. pub priority: Option, /// Optional full assigned-agent replacement. pub assigned_agent_ids: Option>, /// Actor updating the issue. pub actor: IssueActor, } /// Output of [`UpdateIssue::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateIssueOutput { /// Updated issue. pub issue: Issue, } /// Updates issue fields with optimistic concurrency. pub struct UpdateIssue { issues: Arc, contexts: Arc, clock: Arc, events: Arc, } impl UpdateIssue { /// Builds the use case. #[must_use] pub fn new( issues: Arc, contexts: Arc, clock: Arc, events: Arc, ) -> Self { Self { issues, contexts, clock, events, } } /// Executes update. /// /// # Errors /// [`AppError`] on invariant, manifest validation or store failure. pub async fn execute(&self, input: UpdateIssueInput) -> Result { if let Some(agent_ids) = &input.assigned_agent_ids { validate_agents(&self.contexts, &input.project, agent_ids).await?; } let current = self .issues .get_by_ref(&input.project.root, input.issue_ref) .await?; let old_status = current.status; let old_priority = current.priority; let old_assigned = assigned_set(¤t); let assigned_agent_ids = input.assigned_agent_ids.clone(); let updated = current .mutate(input.actor, now(&self.clock), |issue| { if let Some(title) = input.title { issue.title = title; } if let Some(description) = input.description { issue.description = MarkdownDoc::new(description); } if let Some(status) = input.status { issue.status = status; } if let Some(priority) = input.priority { issue.priority = priority; } if let Some(agent_ids) = assigned_agent_ids { replace_assigned_agents(issue, agent_ids); } }) .map_err(|err| AppError::Invalid(err.to_string()))?; self.issues .update(&input.project.root, &updated, input.expected_version) .await?; publish_update_events( &self.events, &updated, old_status, old_priority, old_assigned, ); Ok(UpdateIssueOutput { issue: updated }) } } /// Input for [`UpdateIssueCarnet::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateIssueCarnetInput { /// Project owning the issue. pub project: Project, /// Issue reference. pub issue_ref: IssueRef, /// Expected optimistic version. pub expected_version: IssueVersion, /// Carnet Markdown. pub carnet: String, /// Actor updating the carnet. pub actor: IssueActor, } /// Output of [`UpdateIssueCarnet::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateIssueCarnetOutput { /// Carnet projection. pub carnet: IssueCarnet, } /// Updates an issue carnet. pub struct UpdateIssueCarnet { issues: Arc, clock: Arc, events: Arc, } impl UpdateIssueCarnet { /// Builds the use case. #[must_use] pub fn new( issues: Arc, clock: Arc, events: Arc, ) -> Self { Self { issues, clock, events, } } /// Executes carnet update. /// /// # Errors /// [`AppError`] on store failure or conflict. pub async fn execute( &self, input: UpdateIssueCarnetInput, ) -> Result { let carnet = self .issues .write_carnet( &input.project.root, input.issue_ref, MarkdownDoc::new(input.carnet), input.actor, now(&self.clock), input.expected_version, ) .await?; self.events.publish(DomainEvent::IssueCarnetUpdated { issue_ref: input.issue_ref, version: carnet.version, }); Ok(UpdateIssueCarnetOutput { carnet }) } } /// Input for [`LinkIssues::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LinkIssuesInput { /// Project owning the issues. pub project: Project, /// Source issue. pub issue_ref: IssueRef, /// Target issue. pub target: IssueRef, /// Link kind. pub kind: IssueLinkKind, /// Expected optimistic version. pub expected_version: IssueVersion, /// Actor updating the issue. pub actor: IssueActor, } /// Input for [`UnlinkIssues::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnlinkIssuesInput { /// Project owning the issues. pub project: Project, /// Source issue. pub issue_ref: IssueRef, /// Target issue. pub target: IssueRef, /// Optional link kind. `None` removes every link from `issue_ref` to `target`. pub kind: Option, /// Expected optimistic version. pub expected_version: IssueVersion, /// Actor updating the issue. pub actor: IssueActor, } /// Output for link mutations. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LinkIssuesOutput { /// Updated issue. pub issue: Issue, } /// Adds an issue link. pub struct LinkIssues { issues: Arc, clock: Arc, events: Arc, } impl LinkIssues { /// Builds the use case. #[must_use] pub fn new( issues: Arc, clock: Arc, events: Arc, ) -> Self { Self { issues, clock, events, } } /// Executes link creation. /// /// # Errors /// [`AppError`] on invariant or store failure. pub async fn execute(&self, input: LinkIssuesInput) -> Result { // Ensure target exists and source self-link is caught before persisting. self.issues .get_by_ref(&input.project.root, input.target) .await?; let current = self .issues .get_by_ref(&input.project.root, input.issue_ref) .await?; let updated = current .mutate(input.actor, now(&self.clock), |issue| { let link = IssueLink { target: input.target, kind: input.kind, }; if !issue.links.contains(&link) { issue.links.push(link); } }) .map_err(|err| AppError::Invalid(err.to_string()))?; self.issues .update(&input.project.root, &updated, input.expected_version) .await?; self.events.publish(DomainEvent::IssueLinked { issue_ref: input.issue_ref, target: input.target, kind: input.kind, version: updated.version, }); Ok(LinkIssuesOutput { issue: updated }) } } /// Removes an issue link. pub struct UnlinkIssues { issues: Arc, clock: Arc, events: Arc, } impl UnlinkIssues { /// Builds the use case. #[must_use] pub fn new( issues: Arc, clock: Arc, events: Arc, ) -> Self { Self { issues, clock, events, } } /// Executes link removal. /// /// # Errors /// [`AppError`] on store failure. pub async fn execute(&self, input: UnlinkIssuesInput) -> Result { let current = self .issues .get_by_ref(&input.project.root, input.issue_ref) .await?; let removed: Vec = current .links .iter() .filter(|link| { link.target == input.target && input.kind.map_or(true, |kind| link.kind == kind) }) .map(|link| link.kind) .collect(); let updated = current .mutate(input.actor, now(&self.clock), |issue| { issue.links.retain(|link| { !(link.target == input.target && input.kind.map_or(true, |kind| link.kind == kind)) }); }) .map_err(|err| AppError::Invalid(err.to_string()))?; self.issues .update(&input.project.root, &updated, input.expected_version) .await?; for kind in removed { self.events.publish(DomainEvent::IssueUnlinked { issue_ref: input.issue_ref, target: input.target, kind, version: updated.version, }); } Ok(LinkIssuesOutput { issue: updated }) } } /// Input for [`AssignIssueAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AssignIssueAgentInput { /// Project owning the issue. pub project: Project, /// Issue reference. pub issue_ref: IssueRef, /// Agent to assign or unassign. pub agent_id: AgentId, /// Whether the agent must be assigned. pub assigned: bool, /// Expected optimistic version. pub expected_version: IssueVersion, /// Actor updating the issue. pub actor: IssueActor, } /// Output of [`AssignIssueAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AssignIssueAgentOutput { /// Updated issue. pub issue: Issue, } /// Assigns or unassigns an agent on an issue. pub struct AssignIssueAgent { issues: Arc, contexts: Arc, clock: Arc, events: Arc, } impl AssignIssueAgent { /// Builds the use case. #[must_use] pub fn new( issues: Arc, contexts: Arc, clock: Arc, events: Arc, ) -> Self { Self { issues, contexts, clock, events, } } /// Executes assignment mutation. /// /// # Errors /// [`AppError`] on manifest validation or store failure. pub async fn execute( &self, input: AssignIssueAgentInput, ) -> Result { validate_agents(&self.contexts, &input.project, &[input.agent_id]).await?; let current = self .issues .get_by_ref(&input.project.root, input.issue_ref) .await?; let updated = current .mutate(input.actor, now(&self.clock), |issue| { if input.assigned { if !issue.agent_refs.iter().any(|r| { r.agent_id == input.agent_id && r.role == AgentIssueRole::Assigned }) { issue.agent_refs.push(AgentIssueRef { agent_id: input.agent_id, role: AgentIssueRole::Assigned, }); } } else { issue.agent_refs.retain(|r| { !(r.agent_id == input.agent_id && r.role == AgentIssueRole::Assigned) }); } }) .map_err(|err| AppError::Invalid(err.to_string()))?; self.issues .update(&input.project.root, &updated, input.expected_version) .await?; if input.assigned { self.events.publish(DomainEvent::IssueAgentAssigned { issue_ref: input.issue_ref, agent_id: input.agent_id, version: updated.version, }); } else { self.events.publish(DomainEvent::IssueAgentUnassigned { issue_ref: input.issue_ref, agent_id: input.agent_id, version: updated.version, }); } Ok(AssignIssueAgentOutput { issue: updated }) } } fn now(clock: &Arc) -> u64 { clock.now_millis().max(0) as u64 } async fn validate_agents( contexts: &Arc, project: &Project, agent_ids: &[AgentId], ) -> Result<(), AppError> { if agent_ids.is_empty() { return Ok(()); } let manifest = contexts.load_manifest(project).await?; let known: HashSet = manifest .entries .into_iter() .map(|entry| entry.agent_id) .collect(); for agent_id in agent_ids { if !known.contains(agent_id) { return Err(AppError::NotFound(format!("agent {agent_id}"))); } } Ok(()) } fn assigned_set(issue: &Issue) -> HashSet { issue .agent_refs .iter() .filter(|reference| reference.role == AgentIssueRole::Assigned) .map(|reference| reference.agent_id) .collect() } fn replace_assigned_agents(issue: &mut Issue, agent_ids: Vec) { issue .agent_refs .retain(|reference| reference.role != AgentIssueRole::Assigned); for agent_id in agent_ids { issue.agent_refs.push(AgentIssueRef { agent_id, role: AgentIssueRole::Assigned, }); } } fn publish_update_events( events: &Arc, issue: &Issue, old_status: IssueStatus, old_priority: IssuePriority, old_assigned: HashSet, ) { let issue_ref = issue.reference(); events.publish(DomainEvent::IssueUpdated { issue_ref, version: issue.version, }); if issue.status != old_status { events.publish(DomainEvent::IssueStatusChanged { issue_ref, status: issue.status, version: issue.version, }); } if issue.priority != old_priority { events.publish(DomainEvent::IssuePriorityChanged { issue_ref, priority: issue.priority, version: issue.version, }); } let new_assigned = assigned_set(issue); for agent_id in new_assigned.difference(&old_assigned) { events.publish(DomainEvent::IssueAgentAssigned { issue_ref, agent_id: *agent_id, version: issue.version, }); } for agent_id in old_assigned.difference(&new_assigned) { events.publish(DomainEvent::IssueAgentUnassigned { issue_ref, agent_id: *agent_id, version: issue.version, }); } }