Skip to main content

cliban_sync/linear/
states.rs

1//! Mapping cliban's five fixed statuses onto a Linear team's arbitrary
2//! workflow states, and back.
3//!
4//! This is the one genuinely lossy part of the bridge. cliban has a closed
5//! vocabulary (`backlog`, `in-progress`, `blocked`, `in-review`, `done`);
6//! a Linear team can name its columns anything and have any number of them.
7//! What makes it work without configuration is that every Linear state also
8//! carries a *type* from a closed set, so there is always a defensible answer
9//! even when no name matches.
10//!
11//! Resolution order, both directions:
12//!
13//! 1. **Name.** Normalized comparison, so `"In Progress"`, `"in-progress"` and
14//!    `"In  Progress"` all match cliban's `in-progress`. This is what makes the
15//!    common case need no config — most teams call their columns roughly what
16//!    cliban calls them.
17//! 2. **Type.** `triage`/`backlog`/`unstarted` → `backlog`, `started` →
18//!    `in-progress`, `completed` → `done`, `canceled` → `done` (archived).
19//!
20//! `blocked` and `in-review` have no type of their own — Linear models both as
21//! `started` — so they survive a round trip only when the team has a column
22//! named for them, or an override in `linear.toml`. That is a real limitation
23//! and the config file exists because of it.
24
25use std::collections::BTreeMap;
26
27use super::model::WorkflowState;
28
29/// What a Linear state means on a cliban board.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Mapped {
32    pub status: &'static str,
33    /// Linear's `canceled` has no cliban status: the work is over but it was
34    /// not completed. `done` + archived is the closest honest rendering, and
35    /// it keeps cancelled issues off the board without deleting anything.
36    pub archive: bool,
37}
38
39/// Normalize a state name for comparison: lowercase, runs of non-alphanumerics
40/// collapsed to a single `-`, ends trimmed. `"In  Review!"` → `"in-review"`.
41pub fn normalize(name: &str) -> String {
42    let mut out = String::with_capacity(name.len());
43    let mut pending_sep = false;
44    for ch in name.chars() {
45        if ch.is_alphanumeric() {
46            if pending_sep && !out.is_empty() {
47                out.push('-');
48            }
49            pending_sep = false;
50            out.extend(ch.to_lowercase());
51        } else {
52            pending_sep = true;
53        }
54    }
55    out
56}
57
58/// Linear workflow state → cliban status.
59pub fn to_cliban(state: &WorkflowState) -> Mapped {
60    // Name first: a column literally called "Blocked" or "In Review" carries
61    // more information than its type does, since Linear types both as
62    // "started".
63    let normalized = normalize(&state.name);
64    if cliban_core::schema::ISSUE_STATUSES.contains(&normalized.as_str()) {
65        return Mapped {
66            status: canonical(&normalized),
67            archive: false,
68        };
69    }
70    // A few spellings that are common enough to be worth knowing about.
71    if let Some(status) = alias(&normalized) {
72        return Mapped {
73            status,
74            archive: false,
75        };
76    }
77    match state.kind.as_str() {
78        "started" => Mapped {
79            status: "in-progress",
80            archive: false,
81        },
82        "completed" => Mapped {
83            status: "done",
84            archive: false,
85        },
86        "canceled" | "cancelled" => Mapped {
87            status: "done",
88            archive: true,
89        },
90        // triage, backlog, unstarted, and anything Linear adds later.
91        _ => Mapped {
92            status: "backlog",
93            archive: false,
94        },
95    }
96}
97
98/// cliban status → the best Linear state among a team's `states`.
99///
100/// `overrides` is `[linear.states]` from the config file: a cliban status
101/// mapped to an exact Linear state name. An override naming a state the team
102/// does not have is ignored rather than fatal — teams get reorganized, and
103/// refusing to push because of a stale config line would be worse than falling
104/// back to inference.
105pub fn to_linear<'a>(
106    status: &str,
107    states: &'a [WorkflowState],
108    overrides: &BTreeMap<String, String>,
109) -> Option<&'a WorkflowState> {
110    if let Some(want) = overrides.get(status) {
111        let want_norm = normalize(want);
112        if let Some(found) = states.iter().find(|s| normalize(&s.name) == want_norm) {
113            return Some(found);
114        }
115    }
116
117    // Exact name match, e.g. cliban "in-review" → a column called "In Review".
118    if let Some(found) = states.iter().find(|s| normalize(&s.name) == status) {
119        return Some(found);
120    }
121    if let Some(found) = states
122        .iter()
123        .find(|s| alias(&normalize(&s.name)) == Some(status))
124    {
125        return Some(found);
126    }
127
128    // Fall back to type, taking the leftmost state of the right type so the
129    // choice is stable and matches what a human reads as "the" Todo column.
130    let wanted_kinds: &[&str] = match status {
131        "backlog" => &["backlog", "triage", "unstarted"],
132        "in-progress" | "blocked" | "in-review" => &["started"],
133        "done" => &["completed"],
134        _ => &[],
135    };
136    for kind in wanted_kinds {
137        let mut candidates: Vec<&WorkflowState> =
138            states.iter().filter(|s| s.kind == *kind).collect();
139        candidates.sort_by(|a, b| {
140            a.position
141                .partial_cmp(&b.position)
142                .unwrap_or(std::cmp::Ordering::Equal)
143        });
144        if let Some(first) = candidates.first() {
145            return Some(first);
146        }
147    }
148    None
149}
150
151/// Borrow the `'static` spelling from the canonical list so [`Mapped`] can hold
152/// `&'static str` rather than an allocation.
153fn canonical(normalized: &str) -> &'static str {
154    cliban_core::schema::ISSUE_STATUSES
155        .iter()
156        .copied()
157        .find(|s| *s == normalized)
158        .unwrap_or("backlog")
159}
160
161/// Common column names that mean a cliban status but do not spell it.
162fn alias(normalized: &str) -> Option<&'static str> {
163    match normalized {
164        "todo" | "to-do" | "triage" | "unstarted" | "planned" => Some("backlog"),
165        "doing" | "started" | "wip" | "in-development" | "in-dev" => Some("in-progress"),
166        "review" | "code-review" | "in-code-review" | "reviewing" => Some("in-review"),
167        "on-hold" | "paused" | "stalled" | "waiting" => Some("blocked"),
168        "complete" | "completed" | "shipped" | "merged" | "closed" => Some("done"),
169        _ => None,
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn state(name: &str, kind: &str, position: f64) -> WorkflowState {
178        WorkflowState {
179            id: format!("id-{}", normalize(name)),
180            name: name.into(),
181            kind: kind.into(),
182            position,
183        }
184    }
185
186    fn team_states() -> Vec<WorkflowState> {
187        vec![
188            state("Triage", "triage", 0.0),
189            state("Backlog", "backlog", 1.0),
190            state("Todo", "unstarted", 2.0),
191            state("In Progress", "started", 3.0),
192            state("In Review", "started", 4.0),
193            state("Done", "completed", 5.0),
194            state("Canceled", "canceled", 6.0),
195        ]
196    }
197
198    #[test]
199    fn normalize_collapses_case_and_punctuation() {
200        assert_eq!(normalize("In Progress"), "in-progress");
201        assert_eq!(normalize("in-progress"), "in-progress");
202        assert_eq!(normalize("In  Review!"), "in-review");
203        assert_eq!(normalize("  Done  "), "done");
204        assert_eq!(normalize(""), "");
205    }
206
207    #[test]
208    fn name_beats_type_for_in_review() {
209        // The whole reason name wins: Linear types "In Review" as `started`,
210        // which would otherwise flatten it into in-progress.
211        let mapped = to_cliban(&state("In Review", "started", 4.0));
212        assert_eq!(mapped.status, "in-review");
213        assert!(!mapped.archive);
214    }
215
216    #[test]
217    fn name_beats_type_for_blocked() {
218        let mapped = to_cliban(&state("Blocked", "started", 4.0));
219        assert_eq!(mapped.status, "blocked");
220    }
221
222    #[test]
223    fn type_carries_states_with_unrecognized_names() {
224        assert_eq!(
225            to_cliban(&state("Cooking", "started", 1.0)).status,
226            "in-progress"
227        );
228        assert_eq!(
229            to_cliban(&state("Icebox", "backlog", 1.0)).status,
230            "backlog"
231        );
232        assert_eq!(
233            to_cliban(&state("Shipped 🚀", "completed", 1.0)).status,
234            "done"
235        );
236    }
237
238    #[test]
239    fn canceled_becomes_done_and_archived() {
240        let mapped = to_cliban(&state("Canceled", "canceled", 6.0));
241        assert_eq!(mapped.status, "done");
242        assert!(mapped.archive, "cancelled work should leave the board");
243    }
244
245    #[test]
246    fn an_unknown_type_degrades_to_backlog() {
247        // Linear adding a new state type must not fail an import.
248        assert_eq!(
249            to_cliban(&state("Whatever", "quantum", 1.0)).status,
250            "backlog"
251        );
252    }
253
254    #[test]
255    fn to_linear_prefers_the_matching_name() {
256        let states = team_states();
257        let empty = BTreeMap::new();
258        let s = to_linear("in-review", &states, &empty).unwrap();
259        assert_eq!(s.name, "In Review");
260    }
261
262    #[test]
263    fn to_linear_falls_back_to_type_and_picks_the_leftmost() {
264        // No column named "backlog"? Take the leftmost backlog-ish one.
265        let states = vec![
266            state("Icebox", "backlog", 5.0),
267            state("Someday", "backlog", 2.0),
268            state("Doing", "started", 9.0),
269        ];
270        let s = to_linear("backlog", &states, &BTreeMap::new()).unwrap();
271        assert_eq!(s.name, "Someday", "position, not declaration order");
272    }
273
274    #[test]
275    fn blocked_without_a_column_lands_in_a_started_state() {
276        // The documented lossy case: no "Blocked" column means blocked and
277        // in-progress are indistinguishable upstream.
278        let states = vec![
279            state("In Progress", "started", 1.0),
280            state("Done", "completed", 2.0),
281        ];
282        let s = to_linear("blocked", &states, &BTreeMap::new()).unwrap();
283        assert_eq!(s.name, "In Progress");
284    }
285
286    #[test]
287    fn config_override_wins_over_inference() {
288        let states = team_states();
289        let mut overrides = BTreeMap::new();
290        overrides.insert("in-review".to_string(), "Done".to_string());
291        let s = to_linear("in-review", &states, &overrides).unwrap();
292        assert_eq!(s.name, "Done");
293    }
294
295    #[test]
296    fn a_stale_override_falls_back_rather_than_failing() {
297        let states = team_states();
298        let mut overrides = BTreeMap::new();
299        overrides.insert("in-review".to_string(), "Column We Deleted".to_string());
300        let s = to_linear("in-review", &states, &overrides).unwrap();
301        assert_eq!(s.name, "In Review", "inference still applies");
302    }
303
304    #[test]
305    fn to_linear_returns_none_when_nothing_fits() {
306        let states = vec![state("Done", "completed", 1.0)];
307        assert!(to_linear("backlog", &states, &BTreeMap::new()).is_none());
308    }
309
310    #[test]
311    fn every_cliban_status_resolves_against_a_conventional_team() {
312        let states = team_states();
313        for status in cliban_core::schema::ISSUE_STATUSES {
314            assert!(
315                to_linear(status, &states, &BTreeMap::new()).is_some(),
316                "{status} did not map"
317            );
318        }
319    }
320
321    #[test]
322    fn round_trip_is_stable_for_the_statuses_linear_can_express() {
323        let states = team_states();
324        for status in ["backlog", "in-progress", "in-review", "done"] {
325            let linear = to_linear(status, &states, &BTreeMap::new()).unwrap();
326            assert_eq!(to_cliban(linear).status, status, "{status} did not survive");
327        }
328    }
329}