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
264/// A single entry from `glab issue list --output json`.
265#[derive(Debug, Deserialize)]
266struct GlabIssue {
267    iid: i64,
268    #[serde(default)]
269    title: String,
270    #[serde(default)]
271    description: Option<String>,
272    #[serde(default)]
273    labels: Vec<GlabLabel>,
274    #[serde(default)]
275    state: String,
276    #[serde(default)]
277    updated_at: Option<String>,
278    #[serde(default)]
279    assignees: Vec<GlabAssignee>,
280    /// The parent epic's iid, when the remote reported one.
281    ///
282    /// GitLab's issue hierarchy is a **closed** field — an issue belongs to one
283    /// epic or to none — so a single id is the whole of it.
284    ///
285    /// **Neither spelling is verified.** GitLab's payloads have carried both
286    /// `epic_iid` and `parent_iid` depending on version and endpoint, and this
287    /// repository cannot run `glab` to see which. Accepting either, under one field
288    /// name, is the honest form: it does not assert a spelling it cannot check,
289    /// and a payload that uses neither simply reports no parent. The wire verb
290    /// stays gated behind `PARENT_WIRE_VERIFIED` regardless — reading a field is
291    /// not the same claim as sending one.
292    #[serde(default, alias = "epic_iid", alias = "parent_iid")]
293    parent: Option<i64>,
294}
295
296/// Labels arrive either as plain strings or as `{"id":..,"name":".."}`.
297#[derive(Debug, Deserialize)]
298#[serde(untagged)]
299enum GlabLabel {
300    Name(String),
301    Named { name: String },
302}
303
304#[derive(Debug, Deserialize)]
305struct GlabAssignee {
306    #[serde(default)]
307    username: String,
308}
309
310impl From<GlabIssue> for RemoteIssue {
311    fn from(issue: GlabIssue) -> Self {
312        RemoteIssue {
313            id: issue.iid.to_string(),
314            title: issue.title,
315            description: issue.description.unwrap_or_default(),
316            labels: issue
317                .labels
318                .into_iter()
319                .map(|l| match l {
320                    GlabLabel::Name(name) | GlabLabel::Named { name } => name,
321                })
322                .collect(),
323            state: issue.state,
324            updated_at: issue.updated_at,
325            parent: issue.parent.map(|iid| iid.to_string()),
326            assignee: issue
327                .assignees
328                .into_iter()
329                .next()
330                .map(|a| a.username)
331                .filter(|u| !u.is_empty()),
332        }
333    }
334}
335
336/// A recorded adapter call: the argv plus the env pairs it was spawned with.
337pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
338
339/// A fake runner for adapter unit tests: records argv plus env (for the
340/// token scrub/injection assertions) and returns configured outcomes in
341/// order, falling back to an empty success.
342#[derive(Default)]
343pub struct FakeRunner {
344    calls: std::sync::Mutex<Vec<RecordedCall>>,
345    responses: std::sync::Mutex<Vec<CommandOutcome>>,
346}
347
348impl FakeRunner {
349    #[must_use]
350    pub fn new(responses: Vec<CommandOutcome>) -> Self {
351        FakeRunner {
352            responses: std::sync::Mutex::new(responses),
353            ..FakeRunner::default()
354        }
355    }
356
357    pub fn take_calls(&self) -> Vec<RecordedCall> {
358        self.calls.lock().unwrap().clone()
359    }
360}
361
362impl CommandRunner for FakeRunner {
363    fn run(
364        &self,
365        program: &str,
366        args: &[String],
367        env: &[(String, String)],
368    ) -> Result<CommandOutcome> {
369        self.calls.lock().unwrap().push((
370            std::iter::once(program.to_string())
371                .chain(args.iter().cloned())
372                .collect(),
373            env.to_vec(),
374        ));
375        let outcome = self
376            .responses
377            .lock()
378            .unwrap()
379            .pop()
380            .unwrap_or(CommandOutcome {
381                success: true,
382                status: Some(0),
383                stdout: String::new(),
384                stderr: String::new(),
385            });
386        Ok(outcome)
387    }
388
389    fn as_any(&self) -> &dyn std::any::Any {
390        self
391    }
392}
393
394/// Response helpers shaped like `glab` JSON output.
395impl CommandOutcome {
396    #[must_use]
397    pub fn glab_created(iid: i64) -> CommandOutcome {
398        CommandOutcome {
399            success: true,
400            status: Some(0),
401            stdout: format!(
402                "{{\"iid\": {iid}, \"web_url\": \"https://gitlab.example/acme/tix/-/issues/{iid}\"}}"
403            ),
404            stderr: String::new(),
405        }
406    }
407
408    #[must_use]
409    pub fn glab_list(payload: &str) -> CommandOutcome {
410        CommandOutcome {
411            success: true,
412            status: Some(0),
413            stdout: payload.to_string(),
414            stderr: String::new(),
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn adapter_with(
424        runner: FakeRunner,
425        token: Option<&str>,
426        base_url: Option<&str>,
427    ) -> GitLabAdapter {
428        GitLabAdapter::new(
429            runner,
430            token.map(String::from),
431            base_url.map(String::from),
432            "acme/tix".into(),
433        )
434    }
435
436    /// Read the recorded calls through the owned runner's downcast hook.
437    fn recorded(adapter: &GitLabAdapter) -> Vec<RecordedCall> {
438        adapter
439            .runner
440            .as_any()
441            .downcast_ref::<FakeRunner>()
442            .expect("adapter must hold a FakeRunner")
443            .take_calls()
444    }
445
446    #[test]
447    fn create_records_env_token_and_full_shape() {
448        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(42)]);
449        let mut adapter = adapter_with(runner, Some("gl-token"), None);
450        let id = adapter
451            .create_issue(
452                "Fix login",
453                "body here",
454                &["bug".into(), "auth".into()],
455                Some("alice"),
456                None,
457                "opened",
458            )
459            .unwrap();
460        assert_eq!(id, "42");
461        let calls = recorded(&adapter);
462        let (argv, env) = &calls[0];
463        assert_eq!(argv[0], "glab");
464        assert_eq!(argv[1], "issue");
465        assert_eq!(argv[2], "create");
466        let joined = argv.join(" ");
467        assert!(joined.contains("--project acme/tix"));
468        assert!(joined.contains("--title Fix login"));
469        assert!(joined.contains("--description body here"));
470        assert!(joined.contains("--label bug,auth"));
471        assert!(joined.contains("--assignee @alice"));
472        assert!(joined.contains("--yes"));
473        assert!(joined.contains("--output json"));
474        assert_eq!(env, &[("GITLAB_TOKEN".to_string(), "gl-token".to_string())]);
475    }
476
477    #[test]
478    fn create_with_hostname_includes_hostname_flag() {
479        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(7)]);
480        let mut adapter = adapter_with(runner, None, Some("gitlab.example.com"));
481        adapter
482            .create_issue("T", "b", &[], None, None, "opened")
483            .unwrap();
484        let joined = recorded(&adapter)[0].0.join(" ");
485        assert!(joined.contains("--hostname gitlab.example.com"));
486    }
487
488    #[test]
489    fn create_json_without_iid_is_a_parse_error() {
490        let runner = FakeRunner::new(vec![CommandOutcome::glab_list("{}")]);
491        let mut adapter = adapter_with(runner, None, None);
492        let err = adapter
493            .create_issue("T", "b", &[], None, None, "opened")
494            .unwrap_err();
495        assert!(matches!(err, TixError::External(_)));
496        assert!(err.to_string().contains("iid"));
497    }
498
499    #[test]
500    fn update_uses_iid_position_and_project() {
501        let runner = FakeRunner::new(vec![]);
502        let mut adapter = adapter_with(runner, None, None);
503        adapter
504            .update_issue("777", "New title", "new body", &[], None, None, "opened")
505            .unwrap();
506        let argv = &recorded(&adapter)[0].0;
507        let joined = argv.join(" ");
508        assert!(joined.starts_with("glab issue update 777 "));
509        assert!(joined.contains("--project acme/tix"));
510        assert!(joined.contains("--title New title"));
511        assert!(joined.contains("--description new body"));
512    }
513
514    #[test]
515    fn mark_done_calls_close() {
516        let runner = FakeRunner::new(vec![]);
517        let mut adapter = adapter_with(runner, None, None);
518        adapter.mark_done("777").unwrap();
519        let argv = &recorded(&adapter)[0].0;
520        assert_eq!(
521            argv,
522            &["glab", "issue", "close", "777", "--project", "acme/tix"]
523        );
524    }
525
526    #[test]
527    fn list_open_parses_json_and_filters_closed() {
528        let payload = r#"[
529          {"iid":1,"title":"T1","description":"d1","labels":[{"name":"bug"}],"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignees":[{"username":"alice"}]},
530          {"iid":2,"title":"T2","state":"closed"}
531        ]"#;
532        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(payload)]);
533        let mut adapter = adapter_with(runner, None, None);
534        let issues = adapter.list_open().unwrap();
535        assert_eq!(issues.len(), 1);
536        assert_eq!(issues[0].id, "1");
537        assert_eq!(issues[0].title, "T1");
538        assert_eq!(issues[0].labels, ["bug"]);
539        assert_eq!(issues[0].state, "opened");
540        assert_eq!(
541            issues[0].updated_at.as_deref(),
542            Some("2026-05-11T10:00:00Z")
543        );
544        assert_eq!(issues[0].assignee.as_deref(), Some("alice"));
545    }
546
547    #[test]
548    fn list_accepts_string_labels_too() {
549        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(
550            r#"[{"iid":9,"title":"T","labels":["docs","ops"],"state":"opened"}]"#,
551        )]);
552        let mut adapter = adapter_with(runner, None, None);
553        let issues = adapter.list_open().unwrap();
554        assert_eq!(issues[0].labels, ["docs", "ops"]);
555    }
556
557    #[test]
558    fn failure_message_scrubs_token_from_tool_output() {
559        let runner = FakeRunner::new(vec![CommandOutcome {
560            success: false,
561            status: Some(1),
562            stdout: String::new(),
563            stderr: "GITLAB_TOKEN=gl-token leaked".into(),
564        }]);
565        let mut adapter = adapter_with(runner, Some("gl-token"), None);
566        let err = adapter
567            .create_issue("T", "b", &[], None, None, "opened")
568            .unwrap_err();
569        let text = err.to_string();
570        assert!(text.contains("<token>"), "expected redaction, got: {text}");
571        assert!(!text.contains("gl-token"), "secret leaked: {text}");
572    }
573}