Skip to main content

cliban_sync/linear/
model.rs

1//! Serde shapes for the slice of Linear's GraphQL schema cliban touches.
2//!
3//! Deliberately partial. Every struct here mirrors exactly the fields the
4//! queries in [`super::ops`] request, so a field appearing here without a
5//! matching selection in the query is a deserialization failure waiting to
6//! happen, not dead code.
7
8use chrono::{DateTime, NaiveDate, Utc};
9use serde::Deserialize;
10
11/// Linear wraps every collection in `{ "nodes": [...] }`.
12#[derive(Debug, Clone, Deserialize)]
13pub struct Nodes<T> {
14    pub nodes: Vec<T>,
15}
16
17impl<T> Nodes<T> {
18    pub fn first(self) -> Option<T> {
19        self.nodes.into_iter().next()
20    }
21}
22
23/// A workflow state (Linear's per-team columns).
24#[derive(Debug, Clone, Deserialize, PartialEq)]
25pub struct WorkflowState {
26    pub id: String,
27    pub name: String,
28    /// One of `triage`, `backlog`, `unstarted`, `started`, `completed`,
29    /// `canceled`. Free-form in the API; unknown values fall through to the
30    /// default arm of the status map rather than failing the import.
31    #[serde(rename = "type")]
32    pub kind: String,
33    /// Order within the team's board. Used to break ties when several states
34    /// share a type — the leftmost is the one a human would consider canonical.
35    #[serde(default)]
36    pub position: f64,
37}
38
39#[derive(Debug, Clone, Deserialize, PartialEq)]
40pub struct TeamRef {
41    pub id: String,
42    pub key: String,
43    pub name: String,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct Team {
48    pub id: String,
49    pub key: String,
50    pub name: String,
51    pub states: Nodes<WorkflowState>,
52}
53
54#[derive(Debug, Clone, Deserialize, PartialEq)]
55pub struct LabelRef {
56    pub name: String,
57}
58
59/// One Linear issue, as much of it as cliban mirrors.
60#[derive(Debug, Clone, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct Issue {
63    /// The stable UUID. This is what mutations address.
64    pub id: String,
65    /// The human key, e.g. `ENG-412`.
66    pub identifier: String,
67    pub title: String,
68    /// Linear allows an empty description; it comes back as null, not "".
69    #[serde(default)]
70    pub description: Option<String>,
71    pub url: String,
72    pub updated_at: DateTime<Utc>,
73    /// 0 none, 1 urgent, 2 high, 3 medium, 4 low. A float in the schema.
74    #[serde(default)]
75    pub priority: f64,
76    #[serde(default)]
77    pub due_date: Option<NaiveDate>,
78    pub state: WorkflowState,
79    pub team: TeamRef,
80    #[serde(default = "empty_labels")]
81    pub labels: Nodes<LabelRef>,
82}
83
84fn empty_labels() -> Nodes<LabelRef> {
85    Nodes { nodes: Vec::new() }
86}
87
88impl Issue {
89    /// Label names in the order Linear returned them.
90    pub fn label_names(&self) -> Vec<String> {
91        self.labels.nodes.iter().map(|l| l.name.clone()).collect()
92    }
93
94    /// Description with `None` and `Some("")` collapsed — every consumer wants
95    /// the same thing from both.
96    pub fn description_text(&self) -> &str {
97        self.description.as_deref().unwrap_or("").trim_end()
98    }
99}
100
101/// `priority` as cliban spells it. Linear's scale runs *downward* from urgent,
102/// with 0 meaning "no priority" rather than "lowest", so this is not a plain
103/// numeric ordering.
104pub fn priority_to_cliban(linear: f64) -> &'static str {
105    match linear.round() as i64 {
106        1 => "urgent",
107        2 => "high",
108        3 => "medium",
109        4 => "low",
110        // 0 is "No priority"; anything else is a scale Linear grew after this
111        // was written, and "none" is the honest answer for an unknown level.
112        _ => "none",
113    }
114}
115
116/// The inverse of [`priority_to_cliban`].
117pub fn priority_from_cliban(status: &str) -> f64 {
118    match status {
119        "urgent" => 1.0,
120        "high" => 2.0,
121        "medium" => 3.0,
122        "low" => 4.0,
123        _ => 0.0,
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn priority_round_trips_through_cliban_names() {
133        for name in ["none", "low", "medium", "high", "urgent"] {
134            let back = priority_to_cliban(priority_from_cliban(name));
135            assert_eq!(back, name, "{name} did not survive the round trip");
136        }
137    }
138
139    #[test]
140    fn priority_zero_is_none_not_lowest() {
141        // The trap: 0 sorts lowest numerically but means "unset" in Linear.
142        assert_eq!(priority_to_cliban(0.0), "none");
143        assert_eq!(priority_to_cliban(4.0), "low");
144    }
145
146    #[test]
147    fn unknown_priority_level_degrades_to_none() {
148        assert_eq!(priority_to_cliban(9.0), "none");
149        assert_eq!(priority_from_cliban("catastrophic"), 0.0);
150    }
151
152    #[test]
153    fn issue_parses_with_null_description_and_no_labels() {
154        // Both are what a freshly-created Linear issue actually returns.
155        let json = serde_json::json!({
156            "id": "uuid-1",
157            "identifier": "ENG-412",
158            "title": "Fix the thing",
159            "description": null,
160            "url": "https://linear.app/acme/issue/ENG-412",
161            "updatedAt": "2026-07-29T12:00:00.000Z",
162            "priority": 0,
163            "dueDate": null,
164            "state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
165            "team": {"id": "t1", "key": "ENG", "name": "Engineering"},
166            "labels": {"nodes": []}
167        });
168        let issue: Issue = serde_json::from_value(json).unwrap();
169        assert_eq!(issue.identifier, "ENG-412");
170        assert_eq!(issue.description_text(), "");
171        assert!(issue.label_names().is_empty());
172        assert_eq!(issue.due_date, None);
173    }
174
175    #[test]
176    fn issue_parses_dates_labels_and_priority() {
177        let json = serde_json::json!({
178            "id": "uuid-2",
179            "identifier": "ENG-9",
180            "title": "Ship it",
181            "description": "Some **markdown**.\n",
182            "url": "https://linear.app/acme/issue/ENG-9",
183            "updatedAt": "2026-07-29T12:00:00.000Z",
184            "priority": 2,
185            "dueDate": "2026-08-15",
186            "state": {"id": "s2", "name": "In Review", "type": "started", "position": 3.0},
187            "team": {"id": "t1", "key": "ENG", "name": "Engineering"},
188            "labels": {"nodes": [{"name": "bug"}, {"name": "p0"}]}
189        });
190        let issue: Issue = serde_json::from_value(json).unwrap();
191        assert_eq!(issue.label_names(), vec!["bug", "p0"]);
192        assert_eq!(
193            issue.due_date,
194            Some(NaiveDate::from_ymd_opt(2026, 8, 15).unwrap())
195        );
196        assert_eq!(priority_to_cliban(issue.priority), "high");
197        assert_eq!(issue.description_text(), "Some **markdown**.");
198    }
199}