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}
17
18impl ForgeKind {
19 pub fn display_name(self) -> &'static str {
21 match self {
22 Self::GitHub => "GitHub",
23 }
24 }
25
26 pub fn cli_name(self) -> &'static str {
28 match self {
29 Self::GitHub => "gh",
30 }
31 }
32
33 pub fn auth_login_command(self) -> &'static str {
35 match self {
36 Self::GitHub => "gh auth login",
37 }
38 }
39
40 pub fn as_str(self) -> &'static str {
42 match self {
43 Self::GitHub => "GitHub",
44 }
45 }
46}
47
48impl fmt::Display for ForgeKind {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 formatter.write_str(self.as_str())
51 }
52}
53
54impl FromStr for ForgeKind {
55 type Err = String;
56
57 fn from_str(value: &str) -> Result<Self, Self::Err> {
58 match value {
59 "GitHub" => Ok(Self::GitHub),
60 _ => Err(format!("Unknown review-request forge: {value}")),
61 }
62 }
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ReviewRequestState {
68 Open,
70 Merged,
72 Closed,
74}
75
76impl ReviewRequestState {
77 pub fn as_str(self) -> &'static str {
79 match self {
80 Self::Open => "Open",
81 Self::Merged => "Merged",
82 Self::Closed => "Closed",
83 }
84 }
85}
86
87impl fmt::Display for ReviewRequestState {
88 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89 formatter.write_str(self.as_str())
90 }
91}
92
93impl FromStr for ReviewRequestState {
94 type Err = String;
95
96 fn from_str(value: &str) -> Result<Self, Self::Err> {
97 match value {
98 "Open" => Ok(Self::Open),
99 "Merged" => Ok(Self::Merged),
100 "Closed" => Ok(Self::Closed),
101 _ => Err(format!("Unknown review-request state: {value}")),
102 }
103 }
104}
105
106#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct ReviewRequestSummary {
115 pub display_id: String,
117 pub forge_kind: ForgeKind,
119 pub source_branch: String,
121 pub state: ReviewRequestState,
123 pub status_summary: Option<String>,
125 pub target_branch: String,
127 pub title: String,
129 pub web_url: String,
131}
132
133pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
135
136#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct ForgeRemote {
139 pub forge_kind: ForgeKind,
141 pub host: String,
146 pub namespace: String,
148 pub project: String,
150 pub repo_url: String,
152 pub web_url: String,
154}
155
156impl ForgeRemote {
157 pub fn project_path(&self) -> String {
159 format!("{}/{}", self.namespace, self.project)
160 }
161
162 pub fn review_request_creation_url(
170 &self,
171 source_branch: &str,
172 target_branch: &str,
173 ) -> Result<String, ReviewRequestError> {
174 match self.forge_kind {
175 ForgeKind::GitHub => {
176 github_review_request_creation_url(self, source_branch, target_branch)
177 }
178 }
179 }
180}
181
182#[derive(Clone, Debug, Eq, PartialEq)]
184pub struct CreateReviewRequestInput {
185 pub body: Option<String>,
187 pub source_branch: String,
189 pub target_branch: String,
191 pub title: String,
193}
194
195#[derive(Clone, Debug, Eq, PartialEq)]
197pub enum ReviewRequestError {
198 CliNotInstalled { forge_kind: ForgeKind },
200 AuthenticationRequired {
202 forge_kind: ForgeKind,
204 host: String,
206 detail: Option<String>,
208 },
209 HostResolutionFailed { forge_kind: ForgeKind, host: String },
211 UnsupportedRemote { repo_url: String },
213 OperationFailed {
215 forge_kind: ForgeKind,
216 message: String,
217 },
218}
219
220impl ReviewRequestError {
221 pub fn detail_message(&self) -> String {
223 match self {
224 Self::CliNotInstalled { forge_kind } => format!(
225 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
226 forge_kind.display_name(),
227 forge_kind.cli_name(),
228 forge_kind.cli_name(),
229 forge_kind.auth_login_command(),
230 ),
231 Self::AuthenticationRequired {
232 forge_kind,
233 host,
234 detail,
235 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
236 Self::HostResolutionFailed { forge_kind, host } => format!(
237 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
238 and your network or DNS setup, then retry.",
239 forge_kind.display_name(),
240 ),
241 Self::UnsupportedRemote { repo_url } => format!(
242 "Review requests are only supported for GitHub remotes.\nThis repository remote \
243 is not supported: `{repo_url}`."
244 ),
245 Self::OperationFailed {
246 forge_kind,
247 message,
248 } => format!(
249 "{} review-request operation failed: {message}",
250 forge_kind.display_name()
251 ),
252 }
253 }
254}
255
256fn authentication_required_message(
259 forge_kind: ForgeKind,
260 host: &str,
261 detail: Option<&str>,
262) -> String {
263 let mut message = format!(
264 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
265 forge_kind.display_name(),
266 forge_kind.auth_login_command(),
267 );
268
269 if let Some(detail) = non_empty_detail(detail) {
270 let _ = write!(
272 message,
273 "\n\nOriginal `{}` error:\n```text\n{detail}",
274 forge_kind.cli_name(),
275 );
276 if !detail.ends_with('\n') {
277 message.push('\n');
278 }
279 message.push_str("```");
280 }
281
282 message
283}
284
285fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
287 detail.and_then(|detail| {
288 let trimmed_detail = detail.trim();
289 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
290 })
291}
292
293fn github_review_request_creation_url(
295 remote: &ForgeRemote,
296 source_branch: &str,
297 target_branch: &str,
298) -> Result<String, ReviewRequestError> {
299 let mut url = parsed_remote_web_url(remote)?;
300 let compare_target = if target_branch.trim().is_empty() {
301 source_branch.to_string()
302 } else {
303 format!("{target_branch}...{source_branch}")
304 };
305
306 {
307 let mut path_segments = url
308 .path_segments_mut()
309 .map_err(|()| invalid_web_url_error(remote))?;
310 path_segments.pop_if_empty();
311 path_segments.push("compare");
312 path_segments.push(&compare_target);
313 }
314
315 url.query_pairs_mut().append_pair("expand", "1");
316
317 Ok(url.into())
318}
319
320fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
322 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
323}
324
325fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
327 ReviewRequestError::OperationFailed {
328 forge_kind: remote.forge_kind,
329 message: format!(
330 "repository remote is missing a valid web URL: `{}`",
331 remote.web_url
332 ),
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn authentication_required_message_includes_original_cli_error_detail() {
342 let error = ReviewRequestError::AuthenticationRequired {
344 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
345 forge_kind: ForgeKind::GitHub,
346 host: "github.com".to_string(),
347 };
348
349 let message = error.detail_message();
351
352 assert!(message.contains("GitHub review requests require local CLI authentication"));
354 assert!(message.contains("Run `gh auth login` and retry."));
355 assert!(message.contains("Original `gh` error:"));
356 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
357 assert!(message.contains("```text"));
358 }
359
360 #[test]
361 fn authentication_required_message_omits_empty_original_cli_error_detail() {
362 let error = ReviewRequestError::AuthenticationRequired {
364 detail: Some(" \n".to_string()),
365 forge_kind: ForgeKind::GitHub,
366 host: "github.com".to_string(),
367 };
368
369 let message = error.detail_message();
371
372 assert!(message.contains("Run `gh auth login` and retry."));
374 assert!(!message.contains("Original `gh` error:"));
375 }
376
377 #[test]
378 fn review_request_creation_url_returns_github_compare_link() {
379 let remote = ForgeRemote {
381 forge_kind: ForgeKind::GitHub,
382 host: "github.com".to_string(),
383 namespace: "agentty-xyz".to_string(),
384 project: "agentty".to_string(),
385 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
386 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
387 };
388
389 let url = remote
391 .review_request_creation_url("review/custom-branch", "main")
392 .expect("github compare URL should be created");
393
394 assert_eq!(
396 url,
397 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
398 );
399 }
400
401 #[test]
402 fn review_request_creation_url_rejects_invalid_web_url() {
403 let remote = ForgeRemote {
405 forge_kind: ForgeKind::GitHub,
406 host: "github.com".to_string(),
407 namespace: "agentty-xyz".to_string(),
408 project: "agentty".to_string(),
409 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
410 web_url: "not a url".to_string(),
411 };
412
413 let error = remote
415 .review_request_creation_url("review/custom-branch", "main")
416 .expect_err("invalid web URL should be rejected");
417
418 assert_eq!(
420 error,
421 ReviewRequestError::OperationFailed {
422 forge_kind: ForgeKind::GitHub,
423 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
424 }
425 );
426 }
427}