#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::Path;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
#[cfg(feature = "cancellation")]
#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
pub use processkit::CancellationToken;
mod parse;
pub use parse::{CiStatus, Issue, MergeRequest, Project, Release};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MrCreate {
pub title: String,
pub body: String,
pub source: Option<String>,
pub target: Option<String>,
}
impl MrCreate {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
Self {
title: title.into(),
body: body.into(),
source: None,
target: None,
}
}
pub fn source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
}
pub const BINARY: &str = "glab";
fn reject_flag_like(what: &str, value: &str) -> Result<()> {
vcs_cli_support::reject_flag_like(BINARY, what, value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MergeStrategy {
Merge,
Squash,
Rebase,
}
impl MergeStrategy {
fn flag(self) -> Option<&'static str> {
match self {
MergeStrategy::Merge => None,
MergeStrategy::Squash => Some("--squash"),
MergeStrategy::Rebase => Some("--rebase"),
}
}
}
#[cfg_attr(feature = "mock", mockall::automock)]
#[async_trait::async_trait]
pub trait GitLabApi: Send + Sync {
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
async fn version(&self) -> Result<String>;
async fn auth_status(&self) -> Result<bool>;
async fn repo_view(&self, dir: &Path) -> Result<Project>;
async fn mr_list(&self, dir: &Path) -> Result<Vec<MergeRequest>>;
async fn mr_view(&self, dir: &Path, id: u64) -> Result<MergeRequest>;
async fn mr_create(&self, dir: &Path, spec: MrCreate) -> Result<String>;
async fn mr_merge(&self, dir: &Path, id: u64, strategy: MergeStrategy) -> Result<()>;
async fn mr_ready(&self, dir: &Path, id: u64) -> Result<()>;
async fn mr_close(&self, dir: &Path, id: u64) -> Result<()>;
async fn mr_checks(&self, dir: &Path, id: u64) -> Result<CiStatus>;
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
}
processkit::cli_client!(
pub struct GitLab => BINARY
);
#[async_trait::async_trait]
impl<R: ProcessRunner> GitLabApi for GitLab<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.run(self.core.command(["--version"])).await
}
async fn auth_status(&self) -> Result<bool> {
Ok(self
.core
.exit_code(self.core.command(["auth", "status"]))
.await?
== 0)
}
async fn repo_view(&self, dir: &Path) -> Result<Project> {
self.core
.try_parse(
self.core
.command_in(dir, ["repo", "view", "--output", "json"]),
parse::from_json,
)
.await
}
async fn mr_list(&self, dir: &Path) -> Result<Vec<MergeRequest>> {
self.core
.try_parse(
self.core
.command_in(dir, ["mr", "list", "--per-page", "100", "--output", "json"]),
parse::from_json,
)
.await
}
async fn mr_view(&self, dir: &Path, id: u64) -> Result<MergeRequest> {
let id = id.to_string();
self.core
.try_parse(
self.core
.command_in(dir, ["mr", "view", id.as_str(), "--output", "json"]),
parse::from_json,
)
.await
}
async fn mr_create(&self, dir: &Path, spec: MrCreate) -> Result<String> {
let mut args = vec![
"mr",
"create",
"--title",
spec.title.as_str(),
"--description",
spec.body.as_str(),
"--yes",
];
if let Some(source) = spec.source.as_deref() {
args.push("--source-branch");
args.push(source);
}
if let Some(target) = spec.target.as_deref() {
args.push("--target-branch");
args.push(target);
}
self.core.run(self.core.command_in(dir, args)).await
}
async fn mr_merge(&self, dir: &Path, id: u64, strategy: MergeStrategy) -> Result<()> {
let id = id.to_string();
let mut args = vec!["mr", "merge", id.as_str(), "--yes", "--auto-merge=false"];
if let Some(flag) = strategy.flag() {
args.push(flag);
}
self.core.run_unit(self.core.command_in(dir, args)).await
}
async fn mr_ready(&self, dir: &Path, id: u64) -> Result<()> {
let id = id.to_string();
self.core
.run_unit(
self.core
.command_in(dir, ["mr", "update", id.as_str(), "--ready"]),
)
.await
}
async fn mr_close(&self, dir: &Path, id: u64) -> Result<()> {
let id = id.to_string();
self.core
.run_unit(self.core.command_in(dir, ["mr", "close", id.as_str()]))
.await
}
async fn mr_checks(&self, dir: &Path, id: u64) -> Result<CiStatus> {
let id = id.to_string();
self.core
.try_parse(
self.core
.command_in(dir, ["mr", "view", id.as_str(), "--output", "json"]),
parse::parse_ci_status,
)
.await
}
async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
self.core
.try_parse(
self.core.command_in(
dir,
["issue", "list", "--per-page", "100", "--output", "json"],
),
parse::from_json,
)
.await
}
async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
let number = number.to_string();
self.core
.try_parse(
self.core
.command_in(dir, ["issue", "view", number.as_str(), "--output", "json"]),
parse::from_json,
)
.await
}
async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
self.core
.run(self.core.command_in(
dir,
[
"issue",
"create",
"--title",
title,
"--description",
body,
"--yes",
],
))
.await
}
async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
self.core
.try_parse(
self.core.command_in(
dir,
["release", "list", "--per-page", "100", "--output", "json"],
),
parse::from_json,
)
.await
}
async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
reject_flag_like("tag", tag)?;
self.core
.try_parse(
self.core
.command_in(dir, ["release", "view", tag, "--output", "json"]),
parse::from_json,
)
.await
}
}
impl<R: ProcessRunner> GitLab<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
pub fn at<'a>(&'a self, dir: &'a Path) -> GitLabAt<'a, R> {
GitLabAt { glab: self, dir }
}
}
pub struct GitLabAt<'a, R: ProcessRunner = processkit::JobRunner> {
glab: &'a GitLab<R>,
dir: &'a Path,
}
impl<R: ProcessRunner> Clone for GitLabAt<'_, R> {
fn clone(&self) -> Self {
*self
}
}
impl<R: ProcessRunner> Copy for GitLabAt<'_, R> {}
macro_rules! gitlab_at_forwarders {
(
bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
) => {
impl<'a, R: ProcessRunner> GitLabAt<'a, R> {
$(
#[doc = concat!("Bound form of [`GitLab`]'s `", stringify!($bn), "`.")]
pub async fn $bn(&self, $($ba: $bt),*) -> $br {
self.glab.$bn($($ba),*).await
}
)*
$(
#[doc = concat!("Bound form of [`GitLab`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
pub async fn $dn(&self, $($da: $dt),*) -> $dr {
self.glab.$dn(self.dir, $($da),*).await
}
)*
}
};
}
gitlab_at_forwarders! {
bare {
fn run(args: &[String]) -> Result<String>;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
fn run_args(args: &[&str]) -> Result<String>;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
fn version() -> Result<String>;
fn auth_status() -> Result<bool>;
}
dir {
fn repo_view() -> Result<Project>;
fn mr_list() -> Result<Vec<MergeRequest>>;
fn mr_view(id: u64) -> Result<MergeRequest>;
fn mr_create(spec: MrCreate) -> Result<String>;
fn mr_merge(id: u64, strategy: MergeStrategy) -> Result<()>;
fn mr_ready(id: u64) -> Result<()>;
fn mr_close(id: u64) -> Result<()>;
fn mr_checks(id: u64) -> Result<CiStatus>;
fn issue_list() -> Result<Vec<Issue>>;
fn issue_view(number: u64) -> Result<Issue>;
fn issue_create(title: &str, body: &str) -> Result<String>;
fn release_list() -> Result<Vec<Release>>;
fn release_view(tag: &str) -> Result<Release>;
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_glab() {
assert_eq!(BINARY, "glab");
}
#[allow(dead_code)]
fn bound_view_is_copy_for_default_runner() {
fn assert_copy<T: Copy>() {}
assert_copy::<GitLabAt<'static, processkit::JobRunner>>();
}
#[tokio::test]
async fn bound_view_matches_dir_taking_calls() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok("[]"));
let glab = GitLab::with_runner(&rec);
glab.mr_list(dir).await.unwrap();
glab.at(dir).mr_list().await.unwrap();
glab.mr_ready(dir, 7).await.unwrap();
glab.at(dir).mr_ready(7).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), calls[1].args_str());
assert_eq!(calls[2].args_str(), calls[3].args_str());
assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
}
#[tokio::test]
async fn run_args_forwards_str_slices() {
let glab =
GitLab::with_runner(ScriptedRunner::new().on(["api", "/version"], Reply::ok("ok\n")));
assert_eq!(glab.run_args(&["api", "/version"]).await.unwrap(), "ok");
}
#[tokio::test]
async fn mr_list_parses_scripted_json() {
let json = r#"[{"iid":7,"title":"Add X","state":"opened","source_branch":"feat/x","target_branch":"main","web_url":"u","draft":false}]"#;
let glab = GitLab::with_runner(ScriptedRunner::new().on(["mr", "list"], Reply::ok(json)));
let mrs = glab.mr_list(Path::new(".")).await.expect("mr_list");
assert_eq!(mrs.len(), 1);
assert_eq!(mrs[0].iid, 7);
assert_eq!(mrs[0].target_branch, "main");
}
#[tokio::test]
async fn mr_list_builds_output_json_argv() {
let rec = RecordingRunner::replying(Reply::ok("[]"));
let glab = GitLab::with_runner(&rec);
glab.mr_list(Path::new("/repo")).await.expect("mr_list");
assert_eq!(
rec.only_call().args_str(),
["mr", "list", "--per-page", "100", "--output", "json"]
);
}
#[tokio::test]
async fn auth_status_reads_exit_code() {
let yes = GitLab::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
assert!(yes.auth_status().await.unwrap());
let no = GitLab::with_runner(
ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
);
assert!(!no.auth_status().await.unwrap());
let weird = GitLab::with_runner(ScriptedRunner::new().on(["auth"], Reply::fail(2, "boom")));
assert!(!weird.auth_status().await.unwrap());
}
#[tokio::test]
async fn auth_status_errors_on_timeout() {
let glab = GitLab::with_runner(ScriptedRunner::new().on(["auth"], Reply::timeout()));
assert!(matches!(
glab.auth_status().await.unwrap_err(),
Error::Timeout { .. }
));
}
#[tokio::test]
async fn mr_create_appends_source_and_target() {
let rec = RecordingRunner::replying(Reply::ok("https://gl/mr/9\n"));
let glab = GitLab::with_runner(&rec);
let url = glab
.mr_create(
Path::new("/repo"),
MrCreate::new("T", "B").source("feat/x").target("main"),
)
.await
.expect("mr_create");
assert_eq!(url, "https://gl/mr/9");
assert_eq!(
rec.only_call().args_str(),
[
"mr",
"create",
"--title",
"T",
"--description",
"B",
"--yes",
"--source-branch",
"feat/x",
"--target-branch",
"main"
]
);
}
#[tokio::test]
async fn mr_create_omits_branch_flags_when_none() {
let rec = RecordingRunner::replying(Reply::ok("https://gl/mr/1\n"));
let glab = GitLab::with_runner(&rec);
glab.mr_create(Path::new("/repo"), MrCreate::new("T", "B"))
.await
.expect("mr_create");
assert_eq!(
rec.only_call().args_str(),
[
"mr",
"create",
"--title",
"T",
"--description",
"B",
"--yes"
]
);
}
#[tokio::test]
async fn mr_merge_builds_strategy_argv() {
for (strategy, expected) in [
(
MergeStrategy::Merge,
vec!["mr", "merge", "5", "--yes", "--auto-merge=false"],
),
(
MergeStrategy::Squash,
vec![
"mr",
"merge",
"5",
"--yes",
"--auto-merge=false",
"--squash",
],
),
(
MergeStrategy::Rebase,
vec![
"mr",
"merge",
"5",
"--yes",
"--auto-merge=false",
"--rebase",
],
),
] {
let rec = RecordingRunner::replying(Reply::ok(""));
let glab = GitLab::with_runner(&rec);
glab.mr_merge(Path::new("/repo"), 5, strategy)
.await
.expect("mr_merge");
assert_eq!(rec.only_call().args_str(), expected);
}
}
#[tokio::test]
async fn mr_ready_and_close_build_expected_argv() {
let rec = RecordingRunner::replying(Reply::ok(""));
let glab = GitLab::with_runner(&rec);
glab.mr_ready(Path::new("/repo"), 3).await.expect("ready");
assert_eq!(rec.only_call().args_str(), ["mr", "update", "3", "--ready"]);
let rec = RecordingRunner::replying(Reply::ok(""));
let glab = GitLab::with_runner(&rec);
glab.mr_close(Path::new("/repo"), 3).await.expect("close");
assert_eq!(rec.only_call().args_str(), ["mr", "close", "3"]);
}
#[tokio::test]
async fn mr_checks_buckets_pipeline_status() {
let json = r#"{"iid":4,"head_pipeline":{"status":"failed"}}"#;
let glab = GitLab::with_runner(ScriptedRunner::new().on(["mr", "view"], Reply::ok(json)));
assert_eq!(
glab.mr_checks(Path::new("."), 4).await.unwrap(),
CiStatus::Failing
);
}
#[tokio::test]
async fn issue_list_builds_argv_and_parses() {
let json = r#"[{"iid":3,"title":"Bug","state":"opened","description":"b","web_url":"u"}]"#;
let rec = RecordingRunner::replying(Reply::ok(json));
let glab = GitLab::with_runner(&rec);
let issues = glab
.issue_list(Path::new("/repo"))
.await
.expect("issue_list");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].number, 3);
assert_eq!(issues[0].state, "opened");
assert_eq!(
rec.only_call().args_str(),
["issue", "list", "--per-page", "100", "--output", "json"]
);
}
#[tokio::test]
async fn issue_view_builds_argv_and_parses() {
let json = r#"{"iid":7,"title":"T","state":"closed","description":"body","web_url":"https://gl/i/7"}"#;
let rec = RecordingRunner::replying(Reply::ok(json));
let glab = GitLab::with_runner(&rec);
let issue = glab
.issue_view(Path::new("/repo"), 7)
.await
.expect("issue_view");
assert_eq!(issue.number, 7);
assert_eq!(issue.body, "body");
assert_eq!(issue.url, "https://gl/i/7");
assert_eq!(
rec.only_call().args_str(),
["issue", "view", "7", "--output", "json"]
);
}
#[tokio::test]
async fn issue_create_builds_argv_and_returns_url() {
let rec = RecordingRunner::replying(Reply::ok("https://gl/i/9\n"));
let glab = GitLab::with_runner(&rec);
let url = glab
.issue_create(Path::new("/repo"), "T", "B")
.await
.expect("issue_create");
assert_eq!(url, "https://gl/i/9");
assert_eq!(
rec.only_call().args_str(),
[
"issue",
"create",
"--title",
"T",
"--description",
"B",
"--yes"
]
);
}
#[tokio::test]
async fn release_list_builds_argv_and_parses() {
let json = r#"[{"tag_name":"v1.0","name":"Release 1.0","released_at":"2026-01-02T03:04:05.000Z","_links":{"self":"https://gl/-/releases/v1.0"}}]"#;
let rec = RecordingRunner::replying(Reply::ok(json));
let glab = GitLab::with_runner(&rec);
let releases = glab
.release_list(Path::new("/repo"))
.await
.expect("release_list");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0].tag_name, "v1.0");
assert_eq!(releases[0].url, "https://gl/-/releases/v1.0");
assert_eq!(releases[0].published_at, "2026-01-02T03:04:05.000Z");
assert_eq!(
rec.only_call().args_str(),
["release", "list", "--per-page", "100", "--output", "json"]
);
}
#[tokio::test]
async fn release_view_builds_argv_and_parses() {
let json =
r#"{"tag_name":"v2.1","name":"R","_links":{"self":"https://gl/-/releases/v2.1"}}"#;
let rec = RecordingRunner::replying(Reply::ok(json));
let glab = GitLab::with_runner(&rec);
let rel = glab
.release_view(Path::new("/repo"), "v2.1")
.await
.expect("release_view");
assert_eq!(rel.tag_name, "v2.1");
assert_eq!(rel.url, "https://gl/-/releases/v2.1");
assert_eq!(
rec.only_call().args_str(),
["release", "view", "v2.1", "--output", "json"]
);
}
#[tokio::test]
async fn release_view_rejects_flag_like_tag() {
let glab = GitLab::with_runner(ScriptedRunner::new());
assert!(glab.release_view(Path::new("."), "-evil").await.is_err());
assert!(glab.release_view(Path::new("."), "").await.is_err());
}
#[tokio::test]
async fn repo_view_parses_project() {
let json = r#"{"name":"cli","path_with_namespace":"gitlab-org/cli","default_branch":"main","web_url":"u","visibility":"public"}"#;
let glab = GitLab::with_runner(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
let p = glab.repo_view(Path::new(".")).await.expect("repo_view");
assert_eq!(p.path_with_namespace, "gitlab-org/cli");
assert_eq!(p.default_branch, "main");
}
#[cfg(feature = "mock")]
#[tokio::test]
async fn consumer_mocks_the_interface() {
let mut mock = MockGitLabApi::new();
mock.expect_auth_status().returning(|| Ok(true));
assert!(mock.auth_status().await.unwrap());
}
}
#[doc = include_str!("../docs/gitlab.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {}