Files
IdeA/crates/domain/src/issue.rs
Blomios feeca3462c feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)
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>
2026-07-06 06:41:01 +02:00

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,
},
}