use std::sync::Arc;
use super::{
ForgeCommand, ForgeCommandError, ForgeCommandOutput, ForgeCommandRunner, ForgeFuture,
ForgeKind, ForgeRemote, RequestedReview, ReviewCommentSnapshot, ReviewRequestError,
ReviewRequestSummary, UpdateReviewRequestInput, command_output_detail,
};
#[derive(Clone)]
pub(crate) struct ReviewRequestOperations {
command_runner: Arc<dyn ForgeCommandRunner>,
}
impl ReviewRequestOperations {
pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
Self { command_runner }
}
pub(crate) async fn ensure_authenticated(
&self,
remote: &ForgeRemote,
command: ForgeCommand,
) -> Result<(), ReviewRequestError> {
let output = self
.command_runner
.run(command)
.await
.map_err(|error| map_spawn_error(remote, error))?;
if output.success() {
return Ok(());
}
let detail = command_output_detail(&output);
if looks_like_host_resolution_failure(&detail) {
return Err(ReviewRequestError::HostResolutionFailed {
forge_kind: remote.forge_kind,
host: remote.host.clone(),
});
}
Err(ReviewRequestError::AuthenticationRequired {
detail: Some(detail),
forge_kind: remote.forge_kind,
host: remote.host.clone(),
})
}
pub(crate) fn ensure_authenticated_future(
&self,
remote: ForgeRemote,
auth_status_command: fn(&ForgeRemote) -> ForgeCommand,
) -> ForgeFuture<Result<(), ReviewRequestError>> {
let operations = self.clone();
Box::pin(async move {
operations
.ensure_authenticated(&remote, auth_status_command(&remote))
.await
})
}
pub(crate) async fn run_review_command(
&self,
remote: &ForgeRemote,
command: ForgeCommand,
operation: &str,
) -> Result<ForgeCommandOutput, ReviewRequestError> {
let output = self
.command_runner
.run(command)
.await
.map_err(|error| map_spawn_error(remote, error))?;
if output.success() {
return Ok(output);
}
let detail = command_output_detail(&output);
if looks_like_host_resolution_failure(&detail) {
return Err(ReviewRequestError::HostResolutionFailed {
forge_kind: remote.forge_kind,
host: remote.host.clone(),
});
}
if looks_like_authentication_failure(&detail, remote.forge_kind) {
return Err(ReviewRequestError::AuthenticationRequired {
detail: Some(detail),
forge_kind: remote.forge_kind,
host: remote.host.clone(),
});
}
Err(operation_failed(
remote.forge_kind,
format!("{operation}: {detail}"),
))
}
pub(crate) async fn find_by_source_branch(
&self,
remote: ForgeRemote,
lookup_command: ForgeCommand,
operation: &str,
parse_lookup_display_id: impl FnOnce(&str) -> Result<Option<String>, String>,
refresh_review_request: impl FnOnce(
ForgeRemote,
String,
) -> ForgeFuture<
Result<ReviewRequestSummary, ReviewRequestError>,
>,
) -> Result<Option<ReviewRequestSummary>, ReviewRequestError> {
let output = self
.run_review_command(&remote, lookup_command, operation)
.await?;
let display_id =
map_parse_error(remote.forge_kind, parse_lookup_display_id(&output.stdout))?;
let Some(display_id) = display_id else {
return Ok(None);
};
refresh_review_request(remote, display_id).await.map(Some)
}
pub(crate) fn find_by_source_branch_future(
&self,
remote: ForgeRemote,
source_branch: String,
lookup_command: fn(&ForgeRemote, &str) -> ForgeCommand,
operation: &'static str,
parse_lookup_display_id: fn(&str) -> Result<Option<String>, String>,
refresh_review_request: impl FnOnce(
ForgeRemote,
String,
) -> ForgeFuture<
Result<ReviewRequestSummary, ReviewRequestError>,
> + Send
+ 'static,
) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
let operations = self.clone();
Box::pin(async move {
let lookup_command = lookup_command(&remote, &source_branch);
operations
.find_by_source_branch(
remote,
lookup_command,
operation,
parse_lookup_display_id,
refresh_review_request,
)
.await
})
}
pub(crate) async fn refresh_review_request(
&self,
remote: ForgeRemote,
display_id: String,
parse_display_id: impl FnOnce(&str) -> Result<String, ReviewRequestError>,
view_command: impl FnOnce(&ForgeRemote, &str) -> ForgeCommand,
operation: &str,
parse_view_response: impl FnOnce(&str) -> Result<ReviewRequestSummary, String>,
) -> Result<ReviewRequestSummary, ReviewRequestError> {
let command_display_id = parse_display_id(&display_id)?;
let output = self
.run_review_command(
&remote,
view_command(&remote, &command_display_id),
operation,
)
.await?;
map_parse_error(remote.forge_kind, parse_view_response(&output.stdout))
}
pub(crate) fn refresh_review_request_future(
&self,
remote: ForgeRemote,
display_id: String,
parse_display_id: fn(&str) -> Result<String, ReviewRequestError>,
view_command: fn(&ForgeRemote, &str) -> ForgeCommand,
operation: &'static str,
parse_view_response: fn(&str) -> Result<ReviewRequestSummary, String>,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
let operations = self.clone();
Box::pin(async move {
operations
.refresh_review_request(
remote,
display_id,
parse_display_id,
view_command,
operation,
parse_view_response,
)
.await
})
}
pub(crate) async fn sync_review_request_metadata<Metadata>(
&self,
remote: ForgeRemote,
display_id: String,
input: UpdateReviewRequestInput,
config: SyncReviewRequestMetadataConfig<Metadata>,
refresh_review_request: impl FnOnce(
ForgeRemote,
String,
) -> ForgeFuture<
Result<ReviewRequestSummary, ReviewRequestError>,
>,
) -> Result<ReviewRequestSummary, ReviewRequestError> {
let command_display_id = (config.parse_display_id)(&display_id)?;
let output = self
.run_review_command(
&remote,
(config.view_metadata_command)(&remote, &command_display_id),
config.view_operation,
)
.await?;
let metadata = map_parse_error(
remote.forge_kind,
(config.parse_metadata_response)(&output.stdout),
)?;
if (config.requires_update)(&metadata, &input) {
self.run_review_command(
&remote,
(config.edit_metadata_command)(&remote, &command_display_id, &input),
config.edit_operation,
)
.await?;
}
refresh_review_request(remote, display_id).await
}
pub(crate) fn sync_review_request_metadata_future<Metadata: Send + 'static>(
&self,
remote: ForgeRemote,
display_id: String,
input: UpdateReviewRequestInput,
config: SyncReviewRequestMetadataConfig<Metadata>,
refresh_review_request: impl FnOnce(
ForgeRemote,
String,
) -> ForgeFuture<
Result<ReviewRequestSummary, ReviewRequestError>,
> + Send
+ 'static,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
let operations = self.clone();
Box::pin(async move {
operations
.sync_review_request_metadata(
remote,
display_id,
input,
config,
refresh_review_request,
)
.await
})
}
pub(crate) async fn fetch_review_comment_snapshot(
&self,
remote: ForgeRemote,
display_id: String,
parse_display_id: impl FnOnce(&str) -> Result<String, ReviewRequestError>,
snapshot_command: impl FnOnce(&ForgeRemote, &str) -> ForgeCommand,
operation: &str,
parse_snapshot_response: impl FnOnce(&str) -> Result<ReviewCommentSnapshot, String>,
) -> Result<ReviewCommentSnapshot, ReviewRequestError> {
let command_display_id = parse_display_id(&display_id)?;
let output = self
.run_review_command(
&remote,
snapshot_command(&remote, &command_display_id),
operation,
)
.await?;
map_parse_error(remote.forge_kind, parse_snapshot_response(&output.stdout))
}
pub(crate) fn fetch_review_comment_snapshot_future(
&self,
remote: ForgeRemote,
display_id: String,
parse_display_id: fn(&str) -> Result<String, ReviewRequestError>,
snapshot_command: fn(&ForgeRemote, &str) -> ForgeCommand,
operation: &'static str,
parse_snapshot_response: fn(&str) -> Result<ReviewCommentSnapshot, String>,
) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
let operations = self.clone();
Box::pin(async move {
operations
.fetch_review_comment_snapshot(
remote,
display_id,
parse_display_id,
snapshot_command,
operation,
parse_snapshot_response,
)
.await
})
}
pub(crate) async fn list_requested_reviews(
&self,
remote: ForgeRemote,
command: ForgeCommand,
operation: &str,
parse_requested_reviews: impl FnOnce(&str, &ForgeRemote) -> Result<Vec<RequestedReview>, String>,
) -> Result<Vec<RequestedReview>, ReviewRequestError> {
let output = self.run_review_command(&remote, command, operation).await?;
map_parse_error(
remote.forge_kind,
parse_requested_reviews(&output.stdout, &remote),
)
}
pub(crate) fn list_requested_reviews_future(
&self,
remote: ForgeRemote,
command: fn(&ForgeRemote) -> ForgeCommand,
operation: &'static str,
parse_requested_reviews: fn(&str, &ForgeRemote) -> Result<Vec<RequestedReview>, String>,
) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
let operations = self.clone();
Box::pin(async move {
let command = command(&remote);
operations
.list_requested_reviews(remote, command, operation, parse_requested_reviews)
.await
})
}
}
pub(crate) struct SyncReviewRequestMetadataConfig<Metadata> {
pub(crate) edit_metadata_command:
fn(&ForgeRemote, &str, &UpdateReviewRequestInput) -> ForgeCommand,
pub(crate) edit_operation: &'static str,
pub(crate) parse_display_id: fn(&str) -> Result<String, ReviewRequestError>,
pub(crate) parse_metadata_response: fn(&str) -> Result<Metadata, String>,
pub(crate) requires_update: fn(&Metadata, &UpdateReviewRequestInput) -> bool,
pub(crate) view_metadata_command: fn(&ForgeRemote, &str) -> ForgeCommand,
pub(crate) view_operation: &'static str,
}
pub(crate) fn looks_like_authentication_failure(detail: &str, forge_kind: ForgeKind) -> bool {
let normalized_detail = detail.to_ascii_lowercase();
let auth_login_marker = format!("{} auth login", forge_kind.cli_name());
normalized_detail.contains(&auth_login_marker)
|| normalized_detail.contains("not logged in")
|| normalized_detail.contains("authentication failed")
|| normalized_detail.contains("authentication required")
|| normalized_detail.contains("http 401")
}
pub(crate) fn looks_like_host_resolution_failure(detail: &str) -> bool {
let normalized_detail = detail.to_ascii_lowercase();
normalized_detail.contains("no such host")
|| normalized_detail.contains("name or service not known")
|| normalized_detail.contains("temporary failure in name resolution")
|| normalized_detail.contains("could not resolve host")
|| normalized_detail.contains("lookup ")
}
pub(crate) fn status_summary_parts(parts: &[String]) -> Option<String> {
if parts.is_empty() {
return None;
}
Some(parts.join(", "))
}
pub(crate) fn normalize_provider_label(label: &str) -> String {
let lowercase = label.replace('_', " ").to_ascii_lowercase();
let mut characters = lowercase.chars();
let Some(first_character) = characters.next() else {
return String::new();
};
let mut normalized = first_character.to_uppercase().collect::<String>();
normalized.push_str(characters.as_str());
normalized
}
pub(crate) fn operation_failed(
forge_kind: ForgeKind,
message: impl Into<String>,
) -> ReviewRequestError {
ReviewRequestError::OperationFailed {
forge_kind,
message: message.into(),
}
}
pub(crate) fn map_parse_error<T>(
forge_kind: ForgeKind,
result: Result<T, String>,
) -> Result<T, ReviewRequestError> {
result.map_err(|message| operation_failed(forge_kind, message))
}
pub(crate) fn map_spawn_error(
remote: &ForgeRemote,
error: ForgeCommandError,
) -> ReviewRequestError {
let forge_kind = remote.forge_kind;
match error {
ForgeCommandError::ExecutableNotFound { .. } => {
ReviewRequestError::CliNotInstalled { forge_kind }
}
ForgeCommandError::SpawnFailed { message, .. } => {
if looks_like_host_resolution_failure(&message) {
return ReviewRequestError::HostResolutionFailed {
forge_kind,
host: remote.host.clone(),
};
}
ReviewRequestError::OperationFailed {
forge_kind,
message: format!("failed to execute `{}`: {message}", forge_kind.cli_name()),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn looks_like_authentication_failure_matches_github_cli_login_prompt() {
let detail = "You are not logged into any GitHub hosts. Run `gh auth login`.";
let matched = looks_like_authentication_failure(detail, ForgeKind::GitHub);
assert!(matched);
}
#[test]
fn looks_like_authentication_failure_matches_gitlab_cli_login_prompt() {
let detail = "You are not logged in. Run `glab auth login`.";
let matched = looks_like_authentication_failure(detail, ForgeKind::GitLab);
assert!(matched);
}
#[test]
fn looks_like_authentication_failure_matches_http_401() {
let detail = "HTTP 401 Unauthorized";
let matched_github = looks_like_authentication_failure(detail, ForgeKind::GitHub);
let matched_gitlab = looks_like_authentication_failure(detail, ForgeKind::GitLab);
assert!(matched_github);
assert!(matched_gitlab);
}
#[test]
fn looks_like_authentication_failure_returns_false_for_unrelated_detail() {
let detail = "Request failed: rate limit exceeded";
let matched = looks_like_authentication_failure(detail, ForgeKind::GitHub);
assert!(!matched);
}
#[test]
fn looks_like_host_resolution_failure_matches_common_dns_errors() {
let details = [
"dial tcp: lookup github.com: no such host",
"Name or service not known",
"Temporary failure in name resolution",
"Could not resolve host: gitlab.example.internal",
];
for detail in details {
assert!(
looks_like_host_resolution_failure(detail),
"expected `{detail}` to match",
);
}
}
#[test]
fn looks_like_host_resolution_failure_returns_false_for_unrelated_detail() {
let detail = "HTTP 500 Internal Server Error";
let matched = looks_like_host_resolution_failure(detail);
assert!(!matched);
}
#[test]
fn status_summary_parts_returns_none_for_empty_input() {
let parts: Vec<String> = Vec::new();
let summary = status_summary_parts(&parts);
assert_eq!(summary, None);
}
#[test]
fn status_summary_parts_joins_values_with_commas() {
let parts = vec![
"Draft".to_string(),
"Approved".to_string(),
"Mergeable".to_string(),
];
let summary = status_summary_parts(&parts);
assert_eq!(summary.as_deref(), Some("Draft, Approved, Mergeable"));
}
#[test]
fn normalize_provider_label_capitalizes_first_letter_and_replaces_underscores() {
let label = "CHANGES_REQUESTED";
let normalized = normalize_provider_label(label);
assert_eq!(normalized, "Changes requested");
}
#[test]
fn normalize_provider_label_returns_empty_string_for_empty_input() {
let label = "";
let normalized = normalize_provider_label(label);
assert_eq!(normalized, String::new());
}
#[test]
fn map_spawn_error_maps_executable_not_found_to_cli_not_installed() {
let remote = sample_remote(ForgeKind::GitHub);
let error = ForgeCommandError::ExecutableNotFound {
executable: "gh".to_string(),
};
let review_request_error = map_spawn_error(&remote, error);
assert_eq!(
review_request_error,
ReviewRequestError::CliNotInstalled {
forge_kind: ForgeKind::GitHub,
}
);
}
#[test]
fn map_spawn_error_maps_host_resolution_failure_for_gitlab() {
let remote = sample_remote(ForgeKind::GitLab);
let error = ForgeCommandError::SpawnFailed {
executable: "glab".to_string(),
message: "dial tcp: lookup gitlab.example.internal: no such host".to_string(),
};
let review_request_error = map_spawn_error(&remote, error);
assert_eq!(
review_request_error,
ReviewRequestError::HostResolutionFailed {
forge_kind: ForgeKind::GitLab,
host: "gitlab.example.internal".to_string(),
}
);
}
#[test]
fn map_spawn_error_falls_back_to_operation_failed_with_cli_name() {
let remote = sample_remote(ForgeKind::GitHub);
let error = ForgeCommandError::SpawnFailed {
executable: "gh".to_string(),
message: "permission denied".to_string(),
};
let review_request_error = map_spawn_error(&remote, error);
assert_eq!(
review_request_error,
ReviewRequestError::OperationFailed {
forge_kind: ForgeKind::GitHub,
message: "failed to execute `gh`: permission denied".to_string(),
}
);
}
fn sample_remote(forge_kind: ForgeKind) -> ForgeRemote {
let host = match forge_kind {
ForgeKind::GitHub => "github.com",
ForgeKind::GitLab => "gitlab.example.internal",
};
ForgeRemote {
command_working_directory: None,
forge_kind,
host: host.to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: format!("https://{host}/agentty-xyz/agentty.git"),
web_url: format!("https://{host}/agentty-xyz/agentty"),
}
}
}