//! Issue domain model. //! //! Code uses `Issue` terminology exclusively to avoid colliding with the //! inter-agent delegation model. use std::str::FromStr; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::ids::{AgentId, IssueId, SprintId}; use crate::markdown::MarkdownDoc; /// Sequential per-project issue number. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct IssueNumber(u64); impl IssueNumber { /// Builds an issue number. Values start at 1. /// /// # Errors /// [`IssueError::InvalidNumber`] when `value == 0`. pub fn new(value: u64) -> Result { if value == 0 { return Err(IssueError::InvalidNumber); } Ok(Self(value)) } /// Returns the raw numeric value. #[must_use] pub const fn get(self) -> u64 { self.0 } } impl std::fmt::Display for IssueNumber { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Human-friendly issue reference, formatted as `#`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct IssueRef(IssueNumber); impl IssueRef { /// Builds a reference from a validated number. #[must_use] pub const fn new(number: IssueNumber) -> Self { Self(number) } /// Returns the referenced issue number. #[must_use] pub const fn number(self) -> IssueNumber { self.0 } } impl std::fmt::Display for IssueRef { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "#{}", self.0.get()) } } impl From for IssueRef { fn from(number: IssueNumber) -> Self { Self::new(number) } } impl FromStr for IssueRef { type Err = IssueError; fn from_str(raw: &str) -> Result { let digits = raw .strip_prefix('#') .ok_or_else(|| IssueError::InvalidRef(raw.to_owned()))?; if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { return Err(IssueError::InvalidRef(raw.to_owned())); } let number = digits .parse::() .map_err(|_| IssueError::InvalidRef(raw.to_owned()))?; Ok(Self(IssueNumber::new(number)?)) } } impl Serialize for IssueRef { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.to_string()) } } impl<'de> Deserialize<'de> for IssueRef { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let raw = String::deserialize(deserializer)?; raw.parse().map_err(serde::de::Error::custom) } } /// Optimistic-concurrency version. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct IssueVersion(u64); impl IssueVersion { /// Initial version assigned to a newly-created issue. pub const INITIAL: Self = Self(1); /// Builds a version. Values start at 1. /// /// # Errors /// [`IssueError::InvalidVersion`] when `value == 0`. pub fn new(value: u64) -> Result { if value == 0 { return Err(IssueError::InvalidVersion); } Ok(Self(value)) } /// Returns the raw numeric value. #[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 IssueVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Issue lifecycle status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum IssueStatus { /// Open and not started. Open, /// Being worked. InProgress, /// Ready for QA. Qa, /// Closed. Closed, } /// Issue priority. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum IssuePriority { /// Low priority. Low, /// Default priority. Medium, /// High priority. High, /// Critical priority. Critical, } /// Relationship kind between two issues. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum IssueLinkKind { /// Informational relation. RelatesTo, /// This issue blocks the target. Blocks, /// This issue is blocked by the target. BlockedBy, /// This issue duplicates the target. Duplicates, /// This issue depends on the target. DependsOn, } /// Link from one issue to another. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IssueLink { /// Target issue. pub target: IssueRef, /// Link kind. pub kind: IssueLinkKind, } /// Role an agent has on an issue. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AgentIssueRole { /// Assigned implementer. Assigned, /// Mentioned participant. Mentioned, /// Reviewer. Reviewer, /// Owner. Owner, } /// Agent reference on an issue. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentIssueRef { /// Agent id. pub agent_id: AgentId, /// Agent role. pub role: AgentIssueRole, } /// Actor responsible for an issue mutation. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum IssueActor { /// Human user. User, /// Agent actor. Agent { /// Agent id. agent_id: AgentId, }, /// System actor. System, } /// Issue aggregate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Issue { /// Stable UUID. pub id: IssueId, /// Sequential per-project number. pub number: IssueNumber, /// Human-readable title. pub title: String, /// Markdown description. pub description: MarkdownDoc, /// Lifecycle status. pub status: IssueStatus, /// Priority. pub priority: IssuePriority, /// Sprint membership. `None` means backlog / no sprint. pub sprint: Option, /// Editable issue-local knowledge. pub carnet: MarkdownDoc, /// Links to other issues. pub links: Vec, /// Agent references. pub agent_refs: Vec, /// 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: IssueVersion, } impl Issue { /// Builds a new issue with version 1. /// /// # Errors /// [`IssueError`] when invariants are violated. #[allow(clippy::too_many_arguments)] pub fn new( id: IssueId, number: IssueNumber, title: impl Into, description: MarkdownDoc, status: IssueStatus, priority: IssuePriority, carnet: MarkdownDoc, links: Vec, agent_refs: Vec, actor: IssueActor, now_ms: u64, ) -> Result { let issue = Self { id, number, title: title.into(), description, status, priority, sprint: None, carnet, links, agent_refs, created_by: actor.clone(), updated_by: actor, created_at: now_ms, updated_at: now_ms, version: IssueVersion::INITIAL, }; issue.validate()?; Ok(issue) } /// Rehydrates a persisted issue. /// /// # Errors /// [`IssueError`] when persisted data violates invariants. pub fn rehydrate(issue: Self) -> Result { issue.validate()?; Ok(issue) } /// Returns this issue as `#`. #[must_use] pub const fn reference(&self) -> IssueRef { IssueRef::new(self.number) } /// Validates invariants. /// /// # Errors /// [`IssueError`] when an invariant is violated. pub fn validate(&self) -> Result<(), IssueError> { IssueNumber::new(self.number.get())?; IssueVersion::new(self.version.get())?; if self.title.trim().is_empty() { return Err(IssueError::EmptyTitle); } let own = self.reference(); if self.links.iter().any(|link| link.target == own) { return Err(IssueError::SelfLink { reference: own }); } Ok(()) } /// Applies a mutation and increments the version. /// /// # Errors /// [`IssueError`] when the resulting issue violates invariants. pub fn mutate( mut self, actor: IssueActor, now_ms: u64, f: impl FnOnce(&mut Self), ) -> Result { f(&mut self); self.updated_by = actor; self.updated_at = now_ms; self.version = self.version.next(); self.validate()?; Ok(self) } } /// Carnet projection with its optimistic version. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IssueCarnet { /// Issue reference. pub issue_ref: IssueRef, /// Carnet Markdown body. pub carnet: MarkdownDoc, /// Current issue version. pub version: IssueVersion, /// Actor that last updated the carnet. pub updated_by: IssueActor, /// Last carnet update time, epoch milliseconds. pub updated_at: u64, } /// Index row used by stores and list use cases. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IssueIndexEntry { /// Issue reference. pub issue_ref: IssueRef, /// Relative path to the issue folder. pub path: String, /// Title. pub title: String, /// Status. pub status: IssueStatus, /// Priority. pub priority: IssuePriority, /// Sprint membership. pub sprint: Option, /// Assigned agent ids. pub assigned_agent_ids: Vec, /// Last update time. pub updated_at: u64, } impl From<&Issue> for IssueIndexEntry { fn from(issue: &Issue) -> Self { Self { issue_ref: issue.reference(), path: issue.number.get().to_string(), title: issue.title.clone(), status: issue.status, priority: issue.priority, sprint: issue.sprint, assigned_agent_ids: issue .agent_refs .iter() .filter(|r| r.role == AgentIssueRole::Assigned) .map(|r| r.agent_id) .collect(), updated_at: issue.updated_at, } } } /// Store-side list filter. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct IssueListFilter { /// Allowed statuses. Empty means every status. pub statuses: Vec, /// Allowed priorities. Empty means every priority. pub priorities: Vec, /// Optional assigned agent filter. pub assigned_agent_id: Option, /// Optional sprint membership filter. pub sprint: Option, /// Optional case-insensitive text query. pub text: Option, } /// Domain errors for issue invariants and value objects. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum IssueError { /// Issue numbers are strictly positive. #[error("issue number must be greater than zero")] InvalidNumber, /// Issue versions are strictly positive. #[error("issue version must be greater than zero")] InvalidVersion, /// Issue title cannot be empty. #[error("issue title cannot be empty")] EmptyTitle, /// Issue reference must be exactly `#`. #[error("invalid issue reference: {0}")] InvalidRef(String), /// A link targets the same issue. #[error("issue {reference} cannot link to itself")] SelfLink { /// Self reference. reference: IssueRef, }, }