Skip to main content

tix/sync/
linear.rs

1//! Linear adapter: shells out to the canonical `linear-cli` CLI
2//! (nesszer/linear-cli, the `cargo:linear-cli` crate pinned in `mise.toml`).
3//!
4//! The grammar is the real **0.3.28** surface, checked against the installed
5//! binary on 2026-10-09. Four argv shapes parse — they fail only with
6//! `No workspace selected` (exit 1, because no workspace is configured here):
7//! `issues create -t=ENG -d=D -p=2 -s=Todo -o json -- "T"`,
8//! `issues update -T=T -d=D -s=Done -- ENG-1`,
9//! `issues list -t=ENG -o json`, and
10//! `relations add -r blocks ENG-1 ENG-2`; two clap errors confirm the shape —
11//! `linear-cli issue create "T"` (exit 2, `unrecognized subcommand 'issue'`)
12//! and `linear-cli issues create --title T` (exit 2,
13//! `unexpected argument '--title'`). The plural `issues` is the subcommand; the
14//! title is **positional** on create; the workflow flag is `--state`, not
15//! `--status`. The **API path is unverified**: no workspace is configured here,
16//! so nothing past argument parsing was exercised.
17//!
18//! ## Values are attached; positionals follow `--`
19//!
20//! Every user-controlled option value is attached with `=` (`-d=<body>`,
21//! `-T=<title>`, `-l=<label>`, `-s=<state>`, `-t=<team>`, `-p=<n>`) and the
22//! positional title/id follows the flags behind `--`. This is not cosmetic: tix
23//! bodies are markdown, and a body whose first line is `- item` — or a title
24//! starting with `-` — is rejected by clap when it is a separate argv element
25//! (`error: unexpected argument '- ' found`, exit 2). Separating them would abort
26//! an export mid-run on ordinary data.
27//!
28//! ## The import reads two payloads
29//!
30//! `issues list` enumerates with only `id, identifier, title, priority,
31//! state{name}, assignee{name}, project{name}` — no body, labels, or timestamp —
32//! so an import is **one `issues list` call plus one `issues get <ID>` per open
33//! issue**, the latter returning the full issue object (`description`,
34//! `updatedAt`, `labels.nodes`, `parent`, `project`, …). The two shapes are
35//! modelled separately, exactly as the verbs carry them.
36//!
37//! The tool reads **`LINEAR_API_KEY`** (its own help shows
38//! `printf '%s\n' "$LINEAR_API_KEY" | linear config set-key`); it does not read
39//! `LINEAR_TOKEN`, so that is what [`LinearAdapter::child_env`] injects.
40//!
41//! Linear conveys workflow stage through `-s <state>` (done → `Done`, otherwise
42//! the first work column), so this adapter sets `done_via_status` and never
43//! issues a separate close.
44
45use serde::Deserialize;
46
47use crate::error::{Result, TixError};
48use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
49
50/// The `linear-cli` adapter. Spawns `linear-cli issues <list|get|create|update>`
51/// with the configured team and, when a secret is configured, the
52/// `LINEAR_API_KEY` env pair.
53pub struct LinearAdapter {
54    runner: Box<dyn CommandRunner>,
55    token: Option<String>,
56    team: String,
57}
58
59impl LinearAdapter {
60    pub fn new(runner: impl CommandRunner + 'static, token: Option<String>, team: String) -> Self {
61        LinearAdapter {
62            runner: Box::new(runner),
63            token,
64            team,
65        }
66    }
67
68    fn child_env(&self) -> Vec<(String, String)> {
69        match &self.token {
70            Some(token) => vec![("LINEAR_API_KEY".to_string(), token.clone())],
71            None => Vec::new(),
72        }
73    }
74
75    fn run(&self, args: &[String]) -> Result<CommandOutcome> {
76        self.runner
77            .run(Tool::Linear.binary(), args, &self.child_env())
78    }
79
80    fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
81        let mut message = format!("{context}: {}", outcome.text().trim());
82        if let Some(secret) = &self.token {
83            message = scrub_secret(&message, secret);
84        }
85        TixError::External(message)
86    }
87
88    /// Parse `linear-cli issues create` stdout for the `identifier`.
89    fn parse_identifier(&self, outcome: &CommandOutcome) -> Result<String> {
90        serde_json::from_str::<serde_json::Value>(&outcome.stdout)
91            .ok()
92            .and_then(|value| {
93                value
94                    .get("identifier")
95                    .and_then(serde_json::Value::as_str)
96                    .map(str::to_string)
97            })
98            .ok_or_else(|| {
99                TixError::External(format!(
100                    "`linear-cli issues create` did not return JSON with an `identifier` field (expected {{\"identifier\":…}}): {}",
101                    scrub_secret(outcome.stdout.trim(), self.token.as_deref().unwrap_or(""))
102                ))
103            })
104    }
105
106    /// Fetch one issue's **detail** payload (`issues get <ID> -o json`).
107    fn fetch_issue(&self, identifier: &str) -> Result<RemoteIssue> {
108        let args: Vec<String> = vec![
109            "issues".into(),
110            "get".into(),
111            "-o".into(),
112            "json".into(),
113            identifier.to_string(),
114        ];
115        let outcome = self.run(&args)?;
116        if !outcome.success {
117            return Err(self.external("`linear-cli issues get` failed", &outcome));
118        }
119        let detail: LinearDetailIssue = serde_json::from_str(&outcome.stdout).map_err(|e| {
120            TixError::External(format!(
121                "`linear-cli issues get` did not return a JSON object: {e}\n{}",
122                scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
123            ))
124        })?;
125        Ok(RemoteIssue::from(detail))
126    }
127}
128
129impl TrackerAdapter for LinearAdapter {
130    fn tool(&self) -> Tool {
131        Tool::Linear
132    }
133
134    fn create_issue(
135        &mut self,
136        title: &str,
137        body: &str,
138        labels: &[String],
139        _assignee: Option<&str>,
140        priority: Option<&str>,
141        status: &str,
142    ) -> Result<String> {
143        let mut args: Vec<String> = vec![
144            "issues".into(),
145            "create".into(),
146            format!("-t={}", self.team),
147            format!("-d={body}"),
148        ];
149        for label in labels {
150            args.push(format!("-l={label}"));
151        }
152        if let Some(p) = priority.and_then(linear_priority) {
153            args.push(format!("-p={p}"));
154        }
155        args.push(format!("-s={status}"));
156        args.push("-o".into());
157        args.push("json".into());
158        // `--` so a title starting with `-` is a positional, not a flag.
159        args.push("--".into());
160        args.push(title.to_string());
161        let outcome = self.run(&args)?;
162        if !outcome.success {
163            return Err(self.external("`linear-cli issues create` failed", &outcome));
164        }
165        self.parse_identifier(&outcome)
166    }
167
168    fn update_issue(
169        &mut self,
170        remote_ref: &str,
171        title: &str,
172        body: &str,
173        labels: &[String],
174        _assignee: Option<&str>,
175        priority: Option<&str>,
176        status: &str,
177    ) -> Result<()> {
178        let mut args: Vec<String> = vec![
179            "issues".into(),
180            "update".into(),
181            format!("-T={title}"),
182            format!("-d={body}"),
183        ];
184        for label in labels {
185            args.push(format!("-l={label}"));
186        }
187        if let Some(p) = priority.and_then(linear_priority) {
188            args.push(format!("-p={p}"));
189        }
190        args.push(format!("-s={status}"));
191        // `--` so an id/ref starting with `-` is a positional, not a flag.
192        args.push("--".into());
193        args.push(remote_ref.to_string());
194        let outcome = self.run(&args)?;
195        if !outcome.success {
196            return Err(self.external("`linear-cli issues update` failed", &outcome));
197        }
198        Ok(())
199    }
200
201    /// Linear conveys done via `-s Done`; there is no close command.
202    fn mark_done(&mut self, _remote_ref: &str) -> Result<()> {
203        Ok(())
204    }
205
206    /// Enumerate open issues with `issues list`, then fetch each one's detail.
207    ///
208    /// The list payload carries only `id, identifier, title, priority,
209    /// state{name}, assignee{name}, project{name}` — never a body, labels, or
210    /// timestamp — so the fields an import needs come from a per-issue
211    /// `issues get`. Cost: **one list call plus one get per open issue**.
212    fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
213        let args: Vec<String> = vec![
214            "issues".into(),
215            "list".into(),
216            format!("-t={}", self.team),
217            "-o".into(),
218            "json".into(),
219        ];
220        let outcome = self.run(&args)?;
221        if !outcome.success {
222            return Err(self.external("`linear-cli issues list` failed", &outcome));
223        }
224        let listed: Vec<LinearListIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
225            TixError::External(format!(
226                "`linear-cli issues list` did not return a JSON array: {e}\n{}",
227                scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
228            ))
229        })?;
230        let mut issues = Vec::new();
231        for item in listed {
232            if item.state_name() == "Done" {
233                continue;
234            }
235            issues.push(self.fetch_issue(&item.identifier)?);
236        }
237        Ok(issues)
238    }
239
240    fn done_via_status(&self) -> bool {
241        true
242    }
243
244    /// Linear's `relations add`, the relation affordance the 0.3.28 CLI exposes.
245    ///
246    /// One call per relation, because `relations add` is **additive and creates a
247    /// single edge**: `-r <RELATION> <FROM> <TO>` with the relation kind and the
248    /// two issues' **remote identifiers** (`ENG-123`); the target identifier is
249    /// what [`crate::sync::map_relation_to_remote`] resolved and carried in
250    /// `value`. The first failure stops the run: a partially-created relation set
251    /// is worse than a loud stop, and `export` is not transactional across issues
252    /// either, so a diagnostic-free partial state is exactly what a reader cannot
253    /// detect.
254    fn set_relations(
255        &mut self,
256        remote_ref: &str,
257        relations: &[super::MappedRelation],
258    ) -> Result<()> {
259        for relation in relations {
260            let super::MappedRelation::Remote { field, value } = relation else {
261                continue;
262            };
263            let args: Vec<String> = vec![
264                "relations".into(),
265                "add".into(),
266                "-r".into(),
267                field.clone(),
268                remote_ref.to_string(),
269                value.clone(),
270            ];
271            let outcome = self.run(&args)?;
272            if !outcome.success {
273                return Err(self.external("`linear-cli relations add` failed", &outcome));
274            }
275        }
276        Ok(())
277    }
278
279    /// Linear's side of the project-association seam — and its answer is "nothing"
280    /// (WP08 T036).
281    ///
282    /// Linear's issue carries **one** `project`, so a second membership has nowhere
283    /// to go: there is no list field and therefore no command line that could carry
284    /// one. Sending the first association and diagnosing the rest would split one
285    /// membership rule across two places and quietly drop data; C-005's answer for
286    /// all of them is the diagnostic, and the local list stays whole.
287    ///
288    /// The `debug_assert` pins the same invariant as GitLab's: a value cannot arrive
289    /// while `PROJECT_WIRE_VERIFIED` is empty, and a table flipped without an
290    /// implementation here fails loudly in every test build rather than dropping an
291    /// association in silence.
292    fn set_projects(&mut self, _remote_ref: &str, projects: &[super::MappedProject]) -> Result<()> {
293        debug_assert!(
294            projects.is_empty(),
295            "Linear's issue carries one `project`, so no membership list can be \
296             sent: flip `PROJECT_WIRE_VERIFIED` only together with an \
297             implementation here"
298        );
299        Ok(())
300    }
301
302    /// Linear's side of the epic-reference seam — and its answer is "nothing" (WP08,
303    /// mission `epics-work-items-01M3BYW4`).
304    ///
305    /// Linear has no epic level at all: its issues carry one `project`, and the
306    /// nearest concept above a project is an initiative. Neither `issues list` nor
307    /// `issues get` returns an epic field, so there is nothing to read and no field
308    /// to send.
309    ///
310    /// The `debug_assert` pins the same invariant as GitLab's: a value cannot arrive
311    /// while `EPIC_WIRE_VERIFIED` is empty, and a table flipped without an
312    /// implementation here fails loudly in every test build rather than dropping a
313    /// reference in silence.
314    fn set_epics(&mut self, _remote_ref: &str, epics: &[super::MappedEpic]) -> Result<()> {
315        debug_assert!(
316            epics.is_empty(),
317            "Linear has no epic level, so no epic reference list can be sent: flip \
318             `EPIC_WIRE_VERIFIED` only together with an implementation here"
319        );
320        Ok(())
321    }
322}
323
324/// Map a local priority word to `linear-cli`'s **numeric** `-p` value, or `None`
325/// when it cannot be expressed.
326///
327/// `linear-cli` takes a number (0 = none, 1 = urgent, 2 = high, 3 = normal,
328/// 4 = low), so the words tix stores need translating: `low`→4, `medium`→3,
329/// `high`→2, and the empty string (tix's "explicitly no priority") →0. Anything
330/// else — a custom or unknown string — yields `None`, and the caller **omits the
331/// flag** rather than coercing it. This mirrors the GitLab adapter, which ignores
332/// a priority it cannot map; a wrong priority written silently is worse than none
333/// written at all.
334#[must_use]
335fn linear_priority(priority: &str) -> Option<&'static str> {
336    match priority {
337        "low" => Some("4"),
338        "medium" => Some("3"),
339        "high" => Some("2"),
340        "" => Some("0"),
341        _ => None,
342    }
343}
344
345/// One entry from `linear-cli issues list -o json`, which returns only
346/// `id, identifier, title, priority, state{name}, assignee{name}, project{name}`.
347#[derive(Debug, Deserialize)]
348struct LinearListIssue {
349    identifier: String,
350    #[serde(default, alias = "state")]
351    state: Option<LinearStatus>,
352}
353
354impl LinearListIssue {
355    /// The state name, or the empty string when the payload omitted it.
356    fn state_name(&self) -> &str {
357        self.state.as_ref().map(LinearStatus::name).unwrap_or("")
358    }
359}
360
361/// The full issue object `linear-cli issues get <ID> -o json` prints (the subset
362/// this adapter reads):
363/// `identifier, title, description, updatedAt, state{name}, assignee{name,email},
364/// labels{nodes{name,color}}, parent{identifier,title}, project{name}`.
365#[derive(Debug, Deserialize)]
366struct LinearDetailIssue {
367    identifier: String,
368    #[serde(default)]
369    title: String,
370    #[serde(default)]
371    description: Option<String>,
372    #[serde(default, alias = "state")]
373    state: Option<LinearStatus>,
374    #[serde(default, alias = "updatedAt")]
375    updated_at: Option<String>,
376    #[serde(default)]
377    assignee: Option<LinearAssignee>,
378    #[serde(default)]
379    labels: Option<LinearLabels>,
380    /// The parent issue, reported as `{identifier, title}` (or null).
381    #[serde(default)]
382    parent: Option<serde_json::Value>,
383    /// Linear's single `project`, reported as `{name}`.
384    #[serde(default, alias = "projects")]
385    project: Option<serde_json::Value>,
386}
387
388/// Linear's assignee object: `{name, email}`. Only the name is used (the local
389/// field is a bare handle).
390#[derive(Debug, Deserialize)]
391struct LinearAssignee {
392    #[serde(default)]
393    name: Option<String>,
394}
395
396/// Linear's labels connection: `{nodes: [{name, color}]}`.
397#[derive(Debug, Deserialize)]
398struct LinearLabels {
399    #[serde(default)]
400    nodes: Vec<LinearLabelNode>,
401}
402
403#[derive(Debug, Deserialize)]
404struct LinearLabelNode {
405    #[serde(default)]
406    name: String,
407}
408
409/// `state` arrives either as a plain string or as `{"name":..}`.
410#[derive(Debug, Deserialize)]
411#[serde(untagged)]
412enum LinearStatus {
413    Name(String),
414    Named { name: String },
415}
416
417impl LinearStatus {
418    fn name(&self) -> &str {
419        match self {
420            LinearStatus::Name(name) | LinearStatus::Named { name } => name,
421        }
422    }
423}
424
425impl From<LinearDetailIssue> for RemoteIssue {
426    fn from(issue: LinearDetailIssue) -> Self {
427        RemoteIssue {
428            id: issue.identifier,
429            title: issue.title,
430            description: issue.description.unwrap_or_default(),
431            labels: issue
432                .labels
433                .map(|labels| labels.nodes.into_iter().map(|n| n.name).collect())
434                .unwrap_or_default(),
435            state: issue
436                .state
437                .as_ref()
438                .map(LinearStatus::name)
439                .unwrap_or("")
440                .to_string(),
441            updated_at: issue.updated_at,
442            assignee: issue.assignee.and_then(|a| a.name),
443            // Linear sends the parent as an object (`{identifier, title}`), an id
444            // string, or a list of either. All are accepted so a payload shape
445            // change surfaces as "no parent" rather than as a deserialisation
446            // failure that aborts the whole import. The first entry is taken,
447            // because tix holds one reference and the contract is single-parent.
448            parent: linear_parent_id(issue.parent.as_ref()),
449            projects: crate::sync::remote_projects(issue.project.as_ref()),
450            // Linear has no epic level and neither verb returns an epic field, so
451            // there is never an epic reference to read.
452            epics: Vec::new(),
453        }
454    }
455}
456
457/// The first parent identifier from Linear's parent field, in any of the shapes
458/// it is known to arrive in.
459///
460/// Pure and total: an unrecognised shape yields `None`, which the import treats
461/// as "no parent reported" and reports as a diagnostic. It never panics and
462/// never guesses.
463fn linear_parent_id(value: Option<&serde_json::Value>) -> Option<String> {
464    fn one(value: &serde_json::Value) -> Option<String> {
465        match value {
466            serde_json::Value::String(s) => Some(s.clone()),
467            serde_json::Value::Object(map) => map
468                .get("id")
469                .or_else(|| map.get("identifier"))
470                .and_then(|v| v.as_str())
471                .map(str::to_string),
472            _ => None,
473        }
474    }
475    match value? {
476        serde_json::Value::Array(items) => items.first().and_then(one),
477        other => one(other),
478    }
479}
480
481/// Response helpers shaped like `linear-cli issues ... -o json` output.
482impl CommandOutcome {
483    #[must_use]
484    pub fn linear_created(identifier: &str) -> CommandOutcome {
485        CommandOutcome {
486            success: true,
487            status: Some(0),
488            stdout: format!(
489                "{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
490            ),
491            stderr: String::new(),
492        }
493    }
494
495    #[must_use]
496    pub fn linear_list(payload: &str) -> CommandOutcome {
497        CommandOutcome {
498            success: true,
499            status: Some(0),
500            stdout: payload.to_string(),
501            stderr: String::new(),
502        }
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    #[allow(clippy::wildcard_imports)]
509    use super::*;
510    use crate::sync::MappedRelation;
511    use crate::sync::glab::{FakeRunner, RecordedCall};
512
513    fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
514        LinearAdapter::new(runner, token.map(String::from), "Eng".into())
515    }
516
517    fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
518        adapter
519            .runner
520            .as_any()
521            .downcast_ref::<FakeRunner>()
522            .expect("adapter must hold a FakeRunner")
523            .take_calls()
524    }
525
526    #[test]
527    fn create_attaches_values_and_puts_the_positional_title_behind_double_dash() {
528        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
529        let mut adapter = adapter_with(runner, Some("lin-token"));
530        let id = adapter
531            .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
532            .unwrap();
533        assert_eq!(id, "TIX-7");
534        let (argv, env) = &recorded(&adapter)[0];
535        assert_eq!(
536            argv.join(" "),
537            "linear-cli issues create -t=Eng -d=body -l=bug -p=2 -s=Done -o json -- Fix"
538        );
539        assert_eq!(
540            env,
541            &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
542        );
543    }
544
545    /// A markdown body whose first line is `- item`, and a title starting with
546    /// `-`, are the ordinary case this argv has to survive: attached values and
547    /// the `--` terminator are what keep clap from reading them as flags.
548    #[test]
549    fn create_survives_a_hyphen_leading_body_and_title() {
550        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
551        let mut adapter = adapter_with(runner, None);
552        adapter
553            .create_issue(
554                "-leading title",
555                "- bullet one\n- bullet two",
556                &["bug".into()],
557                None,
558                None,
559                "Todo",
560            )
561            .unwrap();
562        let argv = &recorded(&adapter)[0].0;
563        assert_eq!(
564            argv.join(" "),
565            "linear-cli issues create -t=Eng -d=- bullet one\n- bullet two -l=bug -s=Todo -o json -- -leading title"
566        );
567        assert_eq!(
568            argv.last().unwrap(),
569            "-leading title",
570            "the title is the positional"
571        );
572    }
573
574    #[test]
575    fn update_survives_a_hyphen_leading_title_and_body() {
576        let runner = FakeRunner::new(vec![]);
577        let mut adapter = adapter_with(runner, None);
578        adapter
579            .update_issue("TIX-7", "-tricky title", "- body", &[], None, None, "Done")
580            .unwrap();
581        let argv = &recorded(&adapter)[0].0;
582        assert_eq!(
583            argv.join(" "),
584            "linear-cli issues update -T=-tricky title -d=- body -s=Done -- TIX-7"
585        );
586    }
587
588    /// The injected name is the one the tool actually reads — `LINEAR_API_KEY`,
589    /// not the `LINEAR_TOKEN` this adapter used to send (which never reached it).
590    #[test]
591    fn the_token_is_injected_under_the_name_the_tool_reads() {
592        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-1")]);
593        let mut adapter = adapter_with(runner, Some("lin-token"));
594        adapter
595            .create_issue("T", "b", &[], None, None, "Todo")
596            .unwrap();
597        assert_eq!(
598            &recorded(&adapter)[0].1,
599            &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
600        );
601    }
602
603    #[test]
604    fn create_without_labels_omits_label_flag() {
605        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
606        let mut adapter = adapter_with(runner, None);
607        adapter
608            .create_issue("T", "b", &[], None, None, "todo")
609            .unwrap();
610        let joined = recorded(&adapter)[0].0.join(" ");
611        assert!(!joined.contains("-l="), "{joined}");
612    }
613
614    #[test]
615    fn priority_maps_to_linear_numbers_and_unknown_is_omitted() {
616        assert_eq!(linear_priority("low"), Some("4"));
617        assert_eq!(linear_priority("medium"), Some("3"));
618        assert_eq!(linear_priority("high"), Some("2"));
619        assert_eq!(linear_priority(""), Some("0"), "the empty string is `none`");
620        for unknown in ["urgent", "none", "MEDIUM", "custom-word"] {
621            assert_eq!(
622                linear_priority(unknown),
623                None,
624                "{unknown} must not be coerced to a number"
625            );
626        }
627    }
628
629    #[test]
630    fn an_unmappable_priority_omits_the_priority_flag() {
631        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-9")]);
632        let mut adapter = adapter_with(runner, None);
633        adapter
634            .create_issue("T", "b", &[], None, Some("urgent"), "Todo")
635            .unwrap();
636        let joined = recorded(&adapter)[0].0.join(" ");
637        assert!(
638            !joined.contains("-p="),
639            "unknown priority must be omitted: {joined}"
640        );
641    }
642
643    #[test]
644    fn update_passes_identifier_title_body_labels_priority_and_state() {
645        let runner = FakeRunner::new(vec![]);
646        let mut adapter = adapter_with(runner, None);
647        adapter
648            .update_issue(
649                "TIX-7",
650                "New title",
651                "new body",
652                &["bug".into()],
653                None,
654                Some("low"),
655                "Done",
656            )
657            .unwrap();
658        let joined = recorded(&adapter)[0].0.join(" ");
659        assert_eq!(
660            joined,
661            "linear-cli issues update -T=New title -d=new body -l=bug -p=4 -s=Done -- TIX-7"
662        );
663    }
664
665    #[test]
666    fn done_via_status_is_true_and_mark_done_is_a_noop() {
667        let runner = FakeRunner::new(vec![]);
668        let mut adapter = adapter_with(runner, None);
669        assert!(adapter.done_via_status());
670        adapter.mark_done("TIX-7").unwrap();
671        assert!(recorded(&adapter).is_empty(), "no close call for linear");
672    }
673
674    #[test]
675    fn set_relations_adds_one_edge_per_relation_with_remote_identifiers() {
676        let runner = FakeRunner::new(vec![
677            CommandOutcome::linear_list(""),
678            CommandOutcome::linear_list(""),
679        ]);
680        let mut adapter = adapter_with(runner, None);
681        adapter
682            .set_relations(
683                "TIX-1",
684                &[
685                    MappedRelation::Remote {
686                        field: "blocks".into(),
687                        value: "TIX-2".into(),
688                    },
689                    MappedRelation::Remote {
690                        field: "blocks".into(),
691                        value: "TIX-3".into(),
692                    },
693                ],
694            )
695            .unwrap();
696        let calls = recorded(&adapter);
697        assert_eq!(
698            calls[0].0.join(" "),
699            "linear-cli relations add -r blocks TIX-1 TIX-2"
700        );
701        assert_eq!(
702            calls[1].0.join(" "),
703            "linear-cli relations add -r blocks TIX-1 TIX-3"
704        );
705    }
706
707    /// The list payload carries only the enumeration fields; each open issue's
708    /// body/labels/timestamp come from a per-issue `issues get`. A `Done` issue is
709    /// filtered from the list and never fetched.
710    #[test]
711    fn list_open_enumerates_then_fetches_each_open_issues_detail() {
712        let list = r#"[
713          {"id":"u1","identifier":"TIX-1","title":"T1","priority":3,"state":{"name":"In Progress"}},
714          {"id":"u2","identifier":"TIX-2","title":"T2","state":{"name":"Done"}}
715        ]"#;
716        let detail = r##"{"id":"u1","identifier":"TIX-1","title":"T1","description":"full body",
717          "updatedAt":"2026-05-11T10:00:00Z","state":{"name":"In Progress"},
718          "assignee":{"name":"Al","email":"al@example.com"},
719          "labels":{"nodes":[{"name":"bug","color":"#f00"},{"name":"api","color":"#0f0"}]}}"##;
720        // FakeRunner pops a response per call, from the back: the list call is the
721        // last element pushed, so it is popped first.
722        let runner = FakeRunner::new(vec![
723            CommandOutcome::linear_list(detail),
724            CommandOutcome::linear_list(list),
725        ]);
726        let mut adapter = adapter_with(runner, None);
727        let issues = adapter.list_open().unwrap();
728        assert_eq!(issues.len(), 1, "the Done issue is filtered before the get");
729        assert_eq!(issues[0].id, "TIX-1");
730        assert_eq!(issues[0].title, "T1");
731        assert_eq!(issues[0].description, "full body");
732        assert_eq!(issues[0].labels, ["bug", "api"]);
733        assert_eq!(issues[0].state, "In Progress");
734        assert_eq!(issues[0].assignee.as_deref(), Some("Al"));
735        assert_eq!(
736            issues[0].updated_at.as_deref(),
737            Some("2026-05-11T10:00:00Z")
738        );
739        let calls = recorded(&adapter);
740        assert_eq!(
741            calls.len(),
742            2,
743            "one list call and one get — never a get for the Done issue"
744        );
745        assert_eq!(
746            calls[0].0.join(" "),
747            "linear-cli issues list -t=Eng -o json"
748        );
749        assert_eq!(calls[1].0.join(" "), "linear-cli issues get -o json TIX-1");
750    }
751
752    /// A real list payload has no body/labels/timestamp, so nothing on the list
753    /// side may pretend to read them.
754    #[test]
755    fn a_list_payload_without_detail_fields_does_not_fail_the_import() {
756        let list = r#"[{"id":"u1","identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
757        let detail = r#"{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}"#;
758        let runner = FakeRunner::new(vec![
759            CommandOutcome::linear_list(detail),
760            CommandOutcome::linear_list(list),
761        ]);
762        let mut adapter = adapter_with(runner, None);
763        let issues = adapter.list_open().unwrap();
764        assert_eq!(issues.len(), 1);
765        assert_eq!(issues[0].description, "");
766        assert_eq!(issues[0].labels, [] as [std::string::String; 0]);
767        assert_eq!(issues[0].updated_at, None);
768    }
769
770    #[test]
771    fn create_json_without_identifier_is_a_parse_error() {
772        let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
773        let mut adapter = adapter_with(runner, None);
774        let err = adapter
775            .create_issue("T", "b", &[], None, None, "todo")
776            .unwrap_err();
777        assert!(matches!(err, TixError::External(_)));
778        assert!(err.to_string().contains("identifier"));
779        assert!(
780            err.to_string().contains("linear-cli issues create"),
781            "the error must name the real subcommand: {err}"
782        );
783    }
784
785    #[test]
786    fn a_detail_without_the_identifier_is_a_parse_error() {
787        let list = r#"[{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
788        let runner = FakeRunner::new(vec![
789            CommandOutcome::linear_list("{}"),
790            CommandOutcome::linear_list(list),
791        ]);
792        let mut adapter = adapter_with(runner, None);
793        let err = adapter.list_open().unwrap_err();
794        assert!(matches!(err, TixError::External(_)));
795        assert!(
796            err.to_string().contains("linear-cli issues get"),
797            "the error names the failing verb: {err}"
798        );
799    }
800
801    #[test]
802    fn failure_message_scrubs_token_from_tool_output() {
803        let runner = FakeRunner::new(vec![CommandOutcome {
804            success: false,
805            status: Some(1),
806            stdout: String::new(),
807            stderr: "LINEAR_API_KEY=lin-token leaked".into(),
808        }]);
809        let mut adapter = adapter_with(runner, Some("lin-token"));
810        let err = adapter
811            .create_issue("T", "b", &[], None, None, "todo")
812            .unwrap_err();
813        let text = err.to_string();
814        assert!(text.contains("<token>"), "expected redaction, got: {text}");
815        assert!(!text.contains("lin-token"), "secret leaked: {text}");
816    }
817
818    /// WP08 T036: Linear's single `project` is read from the **detail** payload,
819    /// and nothing is sent for it, because Linear's issue carries one project and
820    /// no list.
821    #[test]
822    fn t036_project_reference_is_read_from_detail_and_never_sent() {
823        let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
824        let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
825          "project":{"name":"One"}}"#;
826        let runner = FakeRunner::new(vec![
827            CommandOutcome::linear_list(detail),
828            CommandOutcome::linear_list(list),
829        ]);
830        let mut adapter = adapter_with(runner, None);
831        let issues = adapter.list_open().unwrap();
832        assert_eq!(
833            issues[0].projects,
834            vec![crate::sync::RemoteProject {
835                id: String::new(),
836                name: Some("One".into()),
837            }],
838            "the real query selects `project{{name}}`, so the id is empty and the name kept"
839        );
840
841        let calls_before = recorded(&adapter).len();
842        adapter.set_projects("TIX-1", &[]).unwrap();
843        assert_eq!(
844            recorded(&adapter).len(),
845            calls_before,
846            "an empty membership set must not spawn any command"
847        );
848    }
849
850    /// WP08 T038: Linear has no epic level, and neither verb returns an epic
851    /// field, so a payload key this adapter does not model yields no reference —
852    /// and nothing is ever sent for it.
853    #[test]
854    fn t038_no_epic_reference_is_read_or_sent() {
855        let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
856        // An unmodelled key is ignored, never mistaken for an epic list.
857        let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
858          "epics":["e-1"]}"#;
859        let runner = FakeRunner::new(vec![
860            CommandOutcome::linear_list(detail),
861            CommandOutcome::linear_list(list),
862        ]);
863        let mut adapter = adapter_with(runner, None);
864        let issues = adapter.list_open().unwrap();
865        assert_eq!(issues[0].epics, [] as [crate::sync::RemoteEpic; 0]);
866
867        let calls_before = recorded(&adapter).len();
868        adapter.set_epics("TIX-1", &[]).unwrap();
869        assert_eq!(
870            recorded(&adapter).len(),
871            calls_before,
872            "an empty reference set must not spawn any command"
873        );
874    }
875}