1use std::time::Duration;
2use std::{fmt, process::Command};
3
4use anyhow::{Context, Result, anyhow, bail};
5
6use crate::git;
7use crate::settings;
8
9pub(super) const CHECK_GRACE_POLLS: u32 = 6;
14
15pub(super) fn check_poll_interval() -> Duration {
17 Duration::from_secs(5)
18}
19
20mod demo;
21mod github;
22mod gitlab;
23mod json;
24
25use demo::DemoProvider;
26use github::GitHubProvider;
27use gitlab::GitLabProvider;
28
29#[derive(Debug, Clone, Copy, Eq, PartialEq)]
30pub enum ProviderKind {
31 GitHub,
32 GitLab,
33 Demo,
36}
37
38impl ProviderKind {
39 fn parse(value: &str) -> Option<Self> {
40 match value.to_ascii_lowercase().as_str() {
41 "github" | "gh" => Some(Self::GitHub),
42 "gitlab" | "glab" => Some(Self::GitLab),
43 "demo" => Some(Self::Demo),
44 _ => None,
45 }
46 }
47}
48
49impl fmt::Display for ProviderKind {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::GitHub => write!(formatter, "github"),
53 Self::GitLab => write!(formatter, "gitlab"),
54 Self::Demo => write!(formatter, "demo"),
55 }
56 }
57}
58
59#[derive(Debug, Eq, PartialEq)]
60pub struct DetectedProvider {
61 pub kind: ProviderKind,
62 pub source: ProviderSource,
63}
64
65#[derive(Debug, Eq, PartialEq)]
66pub enum ProviderSource {
67 Config,
68 Remote { remote: String, url: String },
69}
70
71impl fmt::Display for ProviderSource {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::Config => write!(formatter, "config"),
75 Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
76 }
77 }
78}
79
80#[derive(Debug, Eq, PartialEq)]
81pub enum ReviewState {
82 Open,
83 Merged,
84 Closed,
85 Unknown(String),
86}
87
88#[derive(Debug, Eq, PartialEq)]
89pub struct ReviewRequest {
90 pub id: String,
91 pub branch: String,
92 pub base: String,
93 pub state: ReviewState,
94 pub url: String,
95 pub title: String,
96 pub draft: bool,
97}
98
99pub trait ReviewProvider {
100 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
101
102 fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>>;
107
108 fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String>;
110
111 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
112
113 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
114
115 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
116
117 fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String>;
121
122 fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool>;
125
126 fn mark_ready(&self, review: &ReviewRequest) -> Result<String>;
128
129 fn open_review(&self, review: &ReviewRequest) -> Result<String>;
131}
132
133pub fn detect_provider() -> Result<DetectedProvider> {
134 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
135 let Some(kind) = ProviderKind::parse(&value) else {
136 bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
137 };
138
139 return Ok(DetectedProvider {
140 kind,
141 source: ProviderSource::Config,
142 });
143 }
144
145 let remote = settings::remote()?;
146 let Some(url) = git::remote_url(&remote)? else {
147 bail!("could not detect provider: remote {remote:?} does not exist");
148 };
149
150 let Some(kind) = detect_provider_from_url(&url) else {
151 bail!("could not detect provider from remote {remote} ({url})");
152 };
153
154 Ok(DetectedProvider {
155 kind,
156 source: ProviderSource::Remote { remote, url },
157 })
158}
159
160fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
161 let normalized = url.to_ascii_lowercase();
162
163 if normalized.contains("github.com:") || normalized.contains("github.com/") {
164 Some(ProviderKind::GitHub)
165 } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
166 Some(ProviderKind::GitLab)
167 } else {
168 None
169 }
170}
171
172pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
173 match kind {
174 ProviderKind::GitHub => Box::new(GitHubProvider),
175 ProviderKind::GitLab => Box::new(GitLabProvider),
176 ProviderKind::Demo => Box::new(DemoProvider),
177 }
178}
179
180fn command_output(program: &str, args: &[&str]) -> Result<String> {
181 let output = Command::new(program)
182 .args(args)
183 .output()
184 .with_context(|| format!("failed to run {program}"))?;
185
186 if output.status.success() {
187 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
188 } else {
189 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
190 if stderr.is_empty() {
191 Err(anyhow!("{program} exited with status {}", output.status))
192 } else {
193 Err(anyhow!("{program} failed: {stderr}"))
194 }
195 }
196}
197
198impl fmt::Display for ReviewState {
199 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200 match self {
201 Self::Open => write!(formatter, "open"),
202 Self::Merged => write!(formatter, "merged"),
203 Self::Closed => write!(formatter, "closed"),
204 Self::Unknown(state) => write!(formatter, "{state}"),
205 }
206 }
207}
208
209impl ReviewRequest {
210 pub(crate) fn id_value(&self) -> &str {
211 self.id
212 .strip_prefix('#')
213 .or_else(|| self.id.strip_prefix('!'))
214 .unwrap_or(&self.id)
215 }
216
217 pub fn label(&self) -> String {
219 label(&self.title, &self.id)
220 }
221}
222
223pub(crate) fn label(title: &str, id: &str) -> String {
225 if title.is_empty() {
226 id.to_owned()
227 } else {
228 format!("{title} ({id})")
229 }
230}