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 github;
44mod gitlab;
45mod json;
46
47use demo::DemoProvider;
48use github::GitHubProvider;
49use gitlab::GitLabProvider;
50
51#[derive(Debug, Clone, Copy, Eq, PartialEq)]
52pub enum ProviderKind {
53 GitHub,
54 GitLab,
55 Demo,
58}
59
60impl ProviderKind {
61 fn parse(value: &str) -> Option<Self> {
62 match value.to_ascii_lowercase().as_str() {
63 "github" | "gh" => Some(Self::GitHub),
64 "gitlab" | "glab" => Some(Self::GitLab),
65 "demo" => Some(Self::Demo),
66 _ => None,
67 }
68 }
69}
70
71impl fmt::Display for ProviderKind {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::GitHub => write!(formatter, "github"),
75 Self::GitLab => write!(formatter, "gitlab"),
76 Self::Demo => write!(formatter, "demo"),
77 }
78 }
79}
80
81#[derive(Debug, Eq, PartialEq)]
82pub struct DetectedProvider {
83 pub kind: ProviderKind,
84 pub source: ProviderSource,
85}
86
87#[derive(Debug, Eq, PartialEq)]
88pub enum ProviderSource {
89 Config,
90 Remote { remote: String, url: String },
91}
92
93impl fmt::Display for ProviderSource {
94 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95 match self {
96 Self::Config => write!(formatter, "config"),
97 Self::Remote { remote, url } => {
98 write!(formatter, "remote {remote} ({})", redact_url(url))
99 }
100 }
101 }
102}
103
104#[derive(Debug, Eq, PartialEq)]
105pub enum ReviewState {
106 Open,
107 Merged,
108 Closed,
109 Unknown(String),
110}
111
112#[derive(Debug, Clone, Copy, Eq, PartialEq)]
117pub enum MergeBlocker {
118 ChecksPending,
120 Conflicts,
122 None,
124}
125
126#[derive(Debug, Eq, PartialEq)]
127pub struct ReviewRequest {
128 pub id: String,
129 pub branch: String,
130 pub base: String,
131 pub state: ReviewState,
132 pub url: String,
133 pub title: String,
134 pub draft: bool,
135}
136
137pub enum WaitOutcome {
139 Passed,
141 Failed,
143 Landed,
146}
147
148pub trait ReviewProvider {
149 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
150
151 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
156
157 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
159
160 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
161
162 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
163
164 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
165
166 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
170
171 fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
175
176 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<WaitOutcome>;
180
181 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
184
185 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
187
188 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
191
192 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
194}
195
196pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
200 let provider = detect_provider()?;
201 let client = review_provider(provider.kind);
202 Ok((provider, client))
203}
204
205pub fn owned_review_for_branch(
209 provider: &dyn ReviewProvider,
210 branch: &str,
211) -> Result<Option<ReviewRequest>> {
212 Ok(provider
213 .review_for_branch(branch)?
214 .filter(|review| review.branch == branch))
215}
216
217pub(super) fn review_merged_out_of_band(
221 provider: &dyn ReviewProvider,
222 review: &ReviewRequest,
223) -> Result<bool> {
224 Ok(matches!(
225 provider.review_for_branch(&review.branch)?,
226 Some(current) if current.state == ReviewState::Merged
227 ))
228}
229
230pub fn detect_provider() -> Result<DetectedProvider> {
231 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
232 let Some(kind) = ProviderKind::parse(&value) else {
233 bail!("unsupported stk.provider value {value:?}; expected github, gitlab, or demo");
234 };
235
236 return Ok(DetectedProvider {
237 kind,
238 source: ProviderSource::Config,
239 });
240 }
241
242 let remote = settings::remote()?;
243 let Some(url) = git::remote_url(&remote)? else {
244 bail!("could not detect provider: remote {remote:?} does not exist");
245 };
246
247 let gitlab_host = settings::gitlab_host()?;
248 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref()) else {
249 bail!(
250 "could not detect provider from remote {remote} ({})",
251 redact_url(&url)
252 );
253 };
254
255 Ok(DetectedProvider {
256 kind,
257 source: ProviderSource::Remote { remote, url },
258 })
259}
260
261fn detect_provider_from_url(url: &str, gitlab_host: Option<&str>) -> Option<ProviderKind> {
264 let normalized = url.to_ascii_lowercase();
265 let host = host_of(&normalized);
266 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
269
270 let gitlab_self_hosted = || {
273 gitlab_host.is_some_and(|configured| {
274 let configured = configured.to_ascii_lowercase();
275 is(host_of(&configured))
276 })
277 };
278
279 if is("github.com") {
280 Some(ProviderKind::GitHub)
281 } else if is("gitlab.com") || gitlab_self_hosted() {
282 Some(ProviderKind::GitLab)
283 } else {
284 None
285 }
286}
287
288fn host_of(url: &str) -> &str {
293 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
294 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
298 let host_port = authority
299 .rsplit_once('@')
300 .map_or(authority, |(_, rest)| rest);
301 if let Some(after_bracket) = host_port.strip_prefix('[') {
303 return after_bracket
304 .split_once(']')
305 .map_or(host_port, |(addr, _)| addr);
306 }
307 host_port.split(':').next().unwrap_or(host_port)
309}
310
311fn redact_url(url: &str) -> String {
315 let Some((scheme, rest)) = url.split_once("://") else {
316 return url.to_owned();
317 };
318 let (authority, path) = match rest.split_once('/') {
319 Some((authority, path)) => (authority, Some(path)),
320 None => (rest, None),
321 };
322 let Some((_, host)) = authority.rsplit_once('@') else {
325 return url.to_owned();
326 };
327 match path {
328 Some(path) => format!("{scheme}://{host}/{path}"),
329 None => format!("{scheme}://{host}"),
330 }
331}
332
333pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
334 match kind {
335 ProviderKind::GitHub => Box::new(GitHubProvider),
336 ProviderKind::GitLab => Box::new(GitLabProvider),
337 ProviderKind::Demo => Box::new(DemoProvider),
338 }
339}
340
341fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
344 match program {
345 "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
346 "glab" => Some((
347 "GitLab CLI",
348 "https://gitlab.com/gitlab-org/cli",
349 "glab auth login",
350 )),
351 _ => None,
352 }
353}
354
355fn looks_unauthenticated(stderr: &str) -> bool {
358 let stderr = stderr.to_ascii_lowercase();
359 [
360 "auth login",
361 "not logged",
362 "401",
363 "unauthorized",
364 "authentication required",
365 ]
366 .iter()
367 .any(|needle| stderr.contains(needle))
368}
369
370fn command_output(program: &str, args: &[&str]) -> Result<String> {
371 let output = match Command::new(program).args(args).output() {
372 Ok(output) => output,
373 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
376 if let Some((name, url, auth)) = provider_cli(program) {
377 bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
378 }
379 return Err(error).with_context(|| format!("failed to run {program}"));
380 }
381 Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
382 };
383
384 if output.status.success() {
385 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
386 }
387
388 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
389 if let Some((_, _, auth)) = provider_cli(program)
392 && looks_unauthenticated(&stderr)
393 {
394 bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
395 }
396 if stderr.is_empty() {
397 Err(anyhow!("{program} exited with status {}", output.status))
398 } else {
399 Err(anyhow!("{program} failed: {stderr}"))
400 }
401}
402
403const MERGE_ATTEMPTS: u32 = 3;
407const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
408
409fn is_transient_merge_error(error: &anyhow::Error) -> bool {
413 let text = error.to_string().to_lowercase();
414 [
415 "base branch was modified",
416 "head branch was modified",
417 "try the merge again",
418 ]
419 .iter()
420 .any(|signature| text.contains(signature))
421}
422
423fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
426 retry_transient_merge(MERGE_ATTEMPTS, MERGE_RETRY_BACKOFF, attempt)
427}
428
429fn retry_transient_merge(
430 attempts: u32,
431 backoff: Duration,
432 mut attempt: impl FnMut() -> Result<String>,
433) -> Result<String> {
434 for remaining in (0..attempts).rev() {
435 match attempt() {
436 Ok(output) => return Ok(output),
437 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
438 std::thread::sleep(backoff);
439 }
440 Err(error) => return Err(error),
441 }
442 }
443 Err(anyhow!("merge retried with no attempts left"))
445}
446
447impl fmt::Display for ReviewState {
448 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
449 match self {
450 Self::Open => write!(formatter, "open"),
451 Self::Merged => write!(formatter, "merged"),
452 Self::Closed => write!(formatter, "closed"),
453 Self::Unknown(state) => write!(formatter, "{state}"),
454 }
455 }
456}
457
458impl ReviewRequest {
459 pub(crate) fn id_value(&self) -> &str {
460 self.id
461 .strip_prefix('#')
462 .or_else(|| self.id.strip_prefix('!'))
463 .unwrap_or(&self.id)
464 }
465
466 pub fn label(&self) -> String {
468 label(&self.title, &self.id)
469 }
470}
471
472pub(crate) fn label(title: &str, id: &str) -> String {
474 if title.is_empty() {
475 id.to_owned()
476 } else {
477 format!("{title} ({id})")
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn provider_cli_maps_only_the_provider_clis() {
487 assert!(provider_cli("gh").is_some());
488 assert!(provider_cli("glab").is_some());
489 assert!(provider_cli("git").is_none());
490 }
491
492 #[test]
493 fn looks_unauthenticated_matches_signin_failures_only() {
494 assert!(looks_unauthenticated(
495 "error: not logged into any GitHub hosts"
496 ));
497 assert!(looks_unauthenticated(
498 "To get started, please run: gh auth login"
499 ));
500 assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
501 assert!(!looks_unauthenticated("pull request not found"));
503 assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
504 }
505
506 #[test]
507 fn transient_error_is_retried_then_succeeds() {
508 let mut calls = 0;
509 let result = retry_transient_merge(3, Duration::ZERO, || {
510 calls += 1;
511 if calls < 2 {
512 Err(anyhow!(
513 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
514 ))
515 } else {
516 Ok("merged".to_owned())
517 }
518 });
519 assert_eq!(result.unwrap(), "merged");
520 assert_eq!(calls, 2, "should retry once then succeed");
521 }
522
523 #[test]
524 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
525 let mut calls = 0;
526 let result = retry_transient_merge(3, Duration::ZERO, || {
527 calls += 1;
528 Err(anyhow!("gh failed: Base branch was modified"))
529 });
530 assert!(result.is_err());
531 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
532 }
533
534 #[test]
535 fn a_real_failure_is_not_retried() {
536 let mut calls = 0;
537 let result = retry_transient_merge(3, Duration::ZERO, || {
538 calls += 1;
539 Err(anyhow!(
540 "gh failed: Pull request is not mergeable: conflicts"
541 ))
542 });
543 assert!(result.is_err());
544 assert_eq!(calls, 1, "a non-transient error must surface immediately");
545 }
546
547 #[test]
548 fn host_of_extracts_the_host_across_url_shapes() {
549 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
550 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
551 assert_eq!(
552 host_of("ssh://git@gitlab.example.com:22/g/r"),
553 "gitlab.example.com"
554 );
555 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
556 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
557 assert_eq!(
558 host_of("https://[2001:db8::1]:443/owner/repo"),
559 "2001:db8::1"
560 );
561 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
562 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
564 }
565
566 #[test]
567 fn redact_url_strips_embedded_credentials() {
568 assert_eq!(
570 redact_url("https://x-access-token:ghp_SECRET@github.com/owner/repo.git"),
571 "https://github.com/owner/repo.git"
572 );
573 assert_eq!(
574 redact_url("https://glpat-SECRET@gitlab.com/owner/repo"),
575 "https://gitlab.com/owner/repo"
576 );
577 assert_eq!(redact_url("ssh://git@host:22/g/r"), "ssh://host:22/g/r");
579 }
580
581 #[test]
582 fn redact_url_leaves_credential_free_urls_unchanged() {
583 assert_eq!(
584 redact_url("https://github.com/owner/repo.git"),
585 "https://github.com/owner/repo.git"
586 );
587 assert_eq!(
589 redact_url("git@github.com:owner/repo.git"),
590 "git@github.com:owner/repo.git"
591 );
592 }
593
594 #[test]
595 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
596 let remote = "git@gitlab.example.com:team/repo.git";
597 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
598 assert_eq!(
599 detect_provider_from_url(remote, Some(configured)),
600 Some(ProviderKind::GitLab),
601 "configured {configured:?} should detect the self-hosted host"
602 );
603 }
604 assert_eq!(
606 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
607 None
608 );
609 }
610}