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
222#[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#[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
279pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
281
282#[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
337impl 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 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}