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