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    /// Returns whether Agentty can fetch inline review-thread comments for
76    /// this forge.
77    ///
78    /// GitLab merge-request comment sync is not implemented yet, so callers
79    /// must skip comment-thread fetches and the read-only comments preview
80    /// for non-GitHub forges until a native adapter lands.
81    pub fn supports_review_comments_preview(self) -> bool {
82        match self {
83            Self::GitHub => true,
84            Self::GitLab => false,
85        }
86    }
87}
88
89/// Returns whether `host` looks like one GitLab instance hostname.
90pub fn is_gitlab_host(host: &str) -> bool {
91    host == "gitlab.com"
92        || host.ends_with(".gitlab.com")
93        || host.starts_with("gitlab.")
94        || host.contains(".gitlab.")
95}
96
97impl fmt::Display for ForgeKind {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        formatter.write_str(self.as_str())
100    }
101}
102
103impl FromStr for ForgeKind {
104    type Err = String;
105
106    fn from_str(value: &str) -> Result<Self, Self::Err> {
107        match value {
108            "GitHub" => Ok(Self::GitHub),
109            "GitLab" => Ok(Self::GitLab),
110            _ => Err(format!("Unknown review-request forge: {value}")),
111        }
112    }
113}
114
115/// Normalized remote lifecycle state for one linked review request.
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum ReviewRequestState {
118    /// The linked review request is still open.
119    Open,
120    /// The linked review request was merged upstream.
121    Merged,
122    /// The linked review request was closed without merge.
123    Closed,
124}
125
126impl ReviewRequestState {
127    /// Returns the persisted string representation for this remote state.
128    pub fn as_str(self) -> &'static str {
129        match self {
130            Self::Open => "Open",
131            Self::Merged => "Merged",
132            Self::Closed => "Closed",
133        }
134    }
135}
136
137impl fmt::Display for ReviewRequestState {
138    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139        formatter.write_str(self.as_str())
140    }
141}
142
143impl FromStr for ReviewRequestState {
144    type Err = String;
145
146    fn from_str(value: &str) -> Result<Self, Self::Err> {
147        match value {
148            "Open" => Ok(Self::Open),
149            "Merged" => Ok(Self::Merged),
150            "Closed" => Ok(Self::Closed),
151            _ => Err(format!("Unknown review-request state: {value}")),
152        }
153    }
154}
155
156/// Normalized remote summary for one linked review request.
157///
158/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
159/// `Canceled` retain this metadata so the session can continue to reference the
160/// same remote review request. Remote terminal outcomes are stored in
161/// `state` instead of clearing the link; only an explicit unlink action or
162/// session deletion should remove this metadata.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct ReviewRequestSummary {
165    /// Provider display id such as GitHub `#123`.
166    pub display_id: String,
167    /// Forge family that owns the linked review request.
168    pub forge_kind: ForgeKind,
169    /// Source branch published for review.
170    pub source_branch: String,
171    /// Latest normalized remote lifecycle state.
172    pub state: ReviewRequestState,
173    /// Provider-specific condensed status text for UI display.
174    pub status_summary: Option<String>,
175    /// Target branch receiving the review request.
176    pub target_branch: String,
177    /// Remote review-request title.
178    pub title: String,
179    /// Browser-openable review-request URL.
180    pub web_url: String,
181}
182
183/// Boxed async result used by review-request trait methods.
184pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
185
186/// Normalized repository remote metadata for one supported forge.
187#[derive(Clone, Debug, Eq, PartialEq)]
188pub struct ForgeRemote {
189    /// Repository worktree used when forge CLI commands need local git
190    /// context.
191    pub command_working_directory: Option<PathBuf>,
192    /// Forge family inferred from the repository remote.
193    pub forge_kind: ForgeKind,
194    /// Forge hostname used for browser and API calls.
195    ///
196    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
197    /// are stripped during remote normalization.
198    pub host: String,
199    /// Repository namespace or owner path.
200    pub namespace: String,
201    /// Repository name without a trailing `.git` suffix.
202    pub project: String,
203    /// Original remote URL returned by git.
204    pub repo_url: String,
205    /// Browser-openable repository URL derived from the remote.
206    pub web_url: String,
207}
208
209impl ForgeRemote {
210    /// Returns one remote copy that runs forge CLI commands from
211    /// `working_directory`.
212    #[must_use]
213    pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
214        self.command_working_directory = Some(working_directory);
215
216        self
217    }
218
219    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
220    pub fn project_path(&self) -> String {
221        format!("{}/{}", self.namespace, self.project)
222    }
223
224    /// Returns the browser-openable URL that starts one new pull request or
225    /// review request for `source_branch` into `target_branch`.
226    ///
227    /// # Errors
228    /// Returns [`ReviewRequestError::OperationFailed`] when the stored
229    /// repository web URL is invalid or cannot be converted into a forge
230    /// review-request creation URL.
231    pub fn review_request_creation_url(
232        &self,
233        source_branch: &str,
234        target_branch: &str,
235    ) -> Result<String, ReviewRequestError> {
236        match self.forge_kind {
237            ForgeKind::GitHub => {
238                github_review_request_creation_url(self, source_branch, target_branch)
239            }
240            ForgeKind::GitLab => {
241                gitlab_review_request_creation_url(self, source_branch, target_branch)
242            }
243        }
244    }
245}
246
247/// One inline review comment emitted by a reviewer on a forge review thread.
248#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct ReviewComment {
250    /// Reviewer login or display name.
251    pub author: String,
252    /// Markdown body as authored by the reviewer.
253    pub body: String,
254}
255
256/// One review thread anchored to a line of the review request diff.
257///
258/// Threads group chronological `comments` that share the same anchor. v1 of
259/// Agentty's comments preview renders these read-only, grouped by file and
260/// sorted by `(path, line)` before display.
261#[derive(Clone, Debug, Eq, PartialEq)]
262pub struct ReviewCommentThread {
263    /// Chronological reviewer comments attached to this thread.
264    pub comments: Vec<ReviewComment>,
265    /// Whether the thread has been marked resolved on the forge.
266    pub is_resolved: bool,
267    /// Anchor line number inside the diff, when the forge exposes one.
268    pub line: Option<u32>,
269    /// File path the thread is anchored to, relative to the repository root.
270    pub path: String,
271}
272
273/// Full review-comments payload captured for one review request.
274///
275/// Separates forge-native `threads` (anchored to a file + line) from
276/// `pr_level_comments` (review-request-wide discussion comments that do not
277/// anchor to the diff). The UI renders the two categories side-by-side with a
278/// synthetic "General discussion" entry on top of the comments file tree.
279#[derive(Clone, Debug, Default, Eq, PartialEq)]
280pub struct ReviewCommentSnapshot {
281    /// Chronological review-request-wide comments that do not anchor to a file
282    /// or line.
283    pub pr_level_comments: Vec<ReviewComment>,
284    /// Inline threads grouped by the file and line they are anchored to.
285    pub threads: Vec<ReviewCommentThread>,
286}
287
288/// Input required to create a review request on one forge.
289#[derive(Clone, Debug, Eq, PartialEq)]
290pub struct CreateReviewRequestInput {
291    /// Optional body or description submitted with the review request.
292    pub body: Option<String>,
293    /// Source branch that should be reviewed.
294    pub source_branch: String,
295    /// Target branch that receives the review request.
296    pub target_branch: String,
297    /// Title shown in the forge review-request UI.
298    pub title: String,
299}
300
301/// Review-request failures normalized for actionable UI messaging.
302#[derive(Clone, Debug, Eq, PartialEq)]
303pub enum ReviewRequestError {
304    /// The required forge CLI is not available on the user's machine.
305    CliNotInstalled { forge_kind: ForgeKind },
306    /// The forge CLI is installed but not authorized for the target host.
307    AuthenticationRequired {
308        /// Forge family that reported the authentication failure.
309        forge_kind: ForgeKind,
310        /// Forge host the CLI attempted to access.
311        host: String,
312        /// Original CLI error detail captured from stdout or stderr.
313        detail: Option<String>,
314    },
315    /// The forge host from the repository remote could not be resolved.
316    HostResolutionFailed { forge_kind: ForgeKind, host: String },
317    /// The repository remote does not map to a supported forge.
318    UnsupportedRemote { repo_url: String },
319    /// A forge CLI command ran but failed.
320    OperationFailed {
321        forge_kind: ForgeKind,
322        message: String,
323    },
324}
325
326impl ReviewRequestError {
327    /// Returns actionable user-facing copy for the failure.
328    pub fn detail_message(&self) -> String {
329        match self {
330            Self::CliNotInstalled { forge_kind } => format!(
331                "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
332                forge_kind.display_name(),
333                forge_kind.cli_name(),
334                forge_kind.cli_name(),
335                forge_kind.auth_login_command(),
336            ),
337            Self::AuthenticationRequired {
338                forge_kind,
339                host,
340                detail,
341            } => authentication_required_message(*forge_kind, host, detail.as_deref()),
342            Self::HostResolutionFailed { forge_kind, host } => format!(
343                "{} review requests could not reach `{host}`.\nCheck the repository remote host \
344                 and your network or DNS setup, then retry.",
345                forge_kind.display_name(),
346            ),
347            Self::UnsupportedRemote { repo_url } => format!(
348                "Review requests are only supported for GitHub and GitLab remotes.\nThis \
349                 repository remote is not supported: `{repo_url}`."
350            ),
351            Self::OperationFailed {
352                forge_kind,
353                message,
354            } => format!(
355                "{} review-request operation failed: {message}",
356                forge_kind.display_name()
357            ),
358        }
359    }
360}
361
362/// Returns actionable copy for one CLI authentication failure and preserves
363/// the original CLI output when it is available.
364fn authentication_required_message(
365    forge_kind: ForgeKind,
366    host: &str,
367    detail: Option<&str>,
368) -> String {
369    let mut message = format!(
370        "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
371        forge_kind.display_name(),
372        forge_kind.auth_login_command(),
373    );
374
375    if let Some(detail) = non_empty_detail(detail) {
376        // Infallible: writing to a String cannot fail.
377        let _ = write!(
378            message,
379            "\n\nOriginal `{}` error:\n```text\n{detail}",
380            forge_kind.cli_name(),
381        );
382        if !detail.ends_with('\n') {
383            message.push('\n');
384        }
385        message.push_str("```");
386    }
387
388    message
389}
390
391/// Returns one trimmed CLI error detail when the captured output is not empty.
392fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
393    detail.and_then(|detail| {
394        let trimmed_detail = detail.trim();
395        (!trimmed_detail.is_empty()).then_some(trimmed_detail)
396    })
397}
398
399/// Builds one GitHub compare URL that opens the new pull-request flow.
400fn github_review_request_creation_url(
401    remote: &ForgeRemote,
402    source_branch: &str,
403    target_branch: &str,
404) -> Result<String, ReviewRequestError> {
405    let mut url = parsed_remote_web_url(remote)?;
406    let compare_target = if target_branch.trim().is_empty() {
407        source_branch.to_string()
408    } else {
409        format!("{target_branch}...{source_branch}")
410    };
411
412    {
413        let mut path_segments = url
414            .path_segments_mut()
415            .map_err(|()| invalid_web_url_error(remote))?;
416        path_segments.pop_if_empty();
417        path_segments.push("compare");
418        path_segments.push(&compare_target);
419    }
420
421    url.query_pairs_mut().append_pair("expand", "1");
422
423    Ok(url.into())
424}
425
426/// Builds one GitLab URL that opens the new merge-request flow.
427fn gitlab_review_request_creation_url(
428    remote: &ForgeRemote,
429    source_branch: &str,
430    target_branch: &str,
431) -> Result<String, ReviewRequestError> {
432    let mut url = parsed_remote_web_url(remote)?;
433
434    {
435        let mut path_segments = url
436            .path_segments_mut()
437            .map_err(|()| invalid_web_url_error(remote))?;
438        path_segments.pop_if_empty();
439        path_segments.push("-");
440        path_segments.push("merge_requests");
441        path_segments.push("new");
442    }
443
444    url.query_pairs_mut()
445        .append_pair("merge_request[source_branch]", source_branch)
446        .append_pair("merge_request[target_branch]", target_branch);
447
448    Ok(url.into())
449}
450
451/// Parses the stored repository web URL for one forge remote.
452fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
453    Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
454}
455
456/// Returns one normalized invalid-remote-url error for review-request links.
457fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
458    ReviewRequestError::OperationFailed {
459        forge_kind: remote.forge_kind,
460        message: format!(
461            "repository remote is missing a valid web URL: `{}`",
462            remote.web_url
463        ),
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn authentication_required_message_includes_original_cli_error_detail() {
473        // Arrange
474        let error = ReviewRequestError::AuthenticationRequired {
475            detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
476            forge_kind: ForgeKind::GitHub,
477            host: "github.com".to_string(),
478        };
479
480        // Act
481        let message = error.detail_message();
482
483        // Assert
484        assert!(message.contains("GitHub review requests require local CLI authentication"));
485        assert!(message.contains("Run `gh auth login` and retry."));
486        assert!(message.contains("Original `gh` error:"));
487        assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
488        assert!(message.contains("```text"));
489    }
490
491    #[test]
492    fn authentication_required_message_omits_empty_original_cli_error_detail() {
493        // Arrange
494        let error = ReviewRequestError::AuthenticationRequired {
495            detail: Some("   \n".to_string()),
496            forge_kind: ForgeKind::GitHub,
497            host: "github.com".to_string(),
498        };
499
500        // Act
501        let message = error.detail_message();
502
503        // Assert
504        assert!(message.contains("Run `gh auth login` and retry."));
505        assert!(!message.contains("Original `gh` error:"));
506    }
507
508    #[test]
509    fn review_request_creation_url_returns_github_compare_link() {
510        // Arrange
511        let remote = ForgeRemote {
512            command_working_directory: None,
513            forge_kind: ForgeKind::GitHub,
514            host: "github.com".to_string(),
515            namespace: "agentty-xyz".to_string(),
516            project: "agentty".to_string(),
517            repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
518            web_url: "https://github.com/agentty-xyz/agentty".to_string(),
519        };
520
521        // Act
522        let url = remote
523            .review_request_creation_url("review/custom-branch", "main")
524            .expect("github compare URL should be created");
525
526        // Assert
527        assert_eq!(
528            url,
529            "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
530        );
531    }
532
533    #[test]
534    fn review_request_creation_url_rejects_invalid_web_url() {
535        // Arrange
536        let remote = ForgeRemote {
537            command_working_directory: None,
538            forge_kind: ForgeKind::GitHub,
539            host: "github.com".to_string(),
540            namespace: "agentty-xyz".to_string(),
541            project: "agentty".to_string(),
542            repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
543            web_url: "not a url".to_string(),
544        };
545
546        // Act
547        let error = remote
548            .review_request_creation_url("review/custom-branch", "main")
549            .expect_err("invalid web URL should be rejected");
550
551        // Assert
552        assert_eq!(
553            error,
554            ReviewRequestError::OperationFailed {
555                forge_kind: ForgeKind::GitHub,
556                message: "repository remote is missing a valid web URL: `not a url`".to_string(),
557            }
558        );
559    }
560
561    #[test]
562    fn forge_kind_from_str_gitlab() {
563        // Arrange
564        let raw_forge_kind = "GitLab";
565
566        // Act
567        let forge_kind = raw_forge_kind
568            .parse::<ForgeKind>()
569            .expect("gitlab forge kind should parse");
570
571        // Assert
572        assert_eq!(forge_kind, ForgeKind::GitLab);
573        assert_eq!(forge_kind.cli_name(), "glab");
574        assert_eq!(forge_kind.review_request_name(), "merge request");
575        assert_eq!(forge_kind.review_request_short_name(), "MR");
576    }
577
578    #[test]
579    fn supports_review_comments_preview_only_returns_true_for_github() {
580        // Arrange / Act / Assert
581        assert!(ForgeKind::GitHub.supports_review_comments_preview());
582        assert!(!ForgeKind::GitLab.supports_review_comments_preview());
583    }
584
585    #[test]
586    fn review_request_creation_url_returns_gitlab_merge_request_link() {
587        // Arrange
588        let remote = ForgeRemote {
589            command_working_directory: None,
590            forge_kind: ForgeKind::GitLab,
591            host: "gitlab.com".to_string(),
592            namespace: "agentty-xyz".to_string(),
593            project: "agentty".to_string(),
594            repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
595            web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
596        };
597
598        // Act
599        let url = remote
600            .review_request_creation_url("review/custom-branch", "main")
601            .expect("gitlab merge-request URL should be created");
602
603        // Assert
604        assert_eq!(
605            url,
606            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
607        );
608    }
609}