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
264#[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 #[serde(default, alias = "epic_iid", alias = "parent_iid")]
293 parent: Option<i64>,
294}
295
296#[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
336pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
338
339#[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
394impl 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 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}