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