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(
238 &self,
239 branch: &str,
240 base: &str,
241 draft: bool,
242 title: Option<&str>,
243 ) -> Result<String>;
244
245 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
246
247 fn update_review_title(&self, review: &ReviewRequest, title: &str) -> Result<String>;
251
252 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
253
254 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
255
256 fn review_state(&self, review: &ReviewRequest) -> Result<Option<ReviewState>> {
263 let _ = review;
264 Ok(None)
265 }
266
267 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
271
272 fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
276
277 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
281
282 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
285
286 fn annotate_branches(
292 &self,
293 branches: &[String],
294 detail: bool,
295 ) -> Result<BTreeMap<String, ReviewAnnotation>> {
296 generic_annotate(self, branches, detail)
297 }
298
299 fn check_status(&self, _review: &ReviewRequest) -> Result<CheckStatus> {
303 Ok(CheckStatus::None)
304 }
305
306 fn review_summary(&self, _review: &ReviewRequest) -> Result<ReviewSummary> {
309 Ok(ReviewSummary::default())
310 }
311
312 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
314
315 fn request_reviewers(&self, _review: &ReviewRequest, _reviewers: &[String]) -> Result<String> {
320 bail!("requesting reviewers is not supported by this provider")
321 }
322
323 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
326
327 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
329
330 fn enqueued_branches(&self, _branches: &[String]) -> Result<BTreeSet<String>> {
338 Ok(BTreeSet::new())
339 }
340}
341
342pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
346 let provider = detect_provider()?;
347 let client = review_provider(provider.kind);
348 Ok((provider, client))
349}
350
351fn generic_annotate<P: ReviewProvider + ?Sized>(
358 provider: &P,
359 branches: &[String],
360 detail: bool,
361) -> Result<BTreeMap<String, ReviewAnnotation>> {
362 let wanted: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
363 let reviewed: Vec<ReviewRequest> = provider
364 .open_reviews()?
365 .into_iter()
366 .filter(|review| wanted.contains(review.branch.as_str()))
367 .collect();
368 let names: Vec<String> = reviewed
369 .iter()
370 .map(|review| review.branch.clone())
371 .collect();
372 let queued = provider.enqueued_branches(&names).unwrap_or_default();
373 let mut annotations = BTreeMap::new();
374 for review in reviewed {
375 let checks = provider.check_status(&review).unwrap_or(CheckStatus::None);
376 let summary = if detail {
377 provider.review_summary(&review).ok()
378 } else {
379 None
380 };
381 let is_queued = queued.contains(&review.branch);
382 annotations.insert(
383 review.branch.clone(),
384 ReviewAnnotation {
385 id: review.id,
386 checks,
387 queued: is_queued,
388 summary,
389 },
390 );
391 }
392 Ok(annotations)
393}
394
395pub fn owned_review_for_branch(
399 provider: &dyn ReviewProvider,
400 branch: &str,
401) -> Result<Option<ReviewRequest>> {
402 Ok(provider
403 .review_for_branch(branch)?
404 .filter(|review| review.branch == branch))
405}
406
407pub(super) fn review_merged_out_of_band(
411 provider: &dyn ReviewProvider,
412 review: &ReviewRequest,
413) -> Result<bool> {
414 Ok(matches!(
415 provider.review_for_branch(&review.branch)?,
416 Some(current) if current.state == ReviewState::Merged
417 ))
418}
419
420pub fn detect_provider() -> Result<DetectedProvider> {
421 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
422 let Some(kind) = ProviderKind::parse(&value) else {
423 bail!(
424 "unsupported stk.provider value {value:?}; expected github, gitlab, gitea, or demo"
425 );
426 };
427
428 return Ok(DetectedProvider {
429 kind,
430 source: ProviderSource::Config,
431 });
432 }
433
434 let remote = settings::remote()?;
435 let Some(url) = git::remote_url(&remote)? else {
436 bail!("could not detect provider: remote {remote:?} does not exist");
437 };
438
439 let gitlab_host = settings::gitlab_host()?;
440 let gitea_host = settings::gitea_host()?;
441 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref(), gitea_host.as_deref())
442 else {
443 bail!(
444 "could not detect provider from remote {remote} ({})",
445 redact_url(&url)
446 );
447 };
448
449 Ok(DetectedProvider {
450 kind,
451 source: ProviderSource::Remote { remote, url },
452 })
453}
454
455fn detect_provider_from_url(
459 url: &str,
460 gitlab_host: Option<&str>,
461 gitea_host: Option<&str>,
462) -> Option<ProviderKind> {
463 let normalized = url.to_ascii_lowercase();
464 let host = host_of(&normalized);
465 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
468
469 let self_hosted = |configured: Option<&str>| {
472 configured.is_some_and(|configured| is(host_of(&configured.to_ascii_lowercase())))
473 };
474
475 if is("github.com") {
476 Some(ProviderKind::GitHub)
477 } else if is("gitlab.com") || self_hosted(gitlab_host) {
478 Some(ProviderKind::GitLab)
479 } else if is("gitea.com") || is("codeberg.org") || self_hosted(gitea_host) {
480 Some(ProviderKind::Gitea)
481 } else {
482 None
483 }
484}
485
486fn host_of(url: &str) -> &str {
491 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
492 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
496 let host_port = authority
497 .rsplit_once('@')
498 .map_or(authority, |(_, rest)| rest);
499 if let Some(after_bracket) = host_port.strip_prefix('[') {
501 return after_bracket
502 .split_once(']')
503 .map_or(host_port, |(addr, _)| addr);
504 }
505 host_port.split(':').next().unwrap_or(host_port)
507}
508
509fn redact_url(url: &str) -> String {
513 let Some((scheme, rest)) = url.split_once("://") else {
514 return url.to_owned();
515 };
516 let (authority, path) = match rest.split_once('/') {
517 Some((authority, path)) => (authority, Some(path)),
518 None => (rest, None),
519 };
520 let Some((_, host)) = authority.rsplit_once('@') else {
523 return url.to_owned();
524 };
525 match path {
526 Some(path) => format!("{scheme}://{host}/{path}"),
527 None => format!("{scheme}://{host}"),
528 }
529}
530
531pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
532 match kind {
533 ProviderKind::GitHub => Box::new(GitHubProvider),
534 ProviderKind::GitLab => Box::new(GitLabProvider),
535 ProviderKind::Gitea => Box::new(GiteaProvider),
536 ProviderKind::Demo => Box::new(DemoProvider),
537 }
538}
539
540fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
543 match program {
544 "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
545 "glab" => Some((
546 "GitLab CLI",
547 "https://gitlab.com/gitlab-org/cli",
548 "glab auth login",
549 )),
550 "tea" => Some((
551 "Gitea CLI (tea)",
552 "https://gitea.com/gitea/tea",
553 "tea login add",
554 )),
555 _ => None,
556 }
557}
558
559fn looks_unauthenticated(stderr: &str) -> bool {
562 let stderr = stderr.to_ascii_lowercase();
563 [
564 "auth login",
565 "not logged",
566 "401",
567 "unauthorized",
568 "authentication required",
569 ]
570 .iter()
571 .any(|needle| stderr.contains(needle))
572}
573
574fn command_output(program: &str, args: &[&str]) -> Result<String> {
575 let output = match Command::new(program).args(args).output() {
576 Ok(output) => output,
577 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
580 if let Some((name, url, auth)) = provider_cli(program) {
581 bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
582 }
583 return Err(error).with_context(|| format!("failed to run {program}"));
584 }
585 Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
586 };
587
588 if output.status.success() {
589 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
590 }
591
592 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
593 if let Some((_, _, auth)) = provider_cli(program)
596 && looks_unauthenticated(&stderr)
597 {
598 bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
599 }
600 if stderr.is_empty() {
601 Err(anyhow!("{program} exited with status {}", output.status))
602 } else {
603 Err(anyhow!("{program} failed: {stderr}"))
604 }
605}
606
607const MERGE_ATTEMPTS: u32 = 3;
611const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
612
613fn is_transient_merge_error(error: &anyhow::Error) -> bool {
621 let text = error.to_string().to_lowercase();
622 [
623 "base branch was modified",
624 "head branch was modified",
625 "try the merge again",
626 "method not allowed",
627 "is it still open",
628 "bad gateway",
631 "service unavailable",
632 "gateway time",
633 "internal server error",
634 ]
635 .iter()
636 .any(|signature| text.contains(signature))
637}
638
639fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
644 retry_transient_merge(
645 MERGE_ATTEMPTS,
646 || std::thread::sleep(MERGE_RETRY_BACKOFF),
647 attempt,
648 )
649}
650
651pub(super) fn merge_with_resettle(
657 mut resettle: impl FnMut(),
658 attempt: impl FnMut() -> Result<String>,
659) -> Result<String> {
660 retry_transient_merge(
661 MERGE_ATTEMPTS,
662 move || {
663 std::thread::sleep(MERGE_RETRY_BACKOFF);
666 resettle();
667 },
668 attempt,
669 )
670}
671
672fn retry_transient_merge(
673 attempts: u32,
674 mut on_transient: impl FnMut(),
675 mut attempt: impl FnMut() -> Result<String>,
676) -> Result<String> {
677 for remaining in (0..attempts).rev() {
678 match attempt() {
679 Ok(output) => return Ok(output),
680 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
681 on_transient();
682 }
683 Err(error) => return Err(error),
684 }
685 }
686 Err(anyhow!("merge retried with no attempts left"))
688}
689
690impl fmt::Display for ReviewState {
691 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
692 match self {
693 Self::Open => write!(formatter, "open"),
694 Self::Merged => write!(formatter, "merged"),
695 Self::Closed => write!(formatter, "closed"),
696 Self::Unknown(state) => write!(formatter, "{state}"),
697 }
698 }
699}
700
701impl ReviewRequest {
702 pub(crate) fn id_value(&self) -> &str {
703 self.id
704 .strip_prefix('#')
705 .or_else(|| self.id.strip_prefix('!'))
706 .unwrap_or(&self.id)
707 }
708
709 pub fn label(&self) -> String {
711 label(&self.title, &self.id)
712 }
713}
714
715pub(crate) fn label(title: &str, id: &str) -> String {
717 if title.is_empty() {
718 id.to_owned()
719 } else {
720 format!("{title} ({id})")
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn provider_cli_maps_only_the_provider_clis() {
730 assert!(provider_cli("gh").is_some());
731 assert!(provider_cli("glab").is_some());
732 assert!(provider_cli("git").is_none());
733 }
734
735 #[test]
736 fn looks_unauthenticated_matches_signin_failures_only() {
737 assert!(looks_unauthenticated(
738 "error: not logged into any GitHub hosts"
739 ));
740 assert!(looks_unauthenticated(
741 "To get started, please run: gh auth login"
742 ));
743 assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
744 assert!(!looks_unauthenticated("pull request not found"));
746 assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
747 }
748
749 #[test]
750 fn transient_error_is_retried_then_succeeds() {
751 let mut calls = 0;
752 let result = retry_transient_merge(
753 3,
754 || {},
755 || {
756 calls += 1;
757 if calls < 2 {
758 Err(anyhow!(
759 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
760 ))
761 } else {
762 Ok("merged".to_owned())
763 }
764 },
765 );
766 assert_eq!(result.unwrap(), "merged");
767 assert_eq!(calls, 2, "should retry once then succeed");
768 }
769
770 #[test]
771 fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
772 let mut calls = 0;
773 let result = retry_transient_merge(
774 3,
775 || {},
776 || {
777 calls += 1;
778 if calls < 2 {
779 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
780 } else {
781 Ok("merged".to_owned())
782 }
783 },
784 );
785 assert_eq!(result.unwrap(), "merged");
786 assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
787 }
788
789 #[test]
790 fn the_between_retry_action_runs_once_per_transient_retry() {
791 let mut resettles = 0;
794 let mut calls = 0;
795 let result = retry_transient_merge(
796 3,
797 || resettles += 1,
798 || {
799 calls += 1;
800 if calls < 3 {
802 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
803 } else {
804 Ok("merged".to_owned())
805 }
806 },
807 );
808 assert_eq!(result.unwrap(), "merged");
809 assert_eq!(calls, 3, "should retry until the merge lands");
810 assert_eq!(
811 resettles, 2,
812 "re-poll once per transient retry, not after the final success"
813 );
814 }
815
816 #[test]
817 fn the_between_retry_action_does_not_run_on_a_real_failure() {
818 let mut resettles = 0;
819 let result = retry_transient_merge(
820 3,
821 || resettles += 1,
822 || {
823 Err(anyhow!(
824 "glab failed: Merge request is not mergeable: conflict"
825 ))
826 },
827 );
828 assert!(result.is_err());
829 assert_eq!(resettles, 0, "a non-transient failure must not re-poll");
830 }
831
832 #[test]
833 fn a_transient_5xx_from_the_api_is_retried() {
834 let mut calls = 0;
835 let result = retry_transient_merge(
836 3,
837 || {},
838 || {
839 calls += 1;
840 if calls < 2 {
841 Err(anyhow!(
842 "gh failed: non-200 OK status code: 502 Bad Gateway"
843 ))
844 } else {
845 Ok("merged".to_owned())
846 }
847 },
848 );
849 assert_eq!(result.unwrap(), "merged");
850 assert_eq!(calls, 2, "a 502 is a server hiccup, not a merge verdict");
851 }
852
853 #[test]
854 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
855 let mut calls = 0;
856 let result = retry_transient_merge(
857 3,
858 || {},
859 || {
860 calls += 1;
861 Err(anyhow!("gh failed: Base branch was modified"))
862 },
863 );
864 assert!(result.is_err());
865 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
866 }
867
868 #[test]
869 fn a_real_failure_is_not_retried() {
870 let mut calls = 0;
871 let result = retry_transient_merge(
872 3,
873 || {},
874 || {
875 calls += 1;
876 Err(anyhow!(
877 "gh failed: Pull request is not mergeable: conflicts"
878 ))
879 },
880 );
881 assert!(result.is_err());
882 assert_eq!(calls, 1, "a non-transient error must surface immediately");
883 }
884
885 #[test]
886 fn host_of_extracts_the_host_across_url_shapes() {
887 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
888 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
889 assert_eq!(
890 host_of("ssh://git@gitlab.example.com:22/g/r"),
891 "gitlab.example.com"
892 );
893 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
894 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
895 assert_eq!(
896 host_of("https://[2001:db8::1]:443/owner/repo"),
897 "2001:db8::1"
898 );
899 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
900 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
902 }
903
904 #[test]
905 fn redact_url_strips_embedded_credentials() {
906 assert_eq!(
908 redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
909 "https://github.com/owner/repo.git"
910 );
911 assert_eq!(
912 redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
913 "https://gitlab.com/owner/repo"
914 );
915 assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
917 }
918
919 #[test]
920 fn redact_url_leaves_credential_free_urls_unchanged() {
921 assert_eq!(
922 redact_url("https://github.com/owner/repo.git"),
923 "https://github.com/owner/repo.git"
924 );
925 assert_eq!(
927 redact_url("git@github.com:owner/repo.git"),
928 "git@github.com:owner/repo.git"
929 );
930 }
931
932 #[test]
933 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
934 let remote = "git@gitlab.example.com:team/repo.git";
935 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
936 assert_eq!(
937 detect_provider_from_url(remote, Some(configured), None),
938 Some(ProviderKind::GitLab),
939 "configured {configured:?} should detect the self-hosted host"
940 );
941 }
942 assert_eq!(
944 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com"), None),
945 None
946 );
947 }
948
949 #[test]
950 fn gitea_is_detected_for_gitea_com_codeberg_and_a_configured_host() {
951 assert_eq!(
952 detect_provider_from_url("git@gitea.com:o/r.git", None, None),
953 Some(ProviderKind::Gitea)
954 );
955 assert_eq!(
956 detect_provider_from_url("https://codeberg.org/o/r", None, None),
957 Some(ProviderKind::Gitea)
958 );
959 for configured in ["gitea.example.com", "https://gitea.example.com"] {
960 assert_eq!(
961 detect_provider_from_url("git@gitea.example.com:o/r.git", None, Some(configured)),
962 Some(ProviderKind::Gitea),
963 "configured {configured:?} should detect the self-hosted Gitea host"
964 );
965 }
966 assert_eq!(
968 detect_provider_from_url("git@notgitea.com:o/r", None, Some("gitea.example.com")),
969 None
970 );
971 }
972}