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::pin::Pin;
7use std::str::FromStr;
8
9use url::Url;
10
11/// Shared forge family enum reused by persistence and forge adapters.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ForgeKind {
14    /// GitHub-hosted pull requests.
15    GitHub,
16    /// GitLab-hosted merge requests.
17    GitLab,
18}
19
20impl ForgeKind {
21    /// Returns the user-facing forge name.
22    pub fn display_name(self) -> &'static str {
23        match self {
24            Self::GitHub => "GitHub",
25            Self::GitLab => "GitLab",
26        }
27    }
28
29    /// Returns the CLI executable name used for this forge.
30    pub fn cli_name(self) -> &'static str {
31        match self {
32            Self::GitHub => "gh",
33            Self::GitLab => "glab",
34        }
35    }
36
37    /// Returns the login command users should run to authorize forge access.
38    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    /// Returns the persisted string representation for this forge kind.
46    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/// Normalized remote lifecycle state for one linked review request.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum ReviewRequestState {
75    /// The linked review request is still open.
76    Open,
77    /// The linked review request was merged upstream.
78    Merged,
79    /// The linked review request was closed without merge.
80    Closed,
81}
82
83impl ReviewRequestState {
84    /// Returns the persisted string representation for this remote state.
85    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/// Normalized remote summary for one linked review request.
114///
115/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
116/// `Canceled` retain this metadata so the session can continue to reference the
117/// same remote review request. Remote terminal outcomes are stored in
118/// `state` instead of clearing the link; only an explicit unlink action or
119/// session deletion should remove this metadata.
120#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct ReviewRequestSummary {
122    /// Provider display id such as GitHub `#123` or GitLab `!42`.
123    pub display_id: String,
124    /// Forge family that owns the linked review request.
125    pub forge_kind: ForgeKind,
126    /// Source branch published for review.
127    pub source_branch: String,
128    /// Latest normalized remote lifecycle state.
129    pub state: ReviewRequestState,
130    /// Provider-specific condensed status text for UI display.
131    pub status_summary: Option<String>,
132    /// Target branch receiving the review request.
133    pub target_branch: String,
134    /// Remote review-request title.
135    pub title: String,
136    /// Browser-openable review-request URL.
137    pub web_url: String,
138}
139
140/// Boxed async result used by review-request trait methods.
141pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
142
143/// Normalized repository remote metadata for one supported forge.
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct ForgeRemote {
146    /// Forge family inferred from the repository remote.
147    pub forge_kind: ForgeKind,
148    /// Forge hostname used for browser and API calls.
149    ///
150    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
151    /// are stripped during remote normalization.
152    pub host: String,
153    /// Repository namespace or owner path.
154    pub namespace: String,
155    /// Repository name without a trailing `.git` suffix.
156    pub project: String,
157    /// Original remote URL returned by git.
158    pub repo_url: String,
159    /// Browser-openable repository URL derived from the remote.
160    pub web_url: String,
161}
162
163impl ForgeRemote {
164    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
165    pub fn project_path(&self) -> String {
166        format!("{}/{}", self.namespace, self.project)
167    }
168
169    /// Returns the browser-openable URL that starts one new pull request or
170    /// merge request for `source_branch` into `target_branch`.
171    ///
172    /// # Errors
173    /// Returns [`ReviewRequestError::OperationFailed`] when the stored
174    /// repository web URL is invalid or cannot be converted into a forge
175    /// review-request creation URL.
176    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/// Input required to create a review request on one forge.
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct CreateReviewRequestInput {
195    /// Optional body or description submitted with the review request.
196    pub body: Option<String>,
197    /// Source branch that should be reviewed.
198    pub source_branch: String,
199    /// Target branch that receives the review request.
200    pub target_branch: String,
201    /// Title shown in the forge review-request UI.
202    pub title: String,
203}
204
205/// Review-request failures normalized for actionable UI messaging.
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub enum ReviewRequestError {
208    /// The required forge CLI is not available on the user's machine.
209    CliNotInstalled { forge_kind: ForgeKind },
210    /// The forge CLI is installed but not authorized for the target host.
211    AuthenticationRequired {
212        /// Forge family that reported the authentication failure.
213        forge_kind: ForgeKind,
214        /// Forge host the CLI attempted to access.
215        host: String,
216        /// Original CLI error detail captured from stdout or stderr.
217        detail: Option<String>,
218    },
219    /// The forge host from the repository remote could not be resolved.
220    HostResolutionFailed { forge_kind: ForgeKind, host: String },
221    /// The repository remote does not map to a supported forge.
222    UnsupportedRemote { repo_url: String },
223    /// A forge CLI command ran but failed.
224    OperationFailed {
225        forge_kind: ForgeKind,
226        message: String,
227    },
228}
229
230impl ReviewRequestError {
231    /// Returns actionable user-facing copy for the failure.
232    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
266/// Returns actionable copy for one CLI authentication failure and preserves
267/// the original CLI output when it is available.
268fn 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
294/// Returns one trimmed CLI error detail when the captured output is not empty.
295fn 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
302/// Builds one GitHub compare URL that opens the new pull-request flow.
303fn 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
329/// Builds one GitLab merge-request URL with pre-filled source and target
330/// branch query parameters.
331fn 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
355/// Parses the stored repository web URL for one forge remote.
356fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
357    Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
358}
359
360/// Returns one normalized invalid-remote-url error for review-request links.
361fn 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        // Arrange
378        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        // Act
385        let message = error.detail_message();
386
387        // Assert
388        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        // Arrange
398        let error = ReviewRequestError::AuthenticationRequired {
399            detail: Some("   \n".to_string()),
400            forge_kind: ForgeKind::GitHub,
401            host: "github.com".to_string(),
402        };
403
404        // Act
405        let message = error.detail_message();
406
407        // Assert
408        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        // Arrange
415        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        // Act
425        let url = remote
426            .review_request_creation_url("review/custom-branch", "main")
427            .expect("github compare URL should be created");
428
429        // Assert
430        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        // Arrange
439        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        // Act
449        let url = remote
450            .review_request_creation_url("review/custom-branch", "master")
451            .expect("gitlab merge-request URL should be created");
452
453        // Assert
454        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        // Arrange
463        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        // Act
473        let error = remote
474            .review_request_creation_url("review/custom-branch", "main")
475            .expect_err("invalid web URL should be rejected");
476
477        // Assert
478        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}