use crate::error::RsGuardError;
use crate::http::{build_github_http_client, github_headers, validate_github_base_url};
use crate::retry::with_retry_simple;
use crate::verdict::ReviewState;
use serde_json::json;
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const BOT_SIGNATURE: &str = "<!-- rs-guard-bot -->";
const GITHUB_REVIEW_BODY_LIMIT: usize = 65536;
async fn submit_review_inner(
base_url: &str,
owner: &str,
repo: &str,
pr_number: u64,
state: &ReviewState,
message: &str,
token: &str,
) -> Result<(), RsGuardError> {
let client = build_github_http_client(REQUEST_TIMEOUT)?;
let url = format!(
"{}/repos/{}/{}/pulls/{}/reviews",
base_url.trim_end_matches('/'),
owner,
repo,
pr_number
);
let headers = github_headers(token)?;
let body = json!({
"body": format!("{}\n\n{}", message, BOT_SIGNATURE),
"event": state.as_github_state(),
});
with_retry_simple(|| async {
let resp = client
.post(&url)
.headers(headers.clone())
.json(&body)
.send()
.await
.map_err(|e| {
let status = e.status().map(|s| s.as_u16()).unwrap_or(0);
RsGuardError::GitHubApi {
status,
message: e.to_string(),
}
})?;
let status = resp.status();
if !status.is_success() {
let body_text = resp
.text()
.await
.unwrap_or_else(|e| format!("[failed to read response body: {}]", e));
if status.as_u16() == 422 && body_text.contains("body is too long") {
return Err(RsGuardError::GitHubApi {
status: status.as_u16(),
message: "Review body exceeds GitHub's character limit. \
Consider using a shorter prompt or chunking the diff."
.to_string(),
});
}
return Err(RsGuardError::GitHubApi {
status: status.as_u16(),
message: body_text,
});
}
Ok(())
})
.await
}
pub async fn submit_review(
base_url: &str,
owner: &str,
repo: &str,
pr_number: u64,
state: ReviewState,
message: &str,
token: &str,
) -> Result<(), RsGuardError> {
validate_github_base_url(base_url)?;
let full_body = format!("{}\n\n{}", message, BOT_SIGNATURE);
if full_body.len() > GITHUB_REVIEW_BODY_LIMIT {
return Err(RsGuardError::GitHubApi {
status: 0,
message: format!(
"Review body exceeds GitHub's character limit ({} chars). \
Consider using a shorter prompt or chunking the diff.",
GITHUB_REVIEW_BODY_LIMIT
),
});
}
let result =
submit_review_inner(base_url, owner, repo, pr_number, &state, message, token).await;
match result {
Ok(()) => Ok(()),
Err(err) if err.is_permission_denied() && state != ReviewState::Comment => {
log::warn!(
"Permission denied for {}. Falling back to COMMENT...",
state
);
let fallback_msg = format!("[Bot fallback from {}]\n\n{}", state, message);
submit_review_inner(
base_url,
owner,
repo,
pr_number,
&ReviewState::Comment,
&fallback_msg,
token,
)
.await
}
Err(err) => Err(err),
}
}
pub async fn dismiss_previous_reviews(
base_url: &str,
owner: &str,
repo: &str,
pr_number: u64,
token: &str,
) -> Result<(), RsGuardError> {
validate_github_base_url(base_url)?;
let client = build_github_http_client(REQUEST_TIMEOUT)?;
let url = format!(
"{}/repos/{}/{}/pulls/{}/reviews",
base_url.trim_end_matches('/'),
owner,
repo,
pr_number
);
let headers = github_headers(token)?;
let reviews: Vec<serde_json::Value> = with_retry_simple(|| async {
let resp = client
.get(&url)
.headers(headers.clone())
.send()
.await
.map_err(|e| {
let status = e.status().map(|s| s.as_u16()).unwrap_or(0);
RsGuardError::GitHubApi {
status,
message: e.to_string(),
}
})?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("[failed to read response body: {}]", e));
return Err(RsGuardError::GitHubApi {
status: status.as_u16(),
message: body,
});
}
resp.json().await.map_err(|e| RsGuardError::GitHubApi {
status: 0,
message: e.to_string(),
})
})
.await?;
for review in reviews {
let state = review["state"].as_str().unwrap_or("");
let body = review["body"].as_str().unwrap_or("");
let review_id = review["id"].as_u64();
if state == "CHANGES_REQUESTED" && body.contains(BOT_SIGNATURE) {
if let Some(id) = review_id {
let dismiss_url = format!(
"{}/repos/{}/{}/pulls/{}/reviews/{}/dismissals",
base_url.trim_end_matches('/'),
owner,
repo,
pr_number,
id
);
let dismiss_body = json!({
"message": "Outdated — new review submitted",
});
if let Err(e) = with_retry_simple(|| async {
let resp = client
.put(&dismiss_url)
.headers(headers.clone())
.json(&dismiss_body)
.send()
.await
.map_err(|e| {
let status = e.status().map(|s| s.as_u16()).unwrap_or(0);
RsGuardError::GitHubApi {
status,
message: e.to_string(),
}
})?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("[failed to read response body: {}]", e));
return Err(RsGuardError::GitHubApi {
status: status.as_u16(),
message: body,
});
}
Ok(())
})
.await
{
log::warn!("Failed to dismiss review {}: {}", id, e);
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_submit_review_success() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Approve,
"looks good",
"token",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_submit_review_request_changes_sends_correct_event() {
use wiremock::matchers::body_partial_json;
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.and(body_partial_json(json!({"event": "REQUEST_CHANGES"})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::RequestChanges,
"please fix",
"token",
)
.await;
assert!(
result.is_ok(),
"submit_review(RequestChanges) failed: {:?}",
result
);
}
#[tokio::test]
async fn test_submit_review_approve_sends_correct_event() {
use wiremock::matchers::body_partial_json;
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.and(body_partial_json(json!({"event": "APPROVE"})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Approve,
"lgtm",
"token",
)
.await;
assert!(
result.is_ok(),
"submit_review(Approve) failed: {:?}",
result
);
}
#[tokio::test]
async fn test_submit_review_retryable_then_success() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Comment,
"ok",
"token",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_submit_review_permission_fallback_to_comment() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Approve,
"my review",
"token",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_submit_review_422_not_permitted_fallback_to_comment() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(
ResponseTemplate::new(422)
.set_body_string(r#"{"message":"Unprocessable Entity","errors":["GitHub Actions is not permitted to approve pull requests."]}"#),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Approve,
"my review",
"token",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_submit_review_no_fallback_on_permission_denied_for_comment() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Comment,
"my comment",
"token",
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().is_permission_denied());
}
#[tokio::test]
async fn test_submit_review_invalid_base_url() {
let result = submit_review(
"https://evil.example.com",
"owner",
"repo",
1,
ReviewState::Comment,
"msg",
"token",
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("allowlist"));
}
#[tokio::test]
async fn test_submit_review_invalid_token() {
let result = submit_review(
"http://127.0.0.1:1",
"owner",
"repo",
1,
ReviewState::Comment,
"msg",
"token\x00withnull",
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("token"));
}
#[tokio::test]
async fn test_submit_review_body_too_long() {
let mock_server = MockServer::start().await;
let long_message = "x".repeat(GITHUB_REVIEW_BODY_LIMIT + 100);
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Comment,
&long_message,
"token",
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("exceeds GitHub's character limit"));
}
#[tokio::test]
async fn test_submit_review_body_at_limit() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let message = "x".repeat(GITHUB_REVIEW_BODY_LIMIT - BOT_SIGNATURE.len() - 2);
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Comment,
&message,
"token",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_submit_review_422_body_too_long_error() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(422).set_body_string(
r#"{"message":"Unprocessable Entity","errors":["body is too long"]}"#,
))
.mount(&mock_server)
.await;
let result = submit_review(
&mock_server.uri(),
"owner",
"repo",
1,
ReviewState::Comment,
"test message",
"token",
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("exceeds GitHub's character limit"));
}
#[tokio::test]
async fn test_dismiss_previous_reviews_no_reviews() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&mock_server)
.await;
let result =
dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_dismiss_previous_reviews_dismisses_bot_request_changes() {
let mock_server = MockServer::start().await;
let bot_review = json!({
"id": 42,
"state": "CHANGES_REQUESTED",
"body": "Some review\n\n<!-- rs-guard-bot -->"
});
Mock::given(method("GET"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([bot_review])))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path_regex(
r"/repos/owner/repo/pulls/\d+/reviews/\d+/dismissals",
))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result =
dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_dismiss_previous_reviews_skips_non_bot_reviews() {
let mock_server = MockServer::start().await;
let human_review = json!({
"id": 99,
"state": "CHANGES_REQUESTED",
"body": "Please fix this issue"
});
Mock::given(method("GET"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([human_review])))
.mount(&mock_server)
.await;
let result =
dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_dismiss_previous_reviews_skips_approved_reviews() {
let mock_server = MockServer::start().await;
let approved_review = json!({
"id": 55,
"state": "APPROVED",
"body": "<!-- rs-guard-bot -->\nLGTM"
});
Mock::given(method("GET"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([approved_review])))
.mount(&mock_server)
.await;
let result =
dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_dismiss_previous_reviews_handles_dismissal_error() {
let mock_server = MockServer::start().await;
let bot_review = json!({
"id": 42,
"state": "CHANGES_REQUESTED",
"body": "<!-- rs-guard-bot -->\nReview"
});
Mock::given(method("GET"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([bot_review])))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path_regex(
r"/repos/owner/repo/pulls/\d+/reviews/\d+/dismissals",
))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server"))
.up_to_n_times(4) .mount(&mock_server)
.await;
let result =
dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_dismiss_previous_reviews_invalid_base_url() {
let result =
dismiss_previous_reviews("https://evil.example.com", "owner", "repo", 1, "token").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("allowlist"));
}
#[tokio::test]
async fn test_dismiss_previous_reviews_handles_get_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/owner/repo/pulls/\d+/reviews"))
.respond_with(ResponseTemplate::new(500).set_body_string("Server Error"))
.mount(&mock_server)
.await;
let result =
dismiss_previous_reviews(&mock_server.uri(), "owner", "repo", 1, "token").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("500"));
}
}