1use chrono::{DateTime, NaiveDate, Utc};
9use serde::Deserialize;
10
11#[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#[derive(Debug, Clone, Deserialize, PartialEq)]
25pub struct WorkflowState {
26 pub id: String,
27 pub name: String,
28 #[serde(rename = "type")]
32 pub kind: String,
33 #[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#[derive(Debug, Clone, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct Issue {
63 pub id: String,
65 pub identifier: String,
67 pub title: String,
68 #[serde(default)]
70 pub description: Option<String>,
71 pub url: String,
72 pub updated_at: DateTime<Utc>,
73 #[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 pub fn label_names(&self) -> Vec<String> {
91 self.labels.nodes.iter().map(|l| l.name.clone()).collect()
92 }
93
94 pub fn description_text(&self) -> &str {
97 self.description.as_deref().unwrap_or("").trim_end()
98 }
99}
100
101pub 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 _ => "none",
113 }
114}
115
116pub 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 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 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}