1use std::fmt;
4use std::fmt::Write as _;
5use std::future::Future;
6use std::pin::Pin;
7use std::str::FromStr;
8
9use url::Url;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ForgeKind {
14 GitHub,
16 GitLab,
18}
19
20impl ForgeKind {
21 pub fn display_name(self) -> &'static str {
23 match self {
24 Self::GitHub => "GitHub",
25 Self::GitLab => "GitLab",
26 }
27 }
28
29 pub fn cli_name(self) -> &'static str {
31 match self {
32 Self::GitHub => "gh",
33 Self::GitLab => "glab",
34 }
35 }
36
37 pub fn auth_login_command(self) -> &'static str {
39 match self {
40 Self::GitHub => "gh auth login",
41 Self::GitLab => "glab auth login",
42 }
43 }
44
45 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::GitHub => "GitHub",
49 Self::GitLab => "GitLab",
50 }
51 }
52}
53
54impl fmt::Display for ForgeKind {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 formatter.write_str(self.as_str())
57 }
58}
59
60impl FromStr for ForgeKind {
61 type Err = String;
62
63 fn from_str(value: &str) -> Result<Self, Self::Err> {
64 match value {
65 "GitHub" => Ok(Self::GitHub),
66 "GitLab" => Ok(Self::GitLab),
67 _ => Err(format!("Unknown review-request forge: {value}")),
68 }
69 }
70}
71
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum ReviewRequestState {
75 Open,
77 Merged,
79 Closed,
81}
82
83impl ReviewRequestState {
84 pub fn as_str(self) -> &'static str {
86 match self {
87 Self::Open => "Open",
88 Self::Merged => "Merged",
89 Self::Closed => "Closed",
90 }
91 }
92}
93
94impl fmt::Display for ReviewRequestState {
95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96 formatter.write_str(self.as_str())
97 }
98}
99
100impl FromStr for ReviewRequestState {
101 type Err = String;
102
103 fn from_str(value: &str) -> Result<Self, Self::Err> {
104 match value {
105 "Open" => Ok(Self::Open),
106 "Merged" => Ok(Self::Merged),
107 "Closed" => Ok(Self::Closed),
108 _ => Err(format!("Unknown review-request state: {value}")),
109 }
110 }
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct ReviewRequestSummary {
122 pub display_id: String,
124 pub forge_kind: ForgeKind,
126 pub source_branch: String,
128 pub state: ReviewRequestState,
130 pub status_summary: Option<String>,
132 pub target_branch: String,
134 pub title: String,
136 pub web_url: String,
138}
139
140pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
142
143#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct ForgeRemote {
146 pub forge_kind: ForgeKind,
148 pub host: String,
153 pub namespace: String,
155 pub project: String,
157 pub repo_url: String,
159 pub web_url: String,
161}
162
163impl ForgeRemote {
164 pub fn project_path(&self) -> String {
166 format!("{}/{}", self.namespace, self.project)
167 }
168
169 pub fn review_request_creation_url(
177 &self,
178 source_branch: &str,
179 target_branch: &str,
180 ) -> Result<String, ReviewRequestError> {
181 match self.forge_kind {
182 ForgeKind::GitHub => {
183 github_review_request_creation_url(self, source_branch, target_branch)
184 }
185 ForgeKind::GitLab => {
186 gitlab_review_request_creation_url(self, source_branch, target_branch)
187 }
188 }
189 }
190}
191
192#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct CreateReviewRequestInput {
195 pub body: Option<String>,
197 pub source_branch: String,
199 pub target_branch: String,
201 pub title: String,
203}
204
205#[derive(Clone, Debug, Eq, PartialEq)]
207pub enum ReviewRequestError {
208 CliNotInstalled { forge_kind: ForgeKind },
210 AuthenticationRequired {
212 forge_kind: ForgeKind,
214 host: String,
216 detail: Option<String>,
218 },
219 HostResolutionFailed { forge_kind: ForgeKind, host: String },
221 UnsupportedRemote { repo_url: String },
223 OperationFailed {
225 forge_kind: ForgeKind,
226 message: String,
227 },
228}
229
230impl ReviewRequestError {
231 pub fn detail_message(&self) -> String {
233 match self {
234 Self::CliNotInstalled { forge_kind } => format!(
235 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
236 forge_kind.display_name(),
237 forge_kind.cli_name(),
238 forge_kind.cli_name(),
239 forge_kind.auth_login_command(),
240 ),
241 Self::AuthenticationRequired {
242 forge_kind,
243 host,
244 detail,
245 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
246 Self::HostResolutionFailed { forge_kind, host } => format!(
247 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
248 and your network or DNS setup, then retry.",
249 forge_kind.display_name(),
250 ),
251 Self::UnsupportedRemote { repo_url } => format!(
252 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
253 repository remote is not supported: `{repo_url}`."
254 ),
255 Self::OperationFailed {
256 forge_kind,
257 message,
258 } => format!(
259 "{} review-request operation failed: {message}",
260 forge_kind.display_name()
261 ),
262 }
263 }
264}
265
266fn authentication_required_message(
269 forge_kind: ForgeKind,
270 host: &str,
271 detail: Option<&str>,
272) -> String {
273 let mut message = format!(
274 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
275 forge_kind.display_name(),
276 forge_kind.auth_login_command(),
277 );
278
279 if let Some(detail) = non_empty_detail(detail) {
280 let _ = write!(
281 message,
282 "\n\nOriginal `{}` error:\n```text\n{detail}",
283 forge_kind.cli_name(),
284 );
285 if !detail.ends_with('\n') {
286 message.push('\n');
287 }
288 message.push_str("```");
289 }
290
291 message
292}
293
294fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
296 detail.and_then(|detail| {
297 let trimmed_detail = detail.trim();
298 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
299 })
300}
301
302fn github_review_request_creation_url(
304 remote: &ForgeRemote,
305 source_branch: &str,
306 target_branch: &str,
307) -> Result<String, ReviewRequestError> {
308 let mut url = parsed_remote_web_url(remote)?;
309 let compare_target = if target_branch.trim().is_empty() {
310 source_branch.to_string()
311 } else {
312 format!("{target_branch}...{source_branch}")
313 };
314
315 {
316 let mut path_segments = url
317 .path_segments_mut()
318 .map_err(|()| invalid_web_url_error(remote))?;
319 path_segments.pop_if_empty();
320 path_segments.push("compare");
321 path_segments.push(&compare_target);
322 }
323
324 url.query_pairs_mut().append_pair("expand", "1");
325
326 Ok(url.into())
327}
328
329fn gitlab_review_request_creation_url(
332 remote: &ForgeRemote,
333 source_branch: &str,
334 target_branch: &str,
335) -> Result<String, ReviewRequestError> {
336 let mut url = parsed_remote_web_url(remote)?;
337
338 {
339 let mut path_segments = url
340 .path_segments_mut()
341 .map_err(|()| invalid_web_url_error(remote))?;
342 path_segments.pop_if_empty();
343 path_segments.push("-");
344 path_segments.push("merge_requests");
345 path_segments.push("new");
346 }
347
348 url.query_pairs_mut()
349 .append_pair("merge_request[source_branch]", source_branch)
350 .append_pair("merge_request[target_branch]", target_branch);
351
352 Ok(url.into())
353}
354
355fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
357 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
358}
359
360fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
362 ReviewRequestError::OperationFailed {
363 forge_kind: remote.forge_kind,
364 message: format!(
365 "repository remote is missing a valid web URL: `{}`",
366 remote.web_url
367 ),
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn authentication_required_message_includes_original_cli_error_detail() {
377 let error = ReviewRequestError::AuthenticationRequired {
379 detail: Some("HTTP 401 Unauthorized. Run `glab auth login`.".to_string()),
380 forge_kind: ForgeKind::GitLab,
381 host: "gitlab.example.com".to_string(),
382 };
383
384 let message = error.detail_message();
386
387 assert!(message.contains("GitLab review requests require local CLI authentication"));
389 assert!(message.contains("Run `glab auth login` and retry."));
390 assert!(message.contains("Original `glab` error:"));
391 assert!(message.contains("HTTP 401 Unauthorized. Run `glab auth login`."));
392 assert!(message.contains("```text"));
393 }
394
395 #[test]
396 fn authentication_required_message_omits_empty_original_cli_error_detail() {
397 let error = ReviewRequestError::AuthenticationRequired {
399 detail: Some(" \n".to_string()),
400 forge_kind: ForgeKind::GitHub,
401 host: "github.com".to_string(),
402 };
403
404 let message = error.detail_message();
406
407 assert!(message.contains("Run `gh auth login` and retry."));
409 assert!(!message.contains("Original `gh` error:"));
410 }
411
412 #[test]
413 fn review_request_creation_url_returns_github_compare_link() {
414 let remote = ForgeRemote {
416 forge_kind: ForgeKind::GitHub,
417 host: "github.com".to_string(),
418 namespace: "agentty-xyz".to_string(),
419 project: "agentty".to_string(),
420 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
421 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
422 };
423
424 let url = remote
426 .review_request_creation_url("review/custom-branch", "main")
427 .expect("github compare URL should be created");
428
429 assert_eq!(
431 url,
432 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
433 );
434 }
435
436 #[test]
437 fn review_request_creation_url_returns_gitlab_merge_request_link() {
438 let remote = ForgeRemote {
440 forge_kind: ForgeKind::GitLab,
441 host: "gitlab.com".to_string(),
442 namespace: "group/subgroup".to_string(),
443 project: "agentty".to_string(),
444 repo_url: "git@gitlab.com:group/subgroup/agentty.git".to_string(),
445 web_url: "https://gitlab.com/group/subgroup/agentty".to_string(),
446 };
447
448 let url = remote
450 .review_request_creation_url("review/custom-branch", "master")
451 .expect("gitlab merge-request URL should be created");
452
453 assert_eq!(
455 url,
456 "https://gitlab.com/group/subgroup/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=master"
457 );
458 }
459
460 #[test]
461 fn review_request_creation_url_rejects_invalid_web_url() {
462 let remote = ForgeRemote {
464 forge_kind: ForgeKind::GitHub,
465 host: "github.com".to_string(),
466 namespace: "agentty-xyz".to_string(),
467 project: "agentty".to_string(),
468 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
469 web_url: "not a url".to_string(),
470 };
471
472 let error = remote
474 .review_request_creation_url("review/custom-branch", "main")
475 .expect_err("invalid web URL should be rejected");
476
477 assert_eq!(
479 error,
480 ReviewRequestError::OperationFailed {
481 forge_kind: ForgeKind::GitHub,
482 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
483 }
484 );
485 }
486}