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