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