Skip to main content

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