use serde::Deserialize;
use crate::error::{Result, TixError};
use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
pub struct GitLabAdapter {
runner: Box<dyn CommandRunner>,
token: Option<String>,
base_url: Option<String>,
project: String,
}
impl GitLabAdapter {
pub fn new(
runner: impl CommandRunner + 'static,
token: Option<String>,
base_url: Option<String>,
project: String,
) -> Self {
GitLabAdapter {
runner: Box::new(runner),
token,
base_url,
project,
}
}
fn project_arg(&self) -> (String, String) {
("--project".to_string(), self.project.clone())
}
fn hostname_args(&self) -> Vec<String> {
match &self.base_url {
Some(url) => vec!["--hostname".to_string(), url.clone()],
None => Vec::new(),
}
}
fn child_env(&self) -> Vec<(String, String)> {
match &self.token {
Some(token) => vec![("GITLAB_TOKEN".to_string(), token.clone())],
None => Vec::new(),
}
}
fn run(&self, args: &[String]) -> Result<CommandOutcome> {
self.runner
.run(Tool::GitLab.binary(), args, &self.child_env())
}
fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
let mut message = format!("{context}: {}", outcome.text().trim());
if let Some(secret) = &self.token {
message = scrub_secret(&message, secret);
}
TixError::External(message)
}
fn parse_iid(&self, outcome: &CommandOutcome) -> Result<String> {
let stdout = &outcome.stdout;
serde_json::from_str::<serde_json::Value>(stdout)
.ok()
.and_then(|value| {
value
.get("iid")
.and_then(serde_json::Value::as_i64)
.map(|iid| iid.to_string())
})
.ok_or_else(|| {
TixError::External(format!(
"`glab issue create` did not return JSON with a numeric `iid` field (expected {{\"iid\":…}}): {}",
scrub_secret(stdout.trim(), self.token.as_deref().unwrap_or(""))
))
})
}
}
impl TrackerAdapter for GitLabAdapter {
fn tool(&self) -> Tool {
Tool::GitLab
}
fn create_issue(
&mut self,
title: &str,
body: &str,
labels: &[String],
assignee: Option<&str>,
_priority: Option<&str>,
_status: &str,
) -> Result<String> {
let mut args: Vec<String> = Vec::new();
args.push("issue".to_string());
args.push("create".to_string());
let (k, v) = self.project_arg();
args.push(k);
args.push(v);
args.extend(self.hostname_args());
args.push("--title".to_string());
args.push(title.to_string());
args.push("--description".to_string());
args.push(body.to_string());
if !labels.is_empty() {
args.push("--label".to_string());
args.push(labels.join(","));
}
if let Some(name) = assignee {
args.push("--assignee".to_string());
args.push(format!("@{name}"));
}
args.push("--yes".to_string());
args.push("--output".to_string());
args.push("json".to_string());
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`glab issue create` failed", &outcome));
}
self.parse_iid(&outcome)
}
fn update_issue(
&mut self,
remote_ref: &str,
title: &str,
body: &str,
labels: &[String],
assignee: Option<&str>,
_priority: Option<&str>,
_status: &str,
) -> Result<()> {
let mut args: Vec<String> = Vec::new();
args.push("issue".to_string());
args.push("update".to_string());
args.push(remote_ref.to_string());
let (k, v) = self.project_arg();
args.push(k);
args.push(v);
args.extend(self.hostname_args());
args.push("--title".to_string());
args.push(title.to_string());
args.push("--description".to_string());
args.push(body.to_string());
if !labels.is_empty() {
args.push("--label".to_string());
args.push(labels.join(","));
}
if let Some(name) = assignee {
args.push("--assignee".to_string());
args.push(format!("@{name}"));
}
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`glab issue update` failed", &outcome));
}
Ok(())
}
fn mark_done(&mut self, remote_ref: &str) -> Result<()> {
let mut args: Vec<String> = Vec::new();
args.push("issue".to_string());
args.push("close".to_string());
args.push(remote_ref.to_string());
let (k, v) = self.project_arg();
args.push(k);
args.push(v);
args.extend(self.hostname_args());
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`glab issue close` failed", &outcome));
}
Ok(())
}
fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
let mut args: Vec<String> = Vec::new();
args.push("issue".to_string());
args.push("list".to_string());
let (k, v) = self.project_arg();
args.push(k);
args.push(v);
args.extend(self.hostname_args());
args.push("--output".to_string());
args.push("json".to_string());
let outcome = self.run(&args)?;
if !outcome.success {
return Err(self.external("`glab issue list` failed", &outcome));
}
let issues: Vec<GlabIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
TixError::External(format!(
"`glab issue list` did not return a JSON array: {e}\n{}",
scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
))
})?;
Ok(issues
.into_iter()
.map(RemoteIssue::from)
.filter(|i| i.state != "closed")
.collect())
}
fn done_via_status(&self) -> bool {
false
}
}
#[derive(Debug, Deserialize)]
struct GlabIssue {
iid: i64,
#[serde(default)]
title: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
labels: Vec<GlabLabel>,
#[serde(default)]
state: String,
#[serde(default)]
updated_at: Option<String>,
#[serde(default)]
assignees: Vec<GlabAssignee>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum GlabLabel {
Name(String),
Named { name: String },
}
#[derive(Debug, Deserialize)]
struct GlabAssignee {
#[serde(default)]
username: String,
}
impl From<GlabIssue> for RemoteIssue {
fn from(issue: GlabIssue) -> Self {
RemoteIssue {
id: issue.iid.to_string(),
title: issue.title,
description: issue.description.unwrap_or_default(),
labels: issue
.labels
.into_iter()
.map(|l| match l {
GlabLabel::Name(name) | GlabLabel::Named { name } => name,
})
.collect(),
state: issue.state,
updated_at: issue.updated_at,
assignee: issue
.assignees
.into_iter()
.next()
.map(|a| a.username)
.filter(|u| !u.is_empty()),
}
}
}
pub type RecordedCall = (Vec<String>, Vec<(String, String)>);
#[derive(Default)]
pub struct FakeRunner {
calls: std::sync::Mutex<Vec<RecordedCall>>,
responses: std::sync::Mutex<Vec<CommandOutcome>>,
}
impl FakeRunner {
#[must_use]
pub fn new(responses: Vec<CommandOutcome>) -> Self {
FakeRunner {
responses: std::sync::Mutex::new(responses),
..FakeRunner::default()
}
}
pub fn take_calls(&self) -> Vec<RecordedCall> {
self.calls.lock().unwrap().clone()
}
}
impl CommandRunner for FakeRunner {
fn run(
&self,
program: &str,
args: &[String],
env: &[(String, String)],
) -> Result<CommandOutcome> {
self.calls.lock().unwrap().push((
std::iter::once(program.to_string())
.chain(args.iter().cloned())
.collect(),
env.to_vec(),
));
let outcome = self
.responses
.lock()
.unwrap()
.pop()
.unwrap_or(CommandOutcome {
success: true,
status: Some(0),
stdout: String::new(),
stderr: String::new(),
});
Ok(outcome)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl CommandOutcome {
#[must_use]
pub fn glab_created(iid: i64) -> CommandOutcome {
CommandOutcome {
success: true,
status: Some(0),
stdout: format!(
"{{\"iid\": {iid}, \"web_url\": \"https://gitlab.example/acme/tix/-/issues/{iid}\"}}"
),
stderr: String::new(),
}
}
#[must_use]
pub fn glab_list(payload: &str) -> CommandOutcome {
CommandOutcome {
success: true,
status: Some(0),
stdout: payload.to_string(),
stderr: String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn adapter_with(
runner: FakeRunner,
token: Option<&str>,
base_url: Option<&str>,
) -> GitLabAdapter {
GitLabAdapter::new(
runner,
token.map(String::from),
base_url.map(String::from),
"acme/tix".into(),
)
}
fn recorded(adapter: &GitLabAdapter) -> Vec<RecordedCall> {
adapter
.runner
.as_any()
.downcast_ref::<FakeRunner>()
.expect("adapter must hold a FakeRunner")
.take_calls()
}
#[test]
fn create_records_env_token_and_full_shape() {
let runner = FakeRunner::new(vec![CommandOutcome::glab_created(42)]);
let mut adapter = adapter_with(runner, Some("gl-token"), None);
let id = adapter
.create_issue(
"Fix login",
"body here",
&["bug".into(), "auth".into()],
Some("alice"),
None,
"opened",
)
.unwrap();
assert_eq!(id, "42");
let calls = recorded(&adapter);
let (argv, env) = &calls[0];
assert_eq!(argv[0], "glab");
assert_eq!(argv[1], "issue");
assert_eq!(argv[2], "create");
let joined = argv.join(" ");
assert!(joined.contains("--project acme/tix"));
assert!(joined.contains("--title Fix login"));
assert!(joined.contains("--description body here"));
assert!(joined.contains("--label bug,auth"));
assert!(joined.contains("--assignee @alice"));
assert!(joined.contains("--yes"));
assert!(joined.contains("--output json"));
assert_eq!(env, &[("GITLAB_TOKEN".to_string(), "gl-token".to_string())]);
}
#[test]
fn create_with_hostname_includes_hostname_flag() {
let runner = FakeRunner::new(vec![CommandOutcome::glab_created(7)]);
let mut adapter = adapter_with(runner, None, Some("gitlab.example.com"));
adapter
.create_issue("T", "b", &[], None, None, "opened")
.unwrap();
let joined = recorded(&adapter)[0].0.join(" ");
assert!(joined.contains("--hostname gitlab.example.com"));
}
#[test]
fn create_json_without_iid_is_a_parse_error() {
let runner = FakeRunner::new(vec![CommandOutcome::glab_list("{}")]);
let mut adapter = adapter_with(runner, None, None);
let err = adapter
.create_issue("T", "b", &[], None, None, "opened")
.unwrap_err();
assert!(matches!(err, TixError::External(_)));
assert!(err.to_string().contains("iid"));
}
#[test]
fn update_uses_iid_position_and_project() {
let runner = FakeRunner::new(vec![]);
let mut adapter = adapter_with(runner, None, None);
adapter
.update_issue("777", "New title", "new body", &[], None, None, "opened")
.unwrap();
let argv = &recorded(&adapter)[0].0;
let joined = argv.join(" ");
assert!(joined.starts_with("glab issue update 777 "));
assert!(joined.contains("--project acme/tix"));
assert!(joined.contains("--title New title"));
assert!(joined.contains("--description new body"));
}
#[test]
fn mark_done_calls_close() {
let runner = FakeRunner::new(vec![]);
let mut adapter = adapter_with(runner, None, None);
adapter.mark_done("777").unwrap();
let argv = &recorded(&adapter)[0].0;
assert_eq!(
argv,
&["glab", "issue", "close", "777", "--project", "acme/tix"]
);
}
#[test]
fn list_open_parses_json_and_filters_closed() {
let payload = r#"[
{"iid":1,"title":"T1","description":"d1","labels":[{"name":"bug"}],"state":"opened","updated_at":"2026-05-11T10:00:00Z","assignees":[{"username":"alice"}]},
{"iid":2,"title":"T2","state":"closed"}
]"#;
let runner = FakeRunner::new(vec![CommandOutcome::glab_list(payload)]);
let mut adapter = adapter_with(runner, None, None);
let issues = adapter.list_open().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].id, "1");
assert_eq!(issues[0].title, "T1");
assert_eq!(issues[0].labels, ["bug"]);
assert_eq!(issues[0].state, "opened");
assert_eq!(
issues[0].updated_at.as_deref(),
Some("2026-05-11T10:00:00Z")
);
assert_eq!(issues[0].assignee.as_deref(), Some("alice"));
}
#[test]
fn list_accepts_string_labels_too() {
let runner = FakeRunner::new(vec![CommandOutcome::glab_list(
r#"[{"iid":9,"title":"T","labels":["docs","ops"],"state":"opened"}]"#,
)]);
let mut adapter = adapter_with(runner, None, None);
let issues = adapter.list_open().unwrap();
assert_eq!(issues[0].labels, ["docs", "ops"]);
}
#[test]
fn failure_message_scrubs_token_from_tool_output() {
let runner = FakeRunner::new(vec![CommandOutcome {
success: false,
status: Some(1),
stdout: String::new(),
stderr: "GITLAB_TOKEN=gl-token leaked".into(),
}]);
let mut adapter = adapter_with(runner, Some("gl-token"), None);
let err = adapter
.create_issue("T", "b", &[], None, None, "opened")
.unwrap_err();
let text = err.to_string();
assert!(text.contains("<token>"), "expected redaction, got: {text}");
assert!(!text.contains("gl-token"), "secret leaked: {text}");
}
}