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