use std::sync::Arc;
use super::{
CreateReviewRequestInput, ForgeCommandRunner, ForgeFuture, ForgeKind, ForgeRemote,
GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, RealForgeCommandRunner,
RequestedReview, ReviewCommentSnapshot, ReviewRequestError, ReviewRequestSummary,
UpdateReviewRequestInput, detect_remote,
};
#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
pub trait ReviewRequestClient: Send + Sync {
fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError>;
fn find_by_source_branch(
&self,
remote: ForgeRemote,
source_branch: String,
) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
fn create_review_request(
&self,
remote: ForgeRemote,
input: CreateReviewRequestInput,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
fn refresh_review_request(
&self,
remote: ForgeRemote,
display_id: String,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
fn sync_review_request_metadata(
&self,
remote: ForgeRemote,
display_id: String,
input: UpdateReviewRequestInput,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
fn review_request_web_url(
&self,
review_request: &ReviewRequestSummary,
) -> Result<String, ReviewRequestError>;
fn fetch_review_comment_snapshot(
&self,
remote: ForgeRemote,
display_id: String,
) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
fn list_requested_reviews(
&self,
remote: ForgeRemote,
) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
}
pub struct RealReviewRequestClient {
command_runner: Arc<dyn ForgeCommandRunner>,
}
impl RealReviewRequestClient {
pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
Self { command_runner }
}
}
impl Default for RealReviewRequestClient {
fn default() -> Self {
Self::new(Arc::new(RealForgeCommandRunner))
}
}
impl ReviewRequestClient for RealReviewRequestClient {
fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
detect_remote(&repo_url)
}
fn find_by_source_branch(
&self,
remote: ForgeRemote,
source_branch: String,
) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
self.call_with_authenticated_adapter(remote, move |adapter, remote| {
adapter.find_authenticated_by_source_branch(remote, source_branch)
})
}
fn create_review_request(
&self,
remote: ForgeRemote,
input: CreateReviewRequestInput,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
self.call_with_authenticated_adapter(remote, move |adapter, remote| {
adapter.create_authenticated_review_request(remote, input)
})
}
fn refresh_review_request(
&self,
remote: ForgeRemote,
display_id: String,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
self.call_with_authenticated_adapter(remote, move |adapter, remote| {
adapter.refresh_authenticated_review_request(remote, display_id)
})
}
fn sync_review_request_metadata(
&self,
remote: ForgeRemote,
display_id: String,
input: UpdateReviewRequestInput,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
self.call_with_authenticated_adapter(remote, move |adapter, remote| {
adapter.sync_authenticated_review_request_metadata(remote, display_id, input)
})
}
fn review_request_web_url(
&self,
review_request: &ReviewRequestSummary,
) -> Result<String, ReviewRequestError> {
if review_request.web_url.trim().is_empty() {
return Err(ReviewRequestError::OperationFailed {
forge_kind: review_request.forge_kind,
message: "review request summary is missing a web URL".to_string(),
});
}
Ok(review_request.web_url.clone())
}
fn fetch_review_comment_snapshot(
&self,
remote: ForgeRemote,
display_id: String,
) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
self.call_with_authenticated_adapter(remote, move |adapter, remote| {
adapter.fetch_authenticated_review_comment_snapshot(remote, display_id)
})
}
fn list_requested_reviews(
&self,
remote: ForgeRemote,
) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
self.call_with_authenticated_adapter(remote, move |adapter, remote| {
adapter.list_authenticated_requested_reviews(remote)
})
}
}
impl RealReviewRequestClient {
fn adapter_for(&self, forge_kind: ForgeKind) -> Arc<dyn ReviewRequestAdapter> {
match forge_kind {
ForgeKind::GitHub => Arc::new(GitHubReviewRequestAdapter::new(Arc::clone(
&self.command_runner,
))),
ForgeKind::GitLab => Arc::new(GitLabReviewRequestAdapter::new(Arc::clone(
&self.command_runner,
))),
}
}
fn call_with_authenticated_adapter<T>(
&self,
remote: ForgeRemote,
call: impl FnOnce(
Arc<dyn ReviewRequestAdapter>,
ForgeRemote,
) -> ForgeFuture<Result<T, ReviewRequestError>>
+ Send
+ 'static,
) -> ForgeFuture<Result<T, ReviewRequestError>>
where
T: Send + 'static,
{
let adapter = self.adapter_for(remote.forge_kind);
Box::pin(async move {
adapter.ensure_authenticated(&remote).await?;
call(adapter, remote).await
})
}
}
pub(crate) trait ReviewRequestAdapter: Send + Sync {
fn ensure_authenticated(
&self,
remote: &ForgeRemote,
) -> ForgeFuture<Result<(), ReviewRequestError>>;
fn find_authenticated_by_source_branch(
&self,
remote: ForgeRemote,
source_branch: String,
) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
fn create_authenticated_review_request(
&self,
remote: ForgeRemote,
input: CreateReviewRequestInput,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
fn refresh_authenticated_review_request(
&self,
remote: ForgeRemote,
display_id: String,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
fn sync_authenticated_review_request_metadata(
&self,
remote: ForgeRemote,
display_id: String,
input: UpdateReviewRequestInput,
) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
fn fetch_authenticated_review_comment_snapshot(
&self,
remote: ForgeRemote,
display_id: String,
) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
fn list_authenticated_requested_reviews(
&self,
remote: ForgeRemote,
) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
}
#[cfg(test)]
mod tests {
use mockall::Sequence;
use super::*;
use crate::command::{ForgeCommand, ForgeCommandOutput, MockForgeCommandRunner};
use crate::{ForgeKind, ReviewRequestState};
#[test]
fn review_request_web_url_returns_error_when_summary_is_missing_url() {
let client = RealReviewRequestClient::default();
let review_request = ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "feature/forge".to_string(),
state: ReviewRequestState::Open,
status_summary: Some("Mergeable".to_string()),
target_branch: "main".to_string(),
title: "Add forge boundary".to_string(),
web_url: String::new(),
};
let error = client
.review_request_web_url(&review_request)
.expect_err("missing URL should be rejected");
assert_eq!(
error,
ReviewRequestError::OperationFailed {
forge_kind: ForgeKind::GitHub,
message: "review request summary is missing a web URL".to_string(),
}
);
}
#[test]
fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
let client = RealReviewRequestClient::default();
let review_request = ReviewRequestSummary {
display_id: "!42".to_string(),
forge_kind: ForgeKind::GitLab,
source_branch: "feature/forge".to_string(),
state: ReviewRequestState::Open,
status_summary: Some("Draft".to_string()),
target_branch: "main".to_string(),
title: "Add forge boundary".to_string(),
web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
};
let web_url = client
.review_request_web_url(&review_request)
.expect("gitlab review-request URL should be returned directly");
assert_eq!(
web_url,
"https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
);
}
#[tokio::test]
async fn find_by_source_branch_authenticates_once_before_github_lookup() {
let remote = github_remote();
let mut sequence = Sequence::new();
let mut command_runner = MockForgeCommandRunner::new();
command_runner
.expect_run()
.once()
.in_sequence(&mut sequence)
.withf(|command| {
command_arguments_are(
command,
"gh",
&["auth", "status", "--hostname", "github.com"],
)
})
.returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
command_runner
.expect_run()
.once()
.in_sequence(&mut sequence)
.withf(|command| {
command_arguments_are(
command,
"gh",
&[
"api",
"--hostname",
"github.com",
"--method",
"GET",
"repos/agentty-xyz/agentty/pulls",
"-f",
"head=agentty-xyz:feature/forge",
"-f",
"state=open",
"-f",
"sort=created",
"-f",
"direction=desc",
"-f",
"per_page=1",
],
)
})
.returning(|_| {
Box::pin(async { Ok(success_output(r#"[{"number":42}]"#.to_string())) })
});
command_runner
.expect_run()
.once()
.in_sequence(&mut sequence)
.withf(|command| {
command_arguments_are(
command,
"gh",
&[
"pr",
"view",
"42",
"--repo",
"agentty-xyz/agentty",
"--json",
"number,title,state,url,baseRefName,headRefName,isDraft,mergeStateStatus,\
reviewDecision,mergedAt",
],
)
})
.returning(|_| Box::pin(async { Ok(success_output(github_view_json())) }));
let client = RealReviewRequestClient::new(Arc::new(command_runner));
let review_request = client
.find_by_source_branch(remote, "feature/forge".to_string())
.await
.expect("GitHub lookup should succeed");
assert_eq!(
review_request,
Some(ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "feature/forge".to_string(),
state: ReviewRequestState::Open,
status_summary: Some("Approved, Mergeable".to_string()),
target_branch: "main".to_string(),
title: "Add forge review support".to_string(),
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
})
);
}
#[tokio::test]
async fn refresh_review_request_stops_on_github_authentication_error() {
let remote = github_remote();
let mut command_runner = MockForgeCommandRunner::new();
command_runner
.expect_run()
.once()
.withf(|command| {
command_arguments_are(
command,
"gh",
&["auth", "status", "--hostname", "github.com"],
)
})
.returning(|_| {
Box::pin(async {
Ok(failure_output(
"You are not logged into any GitHub hosts. Run `gh auth login`."
.to_string(),
))
})
});
let client = RealReviewRequestClient::new(Arc::new(command_runner));
let error = client
.refresh_review_request(remote, "#42".to_string())
.await
.expect_err("missing auth should stop before refresh");
assert_eq!(
error,
ReviewRequestError::AuthenticationRequired {
detail: Some(
"You are not logged into any GitHub hosts. Run `gh auth login`.".to_string()
),
forge_kind: ForgeKind::GitHub,
host: "github.com".to_string(),
}
);
}
#[tokio::test]
async fn refresh_review_request_authenticates_before_gitlab_refresh() {
let remote = gitlab_remote();
let mut sequence = Sequence::new();
let mut command_runner = MockForgeCommandRunner::new();
command_runner
.expect_run()
.once()
.in_sequence(&mut sequence)
.withf(|command| {
command_arguments_are(
command,
"glab",
&["auth", "status", "--hostname", "gitlab.com"],
)
})
.returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
command_runner
.expect_run()
.once()
.in_sequence(&mut sequence)
.withf(|command| {
command_arguments_are(
command,
"glab",
&[
"mr",
"view",
"42",
"--repo",
"https://gitlab.com/agentty-xyz/agentty",
"--output",
"json",
],
)
})
.returning(|_| Box::pin(async { Ok(success_output(gitlab_view_json())) }));
let client = RealReviewRequestClient::new(Arc::new(command_runner));
let review_request = client
.refresh_review_request(remote, "!42".to_string())
.await
.expect("GitLab refresh should succeed");
assert_eq!(review_request.display_id, "!42");
assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
}
fn command_arguments_are(
command: &ForgeCommand,
executable: &'static str,
arguments: &[&str],
) -> bool {
let expected_arguments = arguments
.iter()
.map(|argument| (*argument).to_string())
.collect::<Vec<_>>();
command.executable == executable && command.arguments == expected_arguments
}
fn github_remote() -> ForgeRemote {
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: "https://github.com/agentty-xyz/agentty.git".to_string(),
web_url: "https://github.com/agentty-xyz/agentty".to_string(),
}
}
fn gitlab_remote() -> ForgeRemote {
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: "https://gitlab.com/agentty-xyz/agentty.git".to_string(),
web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
}
}
fn success_output(stdout: String) -> ForgeCommandOutput {
ForgeCommandOutput {
exit_code: Some(0),
stderr: String::new(),
stdout,
}
}
fn failure_output(stderr: String) -> ForgeCommandOutput {
ForgeCommandOutput {
exit_code: Some(1),
stderr,
stdout: String::new(),
}
}
fn github_view_json() -> String {
r#"{
"number": 42,
"title": "Add forge review support",
"state": "OPEN",
"url": "https://github.com/agentty-xyz/agentty/pull/42",
"baseRefName": "main",
"headRefName": "feature/forge",
"isDraft": false,
"mergeStateStatus": "CLEAN",
"reviewDecision": "APPROVED",
"mergedAt": null
}"#
.to_string()
}
fn gitlab_view_json() -> String {
r#"{
"draft": true,
"detailed_merge_status": "can_be_merged",
"iid": 42,
"merge_status": "can_be_merged",
"merged_at": null,
"source_branch": "feature/forge",
"state": "opened",
"target_branch": "main",
"title": "Add forge review support",
"description": "Current description.",
"web_url": "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
}"#
.to_string()
}
}