Skip to main content

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}