Skip to content

Commit

Permalink
feat: Adds review plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
Michael Chernicoff authored and j-lanson committed Oct 23, 2024
1 parent f03cf8d commit 2e4302e
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 0 deletions.
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ members = [
"plugins/fuzz",
"plugins/entropy",
"plugins/linguist",
"plugins/review"
]

# Make sure Hipcheck is run with `cargo run`.
Expand Down
10 changes: 10 additions & 0 deletions plugins/github_api/plugin.kdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
publisher "mitre"
name "github_api"
version "0.1.0"
license "Apache-2.0"
entrypoint {
on arch="aarch64-apple-darwin" "./hc-mitre-github_api"
on arch="x86_64-apple-darwin" "./hc-mitre-github_api"
on arch="x86_64-unknown-linux-gnu" "./hc-mitre-github_api"
on arch="x86_64-pc-windows-msvc" "./hc-mitre-github_api"
}
19 changes: 19 additions & 0 deletions plugins/review/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "review_sdk"
version = "0.1.0"
license = "Apache-2.0"
edition = "2021"
publish = false

[dependencies]
clap = { version = "4.5.18", features = ["derive"] }
hipcheck-sdk = { path = "../../sdk/rust", features = ["macros"] }
log = "0.4.22"
schemars = { version = "0.8.21", features = ["url"] }
serde = { version = "1.0.210", features = ["derive", "rc"] }
serde_json = "1.0.128"
tokio = { version = "1.40.0", features = ["rt"] }
url = "2.5.2"

[dev-dependencies]
hipcheck-sdk = { path = "../../sdk/rust", features = ["mock_engine"] }
14 changes: 14 additions & 0 deletions plugins/review/plugin.kdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
publisher "mitre"
name "review"
version "0.1.0"
license "Apache-2.0"
entrypoint {
on arch="aarch64-apple-darwin" "./hc-mitre-review"
on arch="x86_64-apple-darwin" "./hc-mitre-review"
on arch="x86_64-unknown-linux-gnu" "./hc-mitre-review"
on arch="x86_64-pc-windows-msvc" "./hc-mitre-review"
}

dependencies {
plugin "mitre/github_api" version="0.1.0" manifest="./plugins/github_api/plugin.kdl"
}
193 changes: 193 additions & 0 deletions plugins/review/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: Apache-2.0

//! Plugin for querying what percentage of pull requests were merged without review

use clap::Parser;
use hipcheck_sdk::{prelude::*, types::Target};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{result::Result as StdResult, sync::OnceLock};

#[derive(Deserialize)]
struct Config {
percent_threshold: Option<f64>,
}

static CONFIG: OnceLock<Config> = OnceLock::new();

#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct PullRequest {
pub id: u64,
pub reviews: u64,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct PullReview {
pub pull_request: PullRequest,
pub has_review: bool,
}

/// Returns the percentage of commits to the repo that were merged without review
#[query]
async fn review(engine: &mut PluginEngine, value: Target) -> Result<f64> {
log::debug!("running review metric");

// Confirm that the target is a GitHub repo
let Some(remote) = value.remote else {
log::error!("target repository does not have a remote repository URL");
return Err(Error::UnexpectedPluginQueryInputFormat);
};
let Some(known_remote) = remote.known_remote else {
log::error!("target repository is not a GitHub repository or else is missing GitHub repo information");
return Err(Error::UnexpectedPluginQueryInputFormat);
};

// Get a list of all pull requests to the repo, with their corresponding number of reviews
let value = engine
.query("mitre/github_api/pr_reviews", known_remote)
.await
.map_err(|e| {
log::error!(
"failed to get pull request reviews from GitHub for review query: {}",
e
);
Error::UnspecifiedQueryState
})?;

let pull_requests: Vec<PullRequest> =
serde_json::from_value(value).map_err(Error::InvalidJsonInQueryOutput)?;

log::trace!("got pull requests [requests='{:#?}']", pull_requests);

// Create a Vec big enough to hold every single pull request
let mut pull_reviews = Vec::with_capacity(pull_requests.len());

// Create a list of pull requesets, with a boolean indicating if they have at least one review or not
for pull_request in pull_requests {
let has_review = pull_request.reviews > 0;
pull_reviews.push(PullReview {
pull_request,
has_review,
});
}

// Calculate the percentage of unreviewed pull requests out of the total
// If there are no pull requests, return 0.0 to avoid a divide-by-zero error
let num_flagged = pull_reviews.iter().filter(|p| !p.has_review).count() as u64;
let percent_flagged = match (num_flagged, pull_reviews.len()) {
(flagged, total) if flagged != 0 && total != 0 => {
num_flagged as f64 / pull_reviews.len() as f64
}
_ => 0.0,
};

log::info!("completed review query");

Ok(percent_flagged)
}

#[derive(Clone, Debug)]
struct ReviewPlugin;

impl Plugin for ReviewPlugin {
const PUBLISHER: &'static str = "mitre";

const NAME: &'static str = "review";

fn set_config(&self, config: Value) -> StdResult<(), ConfigError> {
let conf =
serde_json::from_value::<Config>(config).map_err(|e| ConfigError::Unspecified {
message: e.to_string(),
})?;
CONFIG.set(conf).map_err(|_e| ConfigError::Unspecified {
message: "config was already set".to_owned(),
})
}

fn default_policy_expr(&self) -> Result<String> {
let Some(conf) = CONFIG.get() else {
log::error!("tried to access config before set by Hipcheck core!");
return Err(Error::UnspecifiedQueryState);
};
match conf.percent_threshold {
Some(threshold) => Ok(format!("lte $ {}", threshold)),
None => Ok("".to_owned()),
}
}

fn explain_default_query(&self) -> Result<Option<String>> {
Ok(Some(
"Percentage of unreviewed commits to the repo".to_string(),
))
}

queries! {}
}

#[derive(Parser, Debug)]
struct Args {
#[arg(long)]
port: u16,
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let args = Args::try_parse().unwrap();
PluginServer::register(ReviewPlugin {})
.listen(args.port)
.await
}

#[cfg(test)]
mod test {
use super::*;

use hipcheck_sdk::types::{KnownRemote, LocalGitRepo, RemoteGitRepo};
use std::result::Result as StdResult;
use url::Url;

fn known_remote() -> KnownRemote {
KnownRemote::GitHub {
owner: "expresjs".to_string(),
repo: "express".to_string(),
}
}

fn mock_responses() -> StdResult<MockResponses, Error> {
let known_remote = known_remote();

let pr1 = PullRequest { id: 1, reviews: 1 };
let pr2 = PullRequest { id: 2, reviews: 3 };
let pr3 = PullRequest { id: 3, reviews: 0 };
let pr4 = PullRequest { id: 4, reviews: 1 };
let prs = vec![pr1, pr2, pr3, pr4];

// when calling into query, the input known_remote gets passed to `pr_reviews`, lets assume it returns the vec of PullRequests `prs`
let mut mock_responses = MockResponses::new();
mock_responses.insert("mitre/github_api/pr_reviews", known_remote, Ok(prs))?;
Ok(mock_responses)
}

#[tokio::test]
async fn test_activity() {
let target = Target {
specifier: "express".to_string(),
local: LocalGitRepo {
path: "/home/users/me/.cache/hipcheck/clones/github/expressjs/express/".to_string(),
git_ref: "main".to_string(),
},
remote: Some(RemoteGitRepo {
url: Url::parse("https://github.com/expressjs/express.git").unwrap(),
known_remote: Some(known_remote()),
}),
package: None,
};

let mut engine = PluginEngine::mock(mock_responses().unwrap());
let result = review(&mut engine, target).await.unwrap();

let expected = 0.25;

assert_eq!(result, expected);
}
}

0 comments on commit 2e4302e

Please sign in to comment.