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