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    use super::*;
509    use crate::sync::MappedRelation;
510    use crate::sync::glab::{FakeRunner, RecordedCall};
511
512    fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
513        LinearAdapter::new(runner, token.map(String::from), "Eng".into())
514    }
515
516    fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
517        adapter
518            .runner
519            .as_any()
520            .downcast_ref::<FakeRunner>()
521            .expect("adapter must hold a FakeRunner")
522            .take_calls()
523    }
524
525    #[test]
526    fn create_attaches_values_and_puts_the_positional_title_behind_double_dash() {
527        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
528        let mut adapter = adapter_with(runner, Some("lin-token"));
529        let id = adapter
530            .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
531            .unwrap();
532        assert_eq!(id, "TIX-7");
533        let (argv, env) = &recorded(&adapter)[0];
534        assert_eq!(
535            argv.join(" "),
536            "linear-cli issues create -t=Eng -d=body -l=bug -p=2 -s=Done -o json -- Fix"
537        );
538        assert_eq!(
539            env,
540            &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
541        );
542    }
543
544    /// A markdown body whose first line is `- item`, and a title starting with
545    /// `-`, are the ordinary case this argv has to survive: attached values and
546    /// the `--` terminator are what keep clap from reading them as flags.
547    #[test]
548    fn create_survives_a_hyphen_leading_body_and_title() {
549        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
550        let mut adapter = adapter_with(runner, None);
551        adapter
552            .create_issue(
553                "-leading title",
554                "- bullet one\n- bullet two",
555                &["bug".into()],
556                None,
557                None,
558                "Todo",
559            )
560            .unwrap();
561        let argv = &recorded(&adapter)[0].0;
562        assert_eq!(
563            argv.join(" "),
564            "linear-cli issues create -t=Eng -d=- bullet one\n- bullet two -l=bug -s=Todo -o json -- -leading title"
565        );
566        assert_eq!(
567            argv.last().unwrap(),
568            "-leading title",
569            "the title is the positional"
570        );
571    }
572
573    #[test]
574    fn update_survives_a_hyphen_leading_title_and_body() {
575        let runner = FakeRunner::new(vec![]);
576        let mut adapter = adapter_with(runner, None);
577        adapter
578            .update_issue("TIX-7", "-tricky title", "- body", &[], None, None, "Done")
579            .unwrap();
580        let argv = &recorded(&adapter)[0].0;
581        assert_eq!(
582            argv.join(" "),
583            "linear-cli issues update -T=-tricky title -d=- body -s=Done -- TIX-7"
584        );
585    }
586
587    /// The injected name is the one the tool actually reads — `LINEAR_API_KEY`,
588    /// not the `LINEAR_TOKEN` this adapter used to send (which never reached it).
589    #[test]
590    fn the_token_is_injected_under_the_name_the_tool_reads() {
591        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-1")]);
592        let mut adapter = adapter_with(runner, Some("lin-token"));
593        adapter
594            .create_issue("T", "b", &[], None, None, "Todo")
595            .unwrap();
596        assert_eq!(
597            &recorded(&adapter)[0].1,
598            &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
599        );
600    }
601
602    #[test]
603    fn create_without_labels_omits_label_flag() {
604        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
605        let mut adapter = adapter_with(runner, None);
606        adapter
607            .create_issue("T", "b", &[], None, None, "todo")
608            .unwrap();
609        let joined = recorded(&adapter)[0].0.join(" ");
610        assert!(!joined.contains("-l="), "{joined}");
611    }
612
613    #[test]
614    fn priority_maps_to_linear_numbers_and_unknown_is_omitted() {
615        assert_eq!(linear_priority("low"), Some("4"));
616        assert_eq!(linear_priority("medium"), Some("3"));
617        assert_eq!(linear_priority("high"), Some("2"));
618        assert_eq!(linear_priority(""), Some("0"), "the empty string is `none`");
619        for unknown in ["urgent", "none", "MEDIUM", "custom-word"] {
620            assert_eq!(
621                linear_priority(unknown),
622                None,
623                "{unknown} must not be coerced to a number"
624            );
625        }
626    }
627
628    #[test]
629    fn an_unmappable_priority_omits_the_priority_flag() {
630        let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-9")]);
631        let mut adapter = adapter_with(runner, None);
632        adapter
633            .create_issue("T", "b", &[], None, Some("urgent"), "Todo")
634            .unwrap();
635        let joined = recorded(&adapter)[0].0.join(" ");
636        assert!(
637            !joined.contains("-p="),
638            "unknown priority must be omitted: {joined}"
639        );
640    }
641
642    #[test]
643    fn update_passes_identifier_title_body_labels_priority_and_state() {
644        let runner = FakeRunner::new(vec![]);
645        let mut adapter = adapter_with(runner, None);
646        adapter
647            .update_issue(
648                "TIX-7",
649                "New title",
650                "new body",
651                &["bug".into()],
652                None,
653                Some("low"),
654                "Done",
655            )
656            .unwrap();
657        let joined = recorded(&adapter)[0].0.join(" ");
658        assert_eq!(
659            joined,
660            "linear-cli issues update -T=New title -d=new body -l=bug -p=4 -s=Done -- TIX-7"
661        );
662    }
663
664    #[test]
665    fn done_via_status_is_true_and_mark_done_is_a_noop() {
666        let runner = FakeRunner::new(vec![]);
667        let mut adapter = adapter_with(runner, None);
668        assert!(adapter.done_via_status());
669        adapter.mark_done("TIX-7").unwrap();
670        assert!(recorded(&adapter).is_empty(), "no close call for linear");
671    }
672
673    #[test]
674    fn set_relations_adds_one_edge_per_relation_with_remote_identifiers() {
675        let runner = FakeRunner::new(vec![
676            CommandOutcome::linear_list(""),
677            CommandOutcome::linear_list(""),
678        ]);
679        let mut adapter = adapter_with(runner, None);
680        adapter
681            .set_relations(
682                "TIX-1",
683                &[
684                    MappedRelation::Remote {
685                        field: "blocks".into(),
686                        value: "TIX-2".into(),
687                    },
688                    MappedRelation::Remote {
689                        field: "blocks".into(),
690                        value: "TIX-3".into(),
691                    },
692                ],
693            )
694            .unwrap();
695        let calls = recorded(&adapter);
696        assert_eq!(
697            calls[0].0.join(" "),
698            "linear-cli relations add -r blocks TIX-1 TIX-2"
699        );
700        assert_eq!(
701            calls[1].0.join(" "),
702            "linear-cli relations add -r blocks TIX-1 TIX-3"
703        );
704    }
705
706    /// The list payload carries only the enumeration fields; each open issue's
707    /// body/labels/timestamp come from a per-issue `issues get`. A `Done` issue is
708    /// filtered from the list and never fetched.
709    #[test]
710    fn list_open_enumerates_then_fetches_each_open_issues_detail() {
711        let list = r#"[
712          {"id":"u1","identifier":"TIX-1","title":"T1","priority":3,"state":{"name":"In Progress"}},
713          {"id":"u2","identifier":"TIX-2","title":"T2","state":{"name":"Done"}}
714        ]"#;
715        let detail = r##"{"id":"u1","identifier":"TIX-1","title":"T1","description":"full body",
716          "updatedAt":"2026-05-11T10:00:00Z","state":{"name":"In Progress"},
717          "assignee":{"name":"Al","email":"al@example.com"},
718          "labels":{"nodes":[{"name":"bug","color":"#f00"},{"name":"api","color":"#0f0"}]}}"##;
719        // FakeRunner pops a response per call, from the back: the list call is the
720        // last element pushed, so it is popped first.
721        let runner = FakeRunner::new(vec![
722            CommandOutcome::linear_list(detail),
723            CommandOutcome::linear_list(list),
724        ]);
725        let mut adapter = adapter_with(runner, None);
726        let issues = adapter.list_open().unwrap();
727        assert_eq!(issues.len(), 1, "the Done issue is filtered before the get");
728        assert_eq!(issues[0].id, "TIX-1");
729        assert_eq!(issues[0].title, "T1");
730        assert_eq!(issues[0].description, "full body");
731        assert_eq!(issues[0].labels, ["bug", "api"]);
732        assert_eq!(issues[0].state, "In Progress");
733        assert_eq!(issues[0].assignee.as_deref(), Some("Al"));
734        assert_eq!(
735            issues[0].updated_at.as_deref(),
736            Some("2026-05-11T10:00:00Z")
737        );
738        let calls = recorded(&adapter);
739        assert_eq!(
740            calls.len(),
741            2,
742            "one list call and one get — never a get for the Done issue"
743        );
744        assert_eq!(
745            calls[0].0.join(" "),
746            "linear-cli issues list -t=Eng -o json"
747        );
748        assert_eq!(calls[1].0.join(" "), "linear-cli issues get -o json TIX-1");
749    }
750
751    /// A real list payload has no body/labels/timestamp, so nothing on the list
752    /// side may pretend to read them.
753    #[test]
754    fn a_list_payload_without_detail_fields_does_not_fail_the_import() {
755        let list = r#"[{"id":"u1","identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
756        let detail = r#"{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}"#;
757        let runner = FakeRunner::new(vec![
758            CommandOutcome::linear_list(detail),
759            CommandOutcome::linear_list(list),
760        ]);
761        let mut adapter = adapter_with(runner, None);
762        let issues = adapter.list_open().unwrap();
763        assert_eq!(issues.len(), 1);
764        assert_eq!(issues[0].description, "");
765        assert_eq!(issues[0].labels, [] as [std::string::String; 0]);
766        assert_eq!(issues[0].updated_at, None);
767    }
768
769    #[test]
770    fn create_json_without_identifier_is_a_parse_error() {
771        let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
772        let mut adapter = adapter_with(runner, None);
773        let err = adapter
774            .create_issue("T", "b", &[], None, None, "todo")
775            .unwrap_err();
776        assert!(matches!(err, TixError::External(_)));
777        assert!(err.to_string().contains("identifier"));
778        assert!(
779            err.to_string().contains("linear-cli issues create"),
780            "the error must name the real subcommand: {err}"
781        );
782    }
783
784    #[test]
785    fn a_detail_without_the_identifier_is_a_parse_error() {
786        let list = r#"[{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
787        let runner = FakeRunner::new(vec![
788            CommandOutcome::linear_list("{}"),
789            CommandOutcome::linear_list(list),
790        ]);
791        let mut adapter = adapter_with(runner, None);
792        let err = adapter.list_open().unwrap_err();
793        assert!(matches!(err, TixError::External(_)));
794        assert!(
795            err.to_string().contains("linear-cli issues get"),
796            "the error names the failing verb: {err}"
797        );
798    }
799
800    #[test]
801    fn failure_message_scrubs_token_from_tool_output() {
802        let runner = FakeRunner::new(vec![CommandOutcome {
803            success: false,
804            status: Some(1),
805            stdout: String::new(),
806            stderr: "LINEAR_API_KEY=lin-token leaked".into(),
807        }]);
808        let mut adapter = adapter_with(runner, Some("lin-token"));
809        let err = adapter
810            .create_issue("T", "b", &[], None, None, "todo")
811            .unwrap_err();
812        let text = err.to_string();
813        assert!(text.contains("<token>"), "expected redaction, got: {text}");
814        assert!(!text.contains("lin-token"), "secret leaked: {text}");
815    }
816
817    /// WP08 T036: Linear's single `project` is read from the **detail** payload,
818    /// and nothing is sent for it, because Linear's issue carries one project and
819    /// no list.
820    #[test]
821    fn t036_project_reference_is_read_from_detail_and_never_sent() {
822        let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
823        let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
824          "project":{"name":"One"}}"#;
825        let runner = FakeRunner::new(vec![
826            CommandOutcome::linear_list(detail),
827            CommandOutcome::linear_list(list),
828        ]);
829        let mut adapter = adapter_with(runner, None);
830        let issues = adapter.list_open().unwrap();
831        assert_eq!(
832            issues[0].projects,
833            vec![crate::sync::RemoteProject {
834                id: String::new(),
835                name: Some("One".into()),
836            }],
837            "the real query selects `project{{name}}`, so the id is empty and the name kept"
838        );
839
840        let calls_before = recorded(&adapter).len();
841        adapter.set_projects("TIX-1", &[]).unwrap();
842        assert_eq!(
843            recorded(&adapter).len(),
844            calls_before,
845            "an empty membership set must not spawn any command"
846        );
847    }
848
849    /// WP08 T038: Linear has no epic level, and neither verb returns an epic
850    /// field, so a payload key this adapter does not model yields no reference —
851    /// and nothing is ever sent for it.
852    #[test]
853    fn t038_no_epic_reference_is_read_or_sent() {
854        let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
855        // An unmodelled key is ignored, never mistaken for an epic list.
856        let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
857          "epics":["e-1"]}"#;
858        let runner = FakeRunner::new(vec![
859            CommandOutcome::linear_list(detail),
860            CommandOutcome::linear_list(list),
861        ]);
862        let mut adapter = adapter_with(runner, None);
863        let issues = adapter.list_open().unwrap();
864        assert_eq!(issues[0].epics, [] as [crate::sync::RemoteEpic; 0]);
865
866        let calls_before = recorded(&adapter).len();
867        adapter.set_epics("TIX-1", &[]).unwrap();
868        assert_eq!(
869            recorded(&adapter).len(),
870            calls_before,
871            "an empty reference set must not spawn any command"
872        );
873    }
874}