Skip to main content

git_stk/providers/
mod.rs

1use std::time::Duration;
2use std::{fmt, process::Command};
3
4use anyhow::{Context, Result, anyhow, bail};
5
6use crate::git;
7use crate::settings;
8
9/// How long to keep polling a "no checks / no pipeline yet" result before
10/// concluding there genuinely are none. A just-pushed branch's checks take a
11/// moment to register, so concluding too early would either merge without
12/// waiting or report a false failure.
13pub(super) const CHECK_GRACE_POLLS: u32 = 6;
14
15/// Delay between `wait_for_checks` polls.
16pub(super) fn check_poll_interval() -> Duration {
17    Duration::from_secs(5)
18}
19
20mod demo;
21mod github;
22mod gitlab;
23mod json;
24
25use demo::DemoProvider;
26use github::GitHubProvider;
27use gitlab::GitLabProvider;
28
29#[derive(Debug, Clone, Copy, Eq, PartialEq)]
30pub enum ProviderKind {
31    GitHub,
32    GitLab,
33    /// Offline stand-in: reviews in `.git`, merges as local squashes. Only
34    /// ever selected explicitly via `stk.provider = demo`.
35    Demo,
36}
37
38impl ProviderKind {
39    fn parse(value: &str) -> Option<Self> {
40        match value.to_ascii_lowercase().as_str() {
41            "github" | "gh" => Some(Self::GitHub),
42            "gitlab" | "glab" => Some(Self::GitLab),
43            "demo" => Some(Self::Demo),
44            _ => None,
45        }
46    }
47}
48
49impl fmt::Display for ProviderKind {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::GitHub => write!(formatter, "github"),
53            Self::GitLab => write!(formatter, "gitlab"),
54            Self::Demo => write!(formatter, "demo"),
55        }
56    }
57}
58
59#[derive(Debug, Eq, PartialEq)]
60pub struct DetectedProvider {
61    pub kind: ProviderKind,
62    pub source: ProviderSource,
63}
64
65#[derive(Debug, Eq, PartialEq)]
66pub enum ProviderSource {
67    Config,
68    Remote { remote: String, url: String },
69}
70
71impl fmt::Display for ProviderSource {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::Config => write!(formatter, "config"),
75            Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
76        }
77    }
78}
79
80#[derive(Debug, Eq, PartialEq)]
81pub enum ReviewState {
82    Open,
83    Merged,
84    Closed,
85    Unknown(String),
86}
87
88#[derive(Debug, Eq, PartialEq)]
89pub struct ReviewRequest {
90    pub id: String,
91    pub branch: String,
92    pub base: String,
93    pub state: ReviewState,
94    pub url: String,
95    pub title: String,
96    pub draft: bool,
97}
98
99pub trait ReviewProvider {
100    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
101
102    /// Like review_for_branch, but also finds closed reviews. Kept separate
103    /// so flows that act on a review (submit, sync, cleanup) never mistake a
104    /// dead review for a live one; only the stack-notes ledger wants closed
105    /// state, to restyle the entry rather than drop it.
106    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
107
108    /// Open a review for the branch; with `draft`, as a draft.
109    fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
110
111    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
112
113    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
114
115    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
116
117    /// Merge the review with the given strategy: squash, rebase, or merge.
118    /// With `auto`, schedule the merge for when required checks pass
119    /// instead of merging now.
120    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
121
122    /// Block until the review's checks settle. Ok(true) when they pass (or
123    /// there are none), Ok(false) when something failed.
124    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
125
126    /// Mark a draft review as ready for review.
127    fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
128
129    /// Close the review without merging, deleting its source branch when
130    /// `delete_branch`. Used to retire a review superseded by a branch rename.
131    fn close_review(&self, review: &ReviewRequest, delete_branch: bool) -> Result<String>;
132
133    /// Open the review in the user's browser.
134    fn open_review(&self, review: &ReviewRequest) -> Result<String>;
135}
136
137pub fn detect_provider() -> Result<DetectedProvider> {
138    if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
139        let Some(kind) = ProviderKind::parse(&value) else {
140            bail!("unsupported stk.provider value {value:?}; expected github, gitlab, or demo");
141        };
142
143        return Ok(DetectedProvider {
144            kind,
145            source: ProviderSource::Config,
146        });
147    }
148
149    let remote = settings::remote()?;
150    let Some(url) = git::remote_url(&remote)? else {
151        bail!("could not detect provider: remote {remote:?} does not exist");
152    };
153
154    let gitlab_host = settings::gitlab_host()?;
155    let Some(kind) = detect_provider_from_url(&url, gitlab_host.as_deref()) else {
156        bail!("could not detect provider from remote {remote} ({url})");
157    };
158
159    Ok(DetectedProvider {
160        kind,
161        source: ProviderSource::Remote { remote, url },
162    })
163}
164
165/// Detect the provider from a remote URL by its host. A configured
166/// `stk.gitlabHost` widens GitLab detection to a self-hosted instance.
167fn detect_provider_from_url(url: &str, gitlab_host: Option<&str>) -> Option<ProviderKind> {
168    let normalized = url.to_ascii_lowercase();
169    let host = host_of(&normalized);
170    // Match the host itself or a subdomain of it, never a look-alike that
171    // merely embeds the name (mygithub.com, evil.com/github.com/...).
172    let is = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
173
174    if is("github.com") {
175        Some(ProviderKind::GitHub)
176    } else if is("gitlab.com") || gitlab_host.is_some_and(|host| is(&host.to_ascii_lowercase())) {
177        Some(ProviderKind::GitLab)
178    } else {
179        None
180    }
181}
182
183/// The host of a git remote URL: the part after any `scheme://` and `user@`,
184/// up to the path, port, or scp-style `:`. Covers `https://host/owner/repo`,
185/// `ssh://git@host/owner/repo`, and scp-like `git@host:owner/repo`.
186fn host_of(url: &str) -> &str {
187    let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
188    let after_user = after_scheme
189        .split_once('@')
190        .map_or(after_scheme, |(_, rest)| rest);
191    after_user.split(['/', ':']).next().unwrap_or(after_user)
192}
193
194pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
195    match kind {
196        ProviderKind::GitHub => Box::new(GitHubProvider),
197        ProviderKind::GitLab => Box::new(GitLabProvider),
198        ProviderKind::Demo => Box::new(DemoProvider),
199    }
200}
201
202fn command_output(program: &str, args: &[&str]) -> Result<String> {
203    let output = Command::new(program)
204        .args(args)
205        .output()
206        .with_context(|| format!("failed to run {program}"))?;
207
208    if output.status.success() {
209        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
210    } else {
211        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
212        if stderr.is_empty() {
213            Err(anyhow!("{program} exited with status {}", output.status))
214        } else {
215            Err(anyhow!("{program} failed: {stderr}"))
216        }
217    }
218}
219
220impl fmt::Display for ReviewState {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        match self {
223            Self::Open => write!(formatter, "open"),
224            Self::Merged => write!(formatter, "merged"),
225            Self::Closed => write!(formatter, "closed"),
226            Self::Unknown(state) => write!(formatter, "{state}"),
227        }
228    }
229}
230
231impl ReviewRequest {
232    pub(crate) fn id_value(&self) -> &str {
233        self.id
234            .strip_prefix('#')
235            .or_else(|| self.id.strip_prefix('!'))
236            .unwrap_or(&self.id)
237    }
238
239    /// "Title (#12)", or just the id when there is no title.
240    pub fn label(&self) -> String {
241        label(&self.title, &self.id)
242    }
243}
244
245/// The display label for a review: "Title (#12)", or the bare id.
246pub(crate) fn label(title: &str, id: &str) -> String {
247    if title.is_empty() {
248        id.to_owned()
249    } else {
250        format!("{title} ({id})")
251    }
252}