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
9/// Shared forge family enum reused by persistence and forge adapters.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum ForgeKind {
12    /// GitHub-hosted pull requests.
13    GitHub,
14    /// GitLab-hosted merge requests.
15    GitLab,
16}
17
18impl ForgeKind {
19    /// Returns the user-facing forge name.
20    pub fn display_name(self) -> &'static str {
21        match self {
22            Self::GitHub => "GitHub",
23            Self::GitLab => "GitLab",
24        }
25    }
26
27    /// Returns the CLI executable name used for this forge.
28    pub fn cli_name(self) -> &'static str {
29        match self {
30            Self::GitHub => "gh",
31            Self::GitLab => "glab",
32        }
33    }
34
35    /// Returns the login command users should run to authorize forge access.
36    pub fn auth_login_command(self) -> &'static str {
37        match self {
38            Self::GitHub => "gh auth login",
39            Self::GitLab => "glab auth login",
40        }
41    }
42
43    /// Returns the persisted string representation for this forge kind.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::GitHub => "GitHub",
47            Self::GitLab => "GitLab",
48        }
49    }
50}
51
52impl fmt::Display for ForgeKind {
53    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54        formatter.write_str(self.as_str())
55    }
56}
57
58impl FromStr for ForgeKind {
59    type Err = String;
60
61    fn from_str(value: &str) -> Result<Self, Self::Err> {
62        match value {
63            "GitHub" => Ok(Self::GitHub),
64            "GitLab" => Ok(Self::GitLab),
65            _ => Err(format!("Unknown review-request forge: {value}")),
66        }
67    }
68}
69
70/// Normalized remote lifecycle state for one linked review request.
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum ReviewRequestState {
73    /// The linked review request is still open.
74    Open,
75    /// The linked review request was merged upstream.
76    Merged,
77    /// The linked review request was closed without merge.
78    Closed,
79}
80
81impl ReviewRequestState {
82    /// Returns the persisted string representation for this remote state.
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Self::Open => "Open",
86            Self::Merged => "Merged",
87            Self::Closed => "Closed",
88        }
89    }
90}
91
92impl fmt::Display for ReviewRequestState {
93    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        formatter.write_str(self.as_str())
95    }
96}
97
98impl FromStr for ReviewRequestState {
99    type Err = String;
100
101    fn from_str(value: &str) -> Result<Self, Self::Err> {
102        match value {
103            "Open" => Ok(Self::Open),
104            "Merged" => Ok(Self::Merged),
105            "Closed" => Ok(Self::Closed),
106            _ => Err(format!("Unknown review-request state: {value}")),
107        }
108    }
109}
110
111/// Normalized remote summary for one linked review request.
112///
113/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
114/// `Canceled` retain this metadata so the session can continue to reference the
115/// same remote review request. Remote terminal outcomes are stored in
116/// `state` instead of clearing the link; only an explicit unlink action or
117/// session deletion should remove this metadata.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct ReviewRequestSummary {
120    /// Provider display id such as GitHub `#123` or GitLab `!42`.
121    pub display_id: String,
122    /// Forge family that owns the linked review request.
123    pub forge_kind: ForgeKind,
124    /// Source branch published for review.
125    pub source_branch: String,
126    /// Latest normalized remote lifecycle state.
127    pub state: ReviewRequestState,
128    /// Provider-specific condensed status text for UI display.
129    pub status_summary: Option<String>,
130    /// Target branch receiving the review request.
131    pub target_branch: String,
132    /// Remote review-request title.
133    pub title: String,
134    /// Browser-openable review-request URL.
135    pub web_url: String,
136}
137
138/// Boxed async result used by review-request trait methods.
139pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
140
141/// Normalized repository remote metadata for one supported forge.
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct ForgeRemote {
144    /// Forge family inferred from the repository remote.
145    pub forge_kind: ForgeKind,
146    /// Forge hostname used for browser and API calls.
147    ///
148    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
149    /// are stripped during remote normalization.
150    pub host: String,
151    /// Repository namespace or owner path.
152    pub namespace: String,
153    /// Repository name without a trailing `.git` suffix.
154    pub project: String,
155    /// Original remote URL returned by git.
156    pub repo_url: String,
157    /// Browser-openable repository URL derived from the remote.
158    pub web_url: String,
159}
160
161impl ForgeRemote {
162    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
163    pub fn project_path(&self) -> String {
164        format!("{}/{}", self.namespace, self.project)
165    }
166}
167
168/// Input required to create a review request on one forge.
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct CreateReviewRequestInput {
171    /// Optional body or description submitted with the review request.
172    pub body: Option<String>,
173    /// Source branch that should be reviewed.
174    pub source_branch: String,
175    /// Target branch that receives the review request.
176    pub target_branch: String,
177    /// Title shown in the forge review-request UI.
178    pub title: String,
179}
180
181/// Review-request failures normalized for actionable UI messaging.
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub enum ReviewRequestError {
184    /// The required forge CLI is not available on the user's machine.
185    CliNotInstalled { forge_kind: ForgeKind },
186    /// The forge CLI is installed but not authorized for the target host.
187    AuthenticationRequired {
188        /// Forge family that reported the authentication failure.
189        forge_kind: ForgeKind,
190        /// Forge host the CLI attempted to access.
191        host: String,
192        /// Original CLI error detail captured from stdout or stderr.
193        detail: Option<String>,
194    },
195    /// The forge host from the repository remote could not be resolved.
196    HostResolutionFailed { forge_kind: ForgeKind, host: String },
197    /// The repository remote does not map to a supported forge.
198    UnsupportedRemote { repo_url: String },
199    /// A forge CLI command ran but failed.
200    OperationFailed {
201        forge_kind: ForgeKind,
202        message: String,
203    },
204}
205
206impl ReviewRequestError {
207    /// Returns actionable user-facing copy for the failure.
208    pub fn detail_message(&self) -> String {
209        match self {
210            Self::CliNotInstalled { forge_kind } => format!(
211                "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
212                forge_kind.display_name(),
213                forge_kind.cli_name(),
214                forge_kind.cli_name(),
215                forge_kind.auth_login_command(),
216            ),
217            Self::AuthenticationRequired {
218                forge_kind,
219                host,
220                detail,
221            } => authentication_required_message(*forge_kind, host, detail.as_deref()),
222            Self::HostResolutionFailed { forge_kind, host } => format!(
223                "{} review requests could not reach `{host}`.\nCheck the repository remote host \
224                 and your network or DNS setup, then retry.",
225                forge_kind.display_name(),
226            ),
227            Self::UnsupportedRemote { repo_url } => format!(
228                "Review requests are only supported for GitHub and GitLab remotes.\nThis \
229                 repository remote is not supported: `{repo_url}`."
230            ),
231            Self::OperationFailed {
232                forge_kind,
233                message,
234            } => format!(
235                "{} review-request operation failed: {message}",
236                forge_kind.display_name()
237            ),
238        }
239    }
240}
241
242/// Returns actionable copy for one CLI authentication failure and preserves
243/// the original CLI output when it is available.
244fn authentication_required_message(
245    forge_kind: ForgeKind,
246    host: &str,
247    detail: Option<&str>,
248) -> String {
249    let mut message = format!(
250        "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
251        forge_kind.display_name(),
252        forge_kind.auth_login_command(),
253    );
254
255    if let Some(detail) = non_empty_detail(detail) {
256        let _ = write!(
257            message,
258            "\n\nOriginal `{}` error:\n```text\n{detail}",
259            forge_kind.cli_name(),
260        );
261        if !detail.ends_with('\n') {
262            message.push('\n');
263        }
264        message.push_str("```");
265    }
266
267    message
268}
269
270/// Returns one trimmed CLI error detail when the captured output is not empty.
271fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
272    detail.and_then(|detail| {
273        let trimmed_detail = detail.trim();
274        (!trimmed_detail.is_empty()).then_some(trimmed_detail)
275    })
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn authentication_required_message_includes_original_cli_error_detail() {
284        // Arrange
285        let error = ReviewRequestError::AuthenticationRequired {
286            detail: Some("HTTP 401 Unauthorized. Run `glab auth login`.".to_string()),
287            forge_kind: ForgeKind::GitLab,
288            host: "gitlab.example.com".to_string(),
289        };
290
291        // Act
292        let message = error.detail_message();
293
294        // Assert
295        assert!(message.contains("GitLab review requests require local CLI authentication"));
296        assert!(message.contains("Run `glab auth login` and retry."));
297        assert!(message.contains("Original `glab` error:"));
298        assert!(message.contains("HTTP 401 Unauthorized. Run `glab auth login`."));
299        assert!(message.contains("```text"));
300    }
301
302    #[test]
303    fn authentication_required_message_omits_empty_original_cli_error_detail() {
304        // Arrange
305        let error = ReviewRequestError::AuthenticationRequired {
306            detail: Some("   \n".to_string()),
307            forge_kind: ForgeKind::GitHub,
308            host: "github.com".to_string(),
309        };
310
311        // Act
312        let message = error.detail_message();
313
314        // Assert
315        assert!(message.contains("Run `gh auth login` and retry."));
316        assert!(!message.contains("Original `gh` error:"));
317    }
318}