1use std::time::Duration;
2use std::{fmt, process::Command};
3
4use anyhow::{Context, Result, anyhow, bail};
5
6use crate::git;
7use crate::settings;
8
9pub(super) const CHECK_GRACE_POLLS: u32 = 6;
14
15pub(super) fn check_poll_interval() -> Duration {
17 Duration::from_secs(5)
18}
19
20pub(super) fn checks_timed_out(review: &ReviewRequest, timeout: Duration) -> anyhow::Error {
24 anyhow!(
25 "{}'s checks have not settled within {}; rerun `git stk merge` once they pass, \
26 or raise stk.checkTimeout",
27 review.id,
28 humanize(timeout),
29 )
30}
31
32fn humanize(duration: Duration) -> String {
34 let seconds = duration.as_secs();
35 if seconds >= 60 && seconds.is_multiple_of(60) {
36 format!("{}m", seconds / 60)
37 } else {
38 format!("{seconds}s")
39 }
40}
41
42mod demo;
43mod gitea;
44mod github;
45mod gitlab;
46mod json;
47
48use demo::DemoProvider;
49use gitea::GiteaProvider;
50use github::GitHubProvider;
51use gitlab::GitLabProvider;
52
53#[derive(Debug, Clone, Copy, Eq, PartialEq)]
54pub enum ProviderKind {
55 GitHub,
56 GitLab,
57 Gitea,
58 Demo,
61}
62
63impl ProviderKind {
64 fn parse(value: &str) -> Option<Self> {
65 match value.to_ascii_lowercase().as_str() {
66 "github" | "gh" => Some(Self::GitHub),
67 "gitlab" | "glab" => Some(Self::GitLab),
68 "gitea" | "tea" => Some(Self::Gitea),
69 "demo" => Some(Self::Demo),
70 _ => None,
71 }
72 }
73}
74
75impl fmt::Display for ProviderKind {
76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 Self::GitHub => write!(formatter, "github"),
79 Self::GitLab => write!(formatter, "gitlab"),
80 Self::Gitea => write!(formatter, "gitea"),
81 Self::Demo => write!(formatter, "demo"),
82 }
83 }
84}
85
86#[derive(Debug, Eq, PartialEq)]
87pub struct DetectedProvider {
88 pub kind: ProviderKind,
89 pub source: ProviderSource,
90}
91
92#[derive(Debug, Eq, PartialEq)]
93pub enum ProviderSource {
94 Config,
95 Remote { remote: String, url: String },
96}
97
98impl fmt::Display for ProviderSource {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 Self::Config => write!(formatter, "config"),
102 Self::Remote { remote, url } => {
103 write!(formatter, "remote {remote} ({})", redact_url(url))
104 }
105 }
106 }
107}
108
109#[derive(Debug, Eq, PartialEq)]
110pub enum ReviewState {
111 Open,
112 Merged,
113 Closed,
114 Unknown(String),
115}
116
117#[derive(Debug, Clone, Copy, Eq, PartialEq)]
122pub enum MergeBlocker {
123 ChecksPending,
125 Conflicts,
127 None,
129}
130
131#[derive(Debug, Eq, PartialEq)]
132pub struct ReviewRequest {
133 pub id: String,
134 pub branch: String,
135 pub base: String,
136 pub state: ReviewState,
137 pub url: String,
138 pub title: String,
139 pub draft: bool,
140}
141
142pub enum WaitOutcome {
144 Passed,
146 Failed,
148 Landed,
151}
152
153pub trait ReviewProvider {
154 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
155
156 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
161
162 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
164
165 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
166
167 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
168
169 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
170
171 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
175
176 fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
180
181 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
185
186 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
189
190 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
192
193 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
196
197 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
199}
200
201pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
205 let provider = detect_provider()?;
206 let client = review_provider(provider.kind);
207 Ok((provider, client))
208}
209
210pub fn owned_review_for_branch(
214 provider: &dyn ReviewProvider,
215 branch: &str,
216) -> Result<Option<ReviewRequest>> {
217 Ok(provider
218 .review_for_branch(branch)?
219 .filter(|review| review.branch == branch))
220}
221
222pub(super) fn review_merged_out_of_band(
226 provider: &dyn ReviewProvider,
227 review: &ReviewRequest,
228) -> Result<bool> {
229 Ok(matches!(
230 provider.review_for_branch(&review.branch)?,
231 Some(current) if current.state == ReviewState::Merged
232 ))
233}
234
235pub fn detect_provider() -> Result<DetectedProvider> {
236 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
237 let Some(kind) = ProviderKind::parse(&value) else {
238 bail!(
239 "unsupported stk.provider value {value:?}; expected github, gitlab, gitea, or demo"
240 );
241 };
242
243 return Ok(DetectedProvider {
244 kind,
245 source: ProviderSource::Config,
246 });
247 }
248
249 let remote = settings::remote()?;
250 let Some(url) = git::remote_url(&remote)? else {
251 bail!("could not detect provider: remote {remote:?} does not exist");
252 };
253
254 let gitlab_host = settings::gitlab_host()?;
255 let gitea_host = settings::gitea_host()?;
256 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref(), gitea_host.as_deref())
257 else {
258 bail!(
259 "could not detect provider from remote {remote} ({})",
260 redact_url(&url)
261 );
262 };
263
264 Ok(DetectedProvider {
265 kind,
266 source: ProviderSource::Remote { remote, url },
267 })
268}
269
270fn detect_provider_from_url(
274 url: &str,
275 gitlab_host: Option<&str>,
276 gitea_host: Option<&str>,
277) -> Option<ProviderKind> {
278 let normalized = url.to_ascii_lowercase();
279 let host = host_of(&normalized);
280 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
283
284 let self_hosted = |configured: Option<&str>| {
287 configured.is_some_and(|configured| is(host_of(&configured.to_ascii_lowercase())))
288 };
289
290 if is("github.com") {
291 Some(ProviderKind::GitHub)
292 } else if is("gitlab.com") || self_hosted(gitlab_host) {
293 Some(ProviderKind::GitLab)
294 } else if is("gitea.com") || is("codeberg.org") || self_hosted(gitea_host) {
295 Some(ProviderKind::Gitea)
296 } else {
297 None
298 }
299}
300
301fn host_of(url: &str) -> &str {
306 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
307 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
311 let host_port = authority
312 .rsplit_once('@')
313 .map_or(authority, |(_, rest)| rest);
314 if let Some(after_bracket) = host_port.strip_prefix('[') {
316 return after_bracket
317 .split_once(']')
318 .map_or(host_port, |(addr, _)| addr);
319 }
320 host_port.split(':').next().unwrap_or(host_port)
322}
323
324fn redact_url(url: &str) -> String {
328 let Some((scheme, rest)) = url.split_once("://") else {
329 return url.to_owned();
330 };
331 let (authority, path) = match rest.split_once('/') {
332 Some((authority, path)) => (authority, Some(path)),
333 None => (rest, None),
334 };
335 let Some((_, host)) = authority.rsplit_once('@') else {
338 return url.to_owned();
339 };
340 match path {
341 Some(path) => format!("{scheme}://{host}/{path}"),
342 None => format!("{scheme}://{host}"),
343 }
344}
345
346pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
347 match kind {
348 ProviderKind::GitHub => Box::new(GitHubProvider),
349 ProviderKind::GitLab => Box::new(GitLabProvider),
350 ProviderKind::Gitea => Box::new(GiteaProvider),
351 ProviderKind::Demo => Box::new(DemoProvider),
352 }
353}
354
355fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
358 match program {
359 "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
360 "glab" => Some((
361 "GitLab CLI",
362 "https://gitlab.com/gitlab-org/cli",
363 "glab auth login",
364 )),
365 "tea" => Some((
366 "Gitea CLI (tea)",
367 "https://gitea.com/gitea/tea",
368 "tea login add",
369 )),
370 _ => None,
371 }
372}
373
374fn looks_unauthenticated(stderr: &str) -> bool {
377 let stderr = stderr.to_ascii_lowercase();
378 [
379 "auth login",
380 "not logged",
381 "401",
382 "unauthorized",
383 "authentication required",
384 ]
385 .iter()
386 .any(|needle| stderr.contains(needle))
387}
388
389fn command_output(program: &str, args: &[&str]) -> Result<String> {
390 let output = match Command::new(program).args(args).output() {
391 Ok(output) => output,
392 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
395 if let Some((name, url, auth)) = provider_cli(program) {
396 bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
397 }
398 return Err(error).with_context(|| format!("failed to run {program}"));
399 }
400 Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
401 };
402
403 if output.status.success() {
404 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
405 }
406
407 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
408 if let Some((_, _, auth)) = provider_cli(program)
411 && looks_unauthenticated(&stderr)
412 {
413 bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
414 }
415 if stderr.is_empty() {
416 Err(anyhow!("{program} exited with status {}", output.status))
417 } else {
418 Err(anyhow!("{program} failed: {stderr}"))
419 }
420}
421
422const MERGE_ATTEMPTS: u32 = 3;
426const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
427
428fn is_transient_merge_error(error: &anyhow::Error) -> bool {
436 let text = error.to_string().to_lowercase();
437 [
438 "base branch was modified",
439 "head branch was modified",
440 "try the merge again",
441 "method not allowed",
442 "is it still open",
443 "bad gateway",
446 "service unavailable",
447 "gateway time",
448 "internal server error",
449 ]
450 .iter()
451 .any(|signature| text.contains(signature))
452}
453
454fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
459 retry_transient_merge(
460 MERGE_ATTEMPTS,
461 || std::thread::sleep(MERGE_RETRY_BACKOFF),
462 attempt,
463 )
464}
465
466pub(super) fn merge_with_resettle(
472 mut resettle: impl FnMut(),
473 attempt: impl FnMut() -> Result<String>,
474) -> Result<String> {
475 retry_transient_merge(
476 MERGE_ATTEMPTS,
477 move || {
478 std::thread::sleep(MERGE_RETRY_BACKOFF);
481 resettle();
482 },
483 attempt,
484 )
485}
486
487fn retry_transient_merge(
488 attempts: u32,
489 mut on_transient: impl FnMut(),
490 mut attempt: impl FnMut() -> Result<String>,
491) -> Result<String> {
492 for remaining in (0..attempts).rev() {
493 match attempt() {
494 Ok(output) => return Ok(output),
495 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
496 on_transient();
497 }
498 Err(error) => return Err(error),
499 }
500 }
501 Err(anyhow!("merge retried with no attempts left"))
503}
504
505impl fmt::Display for ReviewState {
506 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
507 match self {
508 Self::Open => write!(formatter, "open"),
509 Self::Merged => write!(formatter, "merged"),
510 Self::Closed => write!(formatter, "closed"),
511 Self::Unknown(state) => write!(formatter, "{state}"),
512 }
513 }
514}
515
516impl ReviewRequest {
517 pub(crate) fn id_value(&self) -> &str {
518 self.id
519 .strip_prefix('#')
520 .or_else(|| self.id.strip_prefix('!'))
521 .unwrap_or(&self.id)
522 }
523
524 pub fn label(&self) -> String {
526 label(&self.title, &self.id)
527 }
528}
529
530pub(crate) fn label(title: &str, id: &str) -> String {
532 if title.is_empty() {
533 id.to_owned()
534 } else {
535 format!("{title} ({id})")
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn provider_cli_maps_only_the_provider_clis() {
545 assert!(provider_cli("gh").is_some());
546 assert!(provider_cli("glab").is_some());
547 assert!(provider_cli("git").is_none());
548 }
549
550 #[test]
551 fn looks_unauthenticated_matches_signin_failures_only() {
552 assert!(looks_unauthenticated(
553 "error: not logged into any GitHub hosts"
554 ));
555 assert!(looks_unauthenticated(
556 "To get started, please run: gh auth login"
557 ));
558 assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
559 assert!(!looks_unauthenticated("pull request not found"));
561 assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
562 }
563
564 #[test]
565 fn transient_error_is_retried_then_succeeds() {
566 let mut calls = 0;
567 let result = retry_transient_merge(
568 3,
569 || {},
570 || {
571 calls += 1;
572 if calls < 2 {
573 Err(anyhow!(
574 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
575 ))
576 } else {
577 Ok("merged".to_owned())
578 }
579 },
580 );
581 assert_eq!(result.unwrap(), "merged");
582 assert_eq!(calls, 2, "should retry once then succeed");
583 }
584
585 #[test]
586 fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
587 let mut calls = 0;
588 let result = retry_transient_merge(
589 3,
590 || {},
591 || {
592 calls += 1;
593 if calls < 2 {
594 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
595 } else {
596 Ok("merged".to_owned())
597 }
598 },
599 );
600 assert_eq!(result.unwrap(), "merged");
601 assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
602 }
603
604 #[test]
605 fn the_between_retry_action_runs_once_per_transient_retry() {
606 let mut resettles = 0;
609 let mut calls = 0;
610 let result = retry_transient_merge(
611 3,
612 || resettles += 1,
613 || {
614 calls += 1;
615 if calls < 3 {
617 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
618 } else {
619 Ok("merged".to_owned())
620 }
621 },
622 );
623 assert_eq!(result.unwrap(), "merged");
624 assert_eq!(calls, 3, "should retry until the merge lands");
625 assert_eq!(
626 resettles, 2,
627 "re-poll once per transient retry, not after the final success"
628 );
629 }
630
631 #[test]
632 fn the_between_retry_action_does_not_run_on_a_real_failure() {
633 let mut resettles = 0;
634 let result = retry_transient_merge(
635 3,
636 || resettles += 1,
637 || {
638 Err(anyhow!(
639 "glab failed: Merge request is not mergeable: conflict"
640 ))
641 },
642 );
643 assert!(result.is_err());
644 assert_eq!(resettles, 0, "a non-transient failure must not re-poll");
645 }
646
647 #[test]
648 fn a_transient_5xx_from_the_api_is_retried() {
649 let mut calls = 0;
650 let result = retry_transient_merge(
651 3,
652 || {},
653 || {
654 calls += 1;
655 if calls < 2 {
656 Err(anyhow!(
657 "gh failed: non-200 OK status code: 502 Bad Gateway"
658 ))
659 } else {
660 Ok("merged".to_owned())
661 }
662 },
663 );
664 assert_eq!(result.unwrap(), "merged");
665 assert_eq!(calls, 2, "a 502 is a server hiccup, not a merge verdict");
666 }
667
668 #[test]
669 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
670 let mut calls = 0;
671 let result = retry_transient_merge(
672 3,
673 || {},
674 || {
675 calls += 1;
676 Err(anyhow!("gh failed: Base branch was modified"))
677 },
678 );
679 assert!(result.is_err());
680 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
681 }
682
683 #[test]
684 fn a_real_failure_is_not_retried() {
685 let mut calls = 0;
686 let result = retry_transient_merge(
687 3,
688 || {},
689 || {
690 calls += 1;
691 Err(anyhow!(
692 "gh failed: Pull request is not mergeable: conflicts"
693 ))
694 },
695 );
696 assert!(result.is_err());
697 assert_eq!(calls, 1, "a non-transient error must surface immediately");
698 }
699
700 #[test]
701 fn host_of_extracts_the_host_across_url_shapes() {
702 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
703 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
704 assert_eq!(
705 host_of("ssh://git@gitlab.example.com:22/g/r"),
706 "gitlab.example.com"
707 );
708 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
709 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
710 assert_eq!(
711 host_of("https://[2001:db8::1]:443/owner/repo"),
712 "2001:db8::1"
713 );
714 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
715 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
717 }
718
719 #[test]
720 fn redact_url_strips_embedded_credentials() {
721 assert_eq!(
723 redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
724 "https://github.com/owner/repo.git"
725 );
726 assert_eq!(
727 redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
728 "https://gitlab.com/owner/repo"
729 );
730 assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
732 }
733
734 #[test]
735 fn redact_url_leaves_credential_free_urls_unchanged() {
736 assert_eq!(
737 redact_url("https://github.com/owner/repo.git"),
738 "https://github.com/owner/repo.git"
739 );
740 assert_eq!(
742 redact_url("git@github.com:owner/repo.git"),
743 "git@github.com:owner/repo.git"
744 );
745 }
746
747 #[test]
748 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
749 let remote = "git@gitlab.example.com:team/repo.git";
750 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
751 assert_eq!(
752 detect_provider_from_url(remote, Some(configured), None),
753 Some(ProviderKind::GitLab),
754 "configured {configured:?} should detect the self-hosted host"
755 );
756 }
757 assert_eq!(
759 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com"), None),
760 None
761 );
762 }
763
764 #[test]
765 fn gitea_is_detected_for_gitea_com_codeberg_and_a_configured_host() {
766 assert_eq!(
767 detect_provider_from_url("git@gitea.com:o/r.git", None, None),
768 Some(ProviderKind::Gitea)
769 );
770 assert_eq!(
771 detect_provider_from_url("https://codeberg.org/o/r", None, None),
772 Some(ProviderKind::Gitea)
773 );
774 for configured in ["gitea.example.com", "https://gitea.example.com"] {
775 assert_eq!(
776 detect_provider_from_url("git@gitea.example.com:o/r.git", None, Some(configured)),
777 Some(ProviderKind::Gitea),
778 "configured {configured:?} should detect the self-hosted Gitea host"
779 );
780 }
781 assert_eq!(
783 detect_provider_from_url("git@notgitea.com:o/r", None, Some("gitea.example.com")),
784 None
785 );
786 }
787}