Skip to main content

tix/sync/
linear.rs

1//! Linear adapter: shells out to the `linear-cli` CLI under
2//! `contracts/external-tools.md` (Linear row).
3//!
4//! Linear conveys workflow stage through the `--status` flag (done → `Done`,
5//! otherwise the first work column), so this adapter sets `done_via_status`
6//! and never issues a separate close. Create/list use `--output json` to
7//! recover the `identifier` / list payload.
8
9use serde::Deserialize;
10
11use crate::error::{Result, TixError};
12use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
13
14/// The `linear-cli` adapter. Spawns `linear-cli issue <create|update|list>`
15/// with the configured team and, when a secret is configured, the
16/// `LINEAR_TOKEN` env pair.
17pub struct LinearAdapter {
18    runner: Box<dyn CommandRunner>,
19    token: Option<String>,
20    team: String,
21}
22
23impl LinearAdapter {
24    pub fn new(runner: impl CommandRunner + 'static, token: Option<String>, team: String) -> Self {
25        LinearAdapter {
26            runner: Box::new(runner),
27            token,
28            team,
29        }
30    }
31
32    fn child_env(&self) -> Vec<(String, String)> {
33        match &self.token {
34            Some(token) => vec![("LINEAR_TOKEN".to_string(), token.clone())],
35            None => Vec::new(),
36        }
37    }
38
39    fn run(&self, args: &[String]) -> Result<CommandOutcome> {
40        self.runner
41            .run(Tool::Linear.binary(), args, &self.child_env())
42    }
43
44    fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
45        let mut message = format!("{context}: {}", outcome.text().trim());
46        if let Some(secret) = &self.token {
47            message = scrub_secret(&message, secret);
48        }
49        TixError::External(message)
50    }
51
52    /// Parse `linear-cli issue create` stdout for the `identifier`.
53    fn parse_identifier(&self, outcome: &CommandOutcome) -> Result<String> {
54        serde_json::from_str::<serde_json::Value>(&outcome.stdout)
55            .ok()
56            .and_then(|value| {
57                value
58                    .get("identifier")
59                    .and_then(serde_json::Value::as_str)
60                    .map(str::to_string)
61            })
62            .ok_or_else(|| {
63                TixError::External(format!(
64                    "`linear-cli issue create` did not return JSON with an `identifier` field (expected {{\"identifier\":…}}): {}",
65                    scrub_secret(outcome.stdout.trim(), self.token.as_deref().unwrap_or(""))
66                ))
67            })
68    }
69}
70
71impl TrackerAdapter for LinearAdapter {
72    fn tool(&self) -> Tool {
73        Tool::Linear
74    }
75
76    fn create_issue(
77        &mut self,
78        title: &str,
79        body: &str,
80        labels: &[String],
81        _assignee: Option<&str>,
82        priority: Option<&str>,
83        status: &str,
84    ) -> Result<String> {
85        let mut args: Vec<String> = vec![
86            "issue".into(),
87            "create".into(),
88            "--team".into(),
89            self.team.clone(),
90            "--title".into(),
91            title.to_string(),
92            "--description".into(),
93            body.to_string(),
94        ];
95        if !labels.is_empty() {
96            args.push("--label".to_string());
97            args.push(labels.join(","));
98        }
99        if let Some(p) = priority {
100            args.push("--priority".to_string());
101            args.push(p.to_string());
102        }
103        args.push("--status".to_string());
104        args.push(status.to_string());
105        args.push("--output".to_string());
106        args.push("json".to_string());
107        let outcome = self.run(&args)?;
108        if !outcome.success {
109            return Err(self.external("`linear-cli issue create` failed", &outcome));
110        }
111        self.parse_identifier(&outcome)
112    }
113
114    fn update_issue(
115        &mut self,
116        remote_ref: &str,
117        title: &str,
118        body: &str,
119        _labels: &[String],
120        _assignee: Option<&str>,
121        _priority: Option<&str>,
122        status: &str,
123    ) -> Result<()> {
124        let mut args: Vec<String> = vec![
125            "issue".into(),
126            "update".into(),
127            remote_ref.to_string(),
128            "--title".into(),
129            title.to_string(),
130            "--description".into(),
131            body.to_string(),
132        ];
133        args.push("--status".to_string());
134        args.push(status.to_string());
135        let outcome = self.run(&args)?;
136        if !outcome.success {
137            return Err(self.external("`linear-cli issue update` failed", &outcome));
138        }
139        Ok(())
140    }
141
142    /// Linear conveys done via `--status Done`; there is no close command in
143    /// the documented template.
144    fn mark_done(&mut self, _remote_ref: &str) -> Result<()> {
145        Ok(())
146    }
147
148    fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
149        let args: Vec<String> = vec![
150            "issue".into(),
151            "list".into(),
152            "--team".into(),
153            self.team.clone(),
154            "--output".into(),
155            "json".into(),
156        ];
157        let outcome = self.run(&args)?;
158        if !outcome.success {
159            return Err(self.external("`linear-cli issue list` failed", &outcome));
160        }
161        let issues: Vec<LinearIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
162            TixError::External(format!(
163                "`linear-cli issue list` did not return a JSON array: {e}\n{}",
164                scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
165            ))
166        })?;
167        Ok(issues
168            .into_iter()
169            .map(RemoteIssue::from)
170            .filter(|i| i.state != "Done")
171            .collect())
172    }
173
174    fn done_via_status(&self) -> bool {
175        true
176    }
177
178    /// Linear's `relations.create`, the one relation affordance its CLI exposes
179    /// (WP07 T031) — same seam, same signature, different verb.
180    ///
181    /// One call per relation, because `relations.create` creates a single relation.
182    /// The first failure stops the run: a partially-created relation set is worse
183    /// than a loud stop, and `export` is not transactional across issues either, so
184    /// a diagnostic-free partial state is exactly what a reader cannot detect.
185    fn set_relations(
186        &mut self,
187        remote_ref: &str,
188        relations: &[super::MappedRelation],
189    ) -> Result<()> {
190        for relation in relations {
191            let super::MappedRelation::Remote { value, .. } = relation else {
192                continue;
193            };
194            let args: Vec<String> = vec![
195                "relation".into(),
196                "create".into(),
197                "--issue".into(),
198                remote_ref.to_string(),
199                "--type".into(),
200                value.clone(),
201            ];
202            let outcome = self.run(&args)?;
203            if !outcome.success {
204                return Err(self.external("`linear-cli relation create` failed", &outcome));
205            }
206        }
207        Ok(())
208    }
209}
210
211/// A single entry from `linear-cli issue list --output json`.
212#[derive(Debug, Deserialize)]
213struct LinearIssue {
214    identifier: String,
215    #[serde(default)]
216    title: String,
217    #[serde(default)]
218    description: Option<String>,
219    #[serde(default)]
220    labels: Vec<LinearLabel>,
221    #[serde(default, alias = "state")]
222    status: Option<LinearStatus>,
223    #[serde(default, alias = "updatedAt")]
224    updated_at: Option<String>,
225    /// The parent issue's identifier, when the remote reported one.
226    ///
227    /// Linear's hierarchy is a **list** (`parent` on an issue can hold more than
228    /// one entry in the API), and this reads the first. Taking one and saying so
229    /// is the conservative choice: silently dropping the rest would make a
230    /// round trip lossy in a way nothing reports, and tix's contract is
231    /// single-parent anyway, so there is exactly one reference to keep.
232    #[serde(default)]
233    parent: Option<serde_json::Value>,
234}
235
236/// Labels arrive either as plain strings or as `{"name":..}` / `{"value":..}`.
237#[derive(Debug, Deserialize)]
238#[serde(untagged)]
239enum LinearLabel {
240    Name(String),
241    Named { name: String },
242}
243
244/// `status` arrives either as a plain string or as `{"name":..}`.
245#[derive(Debug, Deserialize)]
246#[serde(untagged)]
247enum LinearStatus {
248    Name(String),
249    Named { name: String },
250}
251
252impl From<LinearIssue> for RemoteIssue {
253    fn from(issue: LinearIssue) -> Self {
254        let state = match issue.status {
255            Some(LinearStatus::Name(name)) | Some(LinearStatus::Named { name }) => name,
256            None => String::new(),
257        };
258        RemoteIssue {
259            id: issue.identifier,
260            title: issue.title,
261            description: issue.description.unwrap_or_default(),
262            labels: issue
263                .labels
264                .into_iter()
265                .map(|l| match l {
266                    LinearLabel::Name(name) | LinearLabel::Named { name } => name,
267                })
268                .collect(),
269            state,
270            updated_at: issue.updated_at,
271            assignee: None,
272            // Linear sends the parent as an object, an id string, or a list of
273            // either. All three are accepted so a payload shape change surfaces as
274            // "no parent" rather than as a deserialisation failure that aborts the
275            // whole import. The first entry is taken, because tix holds one
276            // reference and the contract is single-parent.
277            parent: linear_parent_id(issue.parent.as_ref()),
278        }
279    }
280}
281
282/// The first parent identifier from Linear's parent field, in any of the shapes
283/// it is known to arrive in.
284///
285/// Pure and total: an unrecognised shape yields `None`, which the import treats
286/// as "no parent reported" and reports as a diagnostic. It never panics and
287/// never guesses.
288fn linear_parent_id(value: Option<&serde_json::Value>) -> Option<String> {
289    fn one(value: &serde_json::Value) -> Option<String> {
290        match value {
291            serde_json::Value::String(s) => Some(s.clone()),
292            serde_json::Value::Object(map) => map
293                .get("id")
294                .or_else(|| map.get("identifier"))
295                .and_then(|v| v.as_str())
296                .map(str::to_string),
297            _ => None,
298        }
299    }
300    match value? {
301        serde_json::Value::Array(items) => items.first().and_then(one),
302        other => one(other),
303    }
304}
305
306/// Response helpers shaped like `linear-cli` JSON output.
307impl CommandOutcome {
308    #[must_use]
309    pub fn linear_created(identifier: &str) -> CommandOutcome {
310        CommandOutcome {
311            success: true,
312            status: Some(0),
313            stdout: format!(
314                "{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
315            ),
316            stderr: String::new(),
317        }
318    }
319
320    #[must_use]
321    pub fn linear_list(payload: &str) -> CommandOutcome {
322        CommandOutcome {
323            success: true,
324            status: Some(0),
325            stdout: payload.to_string(),
326            stderr: String::new(),
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::sync::glab::{FakeRunner, RecordedCall};
335
336    fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
337        LinearAdapter::new(runner, token.map(String::from), "Eng".into())
338    }
339
340    fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
341        adapter
342            .runner
343            .as_any()
344            .downcast_ref::<FakeRunner>()
345            .expect("adapter must hold a FakeRunner")
346            .take_calls()
347    }
348
349    #[test]
350    fn create_passes_team_title_body_labels_priority_status_and_token() {
351        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
352        let mut adapter = adapter_with(runner, Some("lin-token"));
353        let id = adapter
354            .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
355            .unwrap();
356        assert_eq!(id, "TIX-7");
357        let (argv, env) = &recorded(&adapter)[0];
358        assert_eq!(argv[0], "linear-cli");
359        let joined = argv.join(" ");
360        assert!(joined.contains("issue create --team Eng --title Fix --description body --label bug --priority high --status Done --output json"));
361        assert_eq!(
362            env,
363            &[("LINEAR_TOKEN".to_string(), "lin-token".to_string())]
364        );
365    }
366
367    #[test]
368    fn create_without_labels_omits_label_flag() {
369        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
370        let mut adapter = adapter_with(runner, None);
371        adapter
372            .create_issue("T", "b", &[], None, None, "todo")
373            .unwrap();
374        let joined = recorded(&adapter)[0].0.join(" ");
375        assert!(!joined.contains("--label"));
376    }
377
378    #[test]
379    fn update_passes_identifier_and_status() {
380        let runner = FakeRunner::new(vec![]);
381        let mut adapter = adapter_with(runner, None);
382        adapter
383            .update_issue("TIX-7", "New title", "new body", &[], None, None, "Done")
384            .unwrap();
385        let joined = recorded(&adapter)[0].0.join(" ");
386        assert!(joined.starts_with("linear-cli issue update TIX-7 "));
387        assert!(joined.contains("--title New title"));
388        assert!(joined.contains("--description new body"));
389        assert!(joined.contains("--status Done"));
390    }
391
392    #[test]
393    fn done_via_status_is_true_and_mark_done_is_a_noop() {
394        let runner = FakeRunner::new(vec![]);
395        let mut adapter = adapter_with(runner, None);
396        assert!(adapter.done_via_status());
397        adapter.mark_done("TIX-7").unwrap();
398        assert!(recorded(&adapter).is_empty(), "no close call for linear");
399    }
400
401    #[test]
402    fn list_open_parses_json_object_status_and_filters_done() {
403        let payload = r#"[
404          {"identifier":"TIX-1","title":"T1","description":"d","labels":["bug"],"status":{"name":"In Progress"},"updated_at":"2026-05-11T10:00:00Z"},
405          {"identifier":"TIX-2","title":"T2","status":"Done"}
406        ]"#;
407        let runner = FakeRunner::new(vec![CommandOutcome::linear_list(payload)]);
408        let mut adapter = adapter_with(runner, None);
409        let issues = adapter.list_open().unwrap();
410        assert_eq!(issues.len(), 1);
411        assert_eq!(issues[0].id, "TIX-1");
412        assert_eq!(issues[0].state, "In Progress");
413        assert_eq!(issues[0].labels, ["bug"]);
414        assert_eq!(
415            issues[0].updated_at.as_deref(),
416            Some("2026-05-11T10:00:00Z")
417        );
418    }
419
420    #[test]
421    fn create_json_without_identifier_is_a_parse_error() {
422        let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
423        let mut adapter = adapter_with(runner, None);
424        let err = adapter
425            .create_issue("T", "b", &[], None, None, "todo")
426            .unwrap_err();
427        assert!(matches!(err, TixError::External(_)));
428        assert!(err.to_string().contains("identifier"));
429    }
430
431    #[test]
432    fn failure_message_scrubs_token_from_tool_output() {
433        let runner = FakeRunner::new(vec![CommandOutcome {
434            success: false,
435            status: Some(1),
436            stdout: String::new(),
437            stderr: "LINEAR_TOKEN=lin-token leaked".into(),
438        }]);
439        let mut adapter = adapter_with(runner, Some("lin-token"));
440        let err = adapter
441            .create_issue("T", "b", &[], None, None, "todo")
442            .unwrap_err();
443        let text = err.to_string();
444        assert!(text.contains("<token>"), "expected redaction, got: {text}");
445        assert!(!text.contains("lin-token"), "secret leaked: {text}");
446    }
447}