Skip to main content

vcs_gitlab/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-gitlab` — automate GitLab from Rust by driving the `glab` CLI.
4//!
5//! It shells out to the installed `glab` binary, asks for `--output json`, and
6//! deserializes the result into typed values — so you get *glab's own* behaviour,
7//! host config, and credential handling, not a reimplementation of the GitLab API
8//! client. Async throughout, structured errors, and mockable. Every command runs
9//! inside an OS **job** (via [`processkit`]) so a `glab` subprocess tree can never
10//! be orphaned, and honours an optional per-client
11//! [timeout](GitLab::default_timeout).
12//!
13//! # The surface
14//!
15//! The modelled surface is the **lean merge-request lifecycle** — auth, project
16//! view, the MR lifecycle, plus issues and releases. It deserializes `glab …
17//! --output json` (GitLab's REST JSON, which `glab` passes through) into typed
18//! structs; it never scrapes human-readable output. The sibling
19//! [`vcs-github`](https://crates.io/crates/vcs-github) and
20//! [`vcs-gitea`](https://crates.io/crates/vcs-gitea) wrappers mirror this shape,
21//! and the [`vcs-forge`](https://crates.io/crates/vcs-forge) facade unifies all
22//! three.
23//!
24//! - **[`GitLabApi`]** — the object-safe trait every operation lives on. Depend on
25//!   `&dyn GitLabApi` (or generically on `impl GitLabApi`) so a test can swap the
26//!   real client for a double. Project-scoped methods take the working directory
27//!   as the first argument and return typed results ([`Project`],
28//!   [`MergeRequest`], [`Issue`], [`Release`], [`CiStatus`]) or a structured
29//!   [`Error`]. Unmodelled `glab` commands go through [`run`](GitLabApi::run).
30//! - **[`GitLab`]** — the real client. [`GitLab::new`] uses the job-backed runner;
31//!   [`GitLab::with_runner`] injects a fake one for tests. It is generic over the
32//!   [`ProcessRunner`] seam, defaulting to the production runner.
33//! - **[`GitLabAt`]** — a cwd-bound view ([`GitLab::at`]) whose project-scoped
34//!   methods drop the leading `dir`, so `glab.at(dir).mr_list()` reads as
35//!   `glab.mr_list(dir)` — handy when one client drives one checkout.
36//! - **Builder specs** for the multi-option commands — [`MrCreate`] (title, body,
37//!   optional source/target branch) and the [`MergeStrategy`] enum
38//!   (`Merge`/`Squash`/`Rebase`) — `#[non_exhaustive]`, built with a constructor +
39//!   chained setters, named after the flags they emit.
40//! - **[`auth_status`](GitLabApi::auth_status)** — a best-effort signal, *not* a
41//!   guarantee: a long-standing glab bug can make `glab auth status` exit `0` even
42//!   when unauthenticated, so a `true` means "probably"; a subsequent API call is
43//!   the real test. A `false`, spawn failure, or timeout are faithful.
44//!
45//! # Recipes
46//!
47//! Read state — depend on the trait so the same code takes a real client or a mock:
48//!
49//! ```no_run
50//! use std::path::Path;
51//! use vcs_gitlab::{GitLab, GitLabApi};
52//! # async fn demo() -> Result<(), processkit::Error> {
53//! let glab = GitLab::new();
54//! let dir = Path::new(".");
55//! for mr in glab.mr_list(dir).await? {                 // up to 100 open MRs
56//!     println!("!{} [{}] {}", mr.iid, mr.state, mr.title);
57//! }
58//! # Ok(()) }
59//! ```
60//!
61//! Mutate through the builder specs — `mr_merge` merges *immediately*
62//! (`--auto-merge=false`) rather than enabling merge-when-pipeline-succeeds:
63//!
64//! ```no_run
65//! use std::path::Path;
66//! use vcs_gitlab::{GitLab, GitLabApi, MergeStrategy, MrCreate};
67//! # async fn demo(glab: &GitLab) -> Result<(), processkit::Error> {
68//! let dir = Path::new(".");
69//! let url = glab
70//!     .mr_create(dir, MrCreate::new("Add streaming", "Implements …").target("main"))
71//!     .await?;                                          // the new MR's URL
72//! glab.mr_merge(dir, 12, MergeStrategy::Squash).await?;
73//! # let _ = url; Ok(()) }
74//! ```
75//!
76//! # Testing
77//!
78//! Two seams: enable the **`mock`** feature for a `mockall`-generated
79//! `MockGitLabApi` (stub whole methods), or inject a
80//! [`ScriptedRunner`](processkit::ScriptedRunner) with [`GitLab::with_runner`] to
81//! exercise the *real* argv-building and JSON parsing against canned output. The
82//! cross-cutting testing patterns live in
83//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
84//!
85//! # In-depth guide
86//!
87//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
88//! from `docs/`. See the [`guide`] module.
89
90use std::path::Path;
91
92use processkit::ProcessRunner;
93// Re-export the processkit types in this crate's public API (also brings
94// `Error`/`Result`/`ProcessResult` into scope here).
95pub use processkit::{Error, ProcessResult, Result};
96// Re-exported under the `cancellation` feature so a consumer can name the token
97// for `default_cancel_on` without taking a direct `processkit` dependency.
98#[cfg(feature = "cancellation")]
99#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
100pub use processkit::CancellationToken;
101
102mod parse;
103pub use parse::{CiStatus, Issue, MergeRequest, Project, Release};
104
105/// Options for [`GitLabApi::mr_create`] (`glab mr create`).
106///
107/// `#[non_exhaustive]`, so build it through [`MrCreate::new`] and the chained
108/// setters rather than a struct literal.
109#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub struct MrCreate {
112    /// The MR title (`--title`).
113    pub title: String,
114    /// The MR description (`--description`).
115    pub body: String,
116    /// The source branch (`--source-branch`); `None` = the current branch.
117    pub source: Option<String>,
118    /// The target branch (`--target-branch`); `None` = the project default.
119    pub target: Option<String>,
120}
121
122impl MrCreate {
123    /// An MR with `title` and `body`, source/target left to glab's defaults
124    /// (current branch → project default).
125    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
126        Self {
127            title: title.into(),
128            body: body.into(),
129            source: None,
130            target: None,
131        }
132    }
133
134    /// Set the source branch (`--source-branch`) instead of the current branch.
135    pub fn source(mut self, source: impl Into<String>) -> Self {
136        self.source = Some(source.into());
137        self
138    }
139
140    /// Set the target branch (`--target-branch`) instead of the project default.
141    pub fn target(mut self, target: impl Into<String>) -> Self {
142        self.target = Some(target.into());
143        self
144    }
145}
146
147/// Name of the underlying CLI binary this crate drives.
148///
149/// Note on injection safety: most of the surface has **no bare positional string
150/// slot** for a caller value — MR/issue ids are `u64` (never flag-like), the
151/// title/body/branch arguments ride in flag-VALUE positions (`--title <t>`,
152/// `--source-branch <b>`) where glab consumes the next token verbatim, and
153/// `run`/`run_args` are the caller-owns-the-argv escape hatch. The one exception
154/// is [`release_view`](GitLabApi::release_view)'s bare `<tag>` positional, which
155/// is guarded with `reject_flag_like` (mirroring `vcs-github`'s
156/// `api`/`release_view`); guard any future bare positional the same way.
157pub const BINARY: &str = "glab";
158
159/// Injection guard for bare positional argv slots: a caller-supplied value with
160/// a leading `-` would be parsed by glab's CLI as a *flag*, and an empty value
161/// changes a command's meaning. Refuse both before anything spawns. Flag-VALUE
162/// positions (`--title <t>`, `--source-branch <b>`) need no guard — glab consumes
163/// the next token verbatim there.
164fn reject_flag_like(what: &str, value: &str) -> Result<()> {
165    vcs_cli_support::reject_flag_like(BINARY, what, value)
166}
167
168/// How [`GitLabApi::mr_merge`] merges the MR. GitLab's default is a merge commit;
169/// `Squash`/`Rebase` add the corresponding flag.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171#[non_exhaustive]
172pub enum MergeStrategy {
173    /// A merge commit (glab's default — no extra flag).
174    Merge,
175    /// Squash the commits into one (`--squash`).
176    Squash,
177    /// Rebase the source onto the target (`--rebase`).
178    Rebase,
179}
180
181impl MergeStrategy {
182    /// The glab flag for this strategy, or `None` for the default merge commit.
183    fn flag(self) -> Option<&'static str> {
184        match self {
185            MergeStrategy::Merge => None,
186            MergeStrategy::Squash => Some("--squash"),
187            MergeStrategy::Rebase => Some("--rebase"),
188        }
189    }
190}
191
192/// The GitLab operations this crate exposes — the interface consumers code
193/// against and mock in tests. The **lean MR lifecycle**; reach unmodelled `glab`
194/// commands through [`run`](GitLabApi::run).
195#[cfg_attr(feature = "mock", mockall::automock)]
196#[async_trait::async_trait]
197pub trait GitLabApi: Send + Sync {
198    /// Run `glab <args>`, returning trimmed stdout (throws on a non-zero exit).
199    async fn run(&self, args: &[String]) -> Result<String>;
200    /// Like [`GitLabApi::run`] but never errors on a non-zero exit — returns the
201    /// captured [`ProcessResult`].
202    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
203    /// Installed GitLab CLI version (`glab --version`).
204    async fn version(&self) -> Result<String>;
205    /// Whether the user is authenticated (`glab auth status` exits zero). Reflects
206    /// the exit code as a bool — any non-zero exit reads as `false`, never an
207    /// error; only a spawn failure or timeout errors.
208    ///
209    /// **Caveat:** this reflects glab's exit code, and a long-standing glab bug
210    /// ([gitlab-org/cli#911]) can make `glab auth status` exit `0` even when *not*
211    /// authenticated, so a `true` here is a best-effort signal, not a guarantee —
212    /// a subsequent API call is the real test. A `false`, a spawn failure, or a
213    /// timeout are still reported faithfully.
214    ///
215    /// [gitlab-org/cli#911]: https://gitlab.com/gitlab-org/cli/-/issues/911
216    async fn auth_status(&self) -> Result<bool>;
217    /// The project for `dir` (`glab repo view --output json`).
218    async fn repo_view(&self, dir: &Path) -> Result<Project>;
219    /// Open merge requests for `dir`
220    /// (`glab mr list --per-page 100 --output json`). Returns up to 100 (100 is
221    /// the GitLab API per-page max); use [`run`](GitLabApi::run) for more.
222    async fn mr_list(&self, dir: &Path) -> Result<Vec<MergeRequest>>;
223    /// A single merge request by its project-scoped id
224    /// (`glab mr view <id> --output json`).
225    async fn mr_view(&self, dir: &Path, id: u64) -> Result<MergeRequest>;
226    /// Open a merge request, returning the command's output (the MR URL on
227    /// success) (`glab mr create`). The [`MrCreate`] spec carries the title,
228    /// body, and the optional source (`None` = the current branch) and target
229    /// (`None` = the project default) branches.
230    async fn mr_create(&self, dir: &Path, spec: MrCreate) -> Result<String>;
231    /// Merge a merge request **immediately** (`glab mr merge <id> --yes
232    /// --auto-merge=false [--squash|--rebase]`) — `--auto-merge=false` overrides
233    /// glab's default of enabling merge-when-pipeline-succeeds. See
234    /// [`MergeStrategy`].
235    async fn mr_merge(&self, dir: &Path, id: u64, strategy: MergeStrategy) -> Result<()>;
236    /// Mark a draft merge request as ready (`glab mr update <id> --ready`).
237    async fn mr_ready(&self, dir: &Path, id: u64) -> Result<()>;
238    /// Close a merge request without merging (`glab mr close <id>`).
239    async fn mr_close(&self, dir: &Path, id: u64) -> Result<()>;
240    /// The MR's pipeline status, bucketed (`glab mr view <id> --output json`,
241    /// reading `head_pipeline.status`). [`CiStatus::None`] when no pipeline ran.
242    async fn mr_checks(&self, dir: &Path, id: u64) -> Result<CiStatus>;
243    /// Open issues for `dir`
244    /// (`glab issue list --per-page 100 --output json`). Returns up to 100 (100
245    /// is the GitLab API per-page max); use [`run`](GitLabApi::run) for more.
246    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
247    /// A single issue by its project-scoped id (`iid`)
248    /// (`glab issue view <number> --output json`).
249    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
250    /// Open an issue, returning the command's output (the issue URL on success)
251    /// (`glab issue create --title <t> --description <d> --yes`). `--yes` skips
252    /// glab's interactive submission prompt — mirrors
253    /// [`mr_create`](GitLabApi::mr_create).
254    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
255    /// Releases for `dir` (`glab release list --per-page 100 --output json`).
256    /// Returns up to 100 (100 is the GitLab API per-page max); use
257    /// [`run`](GitLabApi::run) for more.
258    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
259    /// A single release by its tag (`glab release view <tag> --output json`).
260    /// The `tag` is a bare positional, so it is guarded with
261    /// `reject_flag_like` (a leading `-` or empty value is rejected before any
262    /// process spawns).
263    async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release>;
264}
265
266processkit::cli_client!(
267    /// The real GitLab client. Generic over the [`ProcessRunner`] so tests can
268    /// inject a fake process executor; `GitLab::new()` uses the real job-backed
269    /// runner.
270    pub struct GitLab => BINARY
271);
272
273#[async_trait::async_trait]
274impl<R: ProcessRunner> GitLabApi for GitLab<R> {
275    async fn run(&self, args: &[String]) -> Result<String> {
276        self.core.run(self.core.command(args)).await
277    }
278
279    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
280        self.core.output(self.core.command(args)).await
281    }
282
283    async fn version(&self) -> Result<String> {
284        self.core.run(self.core.command(["--version"])).await
285    }
286
287    async fn auth_status(&self) -> Result<bool> {
288        // `glab auth status` exits 0 when authenticated, non-zero when not — an
289        // exit-code answer. `exit_code` reads the exit code without erroring on a
290        // non-zero one (a spawn failure or timeout still errors), so ANY non-zero
291        // exit — not just the documented 1 — maps to "not authenticated" rather
292        // than surfacing as an error (glab's exit codes are not contractual; see
293        // the #911 caveat on the trait method). `probe` would reject an unusual
294        // exit code.
295        Ok(self
296            .core
297            .exit_code(self.core.command(["auth", "status"]))
298            .await?
299            == 0)
300    }
301
302    async fn repo_view(&self, dir: &Path) -> Result<Project> {
303        self.core
304            .try_parse(
305                self.core
306                    .command_in(dir, ["repo", "view", "--output", "json"]),
307                parse::from_json,
308            )
309            .await
310    }
311
312    async fn mr_list(&self, dir: &Path) -> Result<Vec<MergeRequest>> {
313        // `--per-page 100` (the GitLab API max) overrides glab's default page size
314        // of 30, which would otherwise silently truncate the list.
315        self.core
316            .try_parse(
317                self.core
318                    .command_in(dir, ["mr", "list", "--per-page", "100", "--output", "json"]),
319                parse::from_json,
320            )
321            .await
322    }
323
324    async fn mr_view(&self, dir: &Path, id: u64) -> Result<MergeRequest> {
325        let id = id.to_string();
326        self.core
327            .try_parse(
328                self.core
329                    .command_in(dir, ["mr", "view", id.as_str(), "--output", "json"]),
330                parse::from_json,
331            )
332            .await
333    }
334
335    async fn mr_create(&self, dir: &Path, spec: MrCreate) -> Result<String> {
336        // `--yes` skips glab's interactive submission confirmation (a headless run
337        // would otherwise hang waiting on the prompt).
338        let mut args = vec![
339            "mr",
340            "create",
341            "--title",
342            spec.title.as_str(),
343            "--description",
344            spec.body.as_str(),
345            "--yes",
346        ];
347        if let Some(source) = spec.source.as_deref() {
348            args.push("--source-branch");
349            args.push(source);
350        }
351        if let Some(target) = spec.target.as_deref() {
352            args.push("--target-branch");
353            args.push(target);
354        }
355        self.core.run(self.core.command_in(dir, args)).await
356    }
357
358    async fn mr_merge(&self, dir: &Path, id: u64, strategy: MergeStrategy) -> Result<()> {
359        let id = id.to_string();
360        // `--yes` skips the confirmation prompt. `--auto-merge=false` forces an
361        // *immediate* merge: glab's `--auto-merge` defaults to `true`, which —
362        // with a running pipeline — would enable merge-when-pipeline-succeeds
363        // instead of merging now, so a method named `mr_merge` wouldn't actually
364        // merge. The strategy flag is added only for squash/rebase (a plain merge
365        // commit is glab's default).
366        let mut args = vec!["mr", "merge", id.as_str(), "--yes", "--auto-merge=false"];
367        if let Some(flag) = strategy.flag() {
368            args.push(flag);
369        }
370        self.core.run_unit(self.core.command_in(dir, args)).await
371    }
372
373    async fn mr_ready(&self, dir: &Path, id: u64) -> Result<()> {
374        let id = id.to_string();
375        self.core
376            .run_unit(
377                self.core
378                    .command_in(dir, ["mr", "update", id.as_str(), "--ready"]),
379            )
380            .await
381    }
382
383    async fn mr_close(&self, dir: &Path, id: u64) -> Result<()> {
384        let id = id.to_string();
385        self.core
386            .run_unit(self.core.command_in(dir, ["mr", "close", id.as_str()]))
387            .await
388    }
389
390    async fn mr_checks(&self, dir: &Path, id: u64) -> Result<CiStatus> {
391        let id = id.to_string();
392        self.core
393            .try_parse(
394                self.core
395                    .command_in(dir, ["mr", "view", id.as_str(), "--output", "json"]),
396                parse::parse_ci_status,
397            )
398            .await
399    }
400
401    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
402        // `--per-page 100` (the GitLab API max) overrides glab's default page
403        // size of 30, which would otherwise silently truncate the list.
404        self.core
405            .try_parse(
406                self.core.command_in(
407                    dir,
408                    ["issue", "list", "--per-page", "100", "--output", "json"],
409                ),
410                parse::from_json,
411            )
412            .await
413    }
414
415    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
416        let number = number.to_string();
417        self.core
418            .try_parse(
419                self.core
420                    .command_in(dir, ["issue", "view", number.as_str(), "--output", "json"]),
421                parse::from_json,
422            )
423            .await
424    }
425
426    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
427        // `--yes` skips glab's interactive submission confirmation (a headless
428        // run would otherwise hang on the prompt) — same as `mr_create`.
429        self.core
430            .run(self.core.command_in(
431                dir,
432                [
433                    "issue",
434                    "create",
435                    "--title",
436                    title,
437                    "--description",
438                    body,
439                    "--yes",
440                ],
441            ))
442            .await
443    }
444
445    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
446        // `--per-page 100` (the GitLab API max) overrides glab's default page
447        // size of 30, which would otherwise silently truncate the list.
448        self.core
449            .try_parse(
450                self.core.command_in(
451                    dir,
452                    ["release", "list", "--per-page", "100", "--output", "json"],
453                ),
454                parse::from_json,
455            )
456            .await
457    }
458
459    async fn release_view(&self, dir: &Path, tag: &str) -> Result<Release> {
460        reject_flag_like("tag", tag)?;
461        self.core
462            .try_parse(
463                self.core
464                    .command_in(dir, ["release", "view", tag, "--output", "json"]),
465                parse::from_json,
466            )
467            .await
468    }
469}
470
471impl<R: ProcessRunner> GitLab<R> {
472    /// Run `glab <args>` over string slices — `glab.run_args(&["mr", "list"])`
473    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
474    /// trait), so it can take `&[&str]`; forwards to the same path as
475    /// [`GitLabApi::run`].
476    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
477        self.core.run(self.core.command(args)).await
478    }
479
480    /// Like [`run_args`](GitLab::run_args) but never errors on a non-zero exit
481    /// (mirrors [`GitLabApi::run_raw`]).
482    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
483        self.core.output(self.core.command(args)).await
484    }
485
486    /// Bind a working directory, so the project-scoped methods omit that argument:
487    /// `glab.at(dir).mr_list()` runs [`mr_list`](GitLabApi::mr_list) against `dir`.
488    pub fn at<'a>(&'a self, dir: &'a Path) -> GitLabAt<'a, R> {
489        GitLabAt { glab: self, dir }
490    }
491}
492
493/// A [`GitLab`] client with a working directory bound, so its project-scoped
494/// methods drop the leading `dir` argument (`glab.at(dir).mr_list()`). Construct
495/// one with [`GitLab::at`].
496pub struct GitLabAt<'a, R: ProcessRunner = processkit::JobRunner> {
497    glab: &'a GitLab<R>,
498    dir: &'a Path,
499}
500
501// Hand-written rather than derived: holding only references, the view is `Copy`
502// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
503// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the handle.
504impl<R: ProcessRunner> Clone for GitLabAt<'_, R> {
505    fn clone(&self) -> Self {
506        *self
507    }
508}
509impl<R: ProcessRunner> Copy for GitLabAt<'_, R> {}
510
511/// Generate [`GitLabAt`] forwarders: `bare` methods forward verbatim, `dir`
512/// methods inject `self.dir` as the first argument.
513macro_rules! gitlab_at_forwarders {
514    (
515        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
516        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
517    ) => {
518        impl<'a, R: ProcessRunner> GitLabAt<'a, R> {
519            $(
520                #[doc = concat!("Bound form of [`GitLab`]'s `", stringify!($bn), "`.")]
521                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
522                    self.glab.$bn($($ba),*).await
523                }
524            )*
525            $(
526                #[doc = concat!("Bound form of [`GitLab`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
527                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
528                    self.glab.$dn(self.dir, $($da),*).await
529                }
530            )*
531        }
532    };
533}
534
535gitlab_at_forwarders! {
536    bare {
537        fn run(args: &[String]) -> Result<String>;
538        fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
539        fn run_args(args: &[&str]) -> Result<String>;
540        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
541        fn version() -> Result<String>;
542        fn auth_status() -> Result<bool>;
543    }
544    dir {
545        fn repo_view() -> Result<Project>;
546        fn mr_list() -> Result<Vec<MergeRequest>>;
547        fn mr_view(id: u64) -> Result<MergeRequest>;
548        fn mr_create(spec: MrCreate) -> Result<String>;
549        fn mr_merge(id: u64, strategy: MergeStrategy) -> Result<()>;
550        fn mr_ready(id: u64) -> Result<()>;
551        fn mr_close(id: u64) -> Result<()>;
552        fn mr_checks(id: u64) -> Result<CiStatus>;
553        fn issue_list() -> Result<Vec<Issue>>;
554        fn issue_view(number: u64) -> Result<Issue>;
555        fn issue_create(title: &str, body: &str) -> Result<String>;
556        fn release_list() -> Result<Vec<Release>>;
557        fn release_view(tag: &str) -> Result<Release>;
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use processkit::{RecordingRunner, Reply, ScriptedRunner};
565
566    #[test]
567    fn binary_name_is_glab() {
568        assert_eq!(BINARY, "glab");
569    }
570
571    // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
572    #[allow(dead_code)]
573    fn bound_view_is_copy_for_default_runner() {
574        fn assert_copy<T: Copy>() {}
575        assert_copy::<GitLabAt<'static, processkit::JobRunner>>();
576    }
577
578    // The bound view (`glab.at(dir)`) must produce byte-identical argv to the
579    // dir-taking call.
580    #[tokio::test]
581    async fn bound_view_matches_dir_taking_calls() {
582        let dir = Path::new("/repo");
583        let rec = RecordingRunner::replying(Reply::ok("[]"));
584        let glab = GitLab::with_runner(&rec);
585
586        glab.mr_list(dir).await.unwrap();
587        glab.at(dir).mr_list().await.unwrap();
588        glab.mr_ready(dir, 7).await.unwrap();
589        glab.at(dir).mr_ready(7).await.unwrap();
590
591        let calls = rec.calls();
592        assert_eq!(calls[0].args_str(), calls[1].args_str());
593        assert_eq!(calls[2].args_str(), calls[3].args_str());
594        assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
595    }
596
597    #[tokio::test]
598    async fn run_args_forwards_str_slices() {
599        let glab =
600            GitLab::with_runner(ScriptedRunner::new().on(["api", "/version"], Reply::ok("ok\n")));
601        assert_eq!(glab.run_args(&["api", "/version"]).await.unwrap(), "ok");
602    }
603
604    // Hermetic: real mr_list() arg-building + JSON deserialization against canned
605    // output — no `glab` binary or network needed, so this runs on CI.
606    #[tokio::test]
607    async fn mr_list_parses_scripted_json() {
608        let json = r#"[{"iid":7,"title":"Add X","state":"opened","source_branch":"feat/x","target_branch":"main","web_url":"u","draft":false}]"#;
609        let glab = GitLab::with_runner(ScriptedRunner::new().on(["mr", "list"], Reply::ok(json)));
610        let mrs = glab.mr_list(Path::new(".")).await.expect("mr_list");
611        assert_eq!(mrs.len(), 1);
612        assert_eq!(mrs[0].iid, 7);
613        assert_eq!(mrs[0].target_branch, "main");
614    }
615
616    // mr_list builds the `--per-page 100 --output json` argv — the explicit
617    // per-page max overrides glab's default page size (30) so the list is not
618    // silently truncated.
619    #[tokio::test]
620    async fn mr_list_builds_output_json_argv() {
621        let rec = RecordingRunner::replying(Reply::ok("[]"));
622        let glab = GitLab::with_runner(&rec);
623        glab.mr_list(Path::new("/repo")).await.expect("mr_list");
624        assert_eq!(
625            rec.only_call().args_str(),
626            ["mr", "list", "--per-page", "100", "--output", "json"]
627        );
628    }
629
630    // Hermetic: auth_status reflects the exit code without erroring. ANY non-zero
631    // exit — not just the documented 1 — must read as `false`, never an error.
632    #[tokio::test]
633    async fn auth_status_reads_exit_code() {
634        let yes = GitLab::with_runner(ScriptedRunner::new().on(["auth"], Reply::ok("")));
635        assert!(yes.auth_status().await.unwrap());
636        let no = GitLab::with_runner(
637            ScriptedRunner::new().on(["auth"], Reply::fail(1, "not logged in")),
638        );
639        assert!(!no.auth_status().await.unwrap());
640        // An unexpected exit code (e.g. 2) is still just "not authenticated".
641        let weird = GitLab::with_runner(ScriptedRunner::new().on(["auth"], Reply::fail(2, "boom")));
642        assert!(!weird.auth_status().await.unwrap());
643    }
644
645    // A timed-out auth check must error, not silently report "not authenticated".
646    #[tokio::test]
647    async fn auth_status_errors_on_timeout() {
648        let glab = GitLab::with_runner(ScriptedRunner::new().on(["auth"], Reply::timeout()));
649        assert!(matches!(
650            glab.auth_status().await.unwrap_err(),
651            Error::Timeout { .. }
652        ));
653    }
654
655    // mr_create assembles title/description/--yes, then the optional source/target
656    // branch flags, and returns the trimmed output (the MR URL).
657    #[tokio::test]
658    async fn mr_create_appends_source_and_target() {
659        let rec = RecordingRunner::replying(Reply::ok("https://gl/mr/9\n"));
660        let glab = GitLab::with_runner(&rec);
661        let url = glab
662            .mr_create(
663                Path::new("/repo"),
664                MrCreate::new("T", "B").source("feat/x").target("main"),
665            )
666            .await
667            .expect("mr_create");
668        assert_eq!(url, "https://gl/mr/9");
669        assert_eq!(
670            rec.only_call().args_str(),
671            [
672                "mr",
673                "create",
674                "--title",
675                "T",
676                "--description",
677                "B",
678                "--yes",
679                "--source-branch",
680                "feat/x",
681                "--target-branch",
682                "main"
683            ]
684        );
685    }
686
687    // With no source/target, mr_create omits both branch flags.
688    #[tokio::test]
689    async fn mr_create_omits_branch_flags_when_none() {
690        let rec = RecordingRunner::replying(Reply::ok("https://gl/mr/1\n"));
691        let glab = GitLab::with_runner(&rec);
692        glab.mr_create(Path::new("/repo"), MrCreate::new("T", "B"))
693            .await
694            .expect("mr_create");
695        assert_eq!(
696            rec.only_call().args_str(),
697            [
698                "mr",
699                "create",
700                "--title",
701                "T",
702                "--description",
703                "B",
704                "--yes"
705            ]
706        );
707    }
708
709    // mr_merge adds `--yes`, and the strategy flag only for squash/rebase.
710    #[tokio::test]
711    async fn mr_merge_builds_strategy_argv() {
712        for (strategy, expected) in [
713            (
714                MergeStrategy::Merge,
715                vec!["mr", "merge", "5", "--yes", "--auto-merge=false"],
716            ),
717            (
718                MergeStrategy::Squash,
719                vec![
720                    "mr",
721                    "merge",
722                    "5",
723                    "--yes",
724                    "--auto-merge=false",
725                    "--squash",
726                ],
727            ),
728            (
729                MergeStrategy::Rebase,
730                vec![
731                    "mr",
732                    "merge",
733                    "5",
734                    "--yes",
735                    "--auto-merge=false",
736                    "--rebase",
737                ],
738            ),
739        ] {
740            let rec = RecordingRunner::replying(Reply::ok(""));
741            let glab = GitLab::with_runner(&rec);
742            glab.mr_merge(Path::new("/repo"), 5, strategy)
743                .await
744                .expect("mr_merge");
745            assert_eq!(rec.only_call().args_str(), expected);
746        }
747    }
748
749    // mr_ready maps to `mr update <id> --ready`; mr_close to `mr close <id>`.
750    #[tokio::test]
751    async fn mr_ready_and_close_build_expected_argv() {
752        let rec = RecordingRunner::replying(Reply::ok(""));
753        let glab = GitLab::with_runner(&rec);
754        glab.mr_ready(Path::new("/repo"), 3).await.expect("ready");
755        assert_eq!(rec.only_call().args_str(), ["mr", "update", "3", "--ready"]);
756
757        let rec = RecordingRunner::replying(Reply::ok(""));
758        let glab = GitLab::with_runner(&rec);
759        glab.mr_close(Path::new("/repo"), 3).await.expect("close");
760        assert_eq!(rec.only_call().args_str(), ["mr", "close", "3"]);
761    }
762
763    // mr_checks reads the MR's head_pipeline status and buckets it.
764    #[tokio::test]
765    async fn mr_checks_buckets_pipeline_status() {
766        let json = r#"{"iid":4,"head_pipeline":{"status":"failed"}}"#;
767        let glab = GitLab::with_runner(ScriptedRunner::new().on(["mr", "view"], Reply::ok(json)));
768        assert_eq!(
769            glab.mr_checks(Path::new("."), 4).await.unwrap(),
770            CiStatus::Failing
771        );
772    }
773
774    // issue_list builds the `--per-page 100 --output json` argv (per-page max
775    // overrides glab's default page size of 30) and parses the JSON.
776    #[tokio::test]
777    async fn issue_list_builds_argv_and_parses() {
778        let json = r#"[{"iid":3,"title":"Bug","state":"opened","description":"b","web_url":"u"}]"#;
779        let rec = RecordingRunner::replying(Reply::ok(json));
780        let glab = GitLab::with_runner(&rec);
781        let issues = glab
782            .issue_list(Path::new("/repo"))
783            .await
784            .expect("issue_list");
785        assert_eq!(issues.len(), 1);
786        assert_eq!(issues[0].number, 3);
787        assert_eq!(issues[0].state, "opened");
788        assert_eq!(
789            rec.only_call().args_str(),
790            ["issue", "list", "--per-page", "100", "--output", "json"]
791        );
792    }
793
794    // issue_view builds `issue view <number> --output json` and parses the JSON.
795    #[tokio::test]
796    async fn issue_view_builds_argv_and_parses() {
797        let json = r#"{"iid":7,"title":"T","state":"closed","description":"body","web_url":"https://gl/i/7"}"#;
798        let rec = RecordingRunner::replying(Reply::ok(json));
799        let glab = GitLab::with_runner(&rec);
800        let issue = glab
801            .issue_view(Path::new("/repo"), 7)
802            .await
803            .expect("issue_view");
804        assert_eq!(issue.number, 7);
805        assert_eq!(issue.body, "body");
806        assert_eq!(issue.url, "https://gl/i/7");
807        assert_eq!(
808            rec.only_call().args_str(),
809            ["issue", "view", "7", "--output", "json"]
810        );
811    }
812
813    // issue_create assembles title/description/--yes and returns the trimmed
814    // output (the issue URL).
815    #[tokio::test]
816    async fn issue_create_builds_argv_and_returns_url() {
817        let rec = RecordingRunner::replying(Reply::ok("https://gl/i/9\n"));
818        let glab = GitLab::with_runner(&rec);
819        let url = glab
820            .issue_create(Path::new("/repo"), "T", "B")
821            .await
822            .expect("issue_create");
823        assert_eq!(url, "https://gl/i/9");
824        assert_eq!(
825            rec.only_call().args_str(),
826            [
827                "issue",
828                "create",
829                "--title",
830                "T",
831                "--description",
832                "B",
833                "--yes"
834            ]
835        );
836    }
837
838    // release_list builds the `--per-page 100 --output json` argv and parses the
839    // JSON (URL comes off `_links.self`, date off `released_at`).
840    #[tokio::test]
841    async fn release_list_builds_argv_and_parses() {
842        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"}}]"#;
843        let rec = RecordingRunner::replying(Reply::ok(json));
844        let glab = GitLab::with_runner(&rec);
845        let releases = glab
846            .release_list(Path::new("/repo"))
847            .await
848            .expect("release_list");
849        assert_eq!(releases.len(), 1);
850        assert_eq!(releases[0].tag_name, "v1.0");
851        assert_eq!(releases[0].url, "https://gl/-/releases/v1.0");
852        assert_eq!(releases[0].published_at, "2026-01-02T03:04:05.000Z");
853        assert_eq!(
854            rec.only_call().args_str(),
855            ["release", "list", "--per-page", "100", "--output", "json"]
856        );
857    }
858
859    // release_view builds `release view <tag> --output json` and parses the JSON.
860    #[tokio::test]
861    async fn release_view_builds_argv_and_parses() {
862        let json =
863            r#"{"tag_name":"v2.1","name":"R","_links":{"self":"https://gl/-/releases/v2.1"}}"#;
864        let rec = RecordingRunner::replying(Reply::ok(json));
865        let glab = GitLab::with_runner(&rec);
866        let rel = glab
867            .release_view(Path::new("/repo"), "v2.1")
868            .await
869            .expect("release_view");
870        assert_eq!(rel.tag_name, "v2.1");
871        assert_eq!(rel.url, "https://gl/-/releases/v2.1");
872        assert_eq!(
873            rec.only_call().args_str(),
874            ["release", "view", "v2.1", "--output", "json"]
875        );
876    }
877
878    // release_view guards its bare `<tag>` positional: a flag-like or empty tag
879    // is rejected before any process spawns.
880    #[tokio::test]
881    async fn release_view_rejects_flag_like_tag() {
882        let glab = GitLab::with_runner(ScriptedRunner::new());
883        assert!(glab.release_view(Path::new("."), "-evil").await.is_err());
884        assert!(glab.release_view(Path::new("."), "").await.is_err());
885    }
886
887    // repo_view parses the GitLab Project JSON.
888    #[tokio::test]
889    async fn repo_view_parses_project() {
890        let json = r#"{"name":"cli","path_with_namespace":"gitlab-org/cli","default_branch":"main","web_url":"u","visibility":"public"}"#;
891        let glab = GitLab::with_runner(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
892        let p = glab.repo_view(Path::new(".")).await.expect("repo_view");
893        assert_eq!(p.path_with_namespace, "gitlab-org/cli");
894        assert_eq!(p.default_branch, "main");
895    }
896
897    #[cfg(feature = "mock")]
898    #[tokio::test]
899    async fn consumer_mocks_the_interface() {
900        let mut mock = MockGitLabApi::new();
901        mock.expect_auth_status().returning(|| Ok(true));
902        assert!(mock.auth_status().await.unwrap());
903    }
904}
905
906// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
907#[doc = include_str!("../docs/gitlab.md")]
908#[allow(rustdoc::broken_intra_doc_links)]
909pub mod guide {}