1use std::fmt;
4use std::fmt::Write as _;
5use std::future::Future;
6use std::path::PathBuf;
7use std::pin::Pin;
8use std::str::FromStr;
9
10use url::Url;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum ForgeKind {
15 GitHub,
17 GitLab,
19}
20
21impl ForgeKind {
22 pub fn display_name(self) -> &'static str {
24 match self {
25 Self::GitHub => "GitHub",
26 Self::GitLab => "GitLab",
27 }
28 }
29
30 pub fn cli_name(self) -> &'static str {
32 match self {
33 Self::GitHub => "gh",
34 Self::GitLab => "glab",
35 }
36 }
37
38 pub fn auth_login_command(self) -> &'static str {
40 match self {
41 Self::GitHub => "gh auth login",
42 Self::GitLab => "glab auth login",
43 }
44 }
45
46 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::GitHub => "GitHub",
50 Self::GitLab => "GitLab",
51 }
52 }
53
54 pub fn review_request_name(self) -> &'static str {
56 match self {
57 Self::GitHub => "pull request",
58 Self::GitLab => "merge request",
59 }
60 }
61
62 pub fn review_request_display_name(self) -> String {
64 format!("{} {}", self.display_name(), self.review_request_name())
65 }
66
67 pub fn review_request_short_name(self) -> &'static str {
69 match self {
70 Self::GitHub => "PR",
71 Self::GitLab => "MR",
72 }
73 }
74}
75
76pub fn is_gitlab_host(host: &str) -> bool {
78 host == "gitlab.com"
79 || host.ends_with(".gitlab.com")
80 || host.starts_with("gitlab.")
81 || host.contains(".gitlab.")
82}
83
84impl fmt::Display for ForgeKind {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 formatter.write_str(self.as_str())
87 }
88}
89
90impl FromStr for ForgeKind {
91 type Err = String;
92
93 fn from_str(value: &str) -> Result<Self, Self::Err> {
94 match value {
95 "GitHub" => Ok(Self::GitHub),
96 "GitLab" => Ok(Self::GitLab),
97 _ => Err(format!("Unknown review-request forge: {value}")),
98 }
99 }
100}
101
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum ReviewRequestState {
105 Open,
107 Merged,
109 Closed,
111}
112
113impl ReviewRequestState {
114 pub fn as_str(self) -> &'static str {
116 match self {
117 Self::Open => "Open",
118 Self::Merged => "Merged",
119 Self::Closed => "Closed",
120 }
121 }
122}
123
124impl fmt::Display for ReviewRequestState {
125 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126 formatter.write_str(self.as_str())
127 }
128}
129
130impl FromStr for ReviewRequestState {
131 type Err = String;
132
133 fn from_str(value: &str) -> Result<Self, Self::Err> {
134 match value {
135 "Open" => Ok(Self::Open),
136 "Merged" => Ok(Self::Merged),
137 "Closed" => Ok(Self::Closed),
138 _ => Err(format!("Unknown review-request state: {value}")),
139 }
140 }
141}
142
143#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ReviewRequestSummary {
152 pub display_id: String,
154 pub forge_kind: ForgeKind,
156 pub source_branch: String,
158 pub state: ReviewRequestState,
160 pub status_summary: Option<String>,
162 pub target_branch: String,
164 pub title: String,
166 pub web_url: String,
168}
169
170pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
172
173#[derive(Clone, Debug, Eq, PartialEq)]
175pub struct ForgeRemote {
176 pub command_working_directory: Option<PathBuf>,
179 pub forge_kind: ForgeKind,
181 pub host: String,
186 pub namespace: String,
188 pub project: String,
190 pub repo_url: String,
192 pub web_url: String,
194}
195
196impl ForgeRemote {
197 #[must_use]
200 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
201 self.command_working_directory = Some(working_directory);
202
203 self
204 }
205
206 pub fn project_path(&self) -> String {
208 format!("{}/{}", self.namespace, self.project)
209 }
210
211 pub fn review_request_creation_url(
219 &self,
220 source_branch: &str,
221 target_branch: &str,
222 ) -> Result<String, ReviewRequestError> {
223 match self.forge_kind {
224 ForgeKind::GitHub => {
225 github_review_request_creation_url(self, source_branch, target_branch)
226 }
227 ForgeKind::GitLab => {
228 gitlab_review_request_creation_url(self, source_branch, target_branch)
229 }
230 }
231 }
232}
233
234#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct CreateReviewRequestInput {
237 pub body: Option<String>,
239 pub source_branch: String,
241 pub target_branch: String,
243 pub title: String,
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
249pub enum ReviewRequestError {
250 CliNotInstalled { forge_kind: ForgeKind },
252 AuthenticationRequired {
254 forge_kind: ForgeKind,
256 host: String,
258 detail: Option<String>,
260 },
261 HostResolutionFailed { forge_kind: ForgeKind, host: String },
263 UnsupportedRemote { repo_url: String },
265 OperationFailed {
267 forge_kind: ForgeKind,
268 message: String,
269 },
270}
271
272impl ReviewRequestError {
273 pub fn detail_message(&self) -> String {
275 match self {
276 Self::CliNotInstalled { forge_kind } => format!(
277 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
278 forge_kind.display_name(),
279 forge_kind.cli_name(),
280 forge_kind.cli_name(),
281 forge_kind.auth_login_command(),
282 ),
283 Self::AuthenticationRequired {
284 forge_kind,
285 host,
286 detail,
287 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
288 Self::HostResolutionFailed { forge_kind, host } => format!(
289 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
290 and your network or DNS setup, then retry.",
291 forge_kind.display_name(),
292 ),
293 Self::UnsupportedRemote { repo_url } => format!(
294 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
295 repository remote is not supported: `{repo_url}`."
296 ),
297 Self::OperationFailed {
298 forge_kind,
299 message,
300 } => format!(
301 "{} review-request operation failed: {message}",
302 forge_kind.display_name()
303 ),
304 }
305 }
306}
307
308fn authentication_required_message(
311 forge_kind: ForgeKind,
312 host: &str,
313 detail: Option<&str>,
314) -> String {
315 let mut message = format!(
316 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
317 forge_kind.display_name(),
318 forge_kind.auth_login_command(),
319 );
320
321 if let Some(detail) = non_empty_detail(detail) {
322 let _ = write!(
324 message,
325 "\n\nOriginal `{}` error:\n```text\n{detail}",
326 forge_kind.cli_name(),
327 );
328 if !detail.ends_with('\n') {
329 message.push('\n');
330 }
331 message.push_str("```");
332 }
333
334 message
335}
336
337fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
339 detail.and_then(|detail| {
340 let trimmed_detail = detail.trim();
341 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
342 })
343}
344
345fn github_review_request_creation_url(
347 remote: &ForgeRemote,
348 source_branch: &str,
349 target_branch: &str,
350) -> Result<String, ReviewRequestError> {
351 let mut url = parsed_remote_web_url(remote)?;
352 let compare_target = if target_branch.trim().is_empty() {
353 source_branch.to_string()
354 } else {
355 format!("{target_branch}...{source_branch}")
356 };
357
358 {
359 let mut path_segments = url
360 .path_segments_mut()
361 .map_err(|()| invalid_web_url_error(remote))?;
362 path_segments.pop_if_empty();
363 path_segments.push("compare");
364 path_segments.push(&compare_target);
365 }
366
367 url.query_pairs_mut().append_pair("expand", "1");
368
369 Ok(url.into())
370}
371
372fn gitlab_review_request_creation_url(
374 remote: &ForgeRemote,
375 source_branch: &str,
376 target_branch: &str,
377) -> Result<String, ReviewRequestError> {
378 let mut url = parsed_remote_web_url(remote)?;
379
380 {
381 let mut path_segments = url
382 .path_segments_mut()
383 .map_err(|()| invalid_web_url_error(remote))?;
384 path_segments.pop_if_empty();
385 path_segments.push("-");
386 path_segments.push("merge_requests");
387 path_segments.push("new");
388 }
389
390 url.query_pairs_mut()
391 .append_pair("merge_request[source_branch]", source_branch)
392 .append_pair("merge_request[target_branch]", target_branch);
393
394 Ok(url.into())
395}
396
397fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
399 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
400}
401
402fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
404 ReviewRequestError::OperationFailed {
405 forge_kind: remote.forge_kind,
406 message: format!(
407 "repository remote is missing a valid web URL: `{}`",
408 remote.web_url
409 ),
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn authentication_required_message_includes_original_cli_error_detail() {
419 let error = ReviewRequestError::AuthenticationRequired {
421 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
422 forge_kind: ForgeKind::GitHub,
423 host: "github.com".to_string(),
424 };
425
426 let message = error.detail_message();
428
429 assert!(message.contains("GitHub review requests require local CLI authentication"));
431 assert!(message.contains("Run `gh auth login` and retry."));
432 assert!(message.contains("Original `gh` error:"));
433 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
434 assert!(message.contains("```text"));
435 }
436
437 #[test]
438 fn authentication_required_message_omits_empty_original_cli_error_detail() {
439 let error = ReviewRequestError::AuthenticationRequired {
441 detail: Some(" \n".to_string()),
442 forge_kind: ForgeKind::GitHub,
443 host: "github.com".to_string(),
444 };
445
446 let message = error.detail_message();
448
449 assert!(message.contains("Run `gh auth login` and retry."));
451 assert!(!message.contains("Original `gh` error:"));
452 }
453
454 #[test]
455 fn review_request_creation_url_returns_github_compare_link() {
456 let remote = ForgeRemote {
458 command_working_directory: None,
459 forge_kind: ForgeKind::GitHub,
460 host: "github.com".to_string(),
461 namespace: "agentty-xyz".to_string(),
462 project: "agentty".to_string(),
463 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
464 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
465 };
466
467 let url = remote
469 .review_request_creation_url("review/custom-branch", "main")
470 .expect("github compare URL should be created");
471
472 assert_eq!(
474 url,
475 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
476 );
477 }
478
479 #[test]
480 fn review_request_creation_url_rejects_invalid_web_url() {
481 let remote = ForgeRemote {
483 command_working_directory: None,
484 forge_kind: ForgeKind::GitHub,
485 host: "github.com".to_string(),
486 namespace: "agentty-xyz".to_string(),
487 project: "agentty".to_string(),
488 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
489 web_url: "not a url".to_string(),
490 };
491
492 let error = remote
494 .review_request_creation_url("review/custom-branch", "main")
495 .expect_err("invalid web URL should be rejected");
496
497 assert_eq!(
499 error,
500 ReviewRequestError::OperationFailed {
501 forge_kind: ForgeKind::GitHub,
502 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
503 }
504 );
505 }
506
507 #[test]
508 fn forge_kind_from_str_gitlab() {
509 let raw_forge_kind = "GitLab";
511
512 let forge_kind = raw_forge_kind
514 .parse::<ForgeKind>()
515 .expect("gitlab forge kind should parse");
516
517 assert_eq!(forge_kind, ForgeKind::GitLab);
519 assert_eq!(forge_kind.cli_name(), "glab");
520 assert_eq!(forge_kind.review_request_name(), "merge request");
521 assert_eq!(forge_kind.review_request_short_name(), "MR");
522 }
523
524 #[test]
525 fn review_request_creation_url_returns_gitlab_merge_request_link() {
526 let remote = ForgeRemote {
528 command_working_directory: None,
529 forge_kind: ForgeKind::GitLab,
530 host: "gitlab.com".to_string(),
531 namespace: "agentty-xyz".to_string(),
532 project: "agentty".to_string(),
533 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
534 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
535 };
536
537 let url = remote
539 .review_request_creation_url("review/custom-branch", "main")
540 .expect("gitlab merge-request URL should be created");
541
542 assert_eq!(
544 url,
545 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
546 );
547 }
548}