use chrono::Utc;
use git_bot_feedback::{
RestApiClient, RestClientError, ReviewAction, ReviewComment, ReviewOptions,
client::GithubApiClient,
};
use mockito::{Matcher, Server};
use std::{collections::HashMap, env, fmt::Display, fs, io::Write, path::Path};
use tempfile::{NamedTempFile, TempDir};
mod common;
use common::logger_init;
const MARKER: &str = "<!-- git-bot-feedback -->\n";
const SHA: &str = "deadbeef";
const REPO: &str = "2bndy5/git-bot-feedback";
const PR: i64 = 46;
const TOKEN: &str = "123456";
const MOCK_ASSETS_PATH: &str = "tests/assets/reviews/github/";
const RESET_RATE_LIMIT_HEADER: &str = "x-ratelimit-reset";
const REMAINING_RATE_LIMIT_HEADER: &str = "x-ratelimit-remaining";
const QUERY_REVIEW_THREADS: &str = r#"(?s).*"query":"query.*reviewThreads.*"#;
const MUTATION_DELETE: &str = r#"(?s).*"query":"mutation.*deletePullRequestReviewComment.*"#;
const MUTATION_RESOLVE_THREAD: &str = r#"(?s).*"query":"mutation.*resolveReviewThread.*"#;
const MUTATION_HIDE_SUMMARY: &str = r#"(?s).*"query":"mutation.*minimizeComment.*"#;
#[derive(PartialEq, Clone, Copy, Debug)]
enum EventType {
Push,
PullRequest,
}
impl Display for EventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Push => write!(f, "push"),
Self::PullRequest => write!(f, "pull_request"),
}
}
}
struct TestParams {
event_t: EventType,
fail_get_existing_comments: bool,
fail_dismissal: bool,
fail_posting: bool,
bad_existing_comments: bool,
bad_existing_reviews: bool,
no_token: bool,
action: ReviewAction,
delete_outdated: bool,
no_existing_reviews: bool,
is_draft: bool,
is_closed: bool,
is_locked: bool,
}
impl Default for TestParams {
fn default() -> Self {
Self {
event_t: EventType::PullRequest,
fail_get_existing_comments: false,
fail_dismissal: false,
fail_posting: false,
bad_existing_comments: false,
bad_existing_reviews: false,
no_token: false,
action: ReviewAction::Comment,
delete_outdated: false,
no_existing_reviews: false,
is_draft: false,
is_closed: false,
is_locked: false,
}
}
}
#[derive(Debug, Default)]
struct TestControlVars {
new_review_comments: Vec<ReviewComment>,
outdated_comment_ids: Vec<String>,
reused_comment_ids: Vec<String>,
outdated_thread_ids: Vec<String>,
outdated_review_ids: HashMap<i64, String>,
}
impl TestControlVars {
fn aggregate_review_comments(&mut self, artifact: &str) {
let artifact_content = fs::read_to_string(artifact).unwrap();
let json_content: serde_json::Value = serde_json::from_str(&artifact_content).unwrap();
for thread in json_content["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"]
.as_array()
.unwrap()
{
let mut keep = false;
for comment in thread["comments"]["nodes"].as_array().unwrap() {
let body = comment["body"].as_str().unwrap();
if !body.starts_with(MARKER) {
continue; }
let comment_id = comment["id"].as_str().unwrap().to_string();
if body.ends_with("reused bot comment") {
self.new_review_comments.push(ReviewComment {
comment: body.strip_prefix(MARKER).unwrap().to_string(),
line_start: comment["originalStartLine"]
.as_i64()
.or(comment["startLine"].as_i64())
.map(|i| i as u32),
line_end: comment["originalLine"]
.as_i64()
.unwrap_or(comment["line"].as_i64().unwrap())
as u32,
path: comment["path"].as_str().unwrap().to_string(),
});
self.reused_comment_ids.push(comment_id);
keep = true;
} else {
self.outdated_comment_ids.push(comment_id);
}
}
if !keep {
self.outdated_thread_ids
.push(thread["id"].as_str().unwrap().to_string());
}
}
}
fn find_outdated_reviews(&mut self, artifact: &str, regardless: bool) {
let artifact_content = fs::read_to_string(artifact).unwrap();
let json_content: serde_json::Value = serde_json::from_str(&artifact_content).unwrap();
for review in json_content.as_array().unwrap() {
let node_id = review["node_id"].as_str().unwrap().to_string();
let id = review["id"].as_i64().unwrap();
if review["body"].as_str().unwrap().starts_with(MARKER)
&& (regardless || node_id == "dismiss_this_review")
{
self.outdated_review_ids.insert(id, node_id);
}
}
}
}
async fn setup_and_run(lib_root: &Path, test_params: &TestParams) {
unsafe {
env::set_var(
"GITHUB_EVENT_NAME",
test_params.event_t.to_string().as_str(),
);
env::set_var("GITHUB_REPOSITORY", REPO);
env::set_var("GITHUB_SHA", SHA);
if !test_params.no_token {
env::set_var("GITHUB_TOKEN", TOKEN);
}
env::set_var("CI", "true");
if env::var("ACTIONS_STEP_DEBUG").is_err() {
env::set_var("ACTIONS_STEP_DEBUG", "true");
}
}
let mut event_payload_path = NamedTempFile::new_in("./").unwrap();
if test_params.event_t == EventType::PullRequest {
let state = if test_params.is_closed {
"closed"
} else {
"open"
};
let event_payload = serde_json::json!({
"pull_request": {
"draft": test_params.is_draft,
"state": state,
"number": PR,
"locked": test_params.is_locked,
}})
.to_string();
event_payload_path
.write_all(event_payload.as_bytes())
.expect("Failed to create mock event payload.");
unsafe {
env::set_var("GITHUB_EVENT_PATH", event_payload_path.path());
}
}
let reset_timestamp = (Utc::now().timestamp() + 60).to_string();
let asset_path = format!("{}/{MOCK_ASSETS_PATH}", lib_root.to_str().unwrap());
let mut server = Server::new_async().await;
unsafe {
env::set_var("GITHUB_API_URL", server.url());
}
logger_init();
log::set_max_level(log::LevelFilter::Debug);
let mut client = GithubApiClient::new().unwrap();
assert!(client.debug_enabled);
let mut mocks = vec![];
let mut test_control_vars = TestControlVars {
new_review_comments: vec![
ReviewComment {
line_start: Some(40),
line_end: 42,
comment: "A new comment (without prepended marker)".to_string(),
path: "src/lib.rs".to_string(),
},
ReviewComment {
line_start: None,
line_end: 42,
comment: format!("{MARKER}A new comment (with prepended marker)"),
path: "src/lib.rs".to_string(),
},
],
..Default::default()
};
let review_url_path = format!("/repos/{REPO}/pulls/{PR}/reviews");
if test_params.event_t == EventType::PullRequest
&& !test_params.no_token
&& !test_params.is_locked
&& !test_params.is_closed
{
let query = Matcher::Regex(QUERY_REVIEW_THREADS.to_string());
let mut var_matchers = vec![];
let paginated_vars = if test_params.no_existing_reviews {
vec![(None, None)]
} else {
vec![
(None, None),
(Some("pg2"), None),
(Some("pg2"), Some("pg3")),
]
};
for (after_thread, after_comment) in paginated_vars {
var_matchers.push(Matcher::PartialJson(serde_json::json!({
"variables": {
"owner": "2bndy5",
"name": "git-bot-feedback",
"number": PR,
"afterThread": after_thread,
"afterComment": after_comment
}})));
}
for (i, vars) in var_matchers.into_iter().enumerate() {
let artifact = if test_params.no_existing_reviews {
format!("{asset_path}reviews_threads_empty.json")
} else {
format!("{asset_path}reviews_threads_{PR}_pg{}.json", i + 1)
};
let mut mock = server
.mock("POST", "/graphql")
.match_header("Accept", "application/vnd.github.raw+json")
.match_header("Authorization", format!("token {TOKEN}").as_str())
.match_body(Matcher::AllOf(vec![vars, query.clone()]))
.with_header(REMAINING_RATE_LIMIT_HEADER, "50")
.with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str())
.with_status(if test_params.fail_get_existing_comments {
403
} else {
200
})
.expect_at_most(1);
if test_params.bad_existing_comments || test_params.fail_get_existing_comments {
mock = mock.with_body("TEST CONDITION TRIGGERED");
} else {
mock = mock.with_body_from_file(artifact.as_str());
}
mocks.push(mock.create());
if test_params.bad_existing_comments || test_params.fail_get_existing_comments {
break; } else {
test_control_vars.aggregate_review_comments(artifact.as_str());
}
}
if !test_params.bad_existing_comments {
for pg in [1, 2] {
let artifact = format!("{asset_path}reviews_{PR}_pg{pg}.json");
let link = if pg == 1 {
format!("<{}{review_url_path}?page=2>; rel=\"next\"", server.url())
} else {
"".to_string()
};
let mut mock = server
.mock("GET", review_url_path.as_str())
.match_header("Accept", "application/vnd.github.raw+json")
.match_header("Authorization", format!("token {TOKEN}").as_str())
.match_body(Matcher::Any)
.match_query(Matcher::UrlEncoded("page".to_string(), pg.to_string()))
.with_header(REMAINING_RATE_LIMIT_HEADER, "50")
.with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str());
if test_params.bad_existing_reviews {
mock = mock.with_body("TEST CONDITION TRIGGERED");
} else {
mock = mock
.with_header("link", link.as_str())
.with_body_from_file(artifact.as_str());
}
mocks.push(mock.create());
test_control_vars.find_outdated_reviews(
artifact.as_str(),
test_params.no_existing_reviews || test_params.fail_get_existing_comments,
);
if test_params.bad_existing_reviews && pg == 1 {
break; }
}
}
}
if test_params.event_t == EventType::PullRequest
&& !test_params.bad_existing_comments
&& !test_params.no_token
&& !test_params.is_locked
&& !test_params.is_closed
{
for id in &test_control_vars.outdated_comment_ids {
mocks.push(
server
.mock("POST", "/graphql")
.match_body(Matcher::AllOf(vec![
Matcher::PartialJson(serde_json::json!({
"variables": {"id": id}
})),
Matcher::Regex(
if test_params.delete_outdated {
MUTATION_DELETE
} else {
MUTATION_RESOLVE_THREAD
}
.to_string(),
),
]))
.match_header("Authorization", format!("token {TOKEN}").as_str())
.with_header(REMAINING_RATE_LIMIT_HEADER, "50")
.with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str())
.expect_at_most(1)
.create(),
);
}
for id in test_control_vars.outdated_thread_ids {
mocks.push(
server
.mock("POST", "/graphql")
.match_body(Matcher::AllOf(vec![
Matcher::PartialJson(serde_json::json!({
"variables": {"id": id}
})),
Matcher::Regex(MUTATION_RESOLVE_THREAD.to_string()),
]))
.match_header("Authorization", format!("token {TOKEN}").as_str())
.with_header(REMAINING_RATE_LIMIT_HEADER, "50")
.with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str())
.expect_at_most(1)
.create(),
);
}
for (id, node_id) in &test_control_vars.outdated_review_ids {
mocks.push(
server
.mock("POST", "/graphql")
.match_body(Matcher::AllOf(vec![
Matcher::PartialJson(serde_json::json!({
"variables": {"subjectId": node_id}
})),
Matcher::Regex(MUTATION_HIDE_SUMMARY.to_string()),
]))
.match_header("Authorization", format!("token {TOKEN}").as_str())
.with_header(REMAINING_RATE_LIMIT_HEADER, "50")
.with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str())
.expect_at_most(1)
.create(),
);
mocks.push(
server
.mock("PUT", format!("{review_url_path}/{id}/dismissals").as_str())
.match_body(Matcher::JsonString(
r#"{"event":"DISMISS","message":"outdated review"}"#.to_string(),
))
.with_status(if test_params.fail_dismissal { 403 } else { 200 })
.with_header(REMAINING_RATE_LIMIT_HEADER, "50")
.with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str())
.expect_at_most(1)
.create(),
);
}
}
let summary = "This is a summary of the PR review.".to_string();
let review_action = match test_params.action {
ReviewAction::Approve => "APPROVE",
ReviewAction::RequestChanges => "REQUEST_CHANGES",
ReviewAction::Comment => "COMMENT",
};
let new_comment_match = Matcher::PartialJson(serde_json::json!({
"event": review_action,
"body": format!("{MARKER}{summary}"),
}));
if test_params.event_t == EventType::PullRequest
&& !test_params.no_token
&& !test_params.is_locked
&& !test_params.is_draft
&& !test_params.is_closed
{
let mut mock = server
.mock("POST", review_url_path.as_str())
.match_body(new_comment_match)
.with_header(REMAINING_RATE_LIMIT_HEADER, "50")
.with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str())
.with_status(if test_params.fail_posting { 403 } else { 200 });
if !test_params.no_token {
mock = mock.match_header("Authorization", format!("token {TOKEN}").as_str());
}
mocks.push(mock.create());
}
let mut opts = ReviewOptions {
marker: MARKER.to_string(),
action: test_params.action.clone(),
summary: summary,
comments: test_control_vars.new_review_comments.clone(),
delete_review_comments: test_params.delete_outdated,
..Default::default()
};
client.start_log_group("posting review");
if let Err(e) = client.cull_pr_reviews(&mut opts).await {
if test_params.bad_existing_comments || test_params.bad_existing_reviews {
assert!(matches!(e, RestClientError::Json { .. }));
} else if test_params.fail_get_existing_comments || test_params.fail_dismissal {
assert!(matches!(e, RestClientError::RequestContext { .. }));
} else if test_params.no_token {
assert!(matches!(e, RestClientError::EnvVar { .. }));
} else {
panic!("Unexpected error culling existing comments: {e}");
}
}
if let Err(e) = client.post_pr_review(&opts).await {
if test_params.fail_posting {
assert!(matches!(e, RestClientError::RequestContext { .. }));
} else if test_params.no_token {
assert!(matches!(e, RestClientError::EnvVar { .. }));
} else {
panic!("Unexpected error posting review: {e}");
}
}
client.end_log_group("");
for mock in mocks {
mock.assert();
}
}
async fn test_reviews(test_params: &TestParams) {
let tmp_dir = TempDir::new().unwrap();
let lib_root = env::current_dir().unwrap();
env::set_current_dir(tmp_dir.path()).unwrap();
setup_and_run(&lib_root, test_params).await;
env::set_current_dir(lib_root.as_path()).unwrap();
drop(tmp_dir);
}
#[tokio::test]
async fn push() {
test_reviews(&TestParams {
event_t: EventType::Push,
no_token: true, ..Default::default()
})
.await;
}
#[tokio::test]
async fn pr() {
test_reviews(&TestParams::default()).await;
}
#[tokio::test]
async fn fail_get_existing_comments() {
test_reviews(&TestParams {
fail_get_existing_comments: true,
action: ReviewAction::RequestChanges,
..Default::default()
})
.await;
}
#[tokio::test]
async fn bad_existing_reviews() {
test_reviews(&TestParams {
bad_existing_reviews: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn fail_dismissal() {
test_reviews(&TestParams {
fail_dismissal: true,
action: ReviewAction::Approve,
..Default::default()
})
.await;
}
#[tokio::test]
async fn delete_outdated() {
test_reviews(&TestParams {
delete_outdated: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn fail_posting() {
test_reviews(&TestParams {
fail_posting: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn bad_existing_comments() {
test_reviews(&TestParams {
bad_existing_comments: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn no_token() {
test_reviews(&TestParams {
no_token: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn no_existing_reviews() {
test_reviews(&TestParams {
no_existing_reviews: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn is_closed() {
test_reviews(&TestParams {
is_closed: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn is_draft() {
test_reviews(&TestParams {
is_draft: true,
..Default::default()
})
.await;
}
#[tokio::test]
async fn is_locked() {
test_reviews(&TestParams {
is_locked: true,
..Default::default()
})
.await;
}