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}