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}
85
86pub trait ReviewProvider {
87    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
88
89    /// Like review_for_branch, but also finds closed reviews. Kept separate
90    /// so flows that act on a review (submit, sync, cleanup) never mistake a
91    /// dead review for a live one; only the stack-notes ledger wants closed
92    /// state, to restyle the entry rather than drop it.
93    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
94
95    fn create_review(&self, branch: &str, base: &str) -> Result<String>;
96
97    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
98
99    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
100
101    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
102
103    /// Merge the review with the given strategy: squash, rebase, or merge.
104    /// With `auto`, schedule the merge for when required checks pass
105    /// instead of merging now.
106    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
107}
108
109pub fn detect_provider() -> Result<DetectedProvider> {
110    if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
111        let Some(kind) = ProviderKind::parse(&value) else {
112            bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
113        };
114
115        return Ok(DetectedProvider {
116            kind,
117            source: ProviderSource::Config,
118        });
119    }
120
121    let remote = settings::remote()?;
122    let Some(url) = git::remote_url(&remote)? else {
123        bail!("could not detect provider: remote {remote:?} does not exist");
124    };
125
126    let Some(kind) = detect_provider_from_url(&url) else {
127        bail!("could not detect provider from remote {remote} ({url})");
128    };
129
130    Ok(DetectedProvider {
131        kind,
132        source: ProviderSource::Remote { remote, url },
133    })
134}
135
136fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
137    let normalized = url.to_ascii_lowercase();
138
139    if normalized.contains("github.com:") || normalized.contains("github.com/") {
140        Some(ProviderKind::GitHub)
141    } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
142        Some(ProviderKind::GitLab)
143    } else {
144        None
145    }
146}
147
148pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
149    match kind {
150        ProviderKind::GitHub => Box::new(GitHubProvider),
151        ProviderKind::GitLab => Box::new(GitLabProvider),
152        ProviderKind::Demo => Box::new(DemoProvider),
153    }
154}
155
156fn command_output(program: &str, args: &[&str]) -> Result<String> {
157    let output = Command::new(program)
158        .args(args)
159        .output()
160        .with_context(|| format!("failed to run {program}"))?;
161
162    if output.status.success() {
163        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
164    } else {
165        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
166        if stderr.is_empty() {
167            Err(anyhow!("{program} exited with status {}", output.status))
168        } else {
169            Err(anyhow!("{program} failed: {stderr}"))
170        }
171    }
172}
173
174impl fmt::Display for ReviewState {
175    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            Self::Open => write!(formatter, "open"),
178            Self::Merged => write!(formatter, "merged"),
179            Self::Closed => write!(formatter, "closed"),
180            Self::Unknown(state) => write!(formatter, "{state}"),
181        }
182    }
183}
184
185impl ReviewRequest {
186    pub(crate) fn id_value(&self) -> &str {
187        self.id
188            .strip_prefix('#')
189            .or_else(|| self.id.strip_prefix('!'))
190            .unwrap_or(&self.id)
191    }
192
193    /// "Title (#12)", or just the id when there is no title.
194    pub fn label(&self) -> String {
195        label(&self.title, &self.id)
196    }
197}
198
199/// The display label for a review: "Title (#12)", or the bare id.
200pub(crate) fn label(title: &str, id: &str) -> String {
201    if title.is_empty() {
202        id.to_owned()
203    } else {
204        format!("{title} ({id})")
205    }
206}