1use 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
12pub const AGENTTY_REVIEW_REPLY_MARKER_PREFIX: &str = "<!-- agentty review resolution:";
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum ForgeKind {
19 GitHub,
21 GitLab,
23}
24
25impl ForgeKind {
26 pub fn display_name(self) -> &'static str {
28 match self {
29 Self::GitHub => "GitHub",
30 Self::GitLab => "GitLab",
31 }
32 }
33
34 pub fn cli_name(self) -> &'static str {
36 match self {
37 Self::GitHub => "gh",
38 Self::GitLab => "glab",
39 }
40 }
41
42 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 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::GitHub => "GitHub",
54 Self::GitLab => "GitLab",
55 }
56 }
57
58 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 pub fn review_request_display_name(self) -> String {
68 format!("{} {}", self.display_name(), self.review_request_name())
69 }
70
71 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
98pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub enum ReviewRequestState {
109 Open,
111 Merged,
113 Closed,
115}
116
117impl ReviewRequestState {
118 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#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct ReviewRequestSummary {
156 pub display_id: String,
158 pub forge_kind: ForgeKind,
160 pub source_branch: String,
162 pub state: ReviewRequestState,
164 pub status_summary: Option<String>,
166 pub target_branch: String,
168 pub title: String,
170 pub web_url: String,
172}
173
174pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
176
177#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct ForgeRemote {
180 pub command_working_directory: Option<PathBuf>,
183 pub forge_kind: ForgeKind,
185 pub host: String,
190 pub namespace: String,
192 pub project: String,
194 pub repo_url: String,
196 pub web_url: String,
198}
199
200impl ForgeRemote {
201 #[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 pub fn project_path(&self) -> String {
212 format!("{}/{}", self.namespace, self.project)
213 }
214
215 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 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 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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
309pub struct ReviewComment {
310 pub author: String,
312 pub authored_by_current_user: bool,
315 pub body: String,
317}
318
319impl ReviewComment {
320 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 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
354pub enum ReviewCommentAnchorSide {
355 File,
357 New,
359 Old,
361}
362
363#[derive(Clone, Debug, Eq, PartialEq)]
370pub struct ReviewCommentThread {
371 pub anchor_side: ReviewCommentAnchorSide,
373 pub comments: Vec<ReviewComment>,
375 pub id: String,
377 pub is_outdated: Option<bool>,
380 pub is_resolved: bool,
382 pub line: Option<u32>,
384 pub path: String,
386 pub start_line: Option<u32>,
388}
389
390impl ReviewCommentThread {
391 pub fn is_actionable(&self) -> bool {
398 !self.is_resolved && !self.is_addressed_by_agentty()
399 }
400
401 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
419pub struct ReviewCommentSnapshot {
420 pub pr_level_comments: Vec<ReviewComment>,
423 pub threads: Vec<ReviewCommentThread>,
425}
426
427#[derive(Clone, Debug, Eq, PartialEq)]
429pub struct CreateReviewRequestInput {
430 pub body: Option<String>,
432 pub source_branch: String,
434 pub target_branch: String,
436 pub title: String,
438}
439
440#[derive(Clone, Debug, Eq, PartialEq)]
442pub struct ReviewRequestMetadata {
443 pub body: String,
445 pub title: String,
447}
448
449#[derive(Clone, Debug, Eq, PartialEq)]
452pub struct ReviewRequestMetadataFieldUpdate {
453 pub current: String,
455 pub desired: String,
457}
458
459#[derive(Clone, Debug, Eq, PartialEq)]
461pub struct UpdateReviewRequestInput {
462 pub body: Option<ReviewRequestMetadataFieldUpdate>,
464 pub title: Option<ReviewRequestMetadataFieldUpdate>,
466}
467
468#[derive(Clone, Debug, Eq, PartialEq)]
470pub enum ReviewRequestError {
471 CliNotInstalled {
473 forge_kind: ForgeKind,
475 },
476 AuthenticationRequired {
478 forge_kind: ForgeKind,
480 host: String,
482 detail: Option<String>,
484 },
485 HostResolutionFailed {
487 forge_kind: ForgeKind,
489 host: String,
491 },
492 UnsupportedRemote {
494 repo_url: String,
496 },
497 OperationFailed {
499 forge_kind: ForgeKind,
501 message: String,
503 },
504}
505
506impl ReviewRequestError {
507 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 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 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 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;