Passe les filtres de la liste des tickets en sélection multiple (#12). Backend Rust : - IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ et AND inter-champs - TicketListRequestDto en tableaux + from_request (parse/déduplication) - MCP idea_ticket_list aligné sur le nouveau contrat Frontend : - TicketListQuery.statuses/priorities - UI de cases à cocher, toggle/clear des filtres Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24), issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
463 lines
12 KiB
Rust
463 lines
12 KiB
Rust
//! 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<Self, IssueError> {
|
|
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 `#<number>`.
|
|
#[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<IssueNumber> for IssueRef {
|
|
fn from(number: IssueNumber) -> Self {
|
|
Self::new(number)
|
|
}
|
|
}
|
|
|
|
impl FromStr for IssueRef {
|
|
type Err = IssueError;
|
|
|
|
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
|
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::<u64>()
|
|
.map_err(|_| IssueError::InvalidRef(raw.to_owned()))?;
|
|
Ok(Self(IssueNumber::new(number)?))
|
|
}
|
|
}
|
|
|
|
impl Serialize for IssueRef {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.serialize_str(&self.to_string())
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for IssueRef {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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<Self, IssueError> {
|
|
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<SprintId>,
|
|
/// Editable issue-local knowledge.
|
|
pub carnet: MarkdownDoc,
|
|
/// Links to other issues.
|
|
pub links: Vec<IssueLink>,
|
|
/// Agent references.
|
|
pub agent_refs: Vec<AgentIssueRef>,
|
|
/// 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<String>,
|
|
description: MarkdownDoc,
|
|
status: IssueStatus,
|
|
priority: IssuePriority,
|
|
carnet: MarkdownDoc,
|
|
links: Vec<IssueLink>,
|
|
agent_refs: Vec<AgentIssueRef>,
|
|
actor: IssueActor,
|
|
now_ms: u64,
|
|
) -> Result<Self, IssueError> {
|
|
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<Self, IssueError> {
|
|
issue.validate()?;
|
|
Ok(issue)
|
|
}
|
|
|
|
/// Returns this issue as `#<number>`.
|
|
#[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<Self, IssueError> {
|
|
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<SprintId>,
|
|
/// Assigned agent ids.
|
|
pub assigned_agent_ids: Vec<AgentId>,
|
|
/// 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<IssueStatus>,
|
|
/// Allowed priorities. Empty means every priority.
|
|
pub priorities: Vec<IssuePriority>,
|
|
/// Optional assigned agent filter.
|
|
pub assigned_agent_id: Option<AgentId>,
|
|
/// Optional sprint membership filter.
|
|
pub sprint: Option<SprintId>,
|
|
/// Optional case-insensitive text query.
|
|
pub text: Option<String>,
|
|
}
|
|
|
|
/// 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 `#<positive integer>`.
|
|
#[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,
|
|
},
|
|
}
|