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:
2026-07-02 22:34:04 +02:00
parent a9653bc417
commit 8de7be01a8
22 changed files with 4395 additions and 50 deletions

View File

@ -1,7 +1,8 @@
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2).
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
use crate::ids::{AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
use crate::mailbox::TicketId;
use crate::memory::MemorySlug;
use crate::template::TemplateVersion;
@ -168,6 +169,85 @@ pub enum DomainEvent {
/// The project whose index was rebuilt.
project_id: ProjectId,
},
/// An issue was created.
IssueCreated {
/// Stable issue id.
issue_id: IssueId,
/// Human-friendly issue reference.
issue_ref: IssueRef,
},
/// An issue was updated.
IssueUpdated {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New optimistic version.
version: IssueVersion,
},
/// An issue status changed.
IssueStatusChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New status.
status: IssueStatus,
/// New optimistic version.
version: IssueVersion,
},
/// An issue priority changed.
IssuePriorityChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New priority.
priority: IssuePriority,
/// New optimistic version.
version: IssueVersion,
},
/// An issue carnet was updated.
IssueCarnetUpdated {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New optimistic version.
version: IssueVersion,
},
/// Two issues were linked.
IssueLinked {
/// Source issue.
issue_ref: IssueRef,
/// Target issue.
target: IssueRef,
/// Link kind.
kind: IssueLinkKind,
/// New optimistic version.
version: IssueVersion,
},
/// Two issues were unlinked.
IssueUnlinked {
/// Source issue.
issue_ref: IssueRef,
/// Target issue.
target: IssueRef,
/// Link kind.
kind: IssueLinkKind,
/// New optimistic version.
version: IssueVersion,
},
/// An agent was assigned to an issue.
IssueAgentAssigned {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Assigned agent.
agent_id: AgentId,
/// New optimistic version.
version: IssueVersion,
},
/// An agent was unassigned from an issue.
IssueAgentUnassigned {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Unassigned agent.
agent_id: AgentId,
/// New optimistic version.
version: IssueVersion,
},
/// A project's memory grew past the recall budget while no embedder is
/// configured (strategy `none`): the first moment a semantic embedder would
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per

View File

@ -72,6 +72,10 @@ typed_id!(
/// Identifies a [`crate::skill::Skill`].
SkillId
);
typed_id!(
/// Identifies a [`crate::issue::Issue`].
IssueId
);
typed_id!(
/// Identifies a [`crate::terminal::TerminalSession`].
SessionId

454
crates/domain/src/issue.rs Normal file
View 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,
},
}

View File

@ -39,6 +39,7 @@ pub mod fileguard;
pub mod git;
pub mod ids;
pub mod input;
pub mod issue;
pub mod layout;
pub mod live_state;
pub mod mailbox;
@ -67,8 +68,8 @@ mod validation;
pub use error::DomainError;
pub use ids::{
AgentId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, TabId,
TemplateId, WindowId,
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
TabId, TemplateId, WindowId,
};
pub use project::{Project, ProjectPath};
@ -96,6 +97,12 @@ pub use conversation::{
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
pub use issue::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueError, IssueIndexEntry,
IssueLink, IssueLinkKind, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus,
IssueVersion,
};
pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
pub use readiness::{ReadinessPolicy, ReadinessSignal};
@ -158,8 +165,9 @@ pub use ports::{
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask,
Scheduler, SpawnSpec, StoreError, TemplateStore,
IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, MemoryError, MemoryQuery,
MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext,
ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
StoreError, TemplateStore,
};

View File

@ -30,6 +30,9 @@ use thiserror::Error;
use crate::agent::AgentManifest;
use crate::events::DomainEvent;
use crate::ids::{AgentId, NodeId, ScheduleId, SessionId};
use crate::issue::{
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::permission::ProjectPermissions;
@ -461,6 +464,28 @@ pub enum GitError {
Operation(String),
}
/// Errors from the issue store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum IssueStoreError {
/// The requested issue does not exist.
#[error("issue not found")]
NotFound,
/// Optimistic concurrency conflict.
#[error("issue version conflict: expected {expected}, actual {actual}")]
VersionConflict {
/// Expected version.
expected: IssueVersion,
/// Actual persisted version.
actual: IssueVersion,
},
/// The issue payload is invalid.
#[error("issue invalid: {0}")]
Invalid(String),
/// Store I/O or serialization failed.
#[error("issue store failed: {0}")]
Store(String),
}
// ---------------------------------------------------------------------------
// Git port support types
// ---------------------------------------------------------------------------
@ -1191,6 +1216,84 @@ pub trait PermissionStore: Send + Sync {
) -> Result<(), StoreError>;
}
/// Allocates monotonic per-project issue numbers.
#[async_trait]
pub trait IssueNumberAllocator: Send + Sync {
/// Allocates the next number for `root`.
///
/// Implementations must never reuse an allocated number, even if a later
/// issue creation step fails.
///
/// # Errors
/// [`IssueStoreError`] on persistence/lock failure.
async fn allocate_next(&self, root: &ProjectPath) -> Result<IssueNumber, IssueStoreError>;
}
/// Persistence port for project-scoped issues.
#[async_trait]
pub trait IssueStore: Send + Sync {
/// Creates a new issue.
///
/// # Errors
/// [`IssueStoreError`] when the issue already exists or cannot be stored.
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError>;
/// Reads an issue by `#N` reference.
///
/// # Errors
/// [`IssueStoreError::NotFound`] when absent.
async fn get_by_ref(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
) -> Result<Issue, IssueStoreError>;
/// Lists issue index entries matching `filter`.
///
/// # Errors
/// [`IssueStoreError`] on persistence failure.
async fn list(
&self,
root: &ProjectPath,
filter: IssueListFilter,
) -> Result<Vec<IssueIndexEntry>, IssueStoreError>;
/// Replaces an issue after checking its expected version.
///
/// # Errors
/// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict.
async fn update(
&self,
root: &ProjectPath,
issue: &Issue,
expected_version: IssueVersion,
) -> Result<(), IssueStoreError>;
/// Reads only the issue carnet projection.
///
/// # Errors
/// [`IssueStoreError::NotFound`] when absent.
async fn read_carnet(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
) -> Result<IssueCarnet, IssueStoreError>;
/// Replaces an issue carnet and increments the issue version.
///
/// # Errors
/// [`IssueStoreError::VersionConflict`] on optimistic-concurrency conflict.
async fn write_carnet(
&self,
root: &ProjectPath,
issue_ref: IssueRef,
carnet: MarkdownDoc,
actor: crate::issue::IssueActor,
now_ms: u64,
expected_version: IssueVersion,
) -> Result<IssueCarnet, IssueStoreError>;
}
/// Git operations for a project. Named `GitPort` to avoid clashing with the
/// [`crate::git::GitRepository`] *entity* (state image).
#[async_trait]

View File

@ -0,0 +1,120 @@
use std::str::FromStr;
use domain::{
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueError, IssueId, IssueLink,
IssueLinkKind, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc,
};
fn issue_number(n: u64) -> IssueNumber {
IssueNumber::new(n).unwrap()
}
fn issue() -> Issue {
Issue::new(
IssueId::new_random(),
issue_number(7),
"Fix boot reconcile",
MarkdownDoc::new("Description"),
IssueStatus::Open,
IssuePriority::Medium,
MarkdownDoc::new("Carnet"),
vec![IssueLink {
target: IssueRef::from(issue_number(8)),
kind: IssueLinkKind::RelatesTo,
}],
vec![AgentIssueRef {
agent_id: domain::AgentId::new_random(),
role: AgentIssueRole::Assigned,
}],
IssueActor::User,
1_000,
)
.unwrap()
}
#[test]
fn issue_ref_parses_and_formats_strict_hash_number() {
let reference = IssueRef::from_str("#42").unwrap();
assert_eq!(reference.number().get(), 42);
assert_eq!(reference.to_string(), "#42");
assert_eq!(
IssueRef::from_str("42"),
Err(IssueError::InvalidRef("42".into()))
);
assert_eq!(IssueRef::from_str("#0"), Err(IssueError::InvalidNumber));
assert_eq!(
IssueRef::from_str("#abc"),
Err(IssueError::InvalidRef("#abc".into()))
);
}
#[test]
fn issue_number_must_be_positive() {
assert_eq!(IssueNumber::new(0), Err(IssueError::InvalidNumber));
}
#[test]
fn issue_title_must_not_be_empty() {
let err = Issue::new(
IssueId::new_random(),
issue_number(1),
" ",
MarkdownDoc::new("Description"),
IssueStatus::Open,
IssuePriority::Medium,
MarkdownDoc::default(),
Vec::new(),
Vec::new(),
IssueActor::User,
1,
)
.unwrap_err();
assert_eq!(err, IssueError::EmptyTitle);
}
#[test]
fn issue_rejects_self_link() {
let err = Issue::new(
IssueId::new_random(),
issue_number(9),
"Self link",
MarkdownDoc::new("Description"),
IssueStatus::Open,
IssuePriority::Medium,
MarkdownDoc::default(),
vec![IssueLink {
target: IssueRef::from(issue_number(9)),
kind: IssueLinkKind::Blocks,
}],
Vec::new(),
IssueActor::User,
1,
)
.unwrap_err();
assert_eq!(
err,
IssueError::SelfLink {
reference: IssueRef::from(issue_number(9))
}
);
}
#[test]
fn issue_mutation_increments_version_and_updates_actor_time() {
let issue = issue();
assert_eq!(issue.version, IssueVersion::INITIAL);
let updated = issue
.mutate(IssueActor::System, 2_000, |i| {
i.status = IssueStatus::InProgress;
})
.unwrap();
assert_eq!(updated.version.get(), 2);
assert_eq!(updated.status, IssueStatus::InProgress);
assert_eq!(updated.updated_by, IssueActor::System);
assert_eq!(updated.updated_at, 2_000);
}