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 } => write!(formatter, "remote {remote} ({url})"),
98 }
99 }
100}
101
102#[derive(Debug, Eq, PartialEq)]
103pub enum ReviewState {
104 Open,
105 Merged,
106 Closed,
107 Unknown(String),
108}
109
110#[derive(Debug, Clone, Copy, Eq, PartialEq)]
115pub enum MergeBlocker {
116 ChecksPending,
118 Conflicts,
120 None,
122}
123
124#[derive(Debug, Eq, PartialEq)]
125pub struct ReviewRequest {
126 pub id: String,
127 pub branch: String,
128 pub base: String,
129 pub state: ReviewState,
130 pub url: String,
131 pub title: String,
132 pub draft: bool,
133}
134
135pub trait ReviewProvider {
136 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
137
138 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
143
144 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
146
147 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
148
149 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
150
151 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
152
153 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
157
158 fn merge_blocker(&self, review: &ReviewRequest) -> Result<MergeBlocker>;
162
163 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
166
167 fn open_reviews(&self) -> Result<Vec<ReviewRequest>>;
170
171 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
173
174 fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
177
178 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
180}
181
182pub fn detect_review_provider() -> Result<(DetectedProvider, Box<dyn ReviewProvider>)> {
186 let provider = detect_provider()?;
187 let client = review_provider(provider.kind);
188 Ok((provider, client))
189}
190
191pub fn owned_review_for_branch(
195 provider: &dyn ReviewProvider,
196 branch: &str,
197) -> Result<Option<ReviewRequest>> {
198 Ok(provider
199 .review_for_branch(branch)?
200 .filter(|review| review.branch == branch))
201}
202
203pub fn detect_provider() -> Result<DetectedProvider> {
204 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
205 let Some(kind) = ProviderKind::parse(&value) else {
206 bail!("unsupported stk.provider value {value:?}; expected github, gitlab, or demo");
207 };
208
209 return Ok(DetectedProvider {
210 kind,
211 source: ProviderSource::Config,
212 });
213 }
214
215 let remote = settings::remote()?;
216 let Some(url) = git::remote_url(&remote)? else {
217 bail!("could not detect provider: remote {remote:?} does not exist");
218 };
219
220 let gitlab_host = settings::gitlab_host()?;
221 let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref()) else {
222 bail!("could not detect provider from remote {remote} ({url})");
223 };
224
225 Ok(DetectedProvider {
226 kind,
227 source: ProviderSource::Remote { remote, url },
228 })
229}
230
231fn detect_provider_from_url(url: &str, gitlab_host: Option<&str>) -> Option<ProviderKind> {
234 let normalized = url.to_ascii_lowercase();
235 let host = host_of(&normalized);
236 let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
239
240 let gitlab_self_hosted = || {
243 gitlab_host.is_some_and(|configured| {
244 let configured = configured.to_ascii_lowercase();
245 is(host_of(&configured))
246 })
247 };
248
249 if is("github.com") {
250 Some(ProviderKind::GitHub)
251 } else if is("gitlab.com") || gitlab_self_hosted() {
252 Some(ProviderKind::GitLab)
253 } else {
254 None
255 }
256}
257
258fn host_of(url: &str) -> &str {
263 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
264 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
268 let host_port = authority
269 .rsplit_once('@')
270 .map_or(authority, |(_, rest)| rest);
271 if let Some(after_bracket) = host_port.strip_prefix('[') {
273 return after_bracket
274 .split_once(']')
275 .map_or(host_port, |(addr, _)| addr);
276 }
277 host_port.split(':').next().unwrap_or(host_port)
279}
280
281pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
282 match kind {
283 ProviderKind::GitHub => Box::new(GitHubProvider),
284 ProviderKind::GitLab => Box::new(GitLabProvider),
285 ProviderKind::Demo => Box::new(DemoProvider),
286 }
287}
288
289fn provider_cli(program: &str) -> Option<(&'static str, &'static str, &'static str)> {
292 match program {
293 "gh" => Some(("GitHub CLI", "https://cli.github.com", "gh auth login")),
294 "glab" => Some((
295 "GitLab CLI",
296 "https://gitlab.com/gitlab-org/cli",
297 "glab auth login",
298 )),
299 _ => None,
300 }
301}
302
303fn looks_unauthenticated(stderr: &str) -> bool {
306 let stderr = stderr.to_ascii_lowercase();
307 [
308 "auth login",
309 "not logged",
310 "401",
311 "unauthorized",
312 "authentication required",
313 ]
314 .iter()
315 .any(|needle| stderr.contains(needle))
316}
317
318fn command_output(program: &str, args: &[&str]) -> Result<String> {
319 let output = match Command::new(program).args(args).output() {
320 Ok(output) => output,
321 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
324 if let Some((name, url, auth)) = provider_cli(program) {
325 bail!("{program} ({name}) is not installed - get it from {url}, then run `{auth}`");
326 }
327 return Err(error).with_context(|| format!("failed to run {program}"));
328 }
329 Err(error) => return Err(error).with_context(|| format!("failed to run {program}")),
330 };
331
332 if output.status.success() {
333 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
334 }
335
336 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
337 if let Some((_, _, auth)) = provider_cli(program)
340 && looks_unauthenticated(&stderr)
341 {
342 bail!("{program} failed: {stderr}\n(if you are not signed in, run `{auth}`)");
343 }
344 if stderr.is_empty() {
345 Err(anyhow!("{program} exited with status {}", output.status))
346 } else {
347 Err(anyhow!("{program} failed: {stderr}"))
348 }
349}
350
351const MERGE_ATTEMPTS: u32 = 3;
355const MERGE_RETRY_BACKOFF: Duration = Duration::from_millis(1500);
356
357fn is_transient_merge_error(error: &anyhow::Error) -> bool {
361 let text = error.to_string().to_lowercase();
362 [
363 "base branch was modified",
364 "head branch was modified",
365 "try the merge again",
366 ]
367 .iter()
368 .any(|signature| text.contains(signature))
369}
370
371fn merge_with_retry(attempt: impl FnMut() -> Result<String>) -> Result<String> {
374 retry_transient_merge(MERGE_ATTEMPTS, MERGE_RETRY_BACKOFF, attempt)
375}
376
377fn retry_transient_merge(
378 attempts: u32,
379 backoff: Duration,
380 mut attempt: impl FnMut() -> Result<String>,
381) -> Result<String> {
382 for remaining in (0..attempts).rev() {
383 match attempt() {
384 Ok(output) => return Ok(output),
385 Err(error) if remaining > 0 && is_transient_merge_error(&error) => {
386 std::thread::sleep(backoff);
387 }
388 Err(error) => return Err(error),
389 }
390 }
391 Err(anyhow!("merge retried with no attempts left"))
393}
394
395impl fmt::Display for ReviewState {
396 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
397 match self {
398 Self::Open => write!(formatter, "open"),
399 Self::Merged => write!(formatter, "merged"),
400 Self::Closed => write!(formatter, "closed"),
401 Self::Unknown(state) => write!(formatter, "{state}"),
402 }
403 }
404}
405
406impl ReviewRequest {
407 pub(crate) fn id_value(&self) -> &str {
408 self.id
409 .strip_prefix('#')
410 .or_else(|| self.id.strip_prefix('!'))
411 .unwrap_or(&self.id)
412 }
413
414 pub fn label(&self) -> String {
416 label(&self.title, &self.id)
417 }
418}
419
420pub(crate) fn label(title: &str, id: &str) -> String {
422 if title.is_empty() {
423 id.to_owned()
424 } else {
425 format!("{title} ({id})")
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn provider_cli_maps_only_the_provider_clis() {
435 assert!(provider_cli("gh").is_some());
436 assert!(provider_cli("glab").is_some());
437 assert!(provider_cli("git").is_none());
438 }
439
440 #[test]
441 fn looks_unauthenticated_matches_signin_failures_only() {
442 assert!(looks_unauthenticated(
443 "error: not logged into any GitHub hosts"
444 ));
445 assert!(looks_unauthenticated(
446 "To get started, please run: gh auth login"
447 ));
448 assert!(looks_unauthenticated("GET ...: 401 Unauthorized"));
449 assert!(!looks_unauthenticated("pull request not found"));
451 assert!(!looks_unauthenticated("merge conflict in src/lib.rs"));
452 }
453
454 #[test]
455 fn transient_error_is_retried_then_succeeds() {
456 let mut calls = 0;
457 let result = retry_transient_merge(3, Duration::ZERO, || {
458 calls += 1;
459 if calls < 2 {
460 Err(anyhow!(
461 "gh failed: GraphQL: Base branch was modified. Review and try the merge again."
462 ))
463 } else {
464 Ok("merged".to_owned())
465 }
466 });
467 assert_eq!(result.unwrap(), "merged");
468 assert_eq!(calls, 2, "should retry once then succeed");
469 }
470
471 #[test]
472 fn a_persistent_transient_error_gives_up_after_the_attempt_budget() {
473 let mut calls = 0;
474 let result = retry_transient_merge(3, Duration::ZERO, || {
475 calls += 1;
476 Err(anyhow!("gh failed: Base branch was modified"))
477 });
478 assert!(result.is_err());
479 assert_eq!(calls, 3, "should try exactly the budgeted number of times");
480 }
481
482 #[test]
483 fn a_real_failure_is_not_retried() {
484 let mut calls = 0;
485 let result = retry_transient_merge(3, Duration::ZERO, || {
486 calls += 1;
487 Err(anyhow!(
488 "gh failed: Pull request is not mergeable: conflicts"
489 ))
490 });
491 assert!(result.is_err());
492 assert_eq!(calls, 1, "a non-transient error must surface immediately");
493 }
494
495 #[test]
496 fn host_of_extracts_the_host_across_url_shapes() {
497 assert_eq!(host_of("https://github.com/owner/repo.git"), "github.com");
498 assert_eq!(host_of("git@github.com:owner/repo.git"), "github.com");
499 assert_eq!(
500 host_of("ssh://git@gitlab.example.com:22/g/r"),
501 "gitlab.example.com"
502 );
503 assert_eq!(host_of("https://user@github.com/owner/repo"), "github.com");
504 assert_eq!(host_of("https://github.com:8443/owner/repo"), "github.com");
505 assert_eq!(
506 host_of("https://[2001:db8::1]:443/owner/repo"),
507 "2001:db8::1"
508 );
509 assert_eq!(host_of("gitlab.example.com"), "gitlab.example.com");
510 assert_eq!(host_of("https://user@name@github.com/r"), "github.com");
512 }
513
514 #[test]
515 fn self_hosted_gitlab_accepts_a_bare_host_or_a_full_url() {
516 let remote = "git@gitlab.example.com:team/repo.git";
517 for configured in ["gitlab.example.com", "https://gitlab.example.com"] {
518 assert_eq!(
519 detect_provider_from_url(remote, Some(configured)),
520 Some(ProviderKind::GitLab),
521 "configured {configured:?} should detect the self-hosted host"
522 );
523 }
524 assert_eq!(
526 detect_provider_from_url("git@notgitlab.com:o/r", Some("gitlab.example.com")),
527 None
528 );
529 }
530}