use chrono::{DateTime, NaiveDate, Utc};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Nodes<T> {
pub nodes: Vec<T>,
}
impl<T> Nodes<T> {
pub fn first(self) -> Option<T> {
self.nodes.into_iter().next()
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct WorkflowState {
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub position: f64,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct TeamRef {
pub id: String,
pub key: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Team {
pub id: String,
pub key: String,
pub name: String,
pub states: Nodes<WorkflowState>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct LabelRef {
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Issue {
pub id: String,
pub identifier: String,
pub title: String,
#[serde(default)]
pub description: Option<String>,
pub url: String,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub priority: f64,
#[serde(default)]
pub due_date: Option<NaiveDate>,
pub state: WorkflowState,
pub team: TeamRef,
#[serde(default = "empty_labels")]
pub labels: Nodes<LabelRef>,
}
fn empty_labels() -> Nodes<LabelRef> {
Nodes { nodes: Vec::new() }
}
impl Issue {
pub fn label_names(&self) -> Vec<String> {
self.labels.nodes.iter().map(|l| l.name.clone()).collect()
}
pub fn description_text(&self) -> &str {
self.description.as_deref().unwrap_or("").trim_end()
}
}
pub fn priority_to_cliban(linear: f64) -> &'static str {
match linear.round() as i64 {
1 => "urgent",
2 => "high",
3 => "medium",
4 => "low",
_ => "none",
}
}
pub fn priority_from_cliban(status: &str) -> f64 {
match status {
"urgent" => 1.0,
"high" => 2.0,
"medium" => 3.0,
"low" => 4.0,
_ => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_round_trips_through_cliban_names() {
for name in ["none", "low", "medium", "high", "urgent"] {
let back = priority_to_cliban(priority_from_cliban(name));
assert_eq!(back, name, "{name} did not survive the round trip");
}
}
#[test]
fn priority_zero_is_none_not_lowest() {
assert_eq!(priority_to_cliban(0.0), "none");
assert_eq!(priority_to_cliban(4.0), "low");
}
#[test]
fn unknown_priority_level_degrades_to_none() {
assert_eq!(priority_to_cliban(9.0), "none");
assert_eq!(priority_from_cliban("catastrophic"), 0.0);
}
#[test]
fn issue_parses_with_null_description_and_no_labels() {
let json = serde_json::json!({
"id": "uuid-1",
"identifier": "ENG-412",
"title": "Fix the thing",
"description": null,
"url": "https://linear.app/acme/issue/ENG-412",
"updatedAt": "2026-07-29T12:00:00.000Z",
"priority": 0,
"dueDate": null,
"state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
"team": {"id": "t1", "key": "ENG", "name": "Engineering"},
"labels": {"nodes": []}
});
let issue: Issue = serde_json::from_value(json).unwrap();
assert_eq!(issue.identifier, "ENG-412");
assert_eq!(issue.description_text(), "");
assert!(issue.label_names().is_empty());
assert_eq!(issue.due_date, None);
}
#[test]
fn issue_parses_dates_labels_and_priority() {
let json = serde_json::json!({
"id": "uuid-2",
"identifier": "ENG-9",
"title": "Ship it",
"description": "Some **markdown**.\n",
"url": "https://linear.app/acme/issue/ENG-9",
"updatedAt": "2026-07-29T12:00:00.000Z",
"priority": 2,
"dueDate": "2026-08-15",
"state": {"id": "s2", "name": "In Review", "type": "started", "position": 3.0},
"team": {"id": "t1", "key": "ENG", "name": "Engineering"},
"labels": {"nodes": [{"name": "bug"}, {"name": "p0"}]}
});
let issue: Issue = serde_json::from_value(json).unwrap();
assert_eq!(issue.label_names(), vec!["bug", "p0"]);
assert_eq!(
issue.due_date,
Some(NaiveDate::from_ymd_opt(2026, 8, 15).unwrap())
);
assert_eq!(priority_to_cliban(issue.priority), "high");
assert_eq!(issue.description_text(), "Some **markdown**.");
}
}