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 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 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
95
96 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 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
109
110 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
113
114 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
116
117 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 pub fn label(&self) -> String {
207 label(&self.title, &self.id)
208 }
209}
210
211pub(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}