1use std::fmt;
4use std::fmt::Write as _;
5use std::future::Future;
6use std::pin::Pin;
7use std::str::FromStr;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum ForgeKind {
12 GitHub,
14 GitLab,
16}
17
18impl ForgeKind {
19 pub fn display_name(self) -> &'static str {
21 match self {
22 Self::GitHub => "GitHub",
23 Self::GitLab => "GitLab",
24 }
25 }
26
27 pub fn cli_name(self) -> &'static str {
29 match self {
30 Self::GitHub => "gh",
31 Self::GitLab => "glab",
32 }
33 }
34
35 pub fn auth_login_command(self) -> &'static str {
37 match self {
38 Self::GitHub => "gh auth login",
39 Self::GitLab => "glab auth login",
40 }
41 }
42
43 pub fn as_str(self) -> &'static str {
45 match self {
46 Self::GitHub => "GitHub",
47 Self::GitLab => "GitLab",
48 }
49 }
50}
51
52impl fmt::Display for ForgeKind {
53 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54 formatter.write_str(self.as_str())
55 }
56}
57
58impl FromStr for ForgeKind {
59 type Err = String;
60
61 fn from_str(value: &str) -> Result<Self, Self::Err> {
62 match value {
63 "GitHub" => Ok(Self::GitHub),
64 "GitLab" => Ok(Self::GitLab),
65 _ => Err(format!("Unknown review-request forge: {value}")),
66 }
67 }
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum ReviewRequestState {
73 Open,
75 Merged,
77 Closed,
79}
80
81impl ReviewRequestState {
82 pub fn as_str(self) -> &'static str {
84 match self {
85 Self::Open => "Open",
86 Self::Merged => "Merged",
87 Self::Closed => "Closed",
88 }
89 }
90}
91
92impl fmt::Display for ReviewRequestState {
93 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94 formatter.write_str(self.as_str())
95 }
96}
97
98impl FromStr for ReviewRequestState {
99 type Err = String;
100
101 fn from_str(value: &str) -> Result<Self, Self::Err> {
102 match value {
103 "Open" => Ok(Self::Open),
104 "Merged" => Ok(Self::Merged),
105 "Closed" => Ok(Self::Closed),
106 _ => Err(format!("Unknown review-request state: {value}")),
107 }
108 }
109}
110
111#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct ReviewRequestSummary {
120 pub display_id: String,
122 pub forge_kind: ForgeKind,
124 pub source_branch: String,
126 pub state: ReviewRequestState,
128 pub status_summary: Option<String>,
130 pub target_branch: String,
132 pub title: String,
134 pub web_url: String,
136}
137
138pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
140
141#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct ForgeRemote {
144 pub forge_kind: ForgeKind,
146 pub host: String,
151 pub namespace: String,
153 pub project: String,
155 pub repo_url: String,
157 pub web_url: String,
159}
160
161impl ForgeRemote {
162 pub fn project_path(&self) -> String {
164 format!("{}/{}", self.namespace, self.project)
165 }
166}
167
168#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct CreateReviewRequestInput {
171 pub body: Option<String>,
173 pub source_branch: String,
175 pub target_branch: String,
177 pub title: String,
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
183pub enum ReviewRequestError {
184 CliNotInstalled { forge_kind: ForgeKind },
186 AuthenticationRequired {
188 forge_kind: ForgeKind,
190 host: String,
192 detail: Option<String>,
194 },
195 HostResolutionFailed { forge_kind: ForgeKind, host: String },
197 UnsupportedRemote { repo_url: String },
199 OperationFailed {
201 forge_kind: ForgeKind,
202 message: String,
203 },
204}
205
206impl ReviewRequestError {
207 pub fn detail_message(&self) -> String {
209 match self {
210 Self::CliNotInstalled { forge_kind } => format!(
211 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
212 forge_kind.display_name(),
213 forge_kind.cli_name(),
214 forge_kind.cli_name(),
215 forge_kind.auth_login_command(),
216 ),
217 Self::AuthenticationRequired {
218 forge_kind,
219 host,
220 detail,
221 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
222 Self::HostResolutionFailed { forge_kind, host } => format!(
223 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
224 and your network or DNS setup, then retry.",
225 forge_kind.display_name(),
226 ),
227 Self::UnsupportedRemote { repo_url } => format!(
228 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
229 repository remote is not supported: `{repo_url}`."
230 ),
231 Self::OperationFailed {
232 forge_kind,
233 message,
234 } => format!(
235 "{} review-request operation failed: {message}",
236 forge_kind.display_name()
237 ),
238 }
239 }
240}
241
242fn authentication_required_message(
245 forge_kind: ForgeKind,
246 host: &str,
247 detail: Option<&str>,
248) -> String {
249 let mut message = format!(
250 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
251 forge_kind.display_name(),
252 forge_kind.auth_login_command(),
253 );
254
255 if let Some(detail) = non_empty_detail(detail) {
256 let _ = write!(
257 message,
258 "\n\nOriginal `{}` error:\n```text\n{detail}",
259 forge_kind.cli_name(),
260 );
261 if !detail.ends_with('\n') {
262 message.push('\n');
263 }
264 message.push_str("```");
265 }
266
267 message
268}
269
270fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
272 detail.and_then(|detail| {
273 let trimmed_detail = detail.trim();
274 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
275 })
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn authentication_required_message_includes_original_cli_error_detail() {
284 let error = ReviewRequestError::AuthenticationRequired {
286 detail: Some("HTTP 401 Unauthorized. Run `glab auth login`.".to_string()),
287 forge_kind: ForgeKind::GitLab,
288 host: "gitlab.example.com".to_string(),
289 };
290
291 let message = error.detail_message();
293
294 assert!(message.contains("GitLab review requests require local CLI authentication"));
296 assert!(message.contains("Run `glab auth login` and retry."));
297 assert!(message.contains("Original `glab` error:"));
298 assert!(message.contains("HTTP 401 Unauthorized. Run `glab auth login`."));
299 assert!(message.contains("```text"));
300 }
301
302 #[test]
303 fn authentication_required_message_omits_empty_original_cli_error_detail() {
304 let error = ReviewRequestError::AuthenticationRequired {
306 detail: Some(" \n".to_string()),
307 forge_kind: ForgeKind::GitHub,
308 host: "github.com".to_string(),
309 };
310
311 let message = error.detail_message();
313
314 assert!(message.contains("Run `gh auth login` and retry."));
316 assert!(!message.contains("Original `gh` error:"));
317 }
318}