1use std::collections::{BTreeMap, BTreeSet};
2use std::time::Duration;
3use std::{fmt, process::Command};
4
5use anyhow::{Context, Result, anyhow, bail};
6
7use crate::git;
8use crate::settings;
9
10pub(super) const CHECK_GRACE_POLLS: u32 = 6;
15
16pub(super) fn check_poll_interval() -> Duration {
18 Duration::from_secs(5)
19}
20
21pub(super) fn checks_timed_out(review: &ReviewRequest, timeout: Duration) -> anyhow::Error {
25 anyhow!(
26 "{}'s checks have not settled within {}; rerun `git stk merge` once they pass, \
27 or raise stk.checkTimeout",
28 review.id,
29 humanize(timeout),
30 )
31}
32
33fn humanize(duration: Duration) -> String {
35 let seconds = duration.as_secs();
36 if seconds >= 60 && seconds.is_multiple_of(60) {
37 format!("{}m", seconds / 60)
38 } else {
39 format!("{seconds}s")
40 }
41}
42
43mod demo;
44mod gitea;
45mod github;
46mod gitlab;
47mod json;
48
49use demo::DemoProvider;
50use gitea::GiteaProvider;
51use github::GitHubProvider;
52use gitlab::GitLabProvider;
53
54#[derive(Debug, Clone, Copy, Eq, PartialEq)]
55pub enum ProviderKind {
56 GitHub,
57 GitLab,
58 Gitea,
59 Demo,
62}
63
64impl ProviderKind {
65 fn parse(value: &str) -> Option<Self> {
66 match value.to_ascii_lowercase().as_str() {
67 "github" | "gh" => Some(Self::GitHub),
68 "gitlab" | "glab" => Some(Self::GitLab),
69 "gitea" | "tea" => Some(Self::Gitea),
70 "demo" => Some(Self::Demo),
71 _ => None,
72 }
73 }
74}
75
76impl fmt::Display for ProviderKind {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::GitHub => write!(formatter, "github"),
80 Self::GitLab => write!(formatter, "gitlab"),
81 Self::Gitea => write!(formatter, "gitea"),
82 Self::Demo => write!(formatter, "demo"),
83 }
84 }
85}
86
87#[derive(Debug, Eq, PartialEq)]
88pub struct DetectedProvider {
89 pub kind: ProviderKind,
90 pub source: ProviderSource,
91}
92
93#[derive(Debug, Eq, PartialEq)]
94pub enum ProviderSource {
95 Config,
96 Remote { remote: String, url: String },
97}
98
99impl fmt::Display for ProviderSource {
100 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101 match self {
102 Self::Config => write!(formatter, "config"),
103 Self::Remote { remote, url } => {
104 write!(formatter, "remote {remote} ({})", redact_url(url))
105 }
106 }
107 }
108}
109
110#[derive(Debug, Eq, PartialEq)]
111pub enum ReviewState {
112 Open,
113 Merged,
114 Closed,
115 Unknown(String),
116}
117
118#[derive(Debug, Clone, Copy, Eq, PartialEq)]
123pub enum MergeBlocker {
124 ChecksPending,
126 Conflicts,
128 None,
130}
131
132#[derive(Debug, Eq, PartialEq)]
133pub struct ReviewRequest {
134 pub id: String,
135 pub branch: String,
136 pub base: String,
137 pub state: ReviewState,
138 pub url: String,
139 pub title: String,
140 pub draft: bool,
141}
142
143#[derive(Debug, Clone, Copy, Eq, PartialEq)]
147pub enum CheckStatus {
148 Passing,
149 Failing,
150 Pending,
151 None,
152}
153
154impl CheckStatus {
155 pub fn dot(self) -> &'static str {
158 match self {
159 Self::Passing => "🟢 ",
160 Self::Failing => "🔴 ",
161 Self::Pending => "🟡 ",
162 Self::None => "",
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
169pub struct ReviewSummary {
170 pub approvals: u32,
171 pub comments: u32,
172 pub changes_requested: u32,
173}
174
175impl ReviewSummary {
176 pub fn lines(&self) -> Vec<String> {
180 let count =
181 |n: u32, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
182 let mut lines = Vec::new();
183 if self.approvals > 0 {
184 lines.push(count(self.approvals, "approval", "approvals"));
185 }
186 if self.comments > 0 {
187 lines.push(count(self.comments, "comment", "comments"));
188 }
189 if self.changes_requested > 0 {
190 lines.push(count(
191 self.changes_requested,
192 "requested change",
193 "requested changes",
194 ));
195 }
196 lines
197 }
198}
199
200pub const QUEUED_MARK: &str = "🕑 ";
204
205pub struct ReviewAnnotation {
209 pub id: String,
210 pub checks: CheckStatus,
211 pub queued: bool,
212 pub summary: Option<ReviewSummary>,
213}
214
215pub enum WaitOutcome {
217 Passed,
219 Failed,
221 Landed,
224}
225
226pub trait ReviewProvider {
227 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
228
229 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
234
235 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
237
238 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
239
240 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
241
242 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
243
244 fn review_state(&self, review: &ReviewRequest) -> Result<Option<ReviewState>> {
251 let _ = review;
252 Ok(None)
253 }
254
255 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
259
260 fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
264
265 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
269
270 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
273
274 fn annotate_branches(
280 &self,
281 branches: &[String],
282 detail: bool,
283 ) -> Result<BTreeMap<String, ReviewAnnotation>> {
284 generic_annotate(self, branches, detail)
285 }
286
287 fn check_status(&self, _review: &ReviewRequest) -> Result<CheckStatus> {
291 Ok(CheckStatus::None)
292 }
293
294 fn review_summary(&self, _review: &ReviewRequest) -> Result<ReviewSummary> {
297 Ok(ReviewSummary::default())
298 }
299
300 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
302
303 fn request_reviewers(&self, _review: &ReviewRequest, _reviewers: &[String]) -> Result<String> {
308 bail!("requesting reviewers is not supported by this provider")
309 }
310
311 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
314
315 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
317
318 fn enqueued_branches(&self, _branches: &[String]) -> Result<BTreeSet<String>> {
326 Ok(BTreeSet::new())
327 }
328}
329
330pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
334 let provider = detect_provider()?;
335 let client = review_provider(provider.kind);
336 Ok((provider, client))
337}
338
339fn generic_annotate<P: ReviewProvider + ?Sized>(
346 provider: &P,
347 branches: &[String],
348 detail: bool,
349) -> Result<BTreeMap<String, ReviewAnnotation>> {
350 let wanted: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
351 let reviewed: Vec<ReviewRequest> = provider
352 .open_reviews()?
353 .into_iter()
354 .filter(|review| wanted.contains(review.branch.as_str()))
355 .collect();
356 let names: Vec<String> = reviewed
357 .iter()
358 .map(|review| review.branch.clone())
359 .collect();
360 let queued = provider.enqueued_branches(&names).unwrap_or_default();
361 let mut annotations = BTreeMap::new();
362 for review in reviewed {
363 let checks = provider.check_status(&review).unwrap_or(CheckStatus::None);
364 let summary = if detail {
365 provider.review_summary(&review).ok()
366 } else {
367 None
368 };
369 let is_queued = queued.contains(&review.branch);
370 annotations.insert(
371 review.branch.clone(),
372 ReviewAnnotation {
373 id: review.id,
374 checks,
375 queued: is_queued,
376 summary,
377 },
378 );
379 }
380 Ok(annotations)
381}
382
383pub fn owned_review_for_branch(
387 provider: &dyn ReviewProvider,
388 branch: &str,
389) -> Result<Option<ReviewRequest>> {
390 Ok(provider
391 .review_for_branch(branch)?
392 .filter(|review| review.branch == branch))
393}
394
395pub(super) fn review_merged_out_of_band(
399 provider: &dyn ReviewProvider,
400 review: &ReviewRequest,
401) -> Result<bool> {
402 Ok(matches!(
403 provider.review_for_branch(&review.branch)?,
404 Some(current) if current.state == ReviewState::Merged
405 ))
406}
407
408pub fn detect_provider() -> Result<DetectedProvider> {
409 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
410 let Some(kind) = ProviderKind::parse(&value) else {
411 bail!(
412 "unsupported stk.provider value {value:?}; expected github, gitlab, gitea, or demo"
413 );
414 };
415
416 return Ok(DetectedProvider {
417 kind,
418 source: ProviderSource::Config,
419 });
420 }
421
422 let remote = settings::remote()?;
423 let Some(url) = git::remote_url(&remote)? else {
424 bail!("could not detect provider: remote {remote:?} does not exist");
425 };
426
427 let gitlab_host = settings::gitlab_host()?;
428 let gitea_host = settings::gitea_host()?;
429 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref(), gitea_host.as_deref())
430 else {
431 bail!(
432 "could not detect provider from remote {remote} ({})",
433 redact_url(&url)
434 );
435 };
436
437 Ok(DetectedProvider {
438 kind,
439 source: ProviderSource::Remote { remote, url },
440 })
441}
442
443fn detect_provider_from_url(
447 url: &str,
448 gitlab_host: Option<&str>,
449 gitea_host: Option<&str>,
450) -> Option<ProviderKind> {
451 let normalized = url.to_ascii_lowercase();
452 let host = host_of(&normalized);
453 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
456
457 let self_hosted = |configured: Option<&str>| {
460 configured.is_some_and(|configured| is(host_of(&configured.to_ascii_lowercase())))
461 };
462
463 if is("github.com") {
464 Some(ProviderKind::GitHub)
465 } else if is("gitlab.com") || self_hosted(gitlab_host) {
466 Some(ProviderKind::GitLab)
467 } else if is("gitea.com") || is("codeberg.org") || self_hosted(gitea_host) {
468 Some(ProviderKind::Gitea)
469 } else {
470 None
471 }
472}
473
474fn host_of(url: &str) -> &str {
479 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
480 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
484 let host_port = authority
485 .rsplit_once('@')
486 .map_or(authority, |(_, rest)| rest);
487 if let Some(after_bracket) = host_port.strip_prefix('[') {
489 return after_bracket
490 .split_once(']')
491 .map_or(host_port, |(addr, _)| addr);
492 }
493 host_port.split(':').next().unwrap_or(host_port)
495}
496
497fn redact_url(url: &str) -> String {
501 let Some((scheme, rest)) = url.split_once("://") else {
502 return url.to_owned();
503 };
504 let (authority, path) = match rest.split_once('/') {
505 Some((authority, path)) => (authority, Some(path)),
506 None => (rest, None),
507 };
508 let Some((_, host)) = authority.rsplit_once('@') else {
511 return url.to_owned();
512 };
513 match path {
514 Some(path) => format!("{scheme}://{host}/{path}"),
515 None => format!("{scheme}://{host}"),
516 }
517}
518
519pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
520 match kind {
521 ProviderKind::GitHub => Box::new(GitHubProvider),
522 ProviderKind::GitLab => Box::new(GitLabProvider),
523 ProviderKind::Gitea => Box::new(GiteaProvider),
524 ProviderKind::Demo => Box::new(DemoProvider),
525 }
526}
527
528fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
531 match program {
532 "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
533 "glab" => Some((
534 "GitLab CLI",
535 "https://gitlab.com/gitlab-org/cli",
536 "glab auth login",
537 )),
538 "tea" => Some((
539 "Gitea CLI (tea)",
540 "https://gitea.com/gitea/tea",
541 "tea login add",
542 )),
543 _ => None,
544 }
545}
546
547fn looks_unauthenticated(stderr: &str) -> bool {
550 let stderr = stderr.to_ascii_lowercase();
551 [
552 "auth login",
553 "not logged",
554 "401",
555 "unauthorized",
556 "authentication required",
557 ]
558 .iter()
559 .any(|needle| stderr.contains(needle))
560}
561
562fn command_output(program: &str, args: &[&str]) -> Result<String> {
563 let output = match Command::new(program).args(args).output() {
564 Ok(output) => output,
565 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
568 if let Some((name, url, auth)) = provider_cli(program) {
569 bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
570 }
571 return Err(error).with_context(|| format!("failed to run {program}"));
572 }
573 Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
574 };
575
576 if output.status.success() {
577 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
578 }
579
580 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
581 if let Some((_, _, auth)) = provider_cli(program)
584 && looks_unauthenticated(&stderr)
585 {
586 bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
587 }
588 if stderr.is_empty() {
589 Err(anyhow!("{program} exited with status {}", output.status))
590 } else {
591 Err(anyhow!("{program} failed: {stderr}"))
592 }
593}
594
595const MERGE_ATTEMPTS: u32 = 3;
599const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
600
601fn is_transient_merge_error(error: &anyhow::Error) -> bool {
609 let text = error.to_string().to_lowercase();
610 [
611 "base branch was modified",
612 "head branch was modified",
613 "try the merge again",
614 "method not allowed",
615 "is it still open",
616 "bad gateway",
619 "service unavailable",
620 "gateway time",
621 "internal server error",
622 ]
623 .iter()
624 .any(|signature| text.contains(signature))
625}
626
627fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
632 retry_transient_merge(
633 MERGE_ATTEMPTS,
634 || std::thread::sleep(MERGE_RETRY_BACKOFF),
635 attempt,
636 )
637}
638
639pub(super) fn merge_with_resettle(
645 mut resettle: impl FnMut(),
646 attempt: impl FnMut() -> Result<String>,
647) -> Result<String> {
648 retry_transient_merge(
649 MERGE_ATTEMPTS,
650 move || {
651 std::thread::sleep(MERGE_RETRY_BACKOFF);
654 resettle();
655 },
656 attempt,
657 )
658}
659
660fn retry_transient_merge(
661 attempts: u32,
662 mut on_transient: impl FnMut(),
663 mut attempt: impl FnMut() -> Result<String>,
664) -> Result<String> {
665 for remaining in (0..attempts).rev() {
666 match attempt() {
667 Ok(output) => return Ok(output),
668 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
669 on_transient();
670 }
671 Err(error) => return Err(error),
672 }
673 }
674 Err(anyhow!("merge retried with no attempts left"))
676}
677
678impl fmt::Display for ReviewState {
679 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
680 match self {
681 Self::Open => write!(formatter, "open"),
682 Self::Merged => write!(formatter, "merged"),
683 Self::Closed => write!(formatter, "closed"),
684 Self::Unknown(state) => write!(formatter, "{state}"),
685 }
686 }
687}
688
689impl ReviewRequest {
690 pub(crate) fn id_value(&self) -> &str {
691 self.id
692 .strip_prefix('#')
693 .or_else(|| self.id.strip_prefix('!'))
694 .unwrap_or(&self.id)
695 }
696
697 pub fn label(&self) -> String {
699 label(&self.title, &self.id)
700 }
701}
702
703pub(crate) fn label(title: &str, id: &str) -> String {
705 if title.is_empty() {
706 id.to_owned()
707 } else {
708 format!("{title} ({id})")
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
717 fn provider_cli_maps_only_the_provider_clis() {
718 assert!(provider_cli("gh").is_some());
719 assert!(provider_cli("glab").is_some());
720 assert!(provider_cli("git").is_none());
721 }
722
723 #[test]
724 fn looks_unauthenticated_matches_signin_failures_only() {
725 assert!(looks_unauthenticated(
726 "error: not logged into any GitHub hosts"
727 ));
728 assert!(looks_unauthenticated(
729 "To get started, please run: gh auth login"
730 ));
731 assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
732 assert!(!looks_unauthenticated("pull request not found"));
734 assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
735 }
736
737 #[test]
738 fn transient_error_is_retried_then_succeeds() {
739 let mut calls = 0;
740 let result = retry_transient_merge(
741 3,
742 || {},
743 || {
744 calls += 1;
745 if calls < 2 {
746 Err(anyhow!(
747 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
748 ))
749 } else {
750 Ok("merged".to_owned())
751 }
752 },
753 );
754 assert_eq!(result.unwrap(), "merged");
755 assert_eq!(calls, 2, "should retry once then succeed");
756 }
757
758 #[test]
759 fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
760 let mut calls = 0;
761 let result = retry_transient_merge(
762 3,
763 || {},
764 || {
765 calls += 1;
766 if calls < 2 {
767 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
768 } else {
769 Ok("merged".to_owned())
770 }
771 },
772 );
773 assert_eq!(result.unwrap(), "merged");
774 assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
775 }
776
777 #[test]
778 fn the_between_retry_action_runs_once_per_transient_retry() {
779 let mut resettles = 0;
782 let mut calls = 0;
783 let result = retry_transient_merge(
784 3,
785 || resettles += 1,
786 || {
787 calls += 1;
788 if calls < 3 {
790 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
791 } else {
792 Ok("merged".to_owned())
793 }
794 },
795 );
796 assert_eq!(result.unwrap(), "merged");
797 assert_eq!(calls, 3, "should retry until the merge lands");
798 assert_eq!(
799 resettles, 2,
800 "re-poll once per transient retry, not after the final success"
801 );
802 }
803
804 #[test]
805 fn the_between_retry_action_does_not_run_on_a_real_failure() {
806 let mut resettles = 0;
807 let result = retry_transient_merge(
808 3,
809 || resettles += 1,
810 || {
811 Err(anyhow!(
812 "glab failed: Merge request is not mergeable: conflict"
813 ))
814 },
815 );
816 assert!(result.is_err());
817 assert_eq!(resettles, 0, "a non-transient failure must not re-poll");
818 }
819
820 #[test]
821 fn a_transient_5xx_from_the_api_is_retried() {
822 let mut calls = 0;
823 let result = retry_transient_merge(
824 3,
825 || {},
826 || {
827 calls += 1;
828 if calls < 2 {
829 Err(anyhow!(
830 "gh failed: non-200 OK status code: 502 Bad Gateway"
831 ))
832 } else {
833 Ok("merged".to_owned())
834 }
835 },
836 );
837 assert_eq!(result.unwrap(), "merged");
838 assert_eq!(calls, 2, "a 502 is a server hiccup, not a merge verdict");
839 }
840
841 #[test]
842 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
843 let mut calls = 0;
844 let result = retry_transient_merge(
845 3,
846 || {},
847 || {
848 calls += 1;
849 Err(anyhow!("gh failed: Base branch was modified"))
850 },
851 );
852 assert!(result.is_err());
853 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
854 }
855
856 #[test]
857 fn a_real_failure_is_not_retried() {
858 let mut calls = 0;
859 let result = retry_transient_merge(
860 3,
861 || {},
862 || {
863 calls += 1;
864 Err(anyhow!(
865 "gh failed: Pull request is not mergeable: conflicts"
866 ))
867 },
868 );
869 assert!(result.is_err());
870 assert_eq!(calls, 1, "a non-transient error must surface immediately");
871 }
872
873 #[test]
874 fn host_of_extracts_the_host_across_url_shapes() {
875 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
876 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
877 assert_eq!(
878 host_of("ssh://git@gitlab.example.com:22/g/r"),
879 "gitlab.example.com"
880 );
881 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
882 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
883 assert_eq!(
884 host_of("https://[2001:db8::1]:443/owner/repo"),
885 "2001:db8::1"
886 );
887 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
888 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
890 }
891
892 #[test]
893 fn redact_url_strips_embedded_credentials() {
894 assert_eq!(
896 redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
897 "https://github.com/owner/repo.git"
898 );
899 assert_eq!(
900 redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
901 "https://gitlab.com/owner/repo"
902 );
903 assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
905 }
906
907 #[test]
908 fn redact_url_leaves_credential_free_urls_unchanged() {
909 assert_eq!(
910 redact_url("https://github.com/owner/repo.git"),
911 "https://github.com/owner/repo.git"
912 );
913 assert_eq!(
915 redact_url("git@github.com:owner/repo.git"),
916 "git@github.com:owner/repo.git"
917 );
918 }
919
920 #[test]
921 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
922 let remote = "git@gitlab.example.com:team/repo.git";
923 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
924 assert_eq!(
925 detect_provider_from_url(remote, Some(configured), None),
926 Some(ProviderKind::GitLab),
927 "configured {configured:?} should detect the self-hosted host"
928 );
929 }
930 assert_eq!(
932 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com"), None),
933 None
934 );
935 }
936
937 #[test]
938 fn gitea_is_detected_for_gitea_com_codeberg_and_a_configured_host() {
939 assert_eq!(
940 detect_provider_from_url("git@gitea.com:o/r.git", None, None),
941 Some(ProviderKind::Gitea)
942 );
943 assert_eq!(
944 detect_provider_from_url("https://codeberg.org/o/r", None, None),
945 Some(ProviderKind::Gitea)
946 );
947 for configured in ["gitea.example.com", "https://gitea.example.com"] {
948 assert_eq!(
949 detect_provider_from_url("git@gitea.example.com:o/r.git", None, Some(configured)),
950 Some(ProviderKind::Gitea),
951 "configured {configured:?} should detect the self-hosted Gitea host"
952 );
953 }
954 assert_eq!(
956 detect_provider_from_url("git@notgitea.com:o/r", None, Some("gitea.example.com")),
957 None
958 );
959 }
960}