use std::fmt;
use std::fmt::Write as _;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::str::FromStr;
use url::Url;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ForgeKind {
GitHub,
GitLab,
}
impl ForgeKind {
pub fn display_name(self) -> &'static str {
match self {
Self::GitHub => "GitHub",
Self::GitLab => "GitLab",
}
}
pub fn cli_name(self) -> &'static str {
match self {
Self::GitHub => "gh",
Self::GitLab => "glab",
}
}
pub fn auth_login_command(self) -> &'static str {
match self {
Self::GitHub => "gh auth login",
Self::GitLab => "glab auth login",
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::GitHub => "GitHub",
Self::GitLab => "GitLab",
}
}
pub fn review_request_name(self) -> &'static str {
match self {
Self::GitHub => "pull request",
Self::GitLab => "merge request",
}
}
pub fn review_request_display_name(self) -> String {
format!("{} {}", self.display_name(), self.review_request_name())
}
pub fn review_request_short_name(self) -> &'static str {
match self {
Self::GitHub => "PR",
Self::GitLab => "MR",
}
}
pub fn supports_review_comments_preview(self) -> bool {
match self {
Self::GitHub | Self::GitLab => true,
}
}
}
pub fn is_gitlab_host(host: &str) -> bool {
host == "gitlab.com"
|| host.ends_with(".gitlab.com")
|| host.starts_with("gitlab.")
|| host.contains(".gitlab.")
}
impl fmt::Display for ForgeKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ForgeKind {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"GitHub" => Ok(Self::GitHub),
"GitLab" => Ok(Self::GitLab),
_ => Err(format!("Unknown review-request forge: {value}")),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ReviewRequestState {
Open,
Merged,
Closed,
}
impl ReviewRequestState {
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "Open",
Self::Merged => "Merged",
Self::Closed => "Closed",
}
}
}
impl fmt::Display for ReviewRequestState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ReviewRequestState {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"Open" => Ok(Self::Open),
"Merged" => Ok(Self::Merged),
"Closed" => Ok(Self::Closed),
_ => Err(format!("Unknown review-request state: {value}")),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewRequestSummary {
pub display_id: String,
pub forge_kind: ForgeKind,
pub source_branch: String,
pub state: ReviewRequestState,
pub status_summary: Option<String>,
pub target_branch: String,
pub title: String,
pub web_url: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RequestedReviewAudience {
Personal,
Group,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RequestedReview {
pub audience: RequestedReviewAudience,
pub body: Option<String>,
pub comment_snapshot: Option<ReviewCommentSnapshot>,
pub display_id: String,
pub forge_kind: ForgeKind,
pub repository: String,
pub status_summary: Option<String>,
pub title: String,
pub updated_at: Option<String>,
pub web_url: String,
}
pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ForgeRemote {
pub command_working_directory: Option<PathBuf>,
pub forge_kind: ForgeKind,
pub host: String,
pub namespace: String,
pub project: String,
pub repo_url: String,
pub web_url: String,
}
impl ForgeRemote {
#[must_use]
pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
self.command_working_directory = Some(working_directory);
self
}
pub fn project_path(&self) -> String {
format!("{}/{}", self.namespace, self.project)
}
pub fn review_request_creation_url(
&self,
source_branch: &str,
target_branch: &str,
) -> Result<String, ReviewRequestError> {
match self.forge_kind {
ForgeKind::GitHub => {
github_review_request_creation_url(self, source_branch, target_branch)
}
ForgeKind::GitLab => {
gitlab_review_request_creation_url(self, source_branch, target_branch)
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewComment {
pub author: String,
pub body: String,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ReviewCommentAnchorSide {
File,
New,
Old,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewCommentThread {
pub anchor_side: ReviewCommentAnchorSide,
pub comments: Vec<ReviewComment>,
pub is_outdated: Option<bool>,
pub is_resolved: bool,
pub line: Option<u32>,
pub path: String,
pub start_line: Option<u32>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReviewCommentSnapshot {
pub pr_level_comments: Vec<ReviewComment>,
pub threads: Vec<ReviewCommentThread>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CreateReviewRequestInput {
pub body: Option<String>,
pub source_branch: String,
pub target_branch: String,
pub title: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateReviewRequestInput {
pub body: Option<String>,
pub title: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ReviewRequestError {
CliNotInstalled { forge_kind: ForgeKind },
AuthenticationRequired {
forge_kind: ForgeKind,
host: String,
detail: Option<String>,
},
HostResolutionFailed { forge_kind: ForgeKind, host: String },
UnsupportedRemote { repo_url: String },
OperationFailed {
forge_kind: ForgeKind,
message: String,
},
}
impl ReviewRequestError {
pub fn detail_message(&self) -> String {
match self {
Self::CliNotInstalled { forge_kind } => format!(
"{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
forge_kind.display_name(),
forge_kind.cli_name(),
forge_kind.cli_name(),
forge_kind.auth_login_command(),
),
Self::AuthenticationRequired {
forge_kind,
host,
detail,
} => authentication_required_message(*forge_kind, host, detail.as_deref()),
Self::HostResolutionFailed { forge_kind, host } => format!(
"{} review requests could not reach `{host}`.\nCheck the repository remote host \
and your network or DNS setup, then retry.",
forge_kind.display_name(),
),
Self::UnsupportedRemote { repo_url } => format!(
"Review requests are only supported for GitHub and GitLab remotes.\nThis \
repository remote is not supported: `{repo_url}`."
),
Self::OperationFailed {
forge_kind,
message,
} => format!(
"{} review-request operation failed: {message}",
forge_kind.display_name()
),
}
}
}
fn authentication_required_message(
forge_kind: ForgeKind,
host: &str,
detail: Option<&str>,
) -> String {
let mut message = format!(
"{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
forge_kind.display_name(),
forge_kind.auth_login_command(),
);
if let Some(detail) = non_empty_detail(detail) {
let _ = write!(
message,
"\n\nOriginal `{}` error:\n```text\n{detail}",
forge_kind.cli_name(),
);
if !detail.ends_with('\n') {
message.push('\n');
}
message.push_str("```");
}
message
}
fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
detail.and_then(|detail| {
let trimmed_detail = detail.trim();
(!trimmed_detail.is_empty()).then_some(trimmed_detail)
})
}
fn github_review_request_creation_url(
remote: &ForgeRemote,
source_branch: &str,
target_branch: &str,
) -> Result<String, ReviewRequestError> {
let mut url = parsed_remote_web_url(remote)?;
let compare_target = if target_branch.trim().is_empty() {
source_branch.to_string()
} else {
format!("{target_branch}...{source_branch}")
};
{
let mut path_segments = url
.path_segments_mut()
.map_err(|()| invalid_web_url_error(remote))?;
path_segments.pop_if_empty();
path_segments.push("compare");
path_segments.push(&compare_target);
}
url.query_pairs_mut().append_pair("expand", "1");
Ok(url.into())
}
fn gitlab_review_request_creation_url(
remote: &ForgeRemote,
source_branch: &str,
target_branch: &str,
) -> Result<String, ReviewRequestError> {
let mut url = parsed_remote_web_url(remote)?;
{
let mut path_segments = url
.path_segments_mut()
.map_err(|()| invalid_web_url_error(remote))?;
path_segments.pop_if_empty();
path_segments.push("-");
path_segments.push("merge_requests");
path_segments.push("new");
}
url.query_pairs_mut()
.append_pair("merge_request[source_branch]", source_branch)
.append_pair("merge_request[target_branch]", target_branch);
Ok(url.into())
}
fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
}
fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
ReviewRequestError::OperationFailed {
forge_kind: remote.forge_kind,
message: format!(
"repository remote is missing a valid web URL: `{}`",
remote.web_url
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authentication_required_message_includes_original_cli_error_detail() {
let error = ReviewRequestError::AuthenticationRequired {
detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
forge_kind: ForgeKind::GitHub,
host: "github.com".to_string(),
};
let message = error.detail_message();
assert!(message.contains("GitHub review requests require local CLI authentication"));
assert!(message.contains("Run `gh auth login` and retry."));
assert!(message.contains("Original `gh` error:"));
assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
assert!(message.contains("```text"));
}
#[test]
fn authentication_required_message_omits_empty_original_cli_error_detail() {
let error = ReviewRequestError::AuthenticationRequired {
detail: Some(" \n".to_string()),
forge_kind: ForgeKind::GitHub,
host: "github.com".to_string(),
};
let message = error.detail_message();
assert!(message.contains("Run `gh auth login` and retry."));
assert!(!message.contains("Original `gh` error:"));
}
#[test]
fn review_request_creation_url_returns_github_compare_link() {
let remote = ForgeRemote {
command_working_directory: None,
forge_kind: ForgeKind::GitHub,
host: "github.com".to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
web_url: "https://github.com/agentty-xyz/agentty".to_string(),
};
let url = remote
.review_request_creation_url("review/custom-branch", "main")
.expect("github compare URL should be created");
assert_eq!(
url,
"https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
);
}
#[test]
fn review_request_creation_url_rejects_invalid_web_url() {
let remote = ForgeRemote {
command_working_directory: None,
forge_kind: ForgeKind::GitHub,
host: "github.com".to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
web_url: "not a url".to_string(),
};
let error = remote
.review_request_creation_url("review/custom-branch", "main")
.expect_err("invalid web URL should be rejected");
assert_eq!(
error,
ReviewRequestError::OperationFailed {
forge_kind: ForgeKind::GitHub,
message: "repository remote is missing a valid web URL: `not a url`".to_string(),
}
);
}
#[test]
fn forge_kind_from_str_gitlab() {
let raw_forge_kind = "GitLab";
let forge_kind = raw_forge_kind
.parse::<ForgeKind>()
.expect("gitlab forge kind should parse");
assert_eq!(forge_kind, ForgeKind::GitLab);
assert_eq!(forge_kind.cli_name(), "glab");
assert_eq!(forge_kind.review_request_name(), "merge request");
assert_eq!(forge_kind.review_request_short_name(), "MR");
}
#[test]
fn supports_review_comments_preview_returns_true_for_supported_forges() {
assert!(ForgeKind::GitHub.supports_review_comments_preview());
assert!(ForgeKind::GitLab.supports_review_comments_preview());
}
#[test]
fn review_request_creation_url_returns_gitlab_merge_request_link() {
let remote = ForgeRemote {
command_working_directory: None,
forge_kind: ForgeKind::GitLab,
host: "gitlab.com".to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
};
let url = remote
.review_request_creation_url("review/custom-branch", "main")
.expect("gitlab merge-request URL should be created");
assert_eq!(
url,
"https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
);
}
}