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/// Prefix for the per-operation identity marker Agentty appends to review
13/// thread replies.
14pub const AGENTTY_REVIEW_REPLY_MARKER_PREFIX: &str = "<!-- agentty review resolution:";
15
16/// Shared forge family enum reused by persistence and forge adapters.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum ForgeKind {
19    /// GitHub-hosted pull requests.
20    GitHub,
21    /// GitLab-hosted merge requests.
22    GitLab,
23}
24
25impl ForgeKind {
26    /// Returns the user-facing forge name.
27    pub fn display_name(self) -> &'static str {
28        match self {
29            Self::GitHub => "GitHub",
30            Self::GitLab => "GitLab",
31        }
32    }
33
34    /// Returns the CLI executable name used for this forge.
35    pub fn cli_name(self) -> &'static str {
36        match self {
37            Self::GitHub => "gh",
38            Self::GitLab => "glab",
39        }
40    }
41
42    /// Returns the login command users should run to authorize forge access.
43    pub fn auth_login_command(self) -> &'static str {
44        match self {
45            Self::GitHub => "gh auth login",
46            Self::GitLab => "glab auth login",
47        }
48    }
49
50    /// Returns the persisted string representation for this forge kind.
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::GitHub => "GitHub",
54            Self::GitLab => "GitLab",
55        }
56    }
57
58    /// Returns the forge-native review-request noun shown in user-facing copy.
59    pub fn review_request_name(self) -> &'static str {
60        match self {
61            Self::GitHub => "pull request",
62            Self::GitLab => "merge request",
63        }
64    }
65
66    /// Returns the combined forge and review-request name for user-facing copy.
67    pub fn review_request_display_name(self) -> String {
68        format!("{} {}", self.display_name(), self.review_request_name())
69    }
70
71    /// Returns the short UI indicator label for one review request.
72    pub fn review_request_short_name(self) -> &'static str {
73        match self {
74            Self::GitHub => "PR",
75            Self::GitLab => "MR",
76        }
77    }
78}
79
80impl fmt::Display for ForgeKind {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        formatter.write_str(self.as_str())
83    }
84}
85
86impl FromStr for ForgeKind {
87    type Err = String;
88
89    fn from_str(value: &str) -> Result<Self, Self::Err> {
90        match value {
91            "GitHub" => Ok(Self::GitHub),
92            "GitLab" => Ok(Self::GitLab),
93            _ => Err(format!("Unknown review-request forge: {value}")),
94        }
95    }
96}
97
98/// Returns whether `host` looks like one GitLab instance hostname.
99pub fn is_gitlab_host(host: &str) -> bool {
100    host == "gitlab.com"
101        || host.ends_with(".gitlab.com")
102        || host.starts_with("gitlab.")
103        || host.contains(".gitlab.")
104}
105
106/// Normalized remote lifecycle state for one linked review request.
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub enum ReviewRequestState {
109    /// The linked review request is still open.
110    Open,
111    /// The linked review request was merged upstream.
112    Merged,
113    /// The linked review request was closed without merge.
114    Closed,
115}
116
117impl ReviewRequestState {
118    /// Returns the persisted string representation for this remote state.
119    pub fn as_str(self) -> &'static str {
120        match self {
121            Self::Open => "Open",
122            Self::Merged => "Merged",
123            Self::Closed => "Closed",
124        }
125    }
126}
127
128impl fmt::Display for ReviewRequestState {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        formatter.write_str(self.as_str())
131    }
132}
133
134impl FromStr for ReviewRequestState {
135    type Err = String;
136
137    fn from_str(value: &str) -> Result<Self, Self::Err> {
138        match value {
139            "Open" => Ok(Self::Open),
140            "Merged" => Ok(Self::Merged),
141            "Closed" => Ok(Self::Closed),
142            _ => Err(format!("Unknown review-request state: {value}")),
143        }
144    }
145}
146
147/// Normalized remote summary for one linked review request.
148///
149/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
150/// `Canceled` retain this metadata so the session can continue to reference the
151/// same remote review request. Remote terminal outcomes are stored in
152/// `state` instead of clearing the link; only an explicit unlink action or
153/// session deletion should remove this metadata.
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct ReviewRequestSummary {
156    /// Provider display id such as GitHub `#123`.
157    pub display_id: String,
158    /// Forge family that owns the linked review request.
159    pub forge_kind: ForgeKind,
160    /// Source branch published for review.
161    pub source_branch: String,
162    /// Latest normalized remote lifecycle state.
163    pub state: ReviewRequestState,
164    /// Provider-specific condensed status text for UI display.
165    pub status_summary: Option<String>,
166    /// Target branch receiving the review request.
167    pub target_branch: String,
168    /// Remote review-request title.
169    pub title: String,
170    /// Browser-openable review-request URL.
171    pub web_url: String,
172}
173
174/// Boxed async result used by review-request trait methods.
175pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
176
177/// Normalized repository remote metadata for one supported forge.
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct ForgeRemote {
180    /// Repository worktree used when forge CLI commands need local git
181    /// context.
182    pub command_working_directory: Option<PathBuf>,
183    /// Forge family inferred from the repository remote.
184    pub forge_kind: ForgeKind,
185    /// Forge hostname used for browser and API calls.
186    ///
187    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
188    /// are stripped during remote normalization.
189    pub host: String,
190    /// Repository namespace or owner path.
191    pub namespace: String,
192    /// Repository name without a trailing `.git` suffix.
193    pub project: String,
194    /// Credential-free remote URL suitable for display and diagnostics.
195    pub repo_url: String,
196    /// Browser-openable repository URL derived from the remote.
197    pub web_url: String,
198}
199
200impl ForgeRemote {
201    /// Returns one remote copy that runs forge CLI commands from
202    /// `working_directory`.
203    #[must_use]
204    pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
205        self.command_working_directory = Some(working_directory);
206
207        self
208    }
209
210    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
211    pub fn project_path(&self) -> String {
212        format!("{}/{}", self.namespace, self.project)
213    }
214
215    /// Returns the browser-openable URL that starts one new pull request or
216    /// review request for `source_branch` into `target_branch`.
217    ///
218    /// # Errors
219    /// Returns [`ReviewRequestError::OperationFailed`] when the stored
220    /// repository web URL is invalid or cannot be converted into a forge
221    /// review-request creation URL.
222    pub fn review_request_creation_url(
223        &self,
224        source_branch: &str,
225        target_branch: &str,
226    ) -> Result<String, ReviewRequestError> {
227        match self.forge_kind {
228            ForgeKind::GitHub => {
229                Self::github_review_request_creation_url(self, source_branch, target_branch)
230            }
231            ForgeKind::GitLab => {
232                Self::gitlab_review_request_creation_url(self, source_branch, target_branch)
233            }
234        }
235    }
236
237    /// Builds one GitHub compare URL that opens the new pull-request flow.
238    fn github_review_request_creation_url(
239        remote: &ForgeRemote,
240        source_branch: &str,
241        target_branch: &str,
242    ) -> Result<String, ReviewRequestError> {
243        let mut url = Self::parsed_remote_web_url(remote)?;
244        let compare_target = if target_branch.trim().is_empty() {
245            source_branch.to_string()
246        } else {
247            format!("{target_branch}...{source_branch}")
248        };
249
250        {
251            let mut path_segments = url
252                .path_segments_mut()
253                .map_err(|()| Self::invalid_web_url_error(remote))?;
254            path_segments.pop_if_empty();
255            path_segments.push("compare");
256            path_segments.push(&compare_target);
257        }
258
259        url.query_pairs_mut().append_pair("expand", "1");
260
261        Ok(url.into())
262    }
263
264    /// Builds one GitLab URL that opens the new merge-request flow.
265    fn gitlab_review_request_creation_url(
266        remote: &ForgeRemote,
267        source_branch: &str,
268        target_branch: &str,
269    ) -> Result<String, ReviewRequestError> {
270        let mut url = Self::parsed_remote_web_url(remote)?;
271
272        {
273            let mut path_segments = url
274                .path_segments_mut()
275                .map_err(|()| Self::invalid_web_url_error(remote))?;
276            path_segments.pop_if_empty();
277            path_segments.push("-");
278            path_segments.push("merge_requests");
279            path_segments.push("new");
280        }
281
282        url.query_pairs_mut()
283            .append_pair("merge_request[source_branch]", source_branch)
284            .append_pair("merge_request[target_branch]", target_branch);
285
286        Ok(url.into())
287    }
288
289    /// Parses the stored repository web URL for one forge remote.
290    fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
291        Url::parse(&remote.web_url).map_err(|_| Self::invalid_web_url_error(remote))
292    }
293
294    /// Returns one normalized invalid-remote-url error for review-request
295    /// links.
296    fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
297        ReviewRequestError::OperationFailed {
298            forge_kind: remote.forge_kind,
299            message: format!(
300                "repository remote is missing a valid web URL: `{}`",
301                remote.web_url
302            ),
303        }
304    }
305}
306
307/// One inline review comment emitted by a reviewer on a forge review thread.
308#[derive(Clone, Debug, Eq, PartialEq)]
309pub struct ReviewComment {
310    /// Forge author login or display name.
311    pub author: String,
312    /// Whether the forge reports that the authenticated user authored this
313    /// comment.
314    pub authored_by_current_user: bool,
315    /// Markdown body as authored on the forge.
316    pub body: String,
317}
318
319impl ReviewComment {
320    /// Returns whether this comment ends with an Agentty reply marker.
321    pub fn is_agentty_reply(&self) -> bool {
322        if !self.authored_by_current_user {
323            return false;
324        }
325        let Some((reply, marker)) = self.body.rsplit_once(AGENTTY_REVIEW_REPLY_MARKER_PREFIX)
326        else {
327            return false;
328        };
329        let Some(reply_token) = marker.strip_suffix(" -->") else {
330            return false;
331        };
332
333        reply.ends_with("\n\n") && Self::is_uuid_like(reply_token)
334    }
335
336    /// Returns whether `value` has the canonical hyphenated UUID shape used for
337    /// review-reply tokens.
338    fn is_uuid_like(value: &str) -> bool {
339        const GROUP_LENGTHS: [usize; 5] = [8, 4, 4, 4, 12];
340
341        value
342            .split('-')
343            .map(str::as_bytes)
344            .zip(GROUP_LENGTHS)
345            .all(|(group, expected_length)| {
346                group.len() == expected_length && group.iter().all(u8::is_ascii_hexdigit)
347            })
348            && value.matches('-').count() == GROUP_LENGTHS.len() - 1
349    }
350}
351
352/// Diff side used to anchor one inline review-thread comment.
353#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
354pub enum ReviewCommentAnchorSide {
355    /// A file-level thread that is not attached to a specific diff line.
356    File,
357    /// A thread anchored to the new/right side of the diff.
358    New,
359    /// A thread anchored to the old/left side of the diff.
360    Old,
361}
362
363/// One review thread anchored to a line of the review request diff.
364///
365/// Threads group chronological `comments` that share the same anchor. Agentty
366/// renders these in session review-comment views, grouped by file and sorted
367/// by `(path, line)` before display. Session-linked review workflows also use
368/// the native `id` to reply and resolve a thread after its fix is pushed.
369#[derive(Clone, Debug, Eq, PartialEq)]
370pub struct ReviewCommentThread {
371    /// Diff side used with `line` when placing this thread inline.
372    pub anchor_side: ReviewCommentAnchorSide,
373    /// Chronological reviewer comments attached to this thread.
374    pub comments: Vec<ReviewComment>,
375    /// Opaque forge-native thread identifier used for replies and resolution.
376    pub id: String,
377    /// Whether newer changes made the thread's original diff position stale,
378    /// when the forge exposes that state.
379    pub is_outdated: Option<bool>,
380    /// Whether the thread has been marked resolved on the forge.
381    pub is_resolved: bool,
382    /// Anchor line number on `anchor_side`, when the forge exposes one.
383    pub line: Option<u32>,
384    /// File path the thread is anchored to, relative to the repository root.
385    pub path: String,
386    /// Optional first line for a multi-line thread on `anchor_side`.
387    pub start_line: Option<u32>,
388}
389
390impl ReviewCommentThread {
391    /// Returns whether this thread has an unaddressed reviewer message.
392    ///
393    /// Outdated threads remain actionable because their forge-native thread
394    /// identifiers survive after their original line anchors become stale. An
395    /// unresolved thread whose latest comment is an Agentty reply becomes
396    /// actionable again when a reviewer adds a follow-up comment.
397    pub fn is_actionable(&self) -> bool {
398        !self.is_resolved && !self.is_addressed_by_agentty()
399    }
400
401    /// Returns whether Agentty authored the latest comment on this unresolved
402    /// thread.
403    pub fn is_addressed_by_agentty(&self) -> bool {
404        !self.is_resolved
405            && self
406                .comments
407                .last()
408                .is_some_and(ReviewComment::is_agentty_reply)
409    }
410}
411
412/// Full review-comments payload captured for one review request.
413///
414/// Separates forge-native `threads` (anchored to a file + line) from
415/// `pr_level_comments` (review-request-wide discussion comments that do not
416/// anchor to the diff). The UI renders the two categories side-by-side with a
417/// synthetic "General discussion" entry on top of the comments file tree.
418#[derive(Clone, Debug, Default, Eq, PartialEq)]
419pub struct ReviewCommentSnapshot {
420    /// Chronological review-request-wide comments that do not anchor to a file
421    /// or line.
422    pub pr_level_comments: Vec<ReviewComment>,
423    /// Inline threads grouped by the file and line they are anchored to.
424    pub threads: Vec<ReviewCommentThread>,
425}
426
427/// Input required to create a review request on one forge.
428#[derive(Clone, Debug, Eq, PartialEq)]
429pub struct CreateReviewRequestInput {
430    /// Optional body or description submitted with the review request.
431    pub body: Option<String>,
432    /// Source branch that should be reviewed.
433    pub source_branch: String,
434    /// Target branch that receives the review request.
435    pub target_branch: String,
436    /// Title shown in the forge review-request UI.
437    pub title: String,
438}
439
440/// Current remote review-request title and description.
441#[derive(Clone, Debug, Eq, PartialEq)]
442pub struct ReviewRequestMetadata {
443    /// Current body or description.
444    pub body: String,
445    /// Current title.
446    pub title: String,
447}
448
449/// One review-request field update guarded by the remote value used during
450/// semantic reconciliation.
451#[derive(Clone, Debug, Eq, PartialEq)]
452pub struct ReviewRequestMetadataFieldUpdate {
453    /// Remote value read before semantic reconciliation.
454    pub current: String,
455    /// Reconciled value to publish if the remote field is still unchanged.
456    pub desired: String,
457}
458
459/// Input required to conditionally update reconciled review-request metadata.
460#[derive(Clone, Debug, Eq, PartialEq)]
461pub struct UpdateReviewRequestInput {
462    /// Optional description update.
463    pub body: Option<ReviewRequestMetadataFieldUpdate>,
464    /// Optional title update.
465    pub title: Option<ReviewRequestMetadataFieldUpdate>,
466}
467
468/// Review-request failures normalized for actionable UI messaging.
469#[derive(Clone, Debug, Eq, PartialEq)]
470pub enum ReviewRequestError {
471    /// The required forge CLI is not available on the user's machine.
472    CliNotInstalled {
473        /// Forge family whose CLI is unavailable.
474        forge_kind: ForgeKind,
475    },
476    /// The forge CLI is installed but not authorized for the target host.
477    AuthenticationRequired {
478        /// Forge family that reported the authentication failure.
479        forge_kind: ForgeKind,
480        /// Forge host the CLI attempted to access.
481        host: String,
482        /// Original CLI error detail captured from stdout or stderr.
483        detail: Option<String>,
484    },
485    /// The forge host from the repository remote could not be resolved.
486    HostResolutionFailed {
487        /// Forge family inferred from the repository remote.
488        forge_kind: ForgeKind,
489        /// Hostname that could not be resolved.
490        host: String,
491    },
492    /// The repository remote does not map to a supported forge.
493    UnsupportedRemote {
494        /// Repository remote URL that could not be classified.
495        repo_url: String,
496    },
497    /// A forge CLI command ran but failed.
498    OperationFailed {
499        /// Forge family whose command failed.
500        forge_kind: ForgeKind,
501        /// Original CLI failure detail.
502        message: String,
503    },
504}
505
506impl ReviewRequestError {
507    /// Returns actionable user-facing copy for the failure.
508    pub fn detail_message(&self) -> String {
509        match self {
510            Self::CliNotInstalled { forge_kind } => format!(
511                "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
512                forge_kind.display_name(),
513                forge_kind.cli_name(),
514                forge_kind.cli_name(),
515                forge_kind.auth_login_command(),
516            ),
517            Self::AuthenticationRequired {
518                forge_kind,
519                host,
520                detail,
521            } => Self::authentication_required_message(*forge_kind, host, detail.as_deref()),
522            Self::HostResolutionFailed { forge_kind, host } => format!(
523                "{} review requests could not reach `{host}`.\nCheck the repository remote host \
524                 and your network or DNS setup, then retry.",
525                forge_kind.display_name(),
526            ),
527            Self::UnsupportedRemote { repo_url } => format!(
528                "Review requests are only supported for GitHub and GitLab remotes.\nThis \
529                 repository remote is not supported: `{repo_url}`."
530            ),
531            Self::OperationFailed {
532                forge_kind,
533                message,
534            } => format!(
535                "{} review-request operation failed: {message}",
536                forge_kind.display_name()
537            ),
538        }
539    }
540
541    /// Returns actionable copy for one CLI authentication failure and preserves
542    /// the original CLI output when it is available.
543    fn authentication_required_message(
544        forge_kind: ForgeKind,
545        host: &str,
546        detail: Option<&str>,
547    ) -> String {
548        let mut message = format!(
549            "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and \
550             retry.",
551            forge_kind.display_name(),
552            forge_kind.auth_login_command(),
553        );
554
555        if let Some(detail) = Self::non_empty_detail(detail) {
556            // Infallible: writing to a String cannot fail.
557            let _ = write!(
558                message,
559                "\n\nOriginal `{}` error:\n```text\n{detail}",
560                forge_kind.cli_name(),
561            );
562            if !detail.ends_with('\n') {
563                message.push('\n');
564            }
565            message.push_str("```");
566        }
567
568        message
569    }
570
571    /// Returns one trimmed CLI error detail when the captured output is not
572    /// empty.
573    fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
574        detail.and_then(|detail| {
575            let trimmed_detail = detail.trim();
576
577            (!trimmed_detail.is_empty()).then_some(trimmed_detail)
578        })
579    }
580}
581
582#[cfg(test)]
583#[path = "model_test.rs"]
584mod tests;