1use serde::Deserialize;
10
11use crate::error::{Result, TixError};
12use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
13
14pub 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 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 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 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 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 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 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 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#[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 #[serde(default, alias = "epic_iid", alias = "parent_iid")]
339 parent: Option<i64>,
340 #[serde(default)]
353 projects: Option<serde_json::Value>,
354 #[serde(default, alias = "epic_ids")]
371 epics: Option<serde_json::Value>,
372}
373
374#[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
416pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
418
419#[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
474impl 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 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 #[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 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 #[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 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}