Skip to main content

vcs_forge/
dto.rs

1//! Forge-agnostic data types the facade returns, generalising the per-CLI shapes
2//! of `vcs-github`, `vcs-gitlab`, and `vcs-gitea` into one set a consumer can use
3//! without knowing which forge is in play.
4
5/// Which forge backs a [`Forge`](crate::Forge) handle.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize))]
8#[non_exhaustive]
9pub enum ForgeKind {
10    /// GitHub (the `gh` CLI).
11    GitHub,
12    /// GitLab (the `glab` CLI).
13    GitLab,
14    /// Gitea / Forgejo (the `tea` CLI).
15    Gitea,
16}
17
18impl ForgeKind {
19    /// The forge's short name (`"github"` / `"gitlab"` / `"gitea"`).
20    pub fn as_str(self) -> &'static str {
21        match self {
22            ForgeKind::GitHub => "github",
23            ForgeKind::GitLab => "gitlab",
24            ForgeKind::Gitea => "gitea",
25        }
26    }
27
28    /// Best-effort guess of the forge from a git remote URL's host, for the
29    /// **public SaaS** hosts: `github.com` → [`GitHub`](ForgeKind::GitHub),
30    /// `gitlab.com` → [`GitLab`](ForgeKind::GitLab), and `gitea.com` /
31    /// `codeberg.org` → [`Gitea`](ForgeKind::Gitea) — each matching the exact host
32    /// or a proper subdomain (`*.gitlab.com`), never a lookalike
33    /// (`gitlab.com.evil.example` → `None`).
34    ///
35    /// Returns `None` for everything else: a **self-hosted** GitLab/Gitea lives on
36    /// an arbitrary domain that can't be distinguished from any other host (and
37    /// must not be guessed from a substring, which an attacker-controlled host
38    /// could spoof), so pick the kind explicitly there. Accepts both
39    /// `https://host/owner/repo(.git)` and scp-like `git@host:owner/repo.git`.
40    pub fn from_remote_url(url: &str) -> Option<ForgeKind> {
41        let host = host_of(url)?.to_ascii_lowercase();
42        if host_is(&host, "github.com") {
43            Some(ForgeKind::GitHub)
44        } else if host_is(&host, "gitlab.com") {
45            Some(ForgeKind::GitLab)
46        } else if host_is(&host, "gitea.com") || host_is(&host, "codeberg.org") {
47            Some(ForgeKind::Gitea)
48        } else {
49            None
50        }
51    }
52}
53
54/// Whether `host` is exactly `domain` or a **proper subdomain** of it
55/// (`*.domain`) — an anchored match. Crucially, a lookalike such as
56/// `gitlab.com.attacker.net` does NOT match `gitlab.com` (it doesn't *end* with
57/// it after a `.`), and `notgithub.com` does NOT match `github.com`.
58fn host_is(host: &str, domain: &str) -> bool {
59    host == domain
60        || host
61            .strip_suffix(domain)
62            .is_some_and(|prefix| prefix.ends_with('.'))
63}
64
65/// Extract the host from a git remote URL — scheme URLs (`https://host/…`,
66/// `ssh://git@host:22/…`) and scp-like (`git@host:owner/repo.git`).
67fn host_of(url: &str) -> Option<&str> {
68    let rest = match url.split_once("://") {
69        // A scheme URL: take the authority up to the next `/`, then drop userinfo.
70        Some((_scheme, after)) => {
71            let authority = after.split(['/', '?', '#']).next().unwrap_or(after);
72            let host_port = authority.rsplit('@').next().unwrap_or(authority);
73            return host_port.split(':').next().filter(|h| !h.is_empty());
74        }
75        // No scheme: scp-like `user@host:path` or bare `host:path` / `host/path`.
76        None => url,
77    };
78    let after_user = rest.rsplit('@').next().unwrap_or(rest);
79    after_user
80        .split([':', '/'])
81        .next()
82        .filter(|h| !h.is_empty())
83}
84
85/// A pull request (GitHub) / merge request (GitLab) / pull request (Gitea),
86/// unified across the three forges.
87#[derive(Debug, Clone, PartialEq, Eq)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize))]
89#[non_exhaustive]
90pub struct ForgePr {
91    /// The PR/MR number a caller passes to the other operations (GitHub/Gitea
92    /// `number`, GitLab `iid`).
93    pub number: u64,
94    /// Title.
95    pub title: String,
96    /// Normalised state (see [`ForgePrState`]).
97    pub state: ForgePrState,
98    /// Source (head) branch name.
99    pub source_branch: String,
100    /// Target (base) branch name.
101    pub target_branch: String,
102    /// Web URL.
103    pub url: String,
104    /// Whether the PR/MR is a draft. **Best-effort**: only GitLab reports it on
105    /// the lean surface; GitHub and Gitea report `false` here (their lean JSON
106    /// doesn't carry the draft flag).
107    pub draft: bool,
108}
109
110/// The normalised state of a [`ForgePr`], unifying GitHub's `OPEN`/`CLOSED`/
111/// `MERGED`, GitLab's `opened`/`closed`/`locked`/`merged`, and Gitea's
112/// `open`/`closed` (+ a `merged` flag).
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize))]
115#[non_exhaustive]
116pub enum ForgePrState {
117    /// Open / awaiting review.
118    Open,
119    /// Closed without merging (GitLab's `locked` folds in here too).
120    Closed,
121    /// Merged.
122    Merged,
123}
124
125/// A repository (GitHub) / project (GitLab), unified. (Gitea's `tea` has no
126/// current-repo view, so [`repo_view`](crate::ForgeApi::repo_view) is
127/// [`Unsupported`](crate::Error::Unsupported) there.)
128#[derive(Debug, Clone, PartialEq, Eq)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize))]
130#[non_exhaustive]
131pub struct ForgeRepo {
132    /// Repository / project name.
133    pub name: String,
134    /// Owner / namespace (GitHub owner login; GitLab the namespace path).
135    pub owner: String,
136    /// Default branch name (empty for an empty repo).
137    pub default_branch: String,
138    /// Web URL.
139    pub url: String,
140    /// Whether the repository is private/non-public. **Conservative when
141    /// unknown:** if the backend doesn't report visibility (e.g. GitLab omits the
142    /// field), this is `false` (public) rather than `true` — a consumer is never
143    /// told a repo is private without proof.
144    pub private: bool,
145}
146
147/// An issue, unified across the three forges.
148#[derive(Debug, Clone, PartialEq, Eq)]
149#[cfg_attr(feature = "serde", derive(serde::Serialize))]
150#[non_exhaustive]
151pub struct ForgeIssue {
152    /// The issue number a caller passes to the other operations (GitHub/Gitea
153    /// `number`, GitLab `iid`).
154    pub number: u64,
155    /// Title.
156    pub title: String,
157    /// Normalised state (see [`ForgeIssueState`]).
158    pub state: ForgeIssueState,
159    /// Issue body (markdown). **Best-effort:** GitHub's lean `issue_list`
160    /// doesn't fetch it (empty there); [`issue_view`](crate::ForgeApi::issue_view)
161    /// fills it on every forge.
162    pub body: String,
163    /// Web URL. **Best-effort:** empty from GitHub's lean `issue_list`;
164    /// [`issue_view`](crate::ForgeApi::issue_view) fills it on every forge.
165    pub url: String,
166}
167
168/// The normalised state of a [`ForgeIssue`], unifying GitHub's `OPEN`/`CLOSED`,
169/// GitLab's `opened`/`closed`, and Gitea's `open`/`closed`. An unknown state
170/// reads as [`Open`](ForgeIssueState::Open) — a state we don't model is treated
171/// as live, never silently as resolved.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173#[cfg_attr(feature = "serde", derive(serde::Serialize))]
174#[non_exhaustive]
175pub enum ForgeIssueState {
176    /// Open / unresolved.
177    Open,
178    /// Closed.
179    Closed,
180}
181
182/// A release, unified across the three forges. (Gitea's `tea` always lists —
183/// it has no single-release view — so
184/// [`release_view`](crate::ForgeApi::release_view) is
185/// [`Unsupported`](crate::Error::Unsupported) there.)
186#[derive(Debug, Clone, PartialEq, Eq)]
187#[cfg_attr(feature = "serde", derive(serde::Serialize))]
188#[non_exhaustive]
189pub struct ForgeRelease {
190    /// The Git tag the release is attached to (what
191    /// [`release_view`](crate::ForgeApi::release_view) takes).
192    pub tag: String,
193    /// Release title (may be empty — forges commonly default it to the tag).
194    pub title: String,
195    /// Web URL. **Best-effort:** empty from GitHub's lean `release_list`;
196    /// `release_view` fills it where supported.
197    pub url: String,
198    /// Publication timestamp (ISO 8601); `None` for an unpublished draft or
199    /// when the backend doesn't report one.
200    pub published_at: Option<String>,
201}
202
203/// The coarse CI status for a PR/MR, bucketed into the four states a caller acts
204/// on. GitHub aggregates its per-check buckets into this; GitLab maps its
205/// pipeline status; Gitea's `tea` has no checks command, so
206/// [`pr_checks`](crate::ForgeApi::pr_checks) is
207/// [`Unsupported`](crate::Error::Unsupported) there.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize))]
210#[non_exhaustive]
211pub enum CiStatus {
212    /// Everything that ran passed.
213    Passing,
214    /// At least one check failed or was canceled.
215    Failing,
216    /// At least one check is still running, and none failed.
217    Pending,
218    /// No checks/pipeline ran.
219    None,
220}
221
222/// Options for [`pr_create`](crate::ForgeApi::pr_create) — the unified
223/// open-a-PR/MR spec, mapped to each CLI's own flags (gh `--head`/`--base`,
224/// glab `--source-branch`/`--target-branch`, tea `--head`/`--base`).
225///
226/// `#[non_exhaustive]`, so build it through [`PrCreate::new`] and the chained
227/// setters rather than a struct literal.
228#[derive(Debug, Clone, PartialEq, Eq)]
229#[cfg_attr(feature = "serde", derive(serde::Serialize))]
230#[non_exhaustive]
231pub struct PrCreate {
232    /// Title.
233    pub title: String,
234    /// Body / description.
235    pub body: String,
236    /// Source (head) branch; `None` = the current branch.
237    pub source: Option<String>,
238    /// Target (base) branch; `None` = the repository default.
239    pub target: Option<String>,
240}
241
242impl PrCreate {
243    /// A PR/MR from the current branch into the repository's default branch.
244    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
245        Self {
246            title: title.into(),
247            body: body.into(),
248            source: None,
249            target: None,
250        }
251    }
252
253    /// Open from this source (head) branch instead of the current one.
254    pub fn source(mut self, branch: impl Into<String>) -> Self {
255        self.source = Some(branch.into());
256        self
257    }
258
259    /// Open against this target (base) branch instead of the repo default.
260    pub fn target(mut self, branch: impl Into<String>) -> Self {
261        self.target = Some(branch.into());
262        self
263    }
264}
265
266/// How [`pr_merge`](crate::ForgeApi::pr_merge) merges — mapped to each CLI's own
267/// merge-strategy flag.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269#[cfg_attr(feature = "serde", derive(serde::Serialize))]
270#[non_exhaustive]
271pub enum MergeStrategy {
272    /// A merge commit.
273    Merge,
274    /// Squash the commits into one.
275    Squash,
276    /// Rebase the source onto the target.
277    Rebase,
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn from_remote_url_classifies_saas_hosts() {
286        use ForgeKind::*;
287        for (url, want) in [
288            ("https://github.com/o/r.git", Some(GitHub)),
289            ("git@github.com:o/r.git", Some(GitHub)),
290            ("https://foo.github.com/o/r", Some(GitHub)), // proper subdomain
291            ("https://gitlab.com/o/r", Some(GitLab)),
292            ("https://user:pass@gitlab.com/o/r", Some(GitLab)), // userinfo stripped
293            ("ssh://git@gitlab.com:22/o/r.git", Some(GitLab)),
294            ("https://gitea.com/o/r.git", Some(Gitea)),
295            ("git@codeberg.org:o/r.git", Some(Gitea)),
296            ("https://docs.codeberg.org/o/r", Some(Gitea)), // proper subdomain
297        ] {
298            assert_eq!(ForgeKind::from_remote_url(url), want, "{url}");
299        }
300    }
301
302    // A self-hosted instance on an arbitrary domain, and — crucially — a
303    // *lookalike* host an attacker controls, must NOT be classified as a trusted
304    // forge: the safe answer is `None` (the caller picks the kind explicitly).
305    #[test]
306    fn from_remote_url_rejects_self_hosted_and_lookalikes() {
307        for url in [
308            "https://gitlab.example.com/o/r.git",  // self-hosted GitLab
309            "https://gitea.example.org/o/r.git",   // self-hosted Gitea
310            "https://git.acme.io/o/r.git",         // arbitrary
311            "https://gitlab.com.attacker.net/o/r", // lookalike — must not be GitLab
312            "git@gitlab.attacker.com:o/r.git",     // lookalike
313            "https://my-gitea-host.evil.com/o/r",  // substring spoof — must not be Gitea
314            "https://notgithub.com/o/r",           // suffix without the dot
315            "https://github.com.evil.example/o/r", // lookalike — must not be GitHub
316            "",
317        ] {
318            assert_eq!(ForgeKind::from_remote_url(url), None, "{url}");
319        }
320    }
321
322    #[test]
323    fn as_str_maps_each_kind() {
324        assert_eq!(ForgeKind::GitHub.as_str(), "github");
325        assert_eq!(ForgeKind::GitLab.as_str(), "gitlab");
326        assert_eq!(ForgeKind::Gitea.as_str(), "gitea");
327    }
328}
329
330// Property-based fuzzing of `from_remote_url`. The URL/host parsing slices on
331// `://`, `@`, `:`, and `/` and must never panic on a hostile string; and the
332// anchored `host_is` match must never classify a *lookalike* host (an
333// attacker-controlled `github.com.evil.net`) as a trusted forge — the
334// regression net for the unit tests above, which only cover hand-picked cases.
335#[cfg(test)]
336mod proptests {
337    use super::*;
338    use proptest::prelude::*;
339
340    /// A URL shape embedding `host` in each position `from_remote_url` parses —
341    /// scheme URLs (with/without userinfo and port) and the scp-like form — so a
342    /// lookalike host is tested wherever it could appear.
343    fn url_around(host: impl Strategy<Value = String>) -> impl Strategy<Value = String> {
344        host.prop_flat_map(|h| {
345            prop_oneof![
346                Just(format!("https://{h}/o/r.git")),
347                Just(format!("https://user:pass@{h}/o/r")),
348                Just(format!("ssh://git@{h}:22/o/r.git")),
349                Just(format!("git@{h}:o/r.git")),
350                Just(format!("{h}/o/r")),
351            ]
352        })
353    }
354
355    /// Hosts that merely *resemble* a trusted SaaS host but aren't it: a trusted
356    /// domain as a left label (`github.com.evil.net`), a no-dot suffix
357    /// (`notgithub.com`), or the trusted domain buried mid-host — every one must
358    /// classify as `None`.
359    fn lookalike_host() -> impl Strategy<Value = String> {
360        // `prop_oneof!` consumes its strategies, so name the reusable ones as
361        // closures that build a fresh strategy at each use site.
362        let trusted = || {
363            prop_oneof![
364                Just("github.com"),
365                Just("gitlab.com"),
366                Just("gitea.com"),
367                Just("codeberg.org"),
368            ]
369        };
370        // TLDs disjoint from every trusted domain's (`com`/`org`), so a generated
371        // suffix can never BE a trusted domain — `github.com.gitea.com` would be
372        // a genuine subdomain of gitea.com and *correctly* classify, which is not
373        // what this strategy probes.
374        let evil = || "[a-z]{1,8}\\.(net|io|dev|xyz)";
375        prop_oneof![
376            // Trusted domain as a *prefix* label of an attacker domain.
377            (trusted(), evil()).prop_map(|(t, e)| format!("{t}.{e}")),
378            // Trusted domain glued on with no separating dot.
379            (prop_oneof![Just("not"), Just("my"), Just("x")], trusted())
380                .prop_map(|(p, t)| format!("{p}{t}")),
381            // Trusted domain buried as an *inner* label, not the suffix.
382            (evil(), trusted()).prop_map(|(e, t)| format!("x.{t}.{e}")),
383        ]
384    }
385
386    proptest! {
387        // Panic-freedom on completely arbitrary input.
388        #[test]
389        fn from_remote_url_never_panics(s in any::<String>()) {
390            let _ = ForgeKind::from_remote_url(&s);
391        }
392
393        // A lookalike host must NEVER be classified as a trusted forge.
394        #[test]
395        fn from_remote_url_rejects_lookalikes(url in url_around(lookalike_host())) {
396            prop_assert_eq!(
397                ForgeKind::from_remote_url(&url),
398                None,
399                "lookalike must not classify: {}",
400                url
401            );
402        }
403    }
404}
405
406// The optional `serde` feature derives `Serialize` on the unified DTOs.
407#[cfg(all(test, feature = "serde"))]
408mod serde_tests {
409    use super::*;
410
411    #[test]
412    fn forge_pr_serializes_to_clean_json() {
413        let pr = ForgePr {
414            number: 7,
415            title: "Add X".into(),
416            state: ForgePrState::Merged,
417            source_branch: "feat".into(),
418            target_branch: "main".into(),
419            url: "u".into(),
420            draft: false,
421        };
422        let v = serde_json::to_value(&pr).unwrap();
423        assert_eq!(v["number"], 7);
424        assert_eq!(v["state"], "Merged"); // enum → variant name
425        assert_eq!(v["source_branch"], "feat");
426    }
427
428    // The Wave-A DTOs are part of vcs-mcp's JSON wire format — pin their shape:
429    // the state enum serializes as the variant name, an absent publish date as
430    // `null`, and the PrCreate spec keeps its field names.
431    #[test]
432    fn issue_release_and_pr_create_serialize_to_clean_json() {
433        let issue = ForgeIssue {
434            number: 3,
435            title: "Bug".into(),
436            state: ForgeIssueState::Closed,
437            body: "b".into(),
438            url: "u".into(),
439        };
440        let v = serde_json::to_value(&issue).unwrap();
441        assert_eq!(v["number"], 3);
442        assert_eq!(v["state"], "Closed");
443        assert_eq!(v["body"], "b");
444
445        let release = ForgeRelease {
446            tag: "v1".into(),
447            title: "One".into(),
448            url: "u".into(),
449            published_at: None,
450        };
451        let v = serde_json::to_value(&release).unwrap();
452        assert_eq!(v["tag"], "v1");
453        assert!(v["published_at"].is_null(), "draft date must be null");
454
455        let spec = PrCreate::new("T", "B").source("feat");
456        let v = serde_json::to_value(&spec).unwrap();
457        assert_eq!(v["title"], "T");
458        assert_eq!(v["source"], "feat");
459        assert!(v["target"].is_null());
460    }
461}