Skip to main content

tix/sync/
glab.rs

1//! GitLab adapter: shells out to the `glab` CLI under
2//! `contracts/external-tools.md` (GitLab row). Every contract fabricate
3//! template's command shape is reproduced; the only additions are `--output
4//! json` (needed to parse the create/list responses) and the token env
5//! injection.
6//!
7//! Tokens are env-only and scrubbed as `<token>` from any surfaced output.
8
9use serde::Deserialize;
10
11use crate::error::{Result, TixError};
12use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
13
14/// The `glab` adapter. Spawns `glab issue <create|update|close|list>` with
15/// the configured project and, when a secret is configured, the
16/// `GITLAB_TOKEN` env pair.
17pub struct GitLabAdapter {
18    runner: Box<dyn CommandRunner>,
19    token: Option<String>,
20    base_url: Option<String>,
21    project: String,
22}
23
24impl GitLabAdapter {
25    pub fn new(
26        runner: impl CommandRunner + 'static,
27        token: Option<String>,
28        base_url: Option<String>,
29        project: String,
30    ) -> Self {
31        GitLabAdapter {
32            runner: Box::new(runner),
33            token,
34            base_url,
35            project,
36        }
37    }
38
39    fn project_arg(&self) -> (String, String) {
40        ("--project".to_string(), self.project.clone())
41    }
42
43    fn hostname_args(&self) -> Vec<String> {
44        match &self.base_url {
45            Some(url) => vec!["--hostname".to_string(), url.clone()],
46            None => Vec::new(),
47        }
48    }
49
50    /// Environment injected into every child: the token env pair when a
51    /// secret is configured (native glab auth otherwise — no env at all).
52    fn child_env(&self) -> Vec<(String, String)> {
53        match &self.token {
54            Some(token) => vec![("GITLAB_TOKEN".to_string(), token.clone())],
55            None => Vec::new(),
56        }
57    }
58
59    fn run(&self, args: &[String]) -> Result<CommandOutcome> {
60        self.runner
61            .run(Tool::GitLab.binary(), args, &self.child_env())
62    }
63
64    /// Build a [`TixError::External`] for a failed tool invocation, scrubbing
65    /// the token from any surfaced output.
66    fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
67        let mut message = format!("{context}: {}", outcome.text().trim());
68        if let Some(secret) = &self.token {
69            message = scrub_secret(&message, secret);
70        }
71        TixError::External(message)
72    }
73
74    /// Parse `glab issue create` stdout as `{"iid": n}`.
75    fn parse_iid(&self, outcome: &CommandOutcome) -> Result<String> {
76        let stdout = &outcome.stdout;
77        serde_json::from_str::<serde_json::Value>(stdout)
78            .ok()
79            .and_then(|value| {
80                value
81                    .get("iid")
82                    .and_then(serde_json::Value::as_i64)
83                    .map(|iid| iid.to_string())
84            })
85            .ok_or_else(|| {
86                TixError::External(format!(
87                    "`glab issue create` did not return JSON with a numeric `iid` field (expected {{\"iid\":…}}): {}",
88                    scrub_secret(stdout.trim(), self.token.as_deref().unwrap_or(""))
89                ))
90            })
91    }
92}
93
94impl TrackerAdapter for GitLabAdapter {
95    fn tool(&self) -> Tool {
96        Tool::GitLab
97    }
98
99    fn create_issue(
100        &mut self,
101        title: &str,
102        body: &str,
103        labels: &[String],
104        assignee: Option<&str>,
105        _priority: Option<&str>,
106        _status: &str,
107    ) -> Result<String> {
108        let mut args: Vec<String> = Vec::new();
109        args.push("issue".to_string());
110        args.push("create".to_string());
111        let (k, v) = self.project_arg();
112        args.push(k);
113        args.push(v);
114        args.extend(self.hostname_args());
115        args.push("--title".to_string());
116        args.push(title.to_string());
117        args.push("--description".to_string());
118        args.push(body.to_string());
119        if !labels.is_empty() {
120            args.push("--label".to_string());
121            args.push(labels.join(","));
122        }
123        if let Some(name) = assignee {
124            args.push("--assignee".to_string());
125            args.push(format!("@{name}"));
126        }
127        args.push("--yes".to_string());
128        args.push("--output".to_string());
129        args.push("json".to_string());
130        let outcome = self.run(&args)?;
131        if !outcome.success {
132            return Err(self.external("`glab issue create` failed", &outcome));
133        }
134        self.parse_iid(&outcome)
135    }
136
137    fn update_issue(
138        &mut self,
139        remote_ref: &str,
140        title: &str,
141        body: &str,
142        labels: &[String],
143        assignee: Option<&str>,
144        _priority: Option<&str>,
145        _status: &str,
146    ) -> Result<()> {
147        let mut args: Vec<String> = Vec::new();
148        args.push("issue".to_string());
149        args.push("update".to_string());
150        args.push(remote_ref.to_string());
151        let (k, v) = self.project_arg();
152        args.push(k);
153        args.push(v);
154        args.extend(self.hostname_args());
155        args.push("--title".to_string());
156        args.push(title.to_string());
157        args.push("--description".to_string());
158        args.push(body.to_string());
159        if !labels.is_empty() {
160            args.push("--label".to_string());
161            args.push(labels.join(","));
162        }
163        if let Some(name) = assignee {
164            args.push("--assignee".to_string());
165            args.push(format!("@{name}"));
166        }
167        let outcome = self.run(&args)?;
168        if !outcome.success {
169            return Err(self.external("`glab issue update` failed", &outcome));
170        }
171        Ok(())
172    }
173
174    fn mark_done(&mut self, remote_ref: &str) -> Result<()> {
175        let mut args: Vec<String> = Vec::new();
176        args.push("issue".to_string());
177        args.push("close".to_string());
178        args.push(remote_ref.to_string());
179        let (k, v) = self.project_arg();
180        args.push(k);
181        args.push(v);
182        args.extend(self.hostname_args());
183        let outcome = self.run(&args)?;
184        if !outcome.success {
185            return Err(self.external("`glab issue close` failed", &outcome));
186        }
187        Ok(())
188    }
189
190    fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
191        let mut args: Vec<String> = Vec::new();
192        args.push("issue".to_string());
193        args.push("list".to_string());
194        let (k, v) = self.project_arg();
195        args.push(k);
196        args.push(v);
197        args.extend(self.hostname_args());
198        args.push("--output".to_string());
199        args.push("json".to_string());
200        let outcome = self.run(&args)?;
201        if !outcome.success {
202            return Err(self.external("`glab issue list` failed", &outcome));
203        }
204        let issues: Vec<GlabIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
205            TixError::External(format!(
206                "`glab issue list` did not return a JSON array: {e}\n{}",
207                scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
208            ))
209        })?;
210        Ok(issues
211            .into_iter()
212            .map(RemoteIssue::from)
213            .filter(|i| i.state != "closed")
214            .collect())
215    }
216
217    fn done_via_status(&self) -> bool {
218        false
219    }
220
221    /// `glab issue update <ref> --blocking <refs>` (WP07 T030).
222    ///
223    /// The only relation affordance `glab` exposes on an issue update is
224    /// `--blocking`, so a `blocks` edge is the only thing that can leave the
225    /// machine — and that is decided by the shared seam, not here. What arrives
226    /// is already filtered; this method only renders it.
227    ///
228    /// `--blocking` is not additive in `glab`: it *replaces* the issue's blocked-by
229    /// set. So the full set is sent in one call rather than one call per edge,
230    /// which is also one fewer chance to leave the remote half-configured.
231    fn set_relations(
232        &mut self,
233        remote_ref: &str,
234        relations: &[super::MappedRelation],
235    ) -> Result<()> {
236        let blocking: Vec<String> = relations
237            .iter()
238            .filter_map(|r| match r {
239                super::MappedRelation::Remote { value, .. } => Some(value.clone()),
240                super::MappedRelation::Unsupported(_) => None,
241            })
242            .collect();
243        if blocking.is_empty() {
244            // Nothing expressible. Sending an empty `--blocking` would *clear* the
245            // remote's set, which is the opposite of leaving it alone — so a run
246            // that cannot express anything must send nothing at all.
247            return Ok(());
248        }
249        let mut args: Vec<String> = vec!["issue".into(), "update".into(), remote_ref.to_string()];
250        let (k, v) = self.project_arg();
251        args.push(k);
252        args.push(v);
253        args.extend(self.hostname_args());
254        args.push("--blocking".to_string());
255        args.push(blocking.join(","));
256        let outcome = self.run(&args)?;
257        if !outcome.success {
258            return Err(self.external("`glab issue update --blocking` failed", &outcome));
259        }
260        Ok(())
261    }
262
263    /// GitLab's side of the project-association seam — and its answer is "nothing"
264    /// (WP08 T036).
265    ///
266    /// `glab` names the one project an issue **lives in** (`--project <path>`,
267    /// which this adapter already passes on every call) and exposes no membership
268    /// flag, so there is no command line a membership list could be rendered as.
269    /// Inventing one is precisely what T036 step 3 forbids, and sending a partial
270    /// list would drop the rest of it, which C-005 forbids.
271    ///
272    /// So this returns without spawning anything, and the `debug_assert` states the
273    /// invariant the seam holds it to: the table that decides representability
274    /// (`PROJECT_WIRE_VERIFIED`) is empty, so a value cannot reach here — and
275    /// flipping that table without an implementation here would be a silent drop,
276    /// which is loud in every test build instead.
277    fn set_projects(&mut self, _remote_ref: &str, projects: &[super::MappedProject]) -> Result<()> {
278        debug_assert!(
279            projects.is_empty(),
280            "no `glab` command carries a project membership: flip \
281             `PROJECT_WIRE_VERIFIED` only together with an implementation here"
282        );
283        Ok(())
284    }
285
286    /// GitLab's side of the epic-reference seam — and its answer is "nothing" (WP08,
287    /// mission `epics-work-items-01M3BYW4`).
288    ///
289    /// `glab`'s issue carries at most one epic (`epic_iid`, which this adapter
290    /// already reads as the issue's single parent) and exposes no flag to set it and
291    /// no list form, so there is no command line an epic membership list could be
292    /// rendered as. Inventing one is precisely what T038 step 3 forbids, and sending
293    /// one member of a list would drop the rest, which C-007 forbids.
294    ///
295    /// So this returns without spawning anything, and the `debug_assert` states the
296    /// invariant the seam holds it to: the table that decides representability
297    /// (`EPIC_WIRE_VERIFIED`) is empty, so a value cannot reach here — and flipping
298    /// that table without an implementation here would be a silent drop, which is
299    /// loud in every test build instead.
300    fn set_epics(&mut self, _remote_ref: &str, epics: &[super::MappedEpic]) -> Result<()> {
301        debug_assert!(
302            epics.is_empty(),
303            "no `glab` command carries an epic membership: flip `EPIC_WIRE_VERIFIED` \
304             only together with an implementation here"
305        );
306        Ok(())
307    }
308}
309
310/// A single entry from `glab issue list --output json`.
311#[derive(Debug, Deserialize)]
312struct GlabIssue {
313    iid: i64,
314    #[serde(default)]
315    title: String,
316    #[serde(default)]
317    description: Option<String>,
318    #[serde(default)]
319    labels: Vec<GlabLabel>,
320    #[serde(default)]
321    state: String,
322    #[serde(default)]
323    updated_at: Option<String>,
324    #[serde(default)]
325    assignees: Vec<GlabAssignee>,
326    /// The parent epic's iid, when the remote reported one.
327    ///
328    /// GitLab's issue hierarchy is a **closed** field — an issue belongs to one
329    /// epic or to none — so a single id is the whole of it.
330    ///
331    /// **Neither spelling is verified.** GitLab's payloads have carried both
332    /// `epic_iid` and `parent_iid` depending on version and endpoint, and this
333    /// repository cannot run `glab` to see which. Accepting either, under one field
334    /// name, is the honest form: it does not assert a spelling it cannot check,
335    /// and a payload that uses neither simply reports no parent. The wire verb
336    /// stays gated behind `PARENT_WIRE_VERIFIED` regardless — reading a field is
337    /// not the same claim as sending one.
338    #[serde(default, alias = "epic_iid", alias = "parent_iid")]
339    parent: Option<i64>,
340    /// The project references the payload reported for this issue (WP08).
341    ///
342    /// Read through the shared, shape-tolerant [`crate::sync::remote_projects`]
343    /// parser, because the field's exact shape is a claim about a program this
344    /// repository cannot run — `glab issue list --output json` is not verified to
345    /// carry one at all, and a payload that carries none simply yields no
346    /// associations.
347    ///
348    /// **Read, never sent.** Nothing in this build sends a membership
349    /// (`PROJECT_WIRE_VERIFIED` is empty and `glab` has no membership flag), so this
350    /// is only ever what the remote already said. Reading a field is not the same
351    /// claim as writing one, exactly as `parent` above records.
352    #[serde(default)]
353    projects: Option<serde_json::Value>,
354    /// The epic references the payload reported for this issue (WP08, mission
355    /// `epics-work-items-01M3BYW4`).
356    ///
357    /// Read through the shared, shape-tolerant [`crate::sync::remote_epics`] parser,
358    /// because the field's exact shape is a claim about a program this repository
359    /// cannot run — `glab issue list --output json` is not verified to carry one at
360    /// all, and a payload that carries none simply yields no references.
361    ///
362    /// Distinct from `parent` above: `parent` is the issue **hierarchy** (a single
363    /// remote iid resolved to a local number), while this is the epic **registry**
364    /// reference list (ids matched against `tix.toml`). Reading a field is not the
365    /// same claim as writing one.
366    ///
367    /// **Read, never sent.** Nothing in this build sends an epic reference
368    /// (`EPIC_WIRE_VERIFIED` is empty and `glab` has no flag for one), so this is
369    /// only ever what the remote already said.
370    #[serde(default, alias = "epic_ids")]
371    epics: Option<serde_json::Value>,
372}
373
374/// Labels arrive either as plain strings or as `{"id":..,"name":".."}`.
375#[derive(Debug, Deserialize)]
376#[serde(untagged)]
377enum GlabLabel {
378    Name(String),
379    Named { name: String },
380}
381
382#[derive(Debug, Deserialize)]
383struct GlabAssignee {
384    #[serde(default)]
385    username: String,
386}
387
388impl From<GlabIssue> for RemoteIssue {
389    fn from(issue: GlabIssue) -> Self {
390        RemoteIssue {
391            id: issue.iid.to_string(),
392            title: issue.title,
393            description: issue.description.unwrap_or_default(),
394            labels: issue
395                .labels
396                .into_iter()
397                .map(|l| match l {
398                    GlabLabel::Name(name) | GlabLabel::Named { name } => name,
399                })
400                .collect(),
401            state: issue.state,
402            updated_at: issue.updated_at,
403            parent: issue.parent.map(|iid| iid.to_string()),
404            projects: crate::sync::remote_projects(issue.projects.as_ref()),
405            epics: crate::sync::remote_epics(issue.epics.as_ref()),
406            assignee: issue
407                .assignees
408                .into_iter()
409                .next()
410                .map(|a| a.username)
411                .filter(|u| !u.is_empty()),
412        }
413    }
414}
415
416/// A recorded adapter call: the argv plus the env pairs it was spawned with.
417pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
418
419/// A fake runner for adapter unit tests: records argv plus env (for the
420/// token scrub/injection assertions) and returns configured outcomes in
421/// order, falling back to an empty success.
422#[derive(Default)]
423pub struct FakeRunner {
424    calls: std::sync::Mutex<Vec<RecordedCall>>,
425    responses: std::sync::Mutex<Vec<CommandOutcome>>,
426}
427
428impl FakeRunner {
429    #[must_use]
430    pub fn new(responses: Vec<CommandOutcome>) -> Self {
431        FakeRunner {
432            responses: std::sync::Mutex::new(responses),
433            ..FakeRunner::default()
434        }
435    }
436
437    pub fn take_calls(&self) -> Vec<RecordedCall> {
438        self.calls.lock().unwrap().clone()
439    }
440}
441
442impl CommandRunner for FakeRunner {
443    fn run(
444        &self,
445        program: &str,
446        args: &[String],
447        env: &[(String, String)],
448    ) -> Result<CommandOutcome> {
449        self.calls.lock().unwrap().push((
450            std::iter::once(program.to_string())
451                .chain(args.iter().cloned())
452                .collect(),
453            env.to_vec(),
454        ));
455        let outcome = self
456            .responses
457            .lock()
458            .unwrap()
459            .pop()
460            .unwrap_or(CommandOutcome {
461                success: true,
462                status: Some(0),
463                stdout: String::new(),
464                stderr: String::new(),
465            });
466        Ok(outcome)
467    }
468
469    fn as_any(&self) -> &dyn std::any::Any {
470        self
471    }
472}
473
474/// Response helpers shaped like `glab` JSON output.
475impl CommandOutcome {
476    #[must_use]
477    pub fn glab_created(iid: i64) -> CommandOutcome {
478        CommandOutcome {
479            success: true,
480            status: Some(0),
481            stdout: format!(
482                "{{\"iid\": {iid}, \"web_url\": \"https://gitlab.example/acme/tix/-/issues/{iid}\"}}"
483            ),
484            stderr: String::new(),
485        }
486    }
487
488    #[must_use]
489    pub fn glab_list(payload: &str) -> CommandOutcome {
490        CommandOutcome {
491            success: true,
492            status: Some(0),
493            stdout: payload.to_string(),
494            stderr: String::new(),
495        }
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    #[allow(clippy::wildcard_imports)]
502    use super::*;
503
504    fn adapter_with(
505        runner: FakeRunner,
506        token: Option<&str>,
507        base_url: Option<&str>,
508    ) -> GitLabAdapter {
509        GitLabAdapter::new(
510            runner,
511            token.map(String::from),
512            base_url.map(String::from),
513            "acme/tix".into(),
514        )
515    }
516
517    /// Read the recorded calls through the owned runner's downcast hook.
518    fn recorded(adapter: &GitLabAdapter) -> Vec<RecordedCall> {
519        adapter
520            .runner
521            .as_any()
522            .downcast_ref::<FakeRunner>()
523            .expect("adapter must hold a FakeRunner")
524            .take_calls()
525    }
526
527    #[test]
528    fn create_records_env_token_and_full_shape() {
529        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(42)]);
530        let mut adapter = adapter_with(runner, Some("gl-token"), None);
531        let id = adapter
532            .create_issue(
533                "Fix login",
534                "body here",
535                &["bug".into(), "auth".into()],
536                Some("alice"),
537                None,
538                "opened",
539            )
540            .unwrap();
541        assert_eq!(id, "42");
542        let calls = recorded(&adapter);
543        let (argv, env) = &calls[0];
544        assert_eq!(argv[0], "glab");
545        assert_eq!(argv[1], "issue");
546        assert_eq!(argv[2], "create");
547        let joined = argv.join(" ");
548        assert!(joined.contains("--project acme/tix"));
549        assert!(joined.contains("--title Fix login"));
550        assert!(joined.contains("--description body here"));
551        assert!(joined.contains("--label bug,auth"));
552        assert!(joined.contains("--assignee @alice"));
553        assert!(joined.contains("--yes"));
554        assert!(joined.contains("--output json"));
555        assert_eq!(env, &[("GITLAB_TOKEN".to_string(), "gl-token".to_string())]);
556    }
557
558    #[test]
559    fn create_with_hostname_includes_hostname_flag() {
560        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(7)]);
561        let mut adapter = adapter_with(runner, None, Some("gitlab.example.com"));
562        adapter
563            .create_issue("T", "b", &[], None, None, "opened")
564            .unwrap();
565        let joined = recorded(&adapter)[0].0.join(" ");
566        assert!(joined.contains("--hostname gitlab.example.com"));
567    }
568
569    #[test]
570    fn create_json_without_iid_is_a_parse_error() {
571        let runner = FakeRunner::new(vec![CommandOutcome::glab_list("{}")]);
572        let mut adapter = adapter_with(runner, None, None);
573        let err = adapter
574            .create_issue("T", "b", &[], None, None, "opened")
575            .unwrap_err();
576        assert!(matches!(err, TixError::External(_)));
577        assert!(err.to_string().contains("iid"));
578    }
579
580    #[test]
581    fn update_uses_iid_position_and_project() {
582        let runner = FakeRunner::new(vec![]);
583        let mut adapter = adapter_with(runner, None, None);
584        adapter
585            .update_issue("777", "New title", "new body", &[], None, None, "opened")
586            .unwrap();
587        let argv = &recorded(&adapter)[0].0;
588        let joined = argv.join(" ");
589        assert!(joined.starts_with("glab issue update 777 "));
590        assert!(joined.contains("--project acme/tix"));
591        assert!(joined.contains("--title New title"));
592        assert!(joined.contains("--description new body"));
593    }
594
595    #[test]
596    fn mark_done_calls_close() {
597        let runner = FakeRunner::new(vec![]);
598        let mut adapter = adapter_with(runner, None, None);
599        adapter.mark_done("777").unwrap();
600        let argv = &recorded(&adapter)[0].0;
601        assert_eq!(
602            argv,
603            &["glab", "issue", "close", "777", "--project", "acme/tix"]
604        );
605    }
606
607    #[test]
608    fn list_open_parses_json_and_filters_closed() {
609        let payload = r#"[
610          {"iid":1,"title":"T1","description":"d1","labels":[{"name":"bug"}],"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignees":[{"username":"alice"}]},
611          {"iid":2,"title":"T2","state":"closed"}
612        ]"#;
613        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(payload)]);
614        let mut adapter = adapter_with(runner, None, None);
615        let issues = adapter.list_open().unwrap();
616        assert_eq!(issues.len(), 1);
617        assert_eq!(issues[0].id, "1");
618        assert_eq!(issues[0].title, "T1");
619        assert_eq!(issues[0].labels, ["bug"]);
620        assert_eq!(issues[0].state, "opened");
621        assert_eq!(
622            issues[0].updated_at.as_deref(),
623            Some("2026-05-11T10:00:00Z")
624        );
625        assert_eq!(issues[0].assignee.as_deref(), Some("alice"));
626    }
627
628    #[test]
629    fn list_accepts_string_labels_too() {
630        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(
631            r#"[{"iid":9,"title":"T","labels":["docs","ops"],"state":"opened"}]"#,
632        )]);
633        let mut adapter = adapter_with(runner, None, None);
634        let issues = adapter.list_open().unwrap();
635        assert_eq!(issues[0].labels, ["docs", "ops"]);
636    }
637
638    #[test]
639    fn failure_message_scrubs_token_from_tool_output() {
640        let runner = FakeRunner::new(vec![CommandOutcome {
641            success: false,
642            status: Some(1),
643            stdout: String::new(),
644            stderr: "GITLAB_TOKEN=gl-token leaked".into(),
645        }]);
646        let mut adapter = adapter_with(runner, Some("gl-token"), None);
647        let err = adapter
648            .create_issue("T", "b", &[], None, None, "opened")
649            .unwrap_err();
650        let text = err.to_string();
651        assert!(text.contains("<token>"), "expected redaction, got: {text}");
652        assert!(!text.contains("gl-token"), "secret leaked: {text}");
653    }
654
655    /// WP08 T036: the project references a payload reports are **read**, in order,
656    /// and **nothing is sent** for them — no `glab` command carries a membership.
657    ///
658    /// Asserted on the recorded argv too, not only on the return value: the question
659    /// is what would have been sent, and the answer has to be "nothing".
660    #[test]
661    fn t036_project_references_are_read_in_order_and_never_sent() {
662        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(
663            r#"[{"iid":5,"title":"T","state":"opened","projects":["p-1",{"id":"p-2","name":"Two"}]},
664                {"iid":6,"title":"U","state":"opened"}]"#,
665        )]);
666        let mut adapter = adapter_with(runner, None, None);
667        let issues = adapter.list_open().unwrap();
668        assert_eq!(
669            issues[0].projects,
670            vec![
671                crate::sync::RemoteProject {
672                    id: "p-1".into(),
673                    name: None
674                },
675                crate::sync::RemoteProject {
676                    id: "p-2".into(),
677                    name: Some("Two".into())
678                },
679            ],
680            "the payload's order is preserved"
681        );
682        assert!(
683            issues[1].projects.is_empty(),
684            "a payload that reports no projects reports none"
685        );
686
687        let calls_before = recorded(&adapter).len();
688        assert_eq!(calls_before, 1, "the list call, and nothing else");
689        adapter.set_projects("5", &[]).unwrap();
690        // Read the recording **after** the call. An assertion over the pre-call `list`
691        // argv alone could never fail — a list command has no reason to carry a local
692        // project id — so what actually catches a send here is the call count: a
693        // membership that rendered a command line would add one, and the argv scan
694        // below is the second half of the same claim rather than the load-bearing one.
695        let calls = recorded(&adapter);
696        assert_eq!(
697            calls.len(),
698            calls_before,
699            "an empty membership set must not spawn any command"
700        );
701        let joined = calls
702            .iter()
703            .map(|(argv, _)| argv.join(" "))
704            .collect::<Vec<_>>()
705            .join("\n");
706        assert!(
707            !joined.contains("p-1") && !joined.contains("p-2"),
708            "no local project id may reach the command line: {joined}"
709        );
710    }
711
712    /// WP08 T038: the epic references a payload reports are **read**, in order, and
713    /// **nothing is sent** for them — no `glab` command carries an epic membership.
714    ///
715    /// Asserted on the recorded argv too, not only on the return value: the question
716    /// is what would have been sent, and the answer has to be "nothing".
717    #[test]
718    fn t038_epic_references_are_read_in_order_and_never_sent() {
719        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(
720            r#"[{"iid":5,"title":"T","state":"opened","epics":["e-1",{"id":"e-2","name":"Two"}]},
721                {"iid":6,"title":"U","state":"opened"}]"#,
722        )]);
723        let mut adapter = adapter_with(runner, None, None);
724        let issues = adapter.list_open().unwrap();
725        assert_eq!(
726            issues[0].epics,
727            vec![
728                crate::sync::RemoteEpic {
729                    id: "e-1".into(),
730                    name: None
731                },
732                crate::sync::RemoteEpic {
733                    id: "e-2".into(),
734                    name: Some("Two".into())
735                },
736            ],
737            "the payload's order is preserved"
738        );
739        assert!(
740            issues[1].epics.is_empty(),
741            "a payload that reports no epics reports none"
742        );
743
744        let calls_before = recorded(&adapter).len();
745        assert_eq!(calls_before, 1, "the list call, and nothing else");
746        adapter.set_epics("5", &[]).unwrap();
747        // Read the recording **after** the call. An assertion over the pre-call `list`
748        // argv alone could never fail — a list command has no reason to carry a local
749        // epic id — so what actually catches a send here is the call count: a
750        // membership that rendered a command line would add one (`FakeRunner` clones
751        // rather than drains), and the argv scan below is the second half of the same
752        // claim rather than the load-bearing one.
753        let calls = recorded(&adapter);
754        assert_eq!(
755            calls.len(),
756            calls_before,
757            "an empty reference set must not spawn any command"
758        );
759        let joined = calls
760            .iter()
761            .map(|(argv, _)| argv.join(" "))
762            .collect::<Vec<_>>()
763            .join("\n");
764        assert!(
765            !joined.contains("e-1") && !joined.contains("e-2"),
766            "no local epic id may reach the command line: {joined}"
767        );
768    }
769}