Skip to main content

git_stk/providers/
mod.rs

1use std::{fmt, process::Command};
2
3use anyhow::{Context, Result, anyhow, bail};
4
5use crate::git;
6use crate::settings;
7
8mod demo;
9mod github;
10mod gitlab;
11mod json;
12
13use demo::DemoProvider;
14use github::GitHubProvider;
15use gitlab::GitLabProvider;
16
17#[derive(Debug, Clone, Copy, Eq, PartialEq)]
18pub enum ProviderKind {
19    GitHub,
20    GitLab,
21    /// Offline stand-in: reviews in `.git`, merges as local squashes. Only
22    /// ever selected explicitly via `stk.provider = demo`.
23    Demo,
24}
25
26impl ProviderKind {
27    fn parse(value: &str) -> Option<Self> {
28        match value.to_ascii_lowercase().as_str() {
29            "github" | "gh" => Some(Self::GitHub),
30            "gitlab" | "glab" => Some(Self::GitLab),
31            "demo" => Some(Self::Demo),
32            _ => None,
33        }
34    }
35}
36
37impl fmt::Display for ProviderKind {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::GitHub => write!(formatter, "github"),
41            Self::GitLab => write!(formatter, "gitlab"),
42            Self::Demo => write!(formatter, "demo"),
43        }
44    }
45}
46
47#[derive(Debug, Eq, PartialEq)]
48pub struct DetectedProvider {
49    pub kind: ProviderKind,
50    pub source: ProviderSource,
51}
52
53#[derive(Debug, Eq, PartialEq)]
54pub enum ProviderSource {
55    Config,
56    Remote { remote: String, url: String },
57}
58
59impl fmt::Display for ProviderSource {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Config => write!(formatter, "config"),
63            Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
64        }
65    }
66}
67
68#[derive(Debug, Eq, PartialEq)]
69pub enum ReviewState {
70    Open,
71    Merged,
72    Closed,
73    Unknown(String),
74}
75
76#[derive(Debug, Eq, PartialEq)]
77pub struct ReviewRequest {
78    pub id: String,
79    pub branch: String,
80    pub base: String,
81    pub state: ReviewState,
82    pub url: String,
83    pub title: String,
84    pub draft: bool,
85}
86
87pub trait ReviewProvider {
88    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
89
90    /// Like review_for_branch, but also finds closed reviews. Kept separate
91    /// so flows that act on a review (submit, sync, cleanup) never mistake a
92    /// dead review for a live one; only the stack-notes ledger wants closed
93    /// state, to restyle the entry rather than drop it.
94    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
95
96    /// Open a review for the branch; with `draft`, as a draft.
97    fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
98
99    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
100
101    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
102
103    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
104
105    /// Merge the review with the given strategy: squash, rebase, or merge.
106    /// With `auto`, schedule the merge for when required checks pass
107    /// instead of merging now.
108    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
109
110    /// Block until the review's checks settle. Ok(true) when they pass (or
111    /// there are none), Ok(false) when something failed.
112    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
113
114    /// Mark a draft review as ready for review.
115    fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
116}
117
118pub fn detect_provider() -> Result<DetectedProvider> {
119    if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
120        let Some(kind) = ProviderKind::parse(&value) else {
121            bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
122        };
123
124        return Ok(DetectedProvider {
125            kind,
126            source: ProviderSource::Config,
127        });
128    }
129
130    let remote = settings::remote()?;
131    let Some(url) = git::remote_url(&remote)? else {
132        bail!("could not detect provider: remote {remote:?} does not exist");
133    };
134
135    let Some(kind) = detect_provider_from_url(&url) else {
136        bail!("could not detect provider from remote {remote} ({url})");
137    };
138
139    Ok(DetectedProvider {
140        kind,
141        source: ProviderSource::Remote { remote, url },
142    })
143}
144
145fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
146    let normalized = url.to_ascii_lowercase();
147
148    if normalized.contains("github.com:") || normalized.contains("github.com/") {
149        Some(ProviderKind::GitHub)
150    } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
151        Some(ProviderKind::GitLab)
152    } else {
153        None
154    }
155}
156
157pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
158    match kind {
159        ProviderKind::GitHub => Box::new(GitHubProvider),
160        ProviderKind::GitLab => Box::new(GitLabProvider),
161        ProviderKind::Demo => Box::new(DemoProvider),
162    }
163}
164
165fn command_output(program: &str, args: &[&str]) -> Result<String> {
166    let output = Command::new(program)
167        .args(args)
168        .output()
169        .with_context(|| format!("failed to run {program}"))?;
170
171    if output.status.success() {
172        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
173    } else {
174        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
175        if stderr.is_empty() {
176            Err(anyhow!("{program} exited with status {}", output.status))
177        } else {
178            Err(anyhow!("{program} failed: {stderr}"))
179        }
180    }
181}
182
183impl fmt::Display for ReviewState {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        match self {
186            Self::Open => write!(formatter, "open"),
187            Self::Merged => write!(formatter, "merged"),
188            Self::Closed => write!(formatter, "closed"),
189            Self::Unknown(state) => write!(formatter, "{state}"),
190        }
191    }
192}
193
194impl ReviewRequest {
195    pub(crate) fn id_value(&self) -> &str {
196        self.id
197            .strip_prefix('#')
198            .or_else(|| self.id.strip_prefix('!'))
199            .unwrap_or(&self.id)
200    }
201
202    /// "Title (#12)", or just the id when there is no title.
203    pub fn label(&self) -> String {
204        label(&self.title, &self.id)
205    }
206}
207
208/// The display label for a review: "Title (#12)", or the bare id.
209pub(crate) fn label(title: &str, id: &str) -> String {
210    if title.is_empty() {
211        id.to_owned()
212    } else {
213        format!("{title} ({id})")
214    }
215}