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
222/// A single entry from `glab issue list --output json`.
223#[derive(Debug, Deserialize)]
224struct GlabIssue {
225    iid: i64,
226    #[serde(default)]
227    title: String,
228    #[serde(default)]
229    description: Option<String>,
230    #[serde(default)]
231    labels: Vec<GlabLabel>,
232    #[serde(default)]
233    state: String,
234    #[serde(default)]
235    updated_at: Option<String>,
236    #[serde(default)]
237    assignees: Vec<GlabAssignee>,
238}
239
240/// Labels arrive either as plain strings or as `{"id":..,"name":".."}`.
241#[derive(Debug, Deserialize)]
242#[serde(untagged)]
243enum GlabLabel {
244    Name(String),
245    Named { name: String },
246}
247
248#[derive(Debug, Deserialize)]
249struct GlabAssignee {
250    #[serde(default)]
251    username: String,
252}
253
254impl From<GlabIssue> for RemoteIssue {
255    fn from(issue: GlabIssue) -> Self {
256        RemoteIssue {
257            id: issue.iid.to_string(),
258            title: issue.title,
259            description: issue.description.unwrap_or_default(),
260            labels: issue
261                .labels
262                .into_iter()
263                .map(|l| match l {
264                    GlabLabel::Name(name) | GlabLabel::Named { name } => name,
265                })
266                .collect(),
267            state: issue.state,
268            updated_at: issue.updated_at,
269            assignee: issue
270                .assignees
271                .into_iter()
272                .next()
273                .map(|a| a.username)
274                .filter(|u| !u.is_empty()),
275        }
276    }
277}
278
279/// A recorded adapter call: the argv plus the env pairs it was spawned with.
280pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
281
282/// A fake runner for adapter unit tests: records argv plus env (for the
283/// token scrub/injection assertions) and returns configured outcomes in
284/// order, falling back to an empty success.
285#[derive(Default)]
286pub struct FakeRunner {
287    calls: std::sync::Mutex<Vec<RecordedCall>>,
288    responses: std::sync::Mutex<Vec<CommandOutcome>>,
289}
290
291impl FakeRunner {
292    #[must_use]
293    pub fn new(responses: Vec<CommandOutcome>) -> Self {
294        FakeRunner {
295            responses: std::sync::Mutex::new(responses),
296            ..FakeRunner::default()
297        }
298    }
299
300    pub fn take_calls(&self) -> Vec<RecordedCall> {
301        self.calls.lock().unwrap().clone()
302    }
303}
304
305impl CommandRunner for FakeRunner {
306    fn run(
307        &self,
308        program: &str,
309        args: &[String],
310        env: &[(String, String)],
311    ) -> Result<CommandOutcome> {
312        self.calls.lock().unwrap().push((
313            std::iter::once(program.to_string())
314                .chain(args.iter().cloned())
315                .collect(),
316            env.to_vec(),
317        ));
318        let outcome = self
319            .responses
320            .lock()
321            .unwrap()
322            .pop()
323            .unwrap_or(CommandOutcome {
324                success: true,
325                status: Some(0),
326                stdout: String::new(),
327                stderr: String::new(),
328            });
329        Ok(outcome)
330    }
331
332    fn as_any(&self) -> &dyn std::any::Any {
333        self
334    }
335}
336
337/// Response helpers shaped like `glab` JSON output.
338impl CommandOutcome {
339    #[must_use]
340    pub fn glab_created(iid: i64) -> CommandOutcome {
341        CommandOutcome {
342            success: true,
343            status: Some(0),
344            stdout: format!(
345                "{{\"iid\": {iid}, \"web_url\": \"https://gitlab.example/acme/tix/-/issues/{iid}\"}}"
346            ),
347            stderr: String::new(),
348        }
349    }
350
351    #[must_use]
352    pub fn glab_list(payload: &str) -> CommandOutcome {
353        CommandOutcome {
354            success: true,
355            status: Some(0),
356            stdout: payload.to_string(),
357            stderr: String::new(),
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn adapter_with(
367        runner: FakeRunner,
368        token: Option<&str>,
369        base_url: Option<&str>,
370    ) -> GitLabAdapter {
371        GitLabAdapter::new(
372            runner,
373            token.map(String::from),
374            base_url.map(String::from),
375            "acme/tix".into(),
376        )
377    }
378
379    /// Read the recorded calls through the owned runner's downcast hook.
380    fn recorded(adapter: &GitLabAdapter) -> Vec<RecordedCall> {
381        adapter
382            .runner
383            .as_any()
384            .downcast_ref::<FakeRunner>()
385            .expect("adapter must hold a FakeRunner")
386            .take_calls()
387    }
388
389    #[test]
390    fn create_records_env_token_and_full_shape() {
391        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(42)]);
392        let mut adapter = adapter_with(runner, Some("gl-token"), None);
393        let id = adapter
394            .create_issue(
395                "Fix login",
396                "body here",
397                &["bug".into(), "auth".into()],
398                Some("alice"),
399                None,
400                "opened",
401            )
402            .unwrap();
403        assert_eq!(id, "42");
404        let calls = recorded(&adapter);
405        let (argv, env) = &calls[0];
406        assert_eq!(argv[0], "glab");
407        assert_eq!(argv[1], "issue");
408        assert_eq!(argv[2], "create");
409        let joined = argv.join(" ");
410        assert!(joined.contains("--project acme/tix"));
411        assert!(joined.contains("--title Fix login"));
412        assert!(joined.contains("--description body here"));
413        assert!(joined.contains("--label bug,auth"));
414        assert!(joined.contains("--assignee @alice"));
415        assert!(joined.contains("--yes"));
416        assert!(joined.contains("--output json"));
417        assert_eq!(env, &[("GITLAB_TOKEN".to_string(), "gl-token".to_string())]);
418    }
419
420    #[test]
421    fn create_with_hostname_includes_hostname_flag() {
422        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(7)]);
423        let mut adapter = adapter_with(runner, None, Some("gitlab.example.com"));
424        adapter
425            .create_issue("T", "b", &[], None, None, "opened")
426            .unwrap();
427        let joined = recorded(&adapter)[0].0.join(" ");
428        assert!(joined.contains("--hostname gitlab.example.com"));
429    }
430
431    #[test]
432    fn create_json_without_iid_is_a_parse_error() {
433        let runner = FakeRunner::new(vec![CommandOutcome::glab_list("{}")]);
434        let mut adapter = adapter_with(runner, None, None);
435        let err = adapter
436            .create_issue("T", "b", &[], None, None, "opened")
437            .unwrap_err();
438        assert!(matches!(err, TixError::External(_)));
439        assert!(err.to_string().contains("iid"));
440    }
441
442    #[test]
443    fn update_uses_iid_position_and_project() {
444        let runner = FakeRunner::new(vec![]);
445        let mut adapter = adapter_with(runner, None, None);
446        adapter
447            .update_issue("777", "New title", "new body", &[], None, None, "opened")
448            .unwrap();
449        let argv = &recorded(&adapter)[0].0;
450        let joined = argv.join(" ");
451        assert!(joined.starts_with("glab issue update 777 "));
452        assert!(joined.contains("--project acme/tix"));
453        assert!(joined.contains("--title New title"));
454        assert!(joined.contains("--description new body"));
455    }
456
457    #[test]
458    fn mark_done_calls_close() {
459        let runner = FakeRunner::new(vec![]);
460        let mut adapter = adapter_with(runner, None, None);
461        adapter.mark_done("777").unwrap();
462        let argv = &recorded(&adapter)[0].0;
463        assert_eq!(
464            argv,
465            &["glab", "issue", "close", "777", "--project", "acme/tix"]
466        );
467    }
468
469    #[test]
470    fn list_open_parses_json_and_filters_closed() {
471        let payload = r#"[
472          {"iid":1,"title":"T1","description":"d1","labels":[{"name":"bug"}],"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignees":[{"username":"alice"}]},
473          {"iid":2,"title":"T2","state":"closed"}
474        ]"#;
475        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(payload)]);
476        let mut adapter = adapter_with(runner, None, None);
477        let issues = adapter.list_open().unwrap();
478        assert_eq!(issues.len(), 1);
479        assert_eq!(issues[0].id, "1");
480        assert_eq!(issues[0].title, "T1");
481        assert_eq!(issues[0].labels, ["bug"]);
482        assert_eq!(issues[0].state, "opened");
483        assert_eq!(
484            issues[0].updated_at.as_deref(),
485            Some("2026-05-11T10:00:00Z")
486        );
487        assert_eq!(issues[0].assignee.as_deref(), Some("alice"));
488    }
489
490    #[test]
491    fn list_accepts_string_labels_too() {
492        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(
493            r#"[{"iid":9,"title":"T","labels":["docs","ops"],"state":"opened"}]"#,
494        )]);
495        let mut adapter = adapter_with(runner, None, None);
496        let issues = adapter.list_open().unwrap();
497        assert_eq!(issues[0].labels, ["docs", "ops"]);
498    }
499
500    #[test]
501    fn failure_message_scrubs_token_from_tool_output() {
502        let runner = FakeRunner::new(vec![CommandOutcome {
503            success: false,
504            status: Some(1),
505            stdout: String::new(),
506            stderr: "GITLAB_TOKEN=gl-token leaked".into(),
507        }]);
508        let mut adapter = adapter_with(runner, Some("gl-token"), None);
509        let err = adapter
510            .create_issue("T", "b", &[], None, None, "opened")
511            .unwrap_err();
512        let text = err.to_string();
513        assert!(text.contains("<token>"), "expected redaction, got: {text}");
514        assert!(!text.contains("gl-token"), "secret leaked: {text}");
515    }
516}