1use std::collections::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
143pub enum WaitOutcome {
145 Passed,
147 Failed,
149 Landed,
152}
153
154pub trait ReviewProvider {
155 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
156
157 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
162
163 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
165
166 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
167
168 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
169
170 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
171
172 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
176
177 fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
181
182 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
186
187 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
190
191 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
193
194 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
197
198 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
200
201 fn enqueued_branches(&self, _branches: &[String]) -> Result<BTreeSet<String>> {
209 Ok(BTreeSet::new())
210 }
211}
212
213pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
217 let provider = detect_provider()?;
218 let client = review_provider(provider.kind);
219 Ok((provider, client))
220}
221
222pub fn owned_review_for_branch(
226 provider: &dyn ReviewProvider,
227 branch: &str,
228) -> Result<Option<ReviewRequest>> {
229 Ok(provider
230 .review_for_branch(branch)?
231 .filter(|review| review.branch == branch))
232}
233
234pub(super) fn review_merged_out_of_band(
238 provider: &dyn ReviewProvider,
239 review: &ReviewRequest,
240) -> Result<bool> {
241 Ok(matches!(
242 provider.review_for_branch(&review.branch)?,
243 Some(current) if current.state == ReviewState::Merged
244 ))
245}
246
247pub fn detect_provider() -> Result<DetectedProvider> {
248 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
249 let Some(kind) = ProviderKind::parse(&value) else {
250 bail!(
251 "unsupported stk.provider value {value:?}; expected github, gitlab, gitea, or demo"
252 );
253 };
254
255 return Ok(DetectedProvider {
256 kind,
257 source: ProviderSource::Config,
258 });
259 }
260
261 let remote = settings::remote()?;
262 let Some(url) = git::remote_url(&remote)? else {
263 bail!("could not detect provider: remote {remote:?} does not exist");
264 };
265
266 let gitlab_host = settings::gitlab_host()?;
267 let gitea_host = settings::gitea_host()?;
268 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref(), gitea_host.as_deref())
269 else {
270 bail!(
271 "could not detect provider from remote {remote} ({})",
272 redact_url(&url)
273 );
274 };
275
276 Ok(DetectedProvider {
277 kind,
278 source: ProviderSource::Remote { remote, url },
279 })
280}
281
282fn detect_provider_from_url(
286 url: &str,
287 gitlab_host: Option<&str>,
288 gitea_host: Option<&str>,
289) -> Option<ProviderKind> {
290 let normalized = url.to_ascii_lowercase();
291 let host = host_of(&normalized);
292 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
295
296 let self_hosted = |configured: Option<&str>| {
299 configured.is_some_and(|configured| is(host_of(&configured.to_ascii_lowercase())))
300 };
301
302 if is("github.com") {
303 Some(ProviderKind::GitHub)
304 } else if is("gitlab.com") || self_hosted(gitlab_host) {
305 Some(ProviderKind::GitLab)
306 } else if is("gitea.com") || is("codeberg.org") || self_hosted(gitea_host) {
307 Some(ProviderKind::Gitea)
308 } else {
309 None
310 }
311}
312
313fn host_of(url: &str) -> &str {
318 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
319 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
323 let host_port = authority
324 .rsplit_once('@')
325 .map_or(authority, |(_, rest)| rest);
326 if let Some(after_bracket) = host_port.strip_prefix('[') {
328 return after_bracket
329 .split_once(']')
330 .map_or(host_port, |(addr, _)| addr);
331 }
332 host_port.split(':').next().unwrap_or(host_port)
334}
335
336fn redact_url(url: &str) -> String {
340 let Some((scheme, rest)) = url.split_once("://") else {
341 return url.to_owned();
342 };
343 let (authority, path) = match rest.split_once('/') {
344 Some((authority, path)) => (authority, Some(path)),
345 None => (rest, None),
346 };
347 let Some((_, host)) = authority.rsplit_once('@') else {
350 return url.to_owned();
351 };
352 match path {
353 Some(path) => format!("{scheme}://{host}/{path}"),
354 None => format!("{scheme}://{host}"),
355 }
356}
357
358pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
359 match kind {
360 ProviderKind::GitHub => Box::new(GitHubProvider),
361 ProviderKind::GitLab => Box::new(GitLabProvider),
362 ProviderKind::Gitea => Box::new(GiteaProvider),
363 ProviderKind::Demo => Box::new(DemoProvider),
364 }
365}
366
367fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
370 match program {
371 "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
372 "glab" => Some((
373 "GitLab CLI",
374 "https://gitlab.com/gitlab-org/cli",
375 "glab auth login",
376 )),
377 "tea" => Some((
378 "Gitea CLI (tea)",
379 "https://gitea.com/gitea/tea",
380 "tea login add",
381 )),
382 _ => None,
383 }
384}
385
386fn looks_unauthenticated(stderr: &str) -> bool {
389 let stderr = stderr.to_ascii_lowercase();
390 [
391 "auth login",
392 "not logged",
393 "401",
394 "unauthorized",
395 "authentication required",
396 ]
397 .iter()
398 .any(|needle| stderr.contains(needle))
399}
400
401fn command_output(program: &str, args: &[&str]) -> Result<String> {
402 let output = match Command::new(program).args(args).output() {
403 Ok(output) => output,
404 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
407 if let Some((name, url, auth)) = provider_cli(program) {
408 bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
409 }
410 return Err(error).with_context(|| format!("failed to run {program}"));
411 }
412 Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
413 };
414
415 if output.status.success() {
416 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
417 }
418
419 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
420 if let Some((_, _, auth)) = provider_cli(program)
423 && looks_unauthenticated(&stderr)
424 {
425 bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
426 }
427 if stderr.is_empty() {
428 Err(anyhow!("{program} exited with status {}", output.status))
429 } else {
430 Err(anyhow!("{program} failed: {stderr}"))
431 }
432}
433
434const MERGE_ATTEMPTS: u32 = 3;
438const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
439
440fn is_transient_merge_error(error: &anyhow::Error) -> bool {
448 let text = error.to_string().to_lowercase();
449 [
450 "base branch was modified",
451 "head branch was modified",
452 "try the merge again",
453 "method not allowed",
454 "is it still open",
455 "bad gateway",
458 "service unavailable",
459 "gateway time",
460 "internal server error",
461 ]
462 .iter()
463 .any(|signature| text.contains(signature))
464}
465
466fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
471 retry_transient_merge(
472 MERGE_ATTEMPTS,
473 || std::thread::sleep(MERGE_RETRY_BACKOFF),
474 attempt,
475 )
476}
477
478pub(super) fn merge_with_resettle(
484 mut resettle: impl FnMut(),
485 attempt: impl FnMut() -> Result<String>,
486) -> Result<String> {
487 retry_transient_merge(
488 MERGE_ATTEMPTS,
489 move || {
490 std::thread::sleep(MERGE_RETRY_BACKOFF);
493 resettle();
494 },
495 attempt,
496 )
497}
498
499fn retry_transient_merge(
500 attempts: u32,
501 mut on_transient: impl FnMut(),
502 mut attempt: impl FnMut() -> Result<String>,
503) -> Result<String> {
504 for remaining in (0..attempts).rev() {
505 match attempt() {
506 Ok(output) => return Ok(output),
507 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
508 on_transient();
509 }
510 Err(error) => return Err(error),
511 }
512 }
513 Err(anyhow!("merge retried with no attempts left"))
515}
516
517impl fmt::Display for ReviewState {
518 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
519 match self {
520 Self::Open => write!(formatter, "open"),
521 Self::Merged => write!(formatter, "merged"),
522 Self::Closed => write!(formatter, "closed"),
523 Self::Unknown(state) => write!(formatter, "{state}"),
524 }
525 }
526}
527
528impl ReviewRequest {
529 pub(crate) fn id_value(&self) -> &str {
530 self.id
531 .strip_prefix('#')
532 .or_else(|| self.id.strip_prefix('!'))
533 .unwrap_or(&self.id)
534 }
535
536 pub fn label(&self) -> String {
538 label(&self.title, &self.id)
539 }
540}
541
542pub(crate) fn label(title: &str, id: &str) -> String {
544 if title.is_empty() {
545 id.to_owned()
546 } else {
547 format!("{title} ({id})")
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn provider_cli_maps_only_the_provider_clis() {
557 assert!(provider_cli("gh").is_some());
558 assert!(provider_cli("glab").is_some());
559 assert!(provider_cli("git").is_none());
560 }
561
562 #[test]
563 fn looks_unauthenticated_matches_signin_failures_only() {
564 assert!(looks_unauthenticated(
565 "error: not logged into any GitHub hosts"
566 ));
567 assert!(looks_unauthenticated(
568 "To get started, please run: gh auth login"
569 ));
570 assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
571 assert!(!looks_unauthenticated("pull request not found"));
573 assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
574 }
575
576 #[test]
577 fn transient_error_is_retried_then_succeeds() {
578 let mut calls = 0;
579 let result = retry_transient_merge(
580 3,
581 || {},
582 || {
583 calls += 1;
584 if calls < 2 {
585 Err(anyhow!(
586 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
587 ))
588 } else {
589 Ok("merged".to_owned())
590 }
591 },
592 );
593 assert_eq!(result.unwrap(), "merged");
594 assert_eq!(calls, 2, "should retry once then succeed");
595 }
596
597 #[test]
598 fn a_gitlab_405_while_the_merge_status_recomputes_is_retried() {
599 let mut calls = 0;
600 let result = retry_transient_merge(
601 3,
602 || {},
603 || {
604 calls += 1;
605 if calls < 2 {
606 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
607 } else {
608 Ok("merged".to_owned())
609 }
610 },
611 );
612 assert_eq!(result.unwrap(), "merged");
613 assert_eq!(calls, 2, "GitLab's transient 405 should be retried");
614 }
615
616 #[test]
617 fn the_between_retry_action_runs_once_per_transient_retry() {
618 let mut resettles = 0;
621 let mut calls = 0;
622 let result = retry_transient_merge(
623 3,
624 || resettles += 1,
625 || {
626 calls += 1;
627 if calls < 3 {
629 Err(anyhow!("glab failed: ... /merge: 405 Method Not Allowed"))
630 } else {
631 Ok("merged".to_owned())
632 }
633 },
634 );
635 assert_eq!(result.unwrap(), "merged");
636 assert_eq!(calls, 3, "should retry until the merge lands");
637 assert_eq!(
638 resettles, 2,
639 "re-poll once per transient retry, not after the final success"
640 );
641 }
642
643 #[test]
644 fn the_between_retry_action_does_not_run_on_a_real_failure() {
645 let mut resettles = 0;
646 let result = retry_transient_merge(
647 3,
648 || resettles += 1,
649 || {
650 Err(anyhow!(
651 "glab failed: Merge request is not mergeable: conflict"
652 ))
653 },
654 );
655 assert!(result.is_err());
656 assert_eq!(resettles, 0, "a non-transient failure must not re-poll");
657 }
658
659 #[test]
660 fn a_transient_5xx_from_the_api_is_retried() {
661 let mut calls = 0;
662 let result = retry_transient_merge(
663 3,
664 || {},
665 || {
666 calls += 1;
667 if calls < 2 {
668 Err(anyhow!(
669 "gh failed: non-200 OK status code: 502 Bad Gateway"
670 ))
671 } else {
672 Ok("merged".to_owned())
673 }
674 },
675 );
676 assert_eq!(result.unwrap(), "merged");
677 assert_eq!(calls, 2, "a 502 is a server hiccup, not a merge verdict");
678 }
679
680 #[test]
681 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
682 let mut calls = 0;
683 let result = retry_transient_merge(
684 3,
685 || {},
686 || {
687 calls += 1;
688 Err(anyhow!("gh failed: Base branch was modified"))
689 },
690 );
691 assert!(result.is_err());
692 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
693 }
694
695 #[test]
696 fn a_real_failure_is_not_retried() {
697 let mut calls = 0;
698 let result = retry_transient_merge(
699 3,
700 || {},
701 || {
702 calls += 1;
703 Err(anyhow!(
704 "gh failed: Pull request is not mergeable: conflicts"
705 ))
706 },
707 );
708 assert!(result.is_err());
709 assert_eq!(calls, 1, "a non-transient error must surface immediately");
710 }
711
712 #[test]
713 fn host_of_extracts_the_host_across_url_shapes() {
714 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
715 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
716 assert_eq!(
717 host_of("ssh://git@gitlab.example.com:22/g/r"),
718 "gitlab.example.com"
719 );
720 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
721 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
722 assert_eq!(
723 host_of("https://[2001:db8::1]:443/owner/repo"),
724 "2001:db8::1"
725 );
726 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
727 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
729 }
730
731 #[test]
732 fn redact_url_strips_embedded_credentials() {
733 assert_eq!(
735 redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
736 "https://github.com/owner/repo.git"
737 );
738 assert_eq!(
739 redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
740 "https://gitlab.com/owner/repo"
741 );
742 assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
744 }
745
746 #[test]
747 fn redact_url_leaves_credential_free_urls_unchanged() {
748 assert_eq!(
749 redact_url("https://github.com/owner/repo.git"),
750 "https://github.com/owner/repo.git"
751 );
752 assert_eq!(
754 redact_url("git@github.com:owner/repo.git"),
755 "git@github.com:owner/repo.git"
756 );
757 }
758
759 #[test]
760 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
761 let remote = "git@gitlab.example.com:team/repo.git";
762 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
763 assert_eq!(
764 detect_provider_from_url(remote, Some(configured), None),
765 Some(ProviderKind::GitLab),
766 "configured {configured:?} should detect the self-hosted host"
767 );
768 }
769 assert_eq!(
771 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com"), None),
772 None
773 );
774 }
775
776 #[test]
777 fn gitea_is_detected_for_gitea_com_codeberg_and_a_configured_host() {
778 assert_eq!(
779 detect_provider_from_url("git@gitea.com:o/r.git", None, None),
780 Some(ProviderKind::Gitea)
781 );
782 assert_eq!(
783 detect_provider_from_url("https://codeberg.org/o/r", None, None),
784 Some(ProviderKind::Gitea)
785 );
786 for configured in ["gitea.example.com", "https://gitea.example.com"] {
787 assert_eq!(
788 detect_provider_from_url("git@gitea.example.com:o/r.git", None, Some(configured)),
789 Some(ProviderKind::Gitea),
790 "configured {configured:?} should detect the self-hosted Gitea host"
791 );
792 }
793 assert_eq!(
795 detect_provider_from_url("git@notgitea.com:o/r", None, Some("gitea.example.com")),
796 None
797 );
798 }
799}