1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use 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
131pub use vcs_gitea;
135pub use vcs_github;
136pub use vcs_gitlab;
137#[cfg(feature = "cancellation")]
141#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
142pub use processkit::CancellationToken;
143
144enum 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
162pub struct Forge<R: ProcessRunner = JobRunner> {
166 cwd: PathBuf,
167 backend: Backend<R>,
168}
169
170impl Forge<JobRunner> {
171 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 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 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 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 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 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 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 pub fn cwd(&self) -> &Path {
233 &self.cwd
234 }
235
236 pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
238 Forge {
239 cwd: dir.into(),
240 backend: self.backend.shared(),
241 }
242 }
243
244 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 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 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 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 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 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 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 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 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 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 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 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 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 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
389macro_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 #[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 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 #[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 #[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 #[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 #[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 #[tokio::test]
549 async fn gitea_pr_view_filters_and_maps_merged() {
550 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 #[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 #[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 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 #[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 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, ""); assert!(rels[0].published_at.is_some());
632 }
633
634 #[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 #[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 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 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 #[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 #[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 assert_eq!(
721 dynamic.issue_create("T", "B").await.unwrap(),
722 "https://gl/i/9"
723 );
724 }
725}
726
727#[doc = include_str!("../docs/forge.md")]
729#[allow(rustdoc::broken_intra_doc_links)]
730pub mod guide {}