Skip to main content

ag_forge/
model.rs

1//! Shared forge review-request types.
2
3use 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/// Shared forge family enum reused by persistence and forge adapters.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum ForgeKind {
15    /// GitHub-hosted pull requests.
16    GitHub,
17    /// GitLab-hosted merge requests.
18    GitLab,
19}
20
21impl ForgeKind {
22    /// Returns the user-facing forge name.
23    pub fn display_name(self) -> &'static str {
24        match self {
25            Self::GitHub => "GitHub",
26            Self::GitLab => "GitLab",
27        }
28    }
29
30    /// Returns the CLI executable name used for this forge.
31    pub fn cli_name(self) -> &'static str {
32        match self {
33            Self::GitHub => "gh",
34            Self::GitLab => "glab",
35        }
36    }
37
38    /// Returns the login command users should run to authorize forge access.
39    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    /// Returns the persisted string representation for this forge kind.
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::GitHub => "GitHub",
50            Self::GitLab => "GitLab",
51        }
52    }
53
54    /// Returns the forge-native review-request noun shown in user-facing copy.
55    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    /// Returns the combined forge and review-request name for user-facing copy.
63    pub fn review_request_display_name(self) -> String {
64        format!("{} {}", self.display_name(), self.review_request_name())
65    }
66
67    /// Returns the short UI indicator label for one review request.
68    pub fn review_request_short_name(self) -> &'static str {
69        match self {
70            Self::GitHub => "PR",
71            Self::GitLab => "MR",
72        }
73    }
74}
75
76/// Returns whether `host` looks like one GitLab instance hostname.
77pub 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/// Normalized remote lifecycle state for one linked review request.
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum ReviewRequestState {
105    /// The linked review request is still open.
106    Open,
107    /// The linked review request was merged upstream.
108    Merged,
109    /// The linked review request was closed without merge.
110    Closed,
111}
112
113impl ReviewRequestState {
114    /// Returns the persisted string representation for this remote state.
115    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/// Normalized remote summary for one linked review request.
144///
145/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
146/// `Canceled` retain this metadata so the session can continue to reference the
147/// same remote review request. Remote terminal outcomes are stored in
148/// `state` instead of clearing the link; only an explicit unlink action or
149/// session deletion should remove this metadata.
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ReviewRequestSummary {
152    /// Provider display id such as GitHub `#123`.
153    pub display_id: String,
154    /// Forge family that owns the linked review request.
155    pub forge_kind: ForgeKind,
156    /// Source branch published for review.
157    pub source_branch: String,
158    /// Latest normalized remote lifecycle state.
159    pub state: ReviewRequestState,
160    /// Provider-specific condensed status text for UI display.
161    pub status_summary: Option<String>,
162    /// Target branch receiving the review request.
163    pub target_branch: String,
164    /// Remote review-request title.
165    pub title: String,
166    /// Browser-openable review-request URL.
167    pub web_url: String,
168}
169
170/// Boxed async result used by review-request trait methods.
171pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
172
173/// Normalized repository remote metadata for one supported forge.
174#[derive(Clone, Debug, Eq, PartialEq)]
175pub struct ForgeRemote {
176    /// Repository worktree used when forge CLI commands need local git
177    /// context.
178    pub command_working_directory: Option<PathBuf>,
179    /// Forge family inferred from the repository remote.
180    pub forge_kind: ForgeKind,
181    /// Forge hostname used for browser and API calls.
182    ///
183    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
184    /// are stripped during remote normalization.
185    pub host: String,
186    /// Repository namespace or owner path.
187    pub namespace: String,
188    /// Repository name without a trailing `.git` suffix.
189    pub project: String,
190    /// Original remote URL returned by git.
191    pub repo_url: String,
192    /// Browser-openable repository URL derived from the remote.
193    pub web_url: String,
194}
195
196impl ForgeRemote {
197    /// Returns one remote copy that runs forge CLI commands from
198    /// `working_directory`.
199    #[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    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
207    pub fn project_path(&self) -> String {
208        format!("{}/{}", self.namespace, self.project)
209    }
210
211    /// Returns the browser-openable URL that starts one new pull request or
212    /// review request for `source_branch` into `target_branch`.
213    ///
214    /// # Errors
215    /// Returns [`ReviewRequestError::OperationFailed`] when the stored
216    /// repository web URL is invalid or cannot be converted into a forge
217    /// review-request creation URL.
218    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/// Input required to create a review request on one forge.
235#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct CreateReviewRequestInput {
237    /// Optional body or description submitted with the review request.
238    pub body: Option<String>,
239    /// Source branch that should be reviewed.
240    pub source_branch: String,
241    /// Target branch that receives the review request.
242    pub target_branch: String,
243    /// Title shown in the forge review-request UI.
244    pub title: String,
245}
246
247/// Review-request failures normalized for actionable UI messaging.
248#[derive(Clone, Debug, Eq, PartialEq)]
249pub enum ReviewRequestError {
250    /// The required forge CLI is not available on the user's machine.
251    CliNotInstalled { forge_kind: ForgeKind },
252    /// The forge CLI is installed but not authorized for the target host.
253    AuthenticationRequired {
254        /// Forge family that reported the authentication failure.
255        forge_kind: ForgeKind,
256        /// Forge host the CLI attempted to access.
257        host: String,
258        /// Original CLI error detail captured from stdout or stderr.
259        detail: Option<String>,
260    },
261    /// The forge host from the repository remote could not be resolved.
262    HostResolutionFailed { forge_kind: ForgeKind, host: String },
263    /// The repository remote does not map to a supported forge.
264    UnsupportedRemote { repo_url: String },
265    /// A forge CLI command ran but failed.
266    OperationFailed {
267        forge_kind: ForgeKind,
268        message: String,
269    },
270}
271
272impl ReviewRequestError {
273    /// Returns actionable user-facing copy for the failure.
274    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
308/// Returns actionable copy for one CLI authentication failure and preserves
309/// the original CLI output when it is available.
310fn 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        // Infallible: writing to a String cannot fail.
323        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
337/// Returns one trimmed CLI error detail when the captured output is not empty.
338fn 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
345/// Builds one GitHub compare URL that opens the new pull-request flow.
346fn 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
372/// Builds one GitLab URL that opens the new merge-request flow.
373fn 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
397/// Parses the stored repository web URL for one forge remote.
398fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
399    Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
400}
401
402/// Returns one normalized invalid-remote-url error for review-request links.
403fn 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        // Arrange
420        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        // Act
427        let message = error.detail_message();
428
429        // Assert
430        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        // Arrange
440        let error = ReviewRequestError::AuthenticationRequired {
441            detail: Some("   \n".to_string()),
442            forge_kind: ForgeKind::GitHub,
443            host: "github.com".to_string(),
444        };
445
446        // Act
447        let message = error.detail_message();
448
449        // Assert
450        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        // Arrange
457        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        // Act
468        let url = remote
469            .review_request_creation_url("review/custom-branch", "main")
470            .expect("github compare URL should be created");
471
472        // Assert
473        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        // Arrange
482        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        // Act
493        let error = remote
494            .review_request_creation_url("review/custom-branch", "main")
495            .expect_err("invalid web URL should be rejected");
496
497        // Assert
498        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        // Arrange
510        let raw_forge_kind = "GitLab";
511
512        // Act
513        let forge_kind = raw_forge_kind
514            .parse::<ForgeKind>()
515            .expect("gitlab forge kind should parse");
516
517        // Assert
518        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        // Arrange
527        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        // Act
538        let url = remote
539            .review_request_creation_url("review/custom-branch", "main")
540            .expect("gitlab merge-request URL should be created");
541
542        // Assert
543        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}