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
179/// A single entry from `linear-cli issue list --output json`.
180#[derive(Debug, Deserialize)]
181struct LinearIssue {
182    identifier: String,
183    #[serde(default)]
184    title: String,
185    #[serde(default)]
186    description: Option<String>,
187    #[serde(default)]
188    labels: Vec<LinearLabel>,
189    #[serde(default, alias = "state")]
190    status: Option<LinearStatus>,
191    #[serde(default, alias = "updatedAt")]
192    updated_at: Option<String>,
193}
194
195/// Labels arrive either as plain strings or as `{"name":..}` / `{"value":..}`.
196#[derive(Debug, Deserialize)]
197#[serde(untagged)]
198enum LinearLabel {
199    Name(String),
200    Named { name: String },
201}
202
203/// `status` arrives either as a plain string or as `{"name":..}`.
204#[derive(Debug, Deserialize)]
205#[serde(untagged)]
206enum LinearStatus {
207    Name(String),
208    Named { name: String },
209}
210
211impl From<LinearIssue> for RemoteIssue {
212    fn from(issue: LinearIssue) -> Self {
213        let state = match issue.status {
214            Some(LinearStatus::Name(name)) | Some(LinearStatus::Named { name }) => name,
215            None => String::new(),
216        };
217        RemoteIssue {
218            id: issue.identifier,
219            title: issue.title,
220            description: issue.description.unwrap_or_default(),
221            labels: issue
222                .labels
223                .into_iter()
224                .map(|l| match l {
225                    LinearLabel::Name(name) | LinearLabel::Named { name } => name,
226                })
227                .collect(),
228            state,
229            updated_at: issue.updated_at,
230            assignee: None,
231        }
232    }
233}
234
235/// Response helpers shaped like `linear-cli` JSON output.
236impl CommandOutcome {
237    #[must_use]
238    pub fn linear_created(identifier: &str) -> CommandOutcome {
239        CommandOutcome {
240            success: true,
241            status: Some(0),
242            stdout: format!(
243                "{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
244            ),
245            stderr: String::new(),
246        }
247    }
248
249    #[must_use]
250    pub fn linear_list(payload: &str) -> CommandOutcome {
251        CommandOutcome {
252            success: true,
253            status: Some(0),
254            stdout: payload.to_string(),
255            stderr: String::new(),
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::sync::glab::{FakeRunner, RecordedCall};
264
265    fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
266        LinearAdapter::new(runner, token.map(String::from), "Eng".into())
267    }
268
269    fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
270        adapter
271            .runner
272            .as_any()
273            .downcast_ref::<FakeRunner>()
274            .expect("adapter must hold a FakeRunner")
275            .take_calls()
276    }
277
278    #[test]
279    fn create_passes_team_title_body_labels_priority_status_and_token() {
280        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
281        let mut adapter = adapter_with(runner, Some("lin-token"));
282        let id = adapter
283            .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
284            .unwrap();
285        assert_eq!(id, "TIX-7");
286        let (argv, env) = &recorded(&adapter)[0];
287        assert_eq!(argv[0], "linear-cli");
288        let joined = argv.join(" ");
289        assert!(joined.contains("issue create --team Eng --title Fix --description body --label bug --priority high --status Done --output json"));
290        assert_eq!(
291            env,
292            &[("LINEAR_TOKEN".to_string(), "lin-token".to_string())]
293        );
294    }
295
296    #[test]
297    fn create_without_labels_omits_label_flag() {
298        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
299        let mut adapter = adapter_with(runner, None);
300        adapter
301            .create_issue("T", "b", &[], None, None, "todo")
302            .unwrap();
303        let joined = recorded(&adapter)[0].0.join(" ");
304        assert!(!joined.contains("--label"));
305    }
306
307    #[test]
308    fn update_passes_identifier_and_status() {
309        let runner = FakeRunner::new(vec![]);
310        let mut adapter = adapter_with(runner, None);
311        adapter
312            .update_issue("TIX-7", "New title", "new body", &[], None, None, "Done")
313            .unwrap();
314        let joined = recorded(&adapter)[0].0.join(" ");
315        assert!(joined.starts_with("linear-cli issue update TIX-7 "));
316        assert!(joined.contains("--title New title"));
317        assert!(joined.contains("--description new body"));
318        assert!(joined.contains("--status Done"));
319    }
320
321    #[test]
322    fn done_via_status_is_true_and_mark_done_is_a_noop() {
323        let runner = FakeRunner::new(vec![]);
324        let mut adapter = adapter_with(runner, None);
325        assert!(adapter.done_via_status());
326        adapter.mark_done("TIX-7").unwrap();
327        assert!(recorded(&adapter).is_empty(), "no close call for linear");
328    }
329
330    #[test]
331    fn list_open_parses_json_object_status_and_filters_done() {
332        let payload = r#"[
333          {"identifier":"TIX-1","title":"T1","description":"d","labels":["bug"],"status":{"name":"In Progress"},"updated_at":"2026-05-11T10:00:00Z"},
334          {"identifier":"TIX-2","title":"T2","status":"Done"}
335        ]"#;
336        let runner = FakeRunner::new(vec![CommandOutcome::linear_list(payload)]);
337        let mut adapter = adapter_with(runner, None);
338        let issues = adapter.list_open().unwrap();
339        assert_eq!(issues.len(), 1);
340        assert_eq!(issues[0].id, "TIX-1");
341        assert_eq!(issues[0].state, "In Progress");
342        assert_eq!(issues[0].labels, ["bug"]);
343        assert_eq!(
344            issues[0].updated_at.as_deref(),
345            Some("2026-05-11T10:00:00Z")
346        );
347    }
348
349    #[test]
350    fn create_json_without_identifier_is_a_parse_error() {
351        let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
352        let mut adapter = adapter_with(runner, None);
353        let err = adapter
354            .create_issue("T", "b", &[], None, None, "todo")
355            .unwrap_err();
356        assert!(matches!(err, TixError::External(_)));
357        assert!(err.to_string().contains("identifier"));
358    }
359
360    #[test]
361    fn failure_message_scrubs_token_from_tool_output() {
362        let runner = FakeRunner::new(vec![CommandOutcome {
363            success: false,
364            status: Some(1),
365            stdout: String::new(),
366            stderr: "LINEAR_TOKEN=lin-token leaked".into(),
367        }]);
368        let mut adapter = adapter_with(runner, Some("lin-token"));
369        let err = adapter
370            .create_issue("T", "b", &[], None, None, "todo")
371            .unwrap_err();
372        let text = err.to_string();
373        assert!(text.contains("<token>"), "expected redaction, got: {text}");
374        assert!(!text.contains("lin-token"), "secret leaked: {text}");
375    }
376}