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}