vcs-forge 0.1.0

A backend-agnostic facade over the GitHub/GitLab/Gitea CLI wrappers (gh/glab/tea).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Forge-agnostic data types the facade returns, generalising the per-CLI shapes
//! of `vcs-github`, `vcs-gitlab`, and `vcs-gitea` into one set a consumer can use
//! without knowing which forge is in play.

/// Which forge backs a [`Forge`](crate::Forge) handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum ForgeKind {
    /// GitHub (the `gh` CLI).
    GitHub,
    /// GitLab (the `glab` CLI).
    GitLab,
    /// Gitea / Forgejo (the `tea` CLI).
    Gitea,
}

impl ForgeKind {
    /// The forge's short name (`"github"` / `"gitlab"` / `"gitea"`).
    pub fn as_str(self) -> &'static str {
        match self {
            ForgeKind::GitHub => "github",
            ForgeKind::GitLab => "gitlab",
            ForgeKind::Gitea => "gitea",
        }
    }

    /// Best-effort guess of the forge from a git remote URL's host, for the
    /// **public SaaS** hosts: `github.com` → [`GitHub`](ForgeKind::GitHub),
    /// `gitlab.com` → [`GitLab`](ForgeKind::GitLab), and `gitea.com` /
    /// `codeberg.org` → [`Gitea`](ForgeKind::Gitea) — each matching the exact host
    /// or a proper subdomain (`*.gitlab.com`), never a lookalike
    /// (`gitlab.com.evil.example` → `None`).
    ///
    /// Returns `None` for everything else: a **self-hosted** GitLab/Gitea lives on
    /// an arbitrary domain that can't be distinguished from any other host (and
    /// must not be guessed from a substring, which an attacker-controlled host
    /// could spoof), so pick the kind explicitly there. Accepts both
    /// `https://host/owner/repo(.git)` and scp-like `git@host:owner/repo.git`.
    pub fn from_remote_url(url: &str) -> Option<ForgeKind> {
        let host = host_of(url)?.to_ascii_lowercase();
        if host_is(&host, "github.com") {
            Some(ForgeKind::GitHub)
        } else if host_is(&host, "gitlab.com") {
            Some(ForgeKind::GitLab)
        } else if host_is(&host, "gitea.com") || host_is(&host, "codeberg.org") {
            Some(ForgeKind::Gitea)
        } else {
            None
        }
    }
}

/// Whether `host` is exactly `domain` or a **proper subdomain** of it
/// (`*.domain`) — an anchored match. Crucially, a lookalike such as
/// `gitlab.com.attacker.net` does NOT match `gitlab.com` (it doesn't *end* with
/// it after a `.`), and `notgithub.com` does NOT match `github.com`.
fn host_is(host: &str, domain: &str) -> bool {
    host == domain
        || host
            .strip_suffix(domain)
            .is_some_and(|prefix| prefix.ends_with('.'))
}

/// Extract the host from a git remote URL — scheme URLs (`https://host/…`,
/// `ssh://git@host:22/…`) and scp-like (`git@host:owner/repo.git`).
fn host_of(url: &str) -> Option<&str> {
    let rest = match url.split_once("://") {
        // A scheme URL: take the authority up to the next `/`, then drop userinfo.
        Some((_scheme, after)) => {
            let authority = after.split(['/', '?', '#']).next().unwrap_or(after);
            let host_port = authority.rsplit('@').next().unwrap_or(authority);
            return host_port.split(':').next().filter(|h| !h.is_empty());
        }
        // No scheme: scp-like `user@host:path` or bare `host:path` / `host/path`.
        None => url,
    };
    let after_user = rest.rsplit('@').next().unwrap_or(rest);
    after_user
        .split([':', '/'])
        .next()
        .filter(|h| !h.is_empty())
}

/// A pull request (GitHub) / merge request (GitLab) / pull request (Gitea),
/// unified across the three forges.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgePr {
    /// The PR/MR number a caller passes to the other operations (GitHub/Gitea
    /// `number`, GitLab `iid`).
    pub number: u64,
    /// Title.
    pub title: String,
    /// Normalised state (see [`ForgePrState`]).
    pub state: ForgePrState,
    /// Source (head) branch name.
    pub source_branch: String,
    /// Target (base) branch name.
    pub target_branch: String,
    /// Web URL.
    pub url: String,
    /// Whether the PR/MR is a draft. **Best-effort**: only GitLab reports it on
    /// the lean surface; GitHub and Gitea report `false` here (their lean JSON
    /// doesn't carry the draft flag).
    pub draft: bool,
}

/// The normalised state of a [`ForgePr`], unifying GitHub's `OPEN`/`CLOSED`/
/// `MERGED`, GitLab's `opened`/`closed`/`locked`/`merged`, and Gitea's
/// `open`/`closed` (+ a `merged` flag).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum ForgePrState {
    /// Open / awaiting review.
    Open,
    /// Closed without merging (GitLab's `locked` folds in here too).
    Closed,
    /// Merged.
    Merged,
}

/// A repository (GitHub) / project (GitLab), unified. (Gitea's `tea` has no
/// current-repo view, so [`repo_view`](crate::ForgeApi::repo_view) is
/// [`Unsupported`](crate::Error::Unsupported) there.)
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgeRepo {
    /// Repository / project name.
    pub name: String,
    /// Owner / namespace (GitHub owner login; GitLab the namespace path).
    pub owner: String,
    /// Default branch name (empty for an empty repo).
    pub default_branch: String,
    /// Web URL.
    pub url: String,
    /// Whether the repository is private/non-public. **Conservative when
    /// unknown:** if the backend doesn't report visibility (e.g. GitLab omits the
    /// field), this is `false` (public) rather than `true` — a consumer is never
    /// told a repo is private without proof.
    pub private: bool,
}

/// An issue, unified across the three forges.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgeIssue {
    /// The issue number a caller passes to the other operations (GitHub/Gitea
    /// `number`, GitLab `iid`).
    pub number: u64,
    /// Title.
    pub title: String,
    /// Normalised state (see [`ForgeIssueState`]).
    pub state: ForgeIssueState,
    /// Issue body (markdown). **Best-effort:** GitHub's lean `issue_list`
    /// doesn't fetch it (empty there); [`issue_view`](crate::ForgeApi::issue_view)
    /// fills it on every forge.
    pub body: String,
    /// Web URL. **Best-effort:** empty from GitHub's lean `issue_list`;
    /// [`issue_view`](crate::ForgeApi::issue_view) fills it on every forge.
    pub url: String,
}

/// The normalised state of a [`ForgeIssue`], unifying GitHub's `OPEN`/`CLOSED`,
/// GitLab's `opened`/`closed`, and Gitea's `open`/`closed`. An unknown state
/// reads as [`Open`](ForgeIssueState::Open) — a state we don't model is treated
/// as live, never silently as resolved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum ForgeIssueState {
    /// Open / unresolved.
    Open,
    /// Closed.
    Closed,
}

/// A release, unified across the three forges. (Gitea's `tea` always lists —
/// it has no single-release view — so
/// [`release_view`](crate::ForgeApi::release_view) is
/// [`Unsupported`](crate::Error::Unsupported) there.)
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgeRelease {
    /// The Git tag the release is attached to (what
    /// [`release_view`](crate::ForgeApi::release_view) takes).
    pub tag: String,
    /// Release title (may be empty — forges commonly default it to the tag).
    pub title: String,
    /// Web URL. **Best-effort:** empty from GitHub's lean `release_list`;
    /// `release_view` fills it where supported.
    pub url: String,
    /// Publication timestamp (ISO 8601); `None` for an unpublished draft or
    /// when the backend doesn't report one.
    pub published_at: Option<String>,
}

/// The coarse CI status for a PR/MR, bucketed into the four states a caller acts
/// on. GitHub aggregates its per-check buckets into this; GitLab maps its
/// pipeline status; Gitea's `tea` has no checks command, so
/// [`pr_checks`](crate::ForgeApi::pr_checks) is
/// [`Unsupported`](crate::Error::Unsupported) there.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum CiStatus {
    /// Everything that ran passed.
    Passing,
    /// At least one check failed or was canceled.
    Failing,
    /// At least one check is still running, and none failed.
    Pending,
    /// No checks/pipeline ran.
    None,
}

/// Options for [`pr_create`](crate::ForgeApi::pr_create) — the unified
/// open-a-PR/MR spec, mapped to each CLI's own flags (gh `--head`/`--base`,
/// glab `--source-branch`/`--target-branch`, tea `--head`/`--base`).
///
/// `#[non_exhaustive]`, so build it through [`PrCreate::new`] and the chained
/// setters rather than a struct literal.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct PrCreate {
    /// Title.
    pub title: String,
    /// Body / description.
    pub body: String,
    /// Source (head) branch; `None` = the current branch.
    pub source: Option<String>,
    /// Target (base) branch; `None` = the repository default.
    pub target: Option<String>,
}

impl PrCreate {
    /// A PR/MR from the current branch into the repository's default branch.
    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            body: body.into(),
            source: None,
            target: None,
        }
    }

    /// Open from this source (head) branch instead of the current one.
    pub fn source(mut self, branch: impl Into<String>) -> Self {
        self.source = Some(branch.into());
        self
    }

    /// Open against this target (base) branch instead of the repo default.
    pub fn target(mut self, branch: impl Into<String>) -> Self {
        self.target = Some(branch.into());
        self
    }
}

/// How [`pr_merge`](crate::ForgeApi::pr_merge) merges — mapped to each CLI's own
/// merge-strategy flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum MergeStrategy {
    /// A merge commit.
    Merge,
    /// Squash the commits into one.
    Squash,
    /// Rebase the source onto the target.
    Rebase,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn from_remote_url_classifies_saas_hosts() {
        use ForgeKind::*;
        for (url, want) in [
            ("https://github.com/o/r.git", Some(GitHub)),
            ("git@github.com:o/r.git", Some(GitHub)),
            ("https://foo.github.com/o/r", Some(GitHub)), // proper subdomain
            ("https://gitlab.com/o/r", Some(GitLab)),
            ("https://user:pass@gitlab.com/o/r", Some(GitLab)), // userinfo stripped
            ("ssh://git@gitlab.com:22/o/r.git", Some(GitLab)),
            ("https://gitea.com/o/r.git", Some(Gitea)),
            ("git@codeberg.org:o/r.git", Some(Gitea)),
            ("https://docs.codeberg.org/o/r", Some(Gitea)), // proper subdomain
        ] {
            assert_eq!(ForgeKind::from_remote_url(url), want, "{url}");
        }
    }

    // A self-hosted instance on an arbitrary domain, and — crucially — a
    // *lookalike* host an attacker controls, must NOT be classified as a trusted
    // forge: the safe answer is `None` (the caller picks the kind explicitly).
    #[test]
    fn from_remote_url_rejects_self_hosted_and_lookalikes() {
        for url in [
            "https://gitlab.example.com/o/r.git",  // self-hosted GitLab
            "https://gitea.example.org/o/r.git",   // self-hosted Gitea
            "https://git.acme.io/o/r.git",         // arbitrary
            "https://gitlab.com.attacker.net/o/r", // lookalike — must not be GitLab
            "git@gitlab.attacker.com:o/r.git",     // lookalike
            "https://my-gitea-host.evil.com/o/r",  // substring spoof — must not be Gitea
            "https://notgithub.com/o/r",           // suffix without the dot
            "https://github.com.evil.example/o/r", // lookalike — must not be GitHub
            "",
        ] {
            assert_eq!(ForgeKind::from_remote_url(url), None, "{url}");
        }
    }

    #[test]
    fn as_str_maps_each_kind() {
        assert_eq!(ForgeKind::GitHub.as_str(), "github");
        assert_eq!(ForgeKind::GitLab.as_str(), "gitlab");
        assert_eq!(ForgeKind::Gitea.as_str(), "gitea");
    }
}

// Property-based fuzzing of `from_remote_url`. The URL/host parsing slices on
// `://`, `@`, `:`, and `/` and must never panic on a hostile string; and the
// anchored `host_is` match must never classify a *lookalike* host (an
// attacker-controlled `github.com.evil.net`) as a trusted forge — the
// regression net for the unit tests above, which only cover hand-picked cases.
#[cfg(test)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    /// A URL shape embedding `host` in each position `from_remote_url` parses —
    /// scheme URLs (with/without userinfo and port) and the scp-like form — so a
    /// lookalike host is tested wherever it could appear.
    fn url_around(host: impl Strategy<Value = String>) -> impl Strategy<Value = String> {
        host.prop_flat_map(|h| {
            prop_oneof![
                Just(format!("https://{h}/o/r.git")),
                Just(format!("https://user:pass@{h}/o/r")),
                Just(format!("ssh://git@{h}:22/o/r.git")),
                Just(format!("git@{h}:o/r.git")),
                Just(format!("{h}/o/r")),
            ]
        })
    }

    /// Hosts that merely *resemble* a trusted SaaS host but aren't it: a trusted
    /// domain as a left label (`github.com.evil.net`), a no-dot suffix
    /// (`notgithub.com`), or the trusted domain buried mid-host — every one must
    /// classify as `None`.
    fn lookalike_host() -> impl Strategy<Value = String> {
        // `prop_oneof!` consumes its strategies, so name the reusable ones as
        // closures that build a fresh strategy at each use site.
        let trusted = || {
            prop_oneof![
                Just("github.com"),
                Just("gitlab.com"),
                Just("gitea.com"),
                Just("codeberg.org"),
            ]
        };
        // TLDs disjoint from every trusted domain's (`com`/`org`), so a generated
        // suffix can never BE a trusted domain — `github.com.gitea.com` would be
        // a genuine subdomain of gitea.com and *correctly* classify, which is not
        // what this strategy probes.
        let evil = || "[a-z]{1,8}\\.(net|io|dev|xyz)";
        prop_oneof![
            // Trusted domain as a *prefix* label of an attacker domain.
            (trusted(), evil()).prop_map(|(t, e)| format!("{t}.{e}")),
            // Trusted domain glued on with no separating dot.
            (prop_oneof![Just("not"), Just("my"), Just("x")], trusted())
                .prop_map(|(p, t)| format!("{p}{t}")),
            // Trusted domain buried as an *inner* label, not the suffix.
            (evil(), trusted()).prop_map(|(e, t)| format!("x.{t}.{e}")),
        ]
    }

    proptest! {
        // Panic-freedom on completely arbitrary input.
        #[test]
        fn from_remote_url_never_panics(s in any::<String>()) {
            let _ = ForgeKind::from_remote_url(&s);
        }

        // A lookalike host must NEVER be classified as a trusted forge.
        #[test]
        fn from_remote_url_rejects_lookalikes(url in url_around(lookalike_host())) {
            prop_assert_eq!(
                ForgeKind::from_remote_url(&url),
                None,
                "lookalike must not classify: {}",
                url
            );
        }
    }
}

// The optional `serde` feature derives `Serialize` on the unified DTOs.
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
    use super::*;

    #[test]
    fn forge_pr_serializes_to_clean_json() {
        let pr = ForgePr {
            number: 7,
            title: "Add X".into(),
            state: ForgePrState::Merged,
            source_branch: "feat".into(),
            target_branch: "main".into(),
            url: "u".into(),
            draft: false,
        };
        let v = serde_json::to_value(&pr).unwrap();
        assert_eq!(v["number"], 7);
        assert_eq!(v["state"], "Merged"); // enum → variant name
        assert_eq!(v["source_branch"], "feat");
    }

    // The Wave-A DTOs are part of vcs-mcp's JSON wire format — pin their shape:
    // the state enum serializes as the variant name, an absent publish date as
    // `null`, and the PrCreate spec keeps its field names.
    #[test]
    fn issue_release_and_pr_create_serialize_to_clean_json() {
        let issue = ForgeIssue {
            number: 3,
            title: "Bug".into(),
            state: ForgeIssueState::Closed,
            body: "b".into(),
            url: "u".into(),
        };
        let v = serde_json::to_value(&issue).unwrap();
        assert_eq!(v["number"], 3);
        assert_eq!(v["state"], "Closed");
        assert_eq!(v["body"], "b");

        let release = ForgeRelease {
            tag: "v1".into(),
            title: "One".into(),
            url: "u".into(),
            published_at: None,
        };
        let v = serde_json::to_value(&release).unwrap();
        assert_eq!(v["tag"], "v1");
        assert!(v["published_at"].is_null(), "draft date must be null");

        let spec = PrCreate::new("T", "B").source("feat");
        let v = serde_json::to_value(&spec).unwrap();
        assert_eq!(v["title"], "T");
        assert_eq!(v["source"], "feat");
        assert!(v["target"].is_null());
    }
}