Skip to main content

vcs_forge/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-forge` — one PR/MR lifecycle across GitHub, GitLab, and Gitea.
4//!
5//! A forge-agnostic facade over the [`vcs-github`](vcs_github),
6//! [`vcs-gitlab`](vcs_gitlab), and [`vcs-gitea`](vcs_gitea) wrappers: it dispatches
7//! the *common* forge operations (auth, repo view, the PR/MR lifecycle, issues,
8//! releases) to whichever CLI backs the handle and parses each one's output into
9//! **forge-agnostic DTOs** ([`ForgePr`], [`ForgeIssue`], [`ForgeRelease`],
10//! [`ForgeRepo`], …) — so a tool can target *the forge* instead of one specifically.
11//! It is the `gh`/`glab`/`tea` analogue of how [`vcs-core`](https://docs.rs/vcs-core)'s
12//! `Repo` sits over git and jj.
13//!
14//! Unlike a repository, a forge has **no filesystem marker** (`.git`/`.jj`) to
15//! detect — it's identified by the remote *host* — so a [`Forge`] is
16//! **constructed explicitly** ([`Forge::github`] / [`Forge::gitlab`] /
17//! [`Forge::gitea`]), optionally guided by [`ForgeKind::from_remote_url`] applied
18//! to a remote URL the caller already holds.
19//!
20//! # The surface
21//!
22//! - **[`Forge`]** — the cwd-bound, forge-agnostic handle. Operations run against
23//!   the bound directory ([`cwd`](Forge::cwd)); the CLI infers the repository from
24//!   that directory's git remote. [`Forge::github`] / [`gitlab`](Forge::gitlab) /
25//!   [`gitea`](Forge::gitea) build over the real job-backed runner;
26//!   [`at`](Forge::at) re-binds the cwd, sharing the client; [`kind`](Forge::kind)
27//!   reports which forge drives it.
28//! - **[`ForgeApi`]** — the object-safe trait the common surface lives on. Hold a
29//!   `Box<dyn ForgeApi>` / `&dyn ForgeApi` to code against the operations without
30//!   naming the [`ProcessRunner`] generic. Every method mirrors the like-named
31//!   inherent method on [`Forge`]; the trait adds nothing but the `&dyn` boundary.
32//! - **[`ForgeKind`]** — `GitHub` / `GitLab` / `Gitea`. Its pure, best-effort
33//!   [`from_remote_url`](ForgeKind::from_remote_url) classifies the *public SaaS*
34//!   hosts (github.com, gitlab.com, gitea.com, codeberg.org, and proper subdomains)
35//!   with an anchored match — a lookalike like `gitlab.com.attacker.net` and a
36//!   self-hosted instance on an arbitrary domain both return `None` (pick the kind
37//!   yourself).
38//! - **Unified DTOs** — [`ForgePr`] (+ [`ForgePrState`]), [`ForgeIssue`]
39//!   (+ [`ForgeIssueState`]), [`ForgeRelease`], [`ForgeRepo`], [`CiStatus`]; the
40//!   inputs [`PrCreate`] (open-a-PR spec: `new(title, body)` then
41//!   `.source(branch)` / `.target(branch)`, defaulting to the current branch and
42//!   repo default) and [`MergeStrategy`] (`Merge` / `Squash` / `Rebase`). Each
43//!   normalises the three CLIs' shapes — e.g. GitLab's `iid` becomes `number`, and
44//!   `OPEN` / `opened` / `open` all read as one state. Some fields are
45//!   best-effort: `draft`, and the `body`/`url` not present on lean list output.
46//! - **Operation groups** — auth ([`auth_status`](Forge::auth_status)); the repo
47//!   ([`repo_view`](Forge::repo_view)); the PR/MR lifecycle
48//!   ([`pr_list`](Forge::pr_list) / [`pr_view`](Forge::pr_view) /
49//!   [`pr_create`](Forge::pr_create) / [`pr_merge`](Forge::pr_merge) /
50//!   [`pr_mark_ready`](Forge::pr_mark_ready) / [`pr_close`](Forge::pr_close) /
51//!   [`pr_checks`](Forge::pr_checks)); issues ([`issue_list`](Forge::issue_list) /
52//!   [`issue_view`](Forge::issue_view) / [`issue_create`](Forge::issue_create));
53//!   releases ([`release_list`](Forge::release_list) /
54//!   [`release_view`](Forge::release_view)). List ops cap at 100 — drop to the
55//!   wrapped client for more.
56//! - **Capability gaps** — `tea` has no current-repo view, draft toggle, checks
57//!   command, or single-release view, so on a Gitea handle
58//!   [`repo_view`](Forge::repo_view), [`pr_mark_ready`](Forge::pr_mark_ready),
59//!   [`pr_checks`](Forge::pr_checks), and [`release_view`](Forge::release_view)
60//!   return [`Error::Unsupported`] **without spawning**. Classify it with
61//!   [`Error::is_unsupported`].
62//!
63//! The wrappers are re-exported (`vcs_forge::vcs_github` / `vcs_gitlab` /
64//! `vcs_gitea`) so anything beyond the portable intersection — a forge-specific op,
65//! or one the facade marks `Unsupported` — is one constructor away without a new
66//! dependency.
67//!
68//! # Recipes
69//!
70//! Read the open PRs — depend on the trait so the same code takes a real handle or
71//! a test double:
72//!
73//! ```no_run
74//! use vcs_forge::{Forge, ForgeApi};
75//! # async fn demo() -> Result<(), vcs_forge::Error> {
76//! let forge = Forge::github("."); // or ::gitlab(".") / ::gitea(".")
77//! for pr in forge.pr_list().await? {
78//!     println!("#{} [{:?}] {}", pr.number, pr.state, pr.title);
79//! }
80//! # Ok(()) }
81//! ```
82//!
83//! Open a PR/MR with [`PrCreate`] — the facade maps `source`/`target` to each
84//! CLI's own flags, and returns the CLI's success output (a URL on GitHub/GitLab):
85//!
86//! ```no_run
87//! use vcs_forge::{Forge, ForgeApi, PrCreate};
88//! # async fn demo(forge: &Forge) -> Result<(), vcs_forge::Error> {
89//! let spec = PrCreate::new("Add widget", "Closes #12").source("feature");
90//! let out = forge.pr_create(spec).await?;
91//! # let _ = out;
92//! # Ok(()) }
93//! ```
94//!
95//! # Testing
96//!
97//! The facade trait has **no mock feature** — `mockall` can't process the
98//! macro-generated [`ForgeApi`] signatures. Test the *real* dispatch instead:
99//! build a [`Forge`] over an explicit client wrapping a fake runner — e.g.
100//! `Forge::for_github(cwd, GitHub::with_runner(ScriptedRunner::new()))` (likewise
101//! [`for_gitlab`](Forge::for_gitlab) / [`for_gitea`](Forge::for_gitea)) — and
102//! script the canned CLI output, exercising the argv-building and DTO parsing
103//! end to end. The cross-cutting testing patterns live in
104//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
105//!
106//! # In-depth guide
107//!
108//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
109//! from `docs/`. See the [`guide`] module.
110
111use std::path::{Path, PathBuf};
112use std::sync::Arc;
113
114use processkit::{JobRunner, ProcessRunner};
115use vcs_gitea::Gitea;
116use vcs_github::GitHub;
117use vcs_gitlab::GitLab;
118
119mod dto;
120mod error;
121mod gitea_forge;
122mod github_forge;
123mod gitlab_forge;
124
125pub use dto::{
126    CiStatus, ForgeIssue, ForgeIssueState, ForgeKind, ForgePr, ForgePrState, ForgeRelease,
127    ForgeRepo, MergeStrategy, PrCreate,
128};
129pub use error::{Error, Result};
130
131// Re-export the underlying wrappers so a consumer depending only on `vcs-forge`
132// can construct the clients (`Forge::for_github(cwd, GitHub::new())`) and reach
133// forge-specific operations off the common surface.
134pub use vcs_gitea;
135pub use vcs_github;
136pub use vcs_gitlab;
137// Re-exported under the `cancellation` feature so a `vcs-forge`-only consumer can
138// name the token for a `default_cancel_on` client (built via `GitHub`/… then passed
139// to `Forge::for_github`/…) without a direct `processkit` dependency.
140#[cfg(feature = "cancellation")]
141#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
142pub use processkit::CancellationToken;
143
144/// The per-CLI client behind a [`Forge`]. Shared via `Arc` so [`Forge::at`] can
145/// re-anchor the cwd cheaply without rebuilding the client.
146enum Backend<R: ProcessRunner> {
147    GitHub(Arc<GitHub<R>>),
148    GitLab(Arc<GitLab<R>>),
149    Gitea(Arc<Gitea<R>>),
150}
151
152impl<R: ProcessRunner> Backend<R> {
153    fn shared(&self) -> Self {
154        match self {
155            Backend::GitHub(c) => Backend::GitHub(Arc::clone(c)),
156            Backend::GitLab(c) => Backend::GitLab(Arc::clone(c)),
157            Backend::Gitea(c) => Backend::Gitea(Arc::clone(c)),
158        }
159    }
160}
161
162/// A cwd-bound, forge-agnostic handle. Operations run against the bound directory
163/// ([`cwd`](Forge::cwd)); the CLI infers the repository from that directory's git
164/// remote. Use [`at`](Forge::at) for a sibling handle bound elsewhere.
165pub struct Forge<R: ProcessRunner = JobRunner> {
166    cwd: PathBuf,
167    backend: Backend<R>,
168}
169
170impl Forge<JobRunner> {
171    /// A GitHub-backed handle bound to `cwd`, using the real job-backed runner.
172    pub fn github(cwd: impl Into<PathBuf>) -> Self {
173        Forge {
174            cwd: cwd.into(),
175            backend: Backend::GitHub(Arc::new(GitHub::new())),
176        }
177    }
178
179    /// A GitLab-backed handle bound to `cwd`, using the real job-backed runner.
180    pub fn gitlab(cwd: impl Into<PathBuf>) -> Self {
181        Forge {
182            cwd: cwd.into(),
183            backend: Backend::GitLab(Arc::new(GitLab::new())),
184        }
185    }
186
187    /// A Gitea-backed handle bound to `cwd`, using the real job-backed runner.
188    pub fn gitea(cwd: impl Into<PathBuf>) -> Self {
189        Forge {
190            cwd: cwd.into(),
191            backend: Backend::Gitea(Arc::new(Gitea::new())),
192        }
193    }
194}
195
196impl<R: ProcessRunner> Forge<R> {
197    /// Build a GitHub-backed handle from an explicit client — for a custom runner
198    /// (e.g. a test seam) or a pre-configured [`GitHub`].
199    pub fn for_github(cwd: impl Into<PathBuf>, client: GitHub<R>) -> Self {
200        Forge {
201            cwd: cwd.into(),
202            backend: Backend::GitHub(Arc::new(client)),
203        }
204    }
205
206    /// Build a GitLab-backed handle from an explicit [`GitLab`] client.
207    pub fn for_gitlab(cwd: impl Into<PathBuf>, client: GitLab<R>) -> Self {
208        Forge {
209            cwd: cwd.into(),
210            backend: Backend::GitLab(Arc::new(client)),
211        }
212    }
213
214    /// Build a Gitea-backed handle from an explicit [`Gitea`] client.
215    pub fn for_gitea(cwd: impl Into<PathBuf>, client: Gitea<R>) -> Self {
216        Forge {
217            cwd: cwd.into(),
218            backend: Backend::Gitea(Arc::new(client)),
219        }
220    }
221
222    /// Which forge drives this handle.
223    pub fn kind(&self) -> ForgeKind {
224        match &self.backend {
225            Backend::GitHub(_) => ForgeKind::GitHub,
226            Backend::GitLab(_) => ForgeKind::GitLab,
227            Backend::Gitea(_) => ForgeKind::Gitea,
228        }
229    }
230
231    /// The directory operations run against.
232    pub fn cwd(&self) -> &Path {
233        &self.cwd
234    }
235
236    /// A sibling handle bound to `dir`, sharing this handle's client.
237    pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
238        Forge {
239            cwd: dir.into(),
240            backend: self.backend.shared(),
241        }
242    }
243
244    /// Whether the user is authenticated (GitHub/GitLab: a zero-exit `auth
245    /// status`; Gitea: at least one configured login).
246    pub async fn auth_status(&self) -> Result<bool> {
247        match &self.backend {
248            Backend::GitHub(c) => github_forge::auth_status(c).await,
249            Backend::GitLab(c) => gitlab_forge::auth_status(c).await,
250            Backend::Gitea(c) => gitea_forge::auth_status(c).await,
251        }
252    }
253
254    /// The repository/project for the bound directory. **[`Unsupported`](Error::Unsupported)
255    /// on Gitea** (`tea` has no current-repo view).
256    pub async fn repo_view(&self) -> Result<ForgeRepo> {
257        match &self.backend {
258            Backend::GitHub(c) => github_forge::repo_view(c, &self.cwd).await,
259            Backend::GitLab(c) => gitlab_forge::repo_view(c, &self.cwd).await,
260            Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "repo_view")),
261        }
262    }
263
264    /// Open pull/merge requests for the bound directory.
265    pub async fn pr_list(&self) -> Result<Vec<ForgePr>> {
266        match &self.backend {
267            Backend::GitHub(c) => github_forge::pr_list(c, &self.cwd).await,
268            Backend::GitLab(c) => gitlab_forge::pr_list(c, &self.cwd).await,
269            Backend::Gitea(c) => gitea_forge::pr_list(c, &self.cwd).await,
270        }
271    }
272
273    /// A single PR/MR by number (GitLab `iid`). On Gitea this lists and filters
274    /// (`tea` has no single-PR view).
275    pub async fn pr_view(&self, number: u64) -> Result<ForgePr> {
276        match &self.backend {
277            Backend::GitHub(c) => github_forge::pr_view(c, &self.cwd, number).await,
278            Backend::GitLab(c) => gitlab_forge::pr_view(c, &self.cwd, number).await,
279            Backend::Gitea(c) => gitea_forge::pr_view(c, &self.cwd, number).await,
280        }
281    }
282
283    /// Open a PR/MR (see [`PrCreate`]), returning the CLI's success output — a
284    /// URL on GitHub/GitLab; `tea` prints a textual summary (no URL).
285    pub async fn pr_create(&self, spec: PrCreate) -> Result<String> {
286        match &self.backend {
287            Backend::GitHub(c) => github_forge::pr_create(c, &self.cwd, spec).await,
288            Backend::GitLab(c) => gitlab_forge::pr_create(c, &self.cwd, spec).await,
289            Backend::Gitea(c) => gitea_forge::pr_create(c, &self.cwd, spec).await,
290        }
291    }
292
293    /// Merge a PR/MR with the given [`MergeStrategy`].
294    pub async fn pr_merge(&self, number: u64, strategy: MergeStrategy) -> Result<()> {
295        match &self.backend {
296            Backend::GitHub(c) => github_forge::pr_merge(c, &self.cwd, number, strategy).await,
297            Backend::GitLab(c) => gitlab_forge::pr_merge(c, &self.cwd, number, strategy).await,
298            Backend::Gitea(c) => gitea_forge::pr_merge(c, &self.cwd, number, strategy).await,
299        }
300    }
301
302    /// Mark a draft PR/MR as ready for review. **[`Unsupported`](Error::Unsupported)
303    /// on Gitea** (`tea` has no draft toggle — a Gitea draft is a `WIP:` title
304    /// prefix, edited via the raw client).
305    pub async fn pr_mark_ready(&self, number: u64) -> Result<()> {
306        match &self.backend {
307            Backend::GitHub(c) => github_forge::pr_mark_ready(c, &self.cwd, number).await,
308            Backend::GitLab(c) => gitlab_forge::pr_mark_ready(c, &self.cwd, number).await,
309            Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "pr_mark_ready")),
310        }
311    }
312
313    /// Close a PR/MR without merging. `delete_branch` applies to GitHub only
314    /// (`gh pr close --delete-branch`); GitLab and Gitea ignore it.
315    pub async fn pr_close(&self, number: u64, delete_branch: bool) -> Result<()> {
316        match &self.backend {
317            Backend::GitHub(c) => github_forge::pr_close(c, &self.cwd, number, delete_branch).await,
318            Backend::GitLab(c) => gitlab_forge::pr_close(c, &self.cwd, number).await,
319            Backend::Gitea(c) => gitea_forge::pr_close(c, &self.cwd, number).await,
320        }
321    }
322
323    /// The PR/MR's coarse CI status (see [`CiStatus`]). **[`Unsupported`](Error::Unsupported)
324    /// on Gitea** (`tea` has no checks command).
325    pub async fn pr_checks(&self, number: u64) -> Result<CiStatus> {
326        match &self.backend {
327            Backend::GitHub(c) => github_forge::pr_checks(c, &self.cwd, number).await,
328            Backend::GitLab(c) => gitlab_forge::pr_checks(c, &self.cwd, number).await,
329            Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "pr_checks")),
330        }
331    }
332
333    /// Open issues for the bound directory (up to 100; drop to the underlying
334    /// client for more).
335    pub async fn issue_list(&self) -> Result<Vec<ForgeIssue>> {
336        match &self.backend {
337            Backend::GitHub(c) => github_forge::issue_list(c, &self.cwd).await,
338            Backend::GitLab(c) => gitlab_forge::issue_list(c, &self.cwd).await,
339            Backend::Gitea(c) => gitea_forge::issue_list(c, &self.cwd).await,
340        }
341    }
342
343    /// A single issue by number (GitLab `iid`), with `body`/`url` filled.
344    pub async fn issue_view(&self, number: u64) -> Result<ForgeIssue> {
345        match &self.backend {
346            Backend::GitHub(c) => github_forge::issue_view(c, &self.cwd, number).await,
347            Backend::GitLab(c) => gitlab_forge::issue_view(c, &self.cwd, number).await,
348            Backend::Gitea(c) => gitea_forge::issue_view(c, &self.cwd, number).await,
349        }
350    }
351
352    /// Open an issue, returning the CLI's success output — a URL on
353    /// GitHub/GitLab; `tea` prints a textual summary whose final line is the
354    /// URL. (The same honest-output contract as [`pr_create`](Forge::pr_create).)
355    pub async fn issue_create(&self, title: &str, body: &str) -> Result<String> {
356        match &self.backend {
357            Backend::GitHub(c) => github_forge::issue_create(c, &self.cwd, title, body).await,
358            Backend::GitLab(c) => gitlab_forge::issue_create(c, &self.cwd, title, body).await,
359            Backend::Gitea(c) => gitea_forge::issue_create(c, &self.cwd, title, body).await,
360        }
361    }
362
363    /// Releases for the bound directory, newest first (up to 100; drop to the
364    /// underlying client for more).
365    pub async fn release_list(&self) -> Result<Vec<ForgeRelease>> {
366        match &self.backend {
367            Backend::GitHub(c) => github_forge::release_list(c, &self.cwd).await,
368            Backend::GitLab(c) => gitlab_forge::release_list(c, &self.cwd).await,
369            Backend::Gitea(c) => gitea_forge::release_list(c, &self.cwd).await,
370        }
371    }
372
373    /// A single release by tag. **[`Unsupported`](Error::Unsupported) on Gitea**
374    /// (`tea releases` always lists — it has no single-release view; filter
375    /// [`release_list`](Forge::release_list) instead).
376    pub async fn release_view(&self, tag: &str) -> Result<ForgeRelease> {
377        match &self.backend {
378            Backend::GitHub(c) => github_forge::release_view(c, &self.cwd, tag).await,
379            Backend::GitLab(c) => gitlab_forge::release_view(c, &self.cwd, tag).await,
380            Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "release_view")),
381        }
382    }
383}
384
385fn unsupported(forge: ForgeKind, operation: &'static str) -> Error {
386    Error::Unsupported { forge, operation }
387}
388
389/// Generate a facade trait from one signature table: the `#[async_trait]` trait
390/// declaration *and* the delegating `impl … for $Ty<R>`, so the two can never drift
391/// out of sync (a hazard when each is hand-maintained). Every generated body is a
392/// trivial delegation to the like-named inherent method — which method resolution
393/// prefers, so this never recurses; the real backend-`match` dispatch stays
394/// hand-written on the inherent `impl`. `async` methods doc-link to their inherent
395/// twin; `sync` methods carry an explicit doc string (their docs aren't uniform).
396///
397/// A near-identical copy lives in `vcs-core` (`facade_trait!`); the two are
398/// deliberately not shared (separate crates, ~40-line macro — duplication beats a
399/// new dependency). Signatures only — each entry is a bare `&self`/sync method (no
400/// method-level generics, no `&mut self`, no default bodies; a method shaped that
401/// way needs a grammar tweak, not just a table row).
402/// No `mockall::automock`: a Wave-S spike proved it can't process a
403/// trait whose signatures come from `macro_rules!` — captured `$_:ty` fragments
404/// reach `automock` as opaque nonterminal token groups its `syn` parser rejects
405/// ("unsupported type in this position"), whereas `#[async_trait]` tolerates them.
406/// The facade stays test-seam-tested (build a [`Forge`] over a fake runner).
407macro_rules! facade_trait {
408    (
409        $(#[doc = $tdoc:expr])*
410        trait $Trait:ident for $Ty:ident;
411        sync {
412            $( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
413        }
414        async {
415            $( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
416        }
417    ) => {
418        $(#[doc = $tdoc])*
419        #[async_trait::async_trait]
420        pub trait $Trait: Send + Sync {
421            $(
422                #[doc = $sdoc]
423                fn $sn(&self, $($sa: $sat),*) -> $sr;
424            )*
425            $(
426                #[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
427                async fn $an(&self, $($aa: $aat),*) -> $ar;
428            )*
429        }
430
431        // Delegates to the inherent methods, which method resolution prefers — so
432        // these bodies dispatch through the concrete type's real implementations,
433        // not back into the trait.
434        #[async_trait::async_trait]
435        impl<R: ProcessRunner> $Trait for $Ty<R> {
436            $(
437                fn $sn(&self, $($sa: $sat),*) -> $sr {
438                    self.$sn($($sa),*)
439                }
440            )*
441            $(
442                async fn $an(&self, $($aa: $aat),*) -> $ar {
443                    self.$an($($aa),*).await
444                }
445            )*
446        }
447    };
448}
449
450facade_trait! {
451    /// The forge-agnostic common surface of [`Forge`], as a trait — so a consumer can
452    /// hold a `Box<dyn ForgeApi>` / `&dyn ForgeApi` and code against the operations
453    /// without naming the [`ProcessRunner`] generic.
454    ///
455    /// Every method mirrors the like-named inherent method on [`Forge`].
456    trait ForgeApi for Forge;
457    sync {
458        #[doc = "Which forge drives this handle."]
459        fn kind() -> ForgeKind;
460        #[doc = "The directory operations run against."]
461        fn cwd() -> &Path;
462    }
463    async {
464        fn auth_status() -> Result<bool>;
465        fn repo_view() -> Result<ForgeRepo>;
466        fn pr_list() -> Result<Vec<ForgePr>>;
467        fn pr_view(number: u64) -> Result<ForgePr>;
468        fn pr_create(spec: PrCreate) -> Result<String>;
469        fn pr_merge(number: u64, strategy: MergeStrategy) -> Result<()>;
470        fn pr_mark_ready(number: u64) -> Result<()>;
471        fn pr_close(number: u64, delete_branch: bool) -> Result<()>;
472        fn pr_checks(number: u64) -> Result<CiStatus>;
473        fn issue_list() -> Result<Vec<ForgeIssue>>;
474        fn issue_view(number: u64) -> Result<ForgeIssue>;
475        fn issue_create(title: &str, body: &str) -> Result<String>;
476        fn release_list() -> Result<Vec<ForgeRelease>>;
477        fn release_view(tag: &str) -> Result<ForgeRelease>;
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use processkit::{RecordingRunner, Reply, ScriptedRunner};
485
486    fn github(runner: ScriptedRunner) -> Forge<ScriptedRunner> {
487        Forge::for_github("/repo", GitHub::with_runner(runner))
488    }
489    fn gitlab(runner: ScriptedRunner) -> Forge<ScriptedRunner> {
490        Forge::for_gitlab("/repo", GitLab::with_runner(runner))
491    }
492    fn gitea(runner: ScriptedRunner) -> Forge<ScriptedRunner> {
493        Forge::for_gitea("/repo", Gitea::with_runner(runner))
494    }
495
496    #[tokio::test]
497    async fn kind_reflects_backend() {
498        assert_eq!(github(ScriptedRunner::new()).kind(), ForgeKind::GitHub);
499        assert_eq!(gitlab(ScriptedRunner::new()).kind(), ForgeKind::GitLab);
500        assert_eq!(gitea(ScriptedRunner::new()).kind(), ForgeKind::Gitea);
501    }
502
503    // GitHub's "OPEN"/"MERGED" states map onto the unified ForgePrState.
504    #[tokio::test]
505    async fn github_pr_list_maps_to_unified() {
506        let json = r#"[{"number":7,"title":"X","state":"MERGED","headRefName":"feat","baseRefName":"main","url":"u"}]"#;
507        let forge = github(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
508        let prs = forge.pr_list().await.unwrap();
509        assert_eq!(prs[0].number, 7);
510        assert_eq!(prs[0].state, ForgePrState::Merged);
511        assert_eq!(prs[0].source_branch, "feat");
512    }
513
514    // GitLab `repo_view` maps a known "public" visibility to private == false.
515    #[tokio::test]
516    async fn gitlab_repo_view_maps_public_visibility() {
517        let json = r#"{"name":"cli","path_with_namespace":"gitlab-org/cli","default_branch":"main","web_url":"u","visibility":"public"}"#;
518        let forge = gitlab(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
519        let repo = forge.repo_view().await.unwrap();
520        assert_eq!(repo.owner, "gitlab-org");
521        assert_eq!(repo.name, "cli");
522        assert!(!repo.private);
523    }
524
525    // When glab omits `visibility`, the facade must NOT report the repo as private
526    // — an unknown visibility is the conservative `false`, never a false privacy.
527    #[tokio::test]
528    async fn gitlab_repo_view_absent_visibility_is_not_private() {
529        let json =
530            r#"{"name":"cli","path_with_namespace":"o/cli","default_branch":"main","web_url":"u"}"#;
531        let forge = gitlab(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
532        let repo = forge.repo_view().await.unwrap();
533        assert!(!repo.private, "absent visibility must not be private");
534    }
535
536    // GitLab's `iid` becomes the number and "opened" maps to Open.
537    #[tokio::test]
538    async fn gitlab_pr_list_maps_iid_and_state() {
539        let json = r#"[{"iid":12,"title":"X","state":"opened","source_branch":"feat","target_branch":"main","web_url":"u","draft":true}]"#;
540        let forge = gitlab(ScriptedRunner::new().on(["mr", "list"], Reply::ok(json)));
541        let prs = forge.pr_list().await.unwrap();
542        assert_eq!(prs[0].number, 12);
543        assert_eq!(prs[0].state, ForgePrState::Open);
544        assert!(prs[0].draft);
545    }
546
547    // Gitea's `merged` flag drives Merged even though `state` is "closed".
548    #[tokio::test]
549    async fn gitea_pr_view_filters_and_maps_merged() {
550        // tea's table shape: all-string values, flat head/base, merge folded
551        // into the `state` column.
552        let json =
553            r#"[{"index":"9","title":"Nine","state":"merged","head":"f","base":"main","url":"u"}]"#;
554        let forge = gitea(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
555        let pr = forge.pr_view(9).await.unwrap();
556        assert_eq!(pr.state, ForgePrState::Merged);
557        assert_eq!(pr.target_branch, "main");
558    }
559
560    // The Gitea backend reports the four unmodelled ops as Unsupported, naming
561    // the operation — and without spawning anything.
562    #[tokio::test]
563    async fn gitea_unsupported_ops_error_without_spawning() {
564        let rec = RecordingRunner::replying(Reply::ok(""));
565        let forge = Forge::for_gitea("/repo", Gitea::with_runner(&rec));
566        for err in [
567            forge.repo_view().await.unwrap_err(),
568            forge.pr_mark_ready(1).await.unwrap_err(),
569            forge.pr_checks(1).await.unwrap_err(),
570            forge.release_view("v1.0.0").await.unwrap_err(),
571        ] {
572            assert!(err.is_unsupported(), "{err:?}");
573        }
574        assert!(rec.calls().is_empty(), "unsupported ops must not spawn");
575    }
576
577    // Each backend's issue states map onto the unified ForgeIssueState — note
578    // the three different spellings of "open": "OPEN" (gh), "opened" (glab),
579    // "open" (tea) — all must read as Open, and "closed" (any case) as Closed.
580    #[tokio::test]
581    async fn issue_list_maps_states_per_backend() {
582        let json = r#"[{"number":3,"title":"A","state":"OPEN"},{"number":4,"title":"B","state":"CLOSED"}]"#;
583        let forge = github(ScriptedRunner::new().on(["issue", "list"], Reply::ok(json)));
584        let issues = forge.issue_list().await.unwrap();
585        assert_eq!(issues[0].state, ForgeIssueState::Open);
586        assert_eq!(issues[1].state, ForgeIssueState::Closed);
587
588        let json = r#"[{"iid":12,"title":"X","state":"opened","description":"d","web_url":"u"}]"#;
589        let forge = gitlab(ScriptedRunner::new().on(["issue", "list"], Reply::ok(json)));
590        let issues = forge.issue_list().await.unwrap();
591        assert_eq!(issues[0].number, 12);
592        assert_eq!(issues[0].state, ForgeIssueState::Open);
593        assert_eq!(issues[0].body, "d");
594
595        // tea's table shape: all-string values, `index` column.
596        let json = r#"[{"index":"9","title":"Y","state":"open","body":"b","url":"u"}]"#;
597        let forge = gitea(ScriptedRunner::new().on(["issues", "list"], Reply::ok(json)));
598        let issues = forge.issue_list().await.unwrap();
599        assert_eq!(issues[0].number, 9);
600        assert_eq!(issues[0].state, ForgeIssueState::Open);
601    }
602
603    // Releases map per backend; an empty/absent publish timestamp (a draft)
604    // surfaces as None, a present one as Some.
605    #[tokio::test]
606    async fn release_list_maps_published_at_per_backend() {
607        let json = r#"[{"tagName":"v1","name":"One","publishedAt":"2026-01-01T00:00:00Z"},{"tagName":"v2-draft","name":"","publishedAt":"","isDraft":true}]"#;
608        let forge = github(ScriptedRunner::new().on(["release", "list"], Reply::ok(json)));
609        let rels = forge.release_list().await.unwrap();
610        assert_eq!(rels[0].tag, "v1");
611        assert_eq!(
612            rels[0].published_at.as_deref(),
613            Some("2026-01-01T00:00:00Z")
614        );
615        assert_eq!(rels[1].published_at, None);
616
617        let json = r#"[{"tag_name":"v1","name":"One","released_at":"2026-01-01T00:00:00Z","_links":{"self":"u"}}]"#;
618        let forge = gitlab(ScriptedRunner::new().on(["release", "list"], Reply::ok(json)));
619        let rels = forge.release_list().await.unwrap();
620        assert_eq!(rels[0].url, "u");
621        assert!(rels[0].published_at.is_some());
622
623        // tea's release table: `toSnakeCase`d string keys (`tag-_name`,
624        // `published _at`), no release-page URL column.
625        let json = r#"[{"tag-_name":"v1","title":"One","status":"released","published _at":"2026-01-01T00:00:00Z"}]"#;
626        let forge = gitea(ScriptedRunner::new().on(["releases", "list"], Reply::ok(json)));
627        let rels = forge.release_list().await.unwrap();
628        assert_eq!(rels[0].tag, "v1");
629        assert_eq!(rels[0].title, "One");
630        assert_eq!(rels[0].url, ""); // tea exposes no release-page URL
631        assert!(rels[0].published_at.is_some());
632    }
633
634    // The unified MergeStrategy maps to each CLI's own flag.
635    #[tokio::test]
636    async fn pr_merge_maps_strategy_per_backend() {
637        let rec = RecordingRunner::replying(Reply::ok(""));
638        Forge::for_github("/repo", GitHub::with_runner(&rec))
639            .pr_merge(5, MergeStrategy::Squash)
640            .await
641            .unwrap();
642        assert_eq!(rec.only_call().args_str(), ["pr", "merge", "5", "--squash"]);
643
644        let rec = RecordingRunner::replying(Reply::ok(""));
645        Forge::for_gitlab("/repo", GitLab::with_runner(&rec))
646            .pr_merge(5, MergeStrategy::Rebase)
647            .await
648            .unwrap();
649        assert_eq!(
650            rec.only_call().args_str(),
651            [
652                "mr",
653                "merge",
654                "5",
655                "--yes",
656                "--auto-merge=false",
657                "--rebase"
658            ]
659        );
660
661        let rec = RecordingRunner::replying(Reply::ok(""));
662        Forge::for_gitea("/repo", Gitea::with_runner(&rec))
663            .pr_merge(5, MergeStrategy::Merge)
664            .await
665            .unwrap();
666        assert_eq!(
667            rec.only_call().args_str(),
668            ["pr", "merge", "5", "--style", "merge"]
669        );
670    }
671
672    // GitHub's per-check buckets aggregate into one coarse CiStatus.
673    #[tokio::test]
674    async fn github_pr_checks_aggregates_buckets() {
675        let json = r#"[{"name":"a","bucket":"pass"},{"name":"b","bucket":"fail"}]"#;
676        let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
677        assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::Failing);
678
679        let json = r#"[{"name":"a","bucket":"pass"},{"name":"b","bucket":"pending"}]"#;
680        let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
681        assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::Pending);
682
683        // A cancelled check is a failure (short-circuits like `fail`).
684        let json = r#"[{"name":"a","bucket":"pass"},{"name":"b","bucket":"cancel"}]"#;
685        let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
686        assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::Failing);
687
688        // All-skipped (no pass/fail/pending) and an empty list both read as None.
689        let json = r#"[{"name":"a","bucket":"skipping"}]"#;
690        let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
691        assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::None);
692        let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok("[]")));
693        assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::None);
694    }
695
696    // `at` re-binds the cwd while sharing the backend.
697    #[tokio::test]
698    async fn at_rebinds_cwd_and_shares_backend() {
699        let forge = github(ScriptedRunner::new());
700        let moved = forge.at("/repo/sub");
701        assert_eq!(moved.cwd(), Path::new("/repo/sub"));
702        assert_eq!(moved.kind(), ForgeKind::GitHub);
703    }
704
705    // `&dyn ForgeApi` must dispatch through the real inherent methods (a delegating
706    // body that recursed would stack-overflow here instead of returning).
707    #[tokio::test]
708    async fn forge_api_trait_object_dispatches() {
709        let json = r#"[{"iid":1,"title":"X","state":"opened","source_branch":"f","target_branch":"main","web_url":"u"}]"#;
710        let forge = gitlab(
711            ScriptedRunner::new()
712                .on(["mr", "list"], Reply::ok(json))
713                .on(["issue", "create"], Reply::ok("https://gl/i/9\n")),
714        );
715        let dynamic: &dyn ForgeApi = &forge;
716        assert_eq!(dynamic.kind(), ForgeKind::GitLab);
717        assert_eq!(dynamic.pr_list().await.unwrap()[0].number, 1);
718        // Exercise a reference-argument async method through `&dyn` — pins the
719        // async_trait lifetime capture the macro relies on (no-arg calls don't).
720        assert_eq!(
721            dynamic.issue_create("T", "B").await.unwrap(),
722            "https://gl/i/9"
723        );
724    }
725}
726
727// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
728#[doc = include_str!("../docs/forge.md")]
729#[allow(rustdoc::broken_intra_doc_links)]
730pub mod guide {}