oxi/store/issues/types.rs
1//! Issue domain types: status, priority, assignment, metadata, body.
2
3use std::fmt;
4use std::path::PathBuf;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Issue status.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Status {
13 #[default]
14 Open,
15 Closed,
16}
17
18impl fmt::Display for Status {
19 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Open => write!(f, "open"),
22 Self::Closed => write!(f, "closed"),
23 }
24 }
25}
26
27/// Issue priority. Ordered low → critical for sorting.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Priority {
31 Low,
32 #[default]
33 Medium,
34 High,
35 Critical,
36}
37
38impl fmt::Display for Priority {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::Low => write!(f, "low"),
42 Self::Medium => write!(f, "medium"),
43 Self::High => write!(f, "high"),
44 Self::Critical => write!(f, "critical"),
45 }
46 }
47}
48
49/// Who currently owns the work on an issue.
50///
51/// `None` means the issue is free. `Some` means a session has claimed it via
52/// `start`. Validity of an assignment is determined by process liveness (see
53/// [`crate::store::issues::liveness::is_session_alive`]) — there is no expiry
54/// timestamp.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Assignment {
57 /// Owning session id (from `ToolContext.session_id`).
58 pub session: String,
59 /// When the assignment was acquired. Informational only — *not* used for
60 /// expiry decisions. Expiry is governed by process liveness.
61 pub acquired_at: DateTime<Utc>,
62}
63
64/// A reference to a synced GitHub issue. Populated only after Phase 6 sync.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct GithubRef {
67 pub repo: String,
68 pub number: u64,
69 pub url: String,
70}
71
72/// YAML frontmatter for an issue.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct IssueMeta {
75 pub id: u32,
76 pub title: String,
77 #[serde(default)]
78 pub status: Status,
79 #[serde(default)]
80 pub priority: Priority,
81 #[serde(default)]
82 pub labels: Vec<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub assignee: Option<String>,
85 pub created_at: DateTime<Utc>,
86 pub updated_at: DateTime<Utc>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub closed_at: Option<DateTime<Utc>>,
89 /// Session ids linked to this issue (worked-on or referencing sessions).
90 #[serde(default)]
91 pub sessions: Vec<String>,
92 /// Current assignment (liveness-gated). `None` = free.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub assigned_to: Option<Assignment>,
95 /// 🔜 Phase 6: GitHub sync mapping.
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub github: Option<GithubRef>,
98}
99
100/// An in-memory issue: metadata + markdown body + the file path it came from.
101#[derive(Debug, Clone)]
102pub struct Issue {
103 pub meta: IssueMeta,
104 /// Raw markdown body (everything after the `---` frontmatter block).
105 pub body: String,
106 /// Path to the source file (None for unsaved/in-memory issues).
107 pub path: Option<PathBuf>,
108}
109
110impl Issue {
111 /// Combined status badge for list rendering: `▣ open`.
112 pub fn list_badge(&self) -> String {
113 let lock = if self.meta.assigned_to.is_some() {
114 "▣ "
115 } else {
116 ""
117 };
118 format!("{}{}", lock, self.meta.status)
119 }
120}
121
122/// A precise update payload for [`crate::store::issues::FileIssueStore::apply_patch`].
123///
124/// Every field is `Option`: `None` = keep the existing value, `Some` = replace
125/// it. `labels` is the only field with a meaningful empty state —
126/// `Some(vec![])` clears all labels while `None` keeps them. This resolves
127/// defect #3: through the tool schema, "field absent" vs `[]` were previously
128/// indistinguishable, so labels could never be cleared without resending the
129/// full set.
130///
131/// Used by the `issue` tool's `update` action (via
132/// [`crate::store::issues::FileIssueStore::apply_patch`]) and is the
133/// recommended mutation surface for callers that want precise keep-vs-replace
134/// semantics.
135#[derive(Debug, Clone, Default)]
136pub struct IssuePatch {
137 /// Replace the title.
138 pub title: Option<String>,
139 /// Replace the markdown body.
140 pub body: Option<String>,
141 /// Replace the status. Setting [`Status::Open`] also clears `closed_at`
142 /// (see [`crate::store::issues::FileIssueStore::apply_patch`], which fixes
143 /// the latent reopen bug #4).
144 pub status: Option<Status>,
145 /// Replace the priority.
146 pub priority: Option<Priority>,
147 /// Replace the labels wholesale. `Some(vec![])` clears all labels.
148 pub labels: Option<Vec<String>>,
149}