Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le domaine (Issue, ports), l'application (usecases + assistant de ticket), les adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web- server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
578 lines
16 KiB
Rust
578 lines
16 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;
|
|
|
|
/// Stable ticket attachment identifier.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct IssueAttachmentId(String);
|
|
|
|
impl IssueAttachmentId {
|
|
/// Builds an attachment id.
|
|
///
|
|
/// # Errors
|
|
/// [`IssueError::InvalidAttachmentId`] when the id is empty or not filename-safe.
|
|
pub fn new(value: impl Into<String>) -> Result<Self, IssueError> {
|
|
let value = value.into();
|
|
if value.is_empty()
|
|
|| !value
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
{
|
|
return Err(IssueError::InvalidAttachmentId(value));
|
|
}
|
|
Ok(Self(value))
|
|
}
|
|
|
|
/// Returns the raw id.
|
|
#[must_use]
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Metadata for a file attached to a ticket.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IssueAttachment {
|
|
/// Stable attachment id.
|
|
pub id: IssueAttachmentId,
|
|
/// Original display filename.
|
|
pub filename: String,
|
|
/// Stored relative path under the ticket directory.
|
|
pub path: String,
|
|
/// MIME type.
|
|
pub mime: String,
|
|
/// Size in bytes.
|
|
pub size_bytes: u64,
|
|
/// Actor that added the attachment.
|
|
pub added_by: IssueActor,
|
|
/// Add time, epoch milliseconds.
|
|
pub added_at: u64,
|
|
/// Whether an agent/user summarized the attachment into the ticket carnet.
|
|
#[serde(default)]
|
|
pub summarized_in_carnet: bool,
|
|
/// Actor that marked it summarized.
|
|
#[serde(default)]
|
|
pub summarized_by: Option<IssueActor>,
|
|
/// Summary mark time, epoch milliseconds.
|
|
#[serde(default)]
|
|
pub summarized_at: Option<u64>,
|
|
}
|
|
|
|
/// 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>,
|
|
/// Attached files.
|
|
#[serde(default)]
|
|
pub attachments: Vec<IssueAttachment>,
|
|
/// 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,
|
|
attachments: Vec::new(),
|
|
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 });
|
|
}
|
|
let mut attachment_ids = std::collections::HashSet::new();
|
|
for attachment in &self.attachments {
|
|
if !attachment_ids.insert(attachment.id.clone()) {
|
|
return Err(IssueError::DuplicateAttachmentId(
|
|
attachment.id.as_str().to_owned(),
|
|
));
|
|
}
|
|
if attachment.filename.trim().is_empty()
|
|
|| attachment.filename.contains('/')
|
|
|| attachment.filename.contains('\\')
|
|
|| attachment.filename == "."
|
|
|| attachment.filename == ".."
|
|
{
|
|
return Err(IssueError::InvalidAttachmentFilename(
|
|
attachment.filename.clone(),
|
|
));
|
|
}
|
|
if attachment.path.starts_with('/')
|
|
|| attachment.path.starts_with('\\')
|
|
|| attachment.path.contains("..")
|
|
|| !attachment.path.starts_with("attachments/")
|
|
{
|
|
return Err(IssueError::InvalidAttachmentPath(attachment.path.clone()));
|
|
}
|
|
if attachment.mime.trim().is_empty() {
|
|
return Err(IssueError::InvalidAttachmentMime(attachment.mime.clone()));
|
|
}
|
|
}
|
|
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>,
|
|
/// Actor that created the issue.
|
|
#[serde(default = "default_issue_actor_user")]
|
|
pub created_by: IssueActor,
|
|
/// 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(),
|
|
created_by: issue.created_by.clone(),
|
|
updated_at: issue.updated_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_issue_actor_user() -> IssueActor {
|
|
IssueActor::User
|
|
}
|
|
|
|
/// 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 creator filter.
|
|
pub created_by: Option<IssueActor>,
|
|
/// 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,
|
|
},
|
|
/// Attachment id is not flat filename-safe.
|
|
#[error("invalid attachment id: {0}")]
|
|
InvalidAttachmentId(String),
|
|
/// Attachment filename is empty or not flat.
|
|
#[error("invalid attachment filename: {0}")]
|
|
InvalidAttachmentFilename(String),
|
|
/// Attachment stored path is invalid.
|
|
#[error("invalid attachment path: {0}")]
|
|
InvalidAttachmentPath(String),
|
|
/// Attachment MIME type is invalid.
|
|
#[error("invalid attachment mime: {0}")]
|
|
InvalidAttachmentMime(String),
|
|
/// Attachment ids must be unique per issue.
|
|
#[error("duplicate attachment id: {0}")]
|
|
DuplicateAttachmentId(String),
|
|
}
|