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 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 fn merge_review(&self, review: &ReviewRequest, strategy: &str) -> Result<String>;
98}
99
100pub fn detect_provider() -> Result<DetectedProvider> {
101 if let Some(value) = git::config_get(settings::PROVIDER_KEY)? {
102 let Some(kind) = ProviderKind::parse(&value) else {
103 bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
104 };
105
106 return Ok(DetectedProvider {
107 kind,
108 source: ProviderSource::Config,
109 });
110 }
111
112 let remote = settings::remote()?;
113 let Some(url) = git::remote_url(&remote)? else {
114 bail!("could not detect provider: remote {remote:?} does not exist");
115 };
116
117 let Some(kind) = detect_provider_from_url(&url) else {
118 bail!("could not detect provider from remote {remote} ({url})");
119 };
120
121 Ok(DetectedProvider {
122 kind,
123 source: ProviderSource::Remote { remote, url },
124 })
125}
126
127fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
128 let normalized = url.to_ascii_lowercase();
129
130 if normalized.contains("github.com:") || normalized.contains("github.com/") {
131 Some(ProviderKind::GitHub)
132 } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
133 Some(ProviderKind::GitLab)
134 } else {
135 None
136 }
137}
138
139pub(crate) fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
140 match kind {
141 ProviderKind::GitHub => Box::new(GitHubProvider),
142 ProviderKind::GitLab => Box::new(GitLabProvider),
143 }
144}
145
146fn command_output(program: &str, args: &[&str]) -> Result<String> {
147 let output = Command::new(program)
148 .args(args)
149 .output()
150 .with_context(|| format!("failed to run {program}"))?;
151
152 if output.status.success() {
153 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
154 } else {
155 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
156 if stderr.is_empty() {
157 Err(anyhow!("{program} exited with status {}", output.status))
158 } else {
159 Err(anyhow!("{program} failed: {stderr}"))
160 }
161 }
162}
163
164impl fmt::Display for ReviewState {
165 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166 match self {
167 Self::Open => write!(formatter, "open"),
168 Self::Merged => write!(formatter, "merged"),
169 Self::Closed => write!(formatter, "closed"),
170 Self::Unknown(state) => write!(formatter, "{state}"),
171 }
172 }
173}
174
175impl ReviewRequest {
176 pub(crate) fn id_value(&self) -> &str {
177 self.id
178 .strip_prefix('#')
179 .or_else(|| self.id.strip_prefix('!'))
180 .unwrap_or(&self.id)
181 }
182}