feat(tickets): checkpoint backend V1 du système de tickets (T1-T5)
Checkpoint de travail — QA formelle encore à venir (après reset Codex). `cargo build` workspace OK, tests ticket/issue verts (domaine, store, use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé « ticket » côté MCP/UI. - T1 domaine : entité Issue (statut, priorité, liens, carnet), events, ids, ports. - T2 infra : store FS Markdown + allocator de références #N. - T3 application : use cases Issue (create/read/list/update/status/ priority/carnet/link/unlink/assign) + erreurs dédiées. - T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au total), mappés vers les use cases Issue. - T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue + câblage state.rs. Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets, non traitée ici. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
454
crates/domain/src/issue.rs
Normal file
454
crates/domain/src/issue.rs
Normal file
@ -0,0 +1,454 @@
|
||||
//! 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};
|
||||
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,
|
||||
/// 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,
|
||||
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,
|
||||
/// 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,
|
||||
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 {
|
||||
/// Optional status filter.
|
||||
pub status: Option<IssueStatus>,
|
||||
/// Optional priority filter.
|
||||
pub priority: Option<IssuePriority>,
|
||||
/// Optional assigned agent filter.
|
||||
pub assigned_agent_id: Option<AgentId>,
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user