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}
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        }
24    }
25
26    /// Returns the CLI executable name used for this forge.
27    pub fn cli_name(self) -> &'static str {
28        match self {
29            Self::GitHub => "gh",
30        }
31    }
32
33    /// Returns the login command users should run to authorize forge access.
34    pub fn auth_login_command(self) -> &'static str {
35        match self {
36            Self::GitHub => "gh auth login",
37        }
38    }
39
40    /// Returns the persisted string representation for this forge kind.
41    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/// Normalized remote lifecycle state for one linked review request.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ReviewRequestState {
68    /// The linked review request is still open.
69    Open,
70    /// The linked review request was merged upstream.
71    Merged,
72    /// The linked review request was closed without merge.
73    Closed,
74}
75
76impl ReviewRequestState {
77    /// Returns the persisted string representation for this remote state.
78    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/// Normalized remote summary for one linked review request.
107///
108/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
109/// `Canceled` retain this metadata so the session can continue to reference the
110/// same remote review request. Remote terminal outcomes are stored in
111/// `state` instead of clearing the link; only an explicit unlink action or
112/// session deletion should remove this metadata.
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct ReviewRequestSummary {
115    /// Provider display id such as GitHub `#123`.
116    pub display_id: String,
117    /// Forge family that owns the linked review request.
118    pub forge_kind: ForgeKind,
119    /// Source branch published for review.
120    pub source_branch: String,
121    /// Latest normalized remote lifecycle state.
122    pub state: ReviewRequestState,
123    /// Provider-specific condensed status text for UI display.
124    pub status_summary: Option<String>,
125    /// Target branch receiving the review request.
126    pub target_branch: String,
127    /// Remote review-request title.
128    pub title: String,
129    /// Browser-openable review-request URL.
130    pub web_url: String,
131}
132
133/// Boxed async result used by review-request trait methods.
134pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
135
136/// Normalized repository remote metadata for one supported forge.
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct ForgeRemote {
139    /// Forge family inferred from the repository remote.
140    pub forge_kind: ForgeKind,
141    /// Forge hostname used for browser and API calls.
142    ///
143    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
144    /// are stripped during remote normalization.
145    pub host: String,
146    /// Repository namespace or owner path.
147    pub namespace: String,
148    /// Repository name without a trailing `.git` suffix.
149    pub project: String,
150    /// Original remote URL returned by git.
151    pub repo_url: String,
152    /// Browser-openable repository URL derived from the remote.
153    pub web_url: String,
154}
155
156impl ForgeRemote {
157    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
158    pub fn project_path(&self) -> String {
159        format!("{}/{}", self.namespace, self.project)
160    }
161
162    /// Returns the browser-openable URL that starts one new pull request or
163    /// review request for `source_branch` into `target_branch`.
164    ///
165    /// # Errors
166    /// Returns [`ReviewRequestError::OperationFailed`] when the stored
167    /// repository web URL is invalid or cannot be converted into a forge
168    /// review-request creation URL.
169    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/// Input required to create a review request on one forge.
183#[derive(Clone, Debug, Eq, PartialEq)]
184pub struct CreateReviewRequestInput {
185    /// Optional body or description submitted with the review request.
186    pub body: Option<String>,
187    /// Source branch that should be reviewed.
188    pub source_branch: String,
189    /// Target branch that receives the review request.
190    pub target_branch: String,
191    /// Title shown in the forge review-request UI.
192    pub title: String,
193}
194
195/// Review-request failures normalized for actionable UI messaging.
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub enum ReviewRequestError {
198    /// The required forge CLI is not available on the user's machine.
199    CliNotInstalled { forge_kind: ForgeKind },
200    /// The forge CLI is installed but not authorized for the target host.
201    AuthenticationRequired {
202        /// Forge family that reported the authentication failure.
203        forge_kind: ForgeKind,
204        /// Forge host the CLI attempted to access.
205        host: String,
206        /// Original CLI error detail captured from stdout or stderr.
207        detail: Option<String>,
208    },
209    /// The forge host from the repository remote could not be resolved.
210    HostResolutionFailed { forge_kind: ForgeKind, host: String },
211    /// The repository remote does not map to a supported forge.
212    UnsupportedRemote { repo_url: String },
213    /// A forge CLI command ran but failed.
214    OperationFailed {
215        forge_kind: ForgeKind,
216        message: String,
217    },
218}
219
220impl ReviewRequestError {
221    /// Returns actionable user-facing copy for the failure.
222    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
256/// Returns actionable copy for one CLI authentication failure and preserves
257/// the original CLI output when it is available.
258fn 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        // Infallible: writing to a String cannot fail.
271        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
285/// Returns one trimmed CLI error detail when the captured output is not empty.
286fn 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
293/// Builds one GitHub compare URL that opens the new pull-request flow.
294fn 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
320/// Parses the stored repository web URL for one forge remote.
321fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
322    Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
323}
324
325/// Returns one normalized invalid-remote-url error for review-request links.
326fn 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        // Arrange
343        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        // Act
350        let message = error.detail_message();
351
352        // Assert
353        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        // Arrange
363        let error = ReviewRequestError::AuthenticationRequired {
364            detail: Some("   \n".to_string()),
365            forge_kind: ForgeKind::GitHub,
366            host: "github.com".to_string(),
367        };
368
369        // Act
370        let message = error.detail_message();
371
372        // Assert
373        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        // Arrange
380        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        // Act
390        let url = remote
391            .review_request_creation_url("review/custom-branch", "main")
392            .expect("github compare URL should be created");
393
394        // Assert
395        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        // Arrange
404        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        // Act
414        let error = remote
415            .review_request_creation_url("review/custom-branch", "main")
416            .expect_err("invalid web URL should be rejected");
417
418        // Assert
419        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}