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    /// Open the review in the user's browser.
118    fn open_review(&self, review: &ReviewRequest) -> Result<String>;
119}
120
121pub fn detect_provider() -> Result<DetectedProvider> {
122    if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
123        let Some(kind) = ProviderKind::parse(&value) else {
124            bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
125        };
126
127        return Ok(DetectedProvider {
128            kind,
129            source: ProviderSource::Config,
130        });
131    }
132
133    let remote = settings::remote()?;
134    let Some(url) = git::remote_url(&remote)? else {
135        bail!("could not detect provider: remote {remote:?} does not exist");
136    };
137
138    let Some(kind) = detect_provider_from_url(&url) else {
139        bail!("could not detect provider from remote {remote} ({url})");
140    };
141
142    Ok(DetectedProvider {
143        kind,
144        source: ProviderSource::Remote { remote, url },
145    })
146}
147
148fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
149    let normalized = url.to_ascii_lowercase();
150
151    if normalized.contains("github.com:") || normalized.contains("github.com/") {
152        Some(ProviderKind::GitHub)
153    } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
154        Some(ProviderKind::GitLab)
155    } else {
156        None
157    }
158}
159
160pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
161    match kind {
162        ProviderKind::GitHub => Box::new(GitHubProvider),
163        ProviderKind::GitLab => Box::new(GitLabProvider),
164        ProviderKind::Demo => Box::new(DemoProvider),
165    }
166}
167
168fn command_output(program: &str, args: &[&str]) -> Result<String> {
169    let output = Command::new(program)
170        .args(args)
171        .output()
172        .with_context(|| format!("failed to run {program}"))?;
173
174    if output.status.success() {
175        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
176    } else {
177        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
178        if stderr.is_empty() {
179            Err(anyhow!("{program} exited with status {}", output.status))
180        } else {
181            Err(anyhow!("{program} failed: {stderr}"))
182        }
183    }
184}
185
186impl fmt::Display for ReviewState {
187    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            Self::Open => write!(formatter, "open"),
190            Self::Merged => write!(formatter, "merged"),
191            Self::Closed => write!(formatter, "closed"),
192            Self::Unknown(state) => write!(formatter, "{state}"),
193        }
194    }
195}
196
197impl ReviewRequest {
198    pub(crate) fn id_value(&self) -> &str {
199        self.id
200            .strip_prefix('#')
201            .or_else(|| self.id.strip_prefix('!'))
202            .unwrap_or(&self.id)
203    }
204
205    /// "Title (#12)", or just the id when there is no title.
206    pub fn label(&self) -> String {
207        label(&self.title, &self.id)
208    }
209}
210
211/// The display label for a review: "Title (#12)", or the bare id.
212pub(crate) fn label(title: &str, id: &str) -> String {
213    if title.is_empty() {
214        id.to_owned()
215    } else {
216        format!("{title} ({id})")
217    }
218}