Skip to main content

gonzalo_domain/
ticket.rs

1//! Ticket (tracked work item) views — a normalized work-item model over
2//! external ticket platforms (GitHub, Jira, Linear, GitLab, Asana, …).
3//!
4//! See ADR 0010. The canonical model carries a normalized spine (state
5//! category, resolution, actor roles, priority) plus lossless raw fields, so a
6//! provider's native data round-trips while cross-platform queries key off the
7//! normalized form. The provider boundary and per-connection field/state
8//! mapping live in the `gonzalo-ticket` capability crate; these are the
9//! persisted typed views.
10
11use crate::codec::RecordCodec;
12use gonzalo_core::{RecordKey, RecordKind};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16/// The platform a ticket was imported from.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Provider {
19    GitHub,
20    Jira,
21    Linear,
22    GitLab,
23    Asana,
24    AzureDevOps,
25    Bugzilla,
26    /// Any platform without a dedicated variant (Zendesk, Monday, …).
27    Other(String),
28}
29
30/// Normalized lifecycle category — the cross-platform spine (ADR 0010). Each
31/// provider's native status maps onto exactly one of these via a
32/// `StateMapping`; the raw status is retained on [`State`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
34pub enum StateCategory {
35    Triage,
36    Backlog,
37    Open,
38    InProgress,
39    /// Waiting on an external party (support "pending"; dev "blocked").
40    Pending,
41    Done,
42    Canceled,
43}
44
45/// Why a ticket closed — the second state axis Bugzilla and Jira need
46/// (status × resolution). `Duplicate` pairs with a `Link` to the canonical.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub enum Resolution {
49    Done,
50    WontDo,
51    Duplicate,
52    Invalid,
53    CannotReproduce,
54    Moved,
55    Other(String),
56}
57
58/// Normalized state: a category, an optional resolution, and the raw
59/// provider-native status kept for fidelity / write-back.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct State {
62    pub category: StateCategory,
63    pub resolution: Option<Resolution>,
64    pub raw_name: String,
65    pub raw_id: Option<String>,
66}
67
68/// What capacity a person is involved in. Support/ITSM platforms distinguish
69/// requester from assignee from submitter; dev trackers mostly use `Assignee`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub enum ActorRole {
72    Requester,
73    Assignee,
74    Submitter,
75    Follower,
76}
77
78/// A person involved with a ticket, and in what capacity.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct Actor {
81    pub role: ActorRole,
82    pub handle: String,
83    pub display: Option<String>,
84}
85
86/// A container a ticket is filed in (repo, project, board, section). Tickets
87/// may be multi-homed (Asana), so `Ticket` carries a list; `primary` marks the
88/// container whose mapping resolves the canonical state.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct Container {
91    pub kind: String,
92    pub id: String,
93    pub name: Option<String>,
94    pub primary: bool,
95}
96
97/// The nature of a relationship between tickets/records.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99pub enum LinkKind {
100    Blocks,
101    BlockedBy,
102    Relates,
103    Parent,
104    Child,
105    Duplicate,
106}
107
108/// What a link points at: a first-class record (once ingested) or an external
109/// reference that hasn't been imported.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub enum LinkTarget {
112    Record(RecordKey),
113    External(String),
114}
115
116/// A typed relationship to another record or external ticket.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Link {
119    pub kind: LinkKind,
120    pub target: LinkTarget,
121}
122
123/// The source format of a ticket body. The normalized `markdown` is always
124/// populated; `raw` round-trips the native form (e.g. Jira ADF JSON).
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub enum BodyFormat {
127    Markdown,
128    Adf,
129    Html,
130    PlainText,
131}
132
133/// Body text with its source format; `raw` retained for lossless round-trip.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct TicketBody {
136    pub markdown: String,
137    pub format: BodyFormat,
138    pub raw: Option<String>,
139}
140
141/// Normalized priority ordinal (ascending: `None` < … < `Urgent`).
142#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
143pub enum PriorityLevel {
144    None,
145    Low,
146    Medium,
147    High,
148    Urgent,
149}
150
151/// Normalized priority plus the raw provider value.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Priority {
154    pub level: PriorityLevel,
155    pub raw: Option<String>,
156}
157
158/// A tracked work item, normalized across platforms (ADR 0010).
159///
160/// Does not derive `Eq`: `fields` holds arbitrary `serde_json::Value`s for
161/// unmapped provider data, and `Value` is `PartialEq` but not `Eq`.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct Ticket {
164    pub provider: Provider,
165    /// Stable provider-global id — the basis for this record's `RecordKey`.
166    pub uid: String,
167    /// Human display id ("ENG-123", "PROJ-45", "#7").
168    pub display: String,
169    /// Work-item type ("bug", "story", "incident"); provider/process-defined.
170    pub item_type: String,
171    pub title: String,
172    pub state: State,
173    pub priority: Option<Priority>,
174    pub actors: Vec<Actor>,
175    pub labels: Vec<String>,
176    pub containers: Vec<Container>,
177    pub links: Vec<Link>,
178    pub body: TicketBody,
179    /// Custom / unmapped provider fields, retained verbatim.
180    pub fields: BTreeMap<String, serde_json::Value>,
181}
182impl RecordCodec for Ticket {}
183impl Ticket {
184    pub const KIND: RecordKind = RecordKind::Ticket;
185}
186
187/// An append-only comment or lifecycle event on a ticket. Stored under the
188/// `TicketEvent` kind, which merges by union (ADR 0005).
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct TicketEvent {
191    /// The `uid` of the ticket this event belongs to.
192    pub ticket_uid: String,
193    /// Event nature: "comment", "state_change", "sla", …
194    pub kind: String,
195    pub author: String,
196    /// Unix timestamp (seconds).
197    pub at: i64,
198    pub body: String,
199}
200impl RecordCodec for TicketEvent {}
201impl TicketEvent {
202    pub const KIND: RecordKind = RecordKind::TicketEvent;
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use gonzalo_core::RecordKey;
209
210    fn sample_ticket() -> Ticket {
211        let mut fields = BTreeMap::new();
212        fields.insert("story_points".into(), serde_json::json!(5));
213        Ticket {
214            provider: Provider::GitHub,
215            uid: "gh:caliban-ai/gonzalo#15".into(),
216            display: "#15".into(),
217            item_type: "issue".into(),
218            title: "design: ticket-system capability layer".into(),
219            state: State {
220                category: StateCategory::InProgress,
221                resolution: None,
222                raw_name: "In progress".into(),
223                raw_id: Some("47fc9ee4".into()),
224            },
225            priority: Some(Priority {
226                level: PriorityLevel::Medium,
227                raw: Some("important-longterm".into()),
228            }),
229            actors: vec![Actor {
230                role: ActorRole::Assignee,
231                handle: "johnford2002".into(),
232                display: None,
233            }],
234            labels: vec!["area/integration".into(), "kind/design".into()],
235            containers: vec![Container {
236                kind: "repo".into(),
237                id: "caliban-ai/gonzalo".into(),
238                name: Some("gonzalo".into()),
239                primary: true,
240            }],
241            links: vec![Link {
242                kind: LinkKind::Relates,
243                target: LinkTarget::Record(RecordKey::new("caliban", "tickets", "gh:16")),
244            }],
245            body: TicketBody {
246                markdown: "Model tickets as a capability layer.".into(),
247                format: BodyFormat::Markdown,
248                raw: None,
249            },
250            fields,
251        }
252    }
253
254    #[test]
255    fn ticket_roundtrips_through_body() {
256        let t = sample_ticket();
257        assert_eq!(Ticket::from_body(&t.to_body().unwrap()).unwrap(), t);
258        assert_eq!(Ticket::KIND, RecordKind::Ticket);
259    }
260
261    #[test]
262    fn ticket_event_roundtrips_through_body() {
263        let e = TicketEvent {
264            ticket_uid: "gh:caliban-ai/gonzalo#15".into(),
265            kind: "comment".into(),
266            author: "johnford2002".into(),
267            at: 1_750_000_000,
268            body: "Begin the build.".into(),
269        };
270        assert_eq!(TicketEvent::from_body(&e.to_body().unwrap()).unwrap(), e);
271        assert_eq!(TicketEvent::KIND, RecordKind::TicketEvent);
272    }
273
274    #[test]
275    fn priority_levels_are_ordered() {
276        assert!(PriorityLevel::None < PriorityLevel::Urgent);
277        assert!(PriorityLevel::Low < PriorityLevel::High);
278    }
279}