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