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) => name,
265                    GlabLabel::Named { name } => name,
266                })
267                .collect(),
268            state: issue.state,
269            updated_at: issue.updated_at,
270            assignee: issue
271                .assignees
272                .into_iter()
273                .next()
274                .map(|a| a.username)
275                .filter(|u| !u.is_empty()),
276        }
277    }
278}
279
280/// A recorded adapter call: the argv plus the env pairs it was spawned with.
281pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
282
283/// A fake runner for adapter unit tests: records argv plus env (for the
284/// token scrub/injection assertions) and returns configured outcomes in
285/// order, falling back to an empty success.
286#[derive(Default)]
287pub struct FakeRunner {
288    calls: std::sync::Mutex<Vec<RecordedCall>>,
289    responses: std::sync::Mutex<Vec<CommandOutcome>>,
290}
291
292impl FakeRunner {
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    pub fn glab_created(iid: i64) -> CommandOutcome {
340        CommandOutcome {
341            success: true,
342            status: Some(0),
343            stdout: format!(
344                "{{\"iid\": {iid}, \"web_url\": \"https://gitlab.example/acme/tix/-/issues/{iid}\"}}"
345            ),
346            stderr: String::new(),
347        }
348    }
349
350    pub fn glab_list(payload: &str) -> CommandOutcome {
351        CommandOutcome {
352            success: true,
353            status: Some(0),
354            stdout: payload.to_string(),
355            stderr: String::new(),
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn adapter_with(
365        runner: FakeRunner,
366        token: Option<&str>,
367        base_url: Option<&str>,
368    ) -> GitLabAdapter {
369        GitLabAdapter::new(
370            runner,
371            token.map(String::from),
372            base_url.map(String::from),
373            "acme/tix".into(),
374        )
375    }
376
377    /// Read the recorded calls through the owned runner's downcast hook.
378    fn recorded(adapter: &GitLabAdapter) -> Vec<RecordedCall> {
379        adapter
380            .runner
381            .as_any()
382            .downcast_ref::<FakeRunner>()
383            .expect("adapter must hold a FakeRunner")
384            .take_calls()
385    }
386
387    #[test]
388    fn create_records_env_token_and_full_shape() {
389        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(42)]);
390        let mut adapter = adapter_with(runner, Some("gl-token"), None);
391        let id = adapter
392            .create_issue(
393                "Fix login",
394                "body here",
395                &["bug".into(), "auth".into()],
396                Some("alice"),
397                None,
398                "opened",
399            )
400            .unwrap();
401        assert_eq!(id, "42");
402        let calls = recorded(&adapter);
403        let (argv, env) = &calls[0];
404        assert_eq!(argv[0], "glab");
405        assert_eq!(argv[1], "issue");
406        assert_eq!(argv[2], "create");
407        let joined = argv.join(" ");
408        assert!(joined.contains("--project acme/tix"));
409        assert!(joined.contains("--title Fix login"));
410        assert!(joined.contains("--description body here"));
411        assert!(joined.contains("--label bug,auth"));
412        assert!(joined.contains("--assignee @alice"));
413        assert!(joined.contains("--yes"));
414        assert!(joined.contains("--output json"));
415        assert_eq!(env, &[("GITLAB_TOKEN".to_string(), "gl-token".to_string())]);
416    }
417
418    #[test]
419    fn create_with_hostname_includes_hostname_flag() {
420        let runner = FakeRunner::new(vec![CommandOutcome::glab_created(7)]);
421        let mut adapter = adapter_with(runner, None, Some("gitlab.example.com"));
422        adapter
423            .create_issue("T", "b", &[], None, None, "opened")
424            .unwrap();
425        let joined = recorded(&adapter)[0].0.join(" ");
426        assert!(joined.contains("--hostname gitlab.example.com"));
427    }
428
429    #[test]
430    fn create_json_without_iid_is_a_parse_error() {
431        let runner = FakeRunner::new(vec![CommandOutcome::glab_list("{}")]);
432        let mut adapter = adapter_with(runner, None, None);
433        let err = adapter
434            .create_issue("T", "b", &[], None, None, "opened")
435            .unwrap_err();
436        assert!(matches!(err, TixError::External(_)));
437        assert!(err.to_string().contains("iid"));
438    }
439
440    #[test]
441    fn update_uses_iid_position_and_project() {
442        let runner = FakeRunner::new(vec![]);
443        let mut adapter = adapter_with(runner, None, None);
444        adapter
445            .update_issue("777", "New title", "new body", &[], None, None, "opened")
446            .unwrap();
447        let argv = &recorded(&adapter)[0].0;
448        let joined = argv.join(" ");
449        assert!(joined.starts_with("glab issue update 777 "));
450        assert!(joined.contains("--project acme/tix"));
451        assert!(joined.contains("--title New title"));
452        assert!(joined.contains("--description new body"));
453    }
454
455    #[test]
456    fn mark_done_calls_close() {
457        let runner = FakeRunner::new(vec![]);
458        let mut adapter = adapter_with(runner, None, None);
459        adapter.mark_done("777").unwrap();
460        let argv = &recorded(&adapter)[0].0;
461        assert_eq!(
462            argv,
463            &["glab", "issue", "close", "777", "--project", "acme/tix"]
464        );
465    }
466
467    #[test]
468    fn list_open_parses_json_and_filters_closed() {
469        let payload = r#"[
470          {"iid":1,"title":"T1","description":"d1","labels":[{"name":"bug"}],"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignees":[{"username":"alice"}]},
471          {"iid":2,"title":"T2","state":"closed"}
472        ]"#;
473        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(payload)]);
474        let mut adapter = adapter_with(runner, None, None);
475        let issues = adapter.list_open().unwrap();
476        assert_eq!(issues.len(), 1);
477        assert_eq!(issues[0].id, "1");
478        assert_eq!(issues[0].title, "T1");
479        assert_eq!(issues[0].labels, ["bug"]);
480        assert_eq!(issues[0].state, "opened");
481        assert_eq!(
482            issues[0].updated_at.as_deref(),
483            Some("2026-05-11T10:00:00Z")
484        );
485        assert_eq!(issues[0].assignee.as_deref(), Some("alice"));
486    }
487
488    #[test]
489    fn list_accepts_string_labels_too() {
490        let runner = FakeRunner::new(vec![CommandOutcome::glab_list(
491            r#"[{"iid":9,"title":"T","labels":["docs","ops"],"state":"opened"}]"#,
492        )]);
493        let mut adapter = adapter_with(runner, None, None);
494        let issues = adapter.list_open().unwrap();
495        assert_eq!(issues[0].labels, ["docs", "ops"]);
496    }
497
498    #[test]
499    fn failure_message_scrubs_token_from_tool_output() {
500        let runner = FakeRunner::new(vec![CommandOutcome {
501            success: false,
502            status: Some(1),
503            stdout: String::new(),
504            stderr: "GITLAB_TOKEN=gl-token leaked".into(),
505        }]);
506        let mut adapter = adapter_with(runner, Some("gl-token"), None);
507        let err = adapter
508            .create_issue("T", "b", &[], None, None, "opened")
509            .unwrap_err();
510        let text = err.to_string();
511        assert!(text.contains("<token>"), "expected redaction, got: {text}");
512        assert!(!text.contains("gl-token"), "secret leaked: {text}");
513    }
514}