diff --git a/Cargo.lock b/Cargo.lock index 8c0ce985..545f2e19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2576,6 +2576,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "review_sdk" +version = "0.1.0" +dependencies = [ + "clap", + "hipcheck-sdk", + "log", + "schemars", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "ring" version = "0.17.8" diff --git a/Cargo.toml b/Cargo.toml index 213b4b39..4cc6aed3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "plugins/fuzz", "plugins/entropy", "plugins/linguist", + "plugins/review" ] # Make sure Hipcheck is run with `cargo run`. diff --git a/plugins/github_api/plugin.kdl b/plugins/github_api/plugin.kdl new file mode 100644 index 00000000..59879c62 --- /dev/null +++ b/plugins/github_api/plugin.kdl @@ -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" +} \ No newline at end of file diff --git a/plugins/review/Cargo.toml b/plugins/review/Cargo.toml new file mode 100644 index 00000000..9b0b2136 --- /dev/null +++ b/plugins/review/Cargo.toml @@ -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"] } diff --git a/plugins/review/plugin.kdl b/plugins/review/plugin.kdl new file mode 100644 index 00000000..83f03557 --- /dev/null +++ b/plugins/review/plugin.kdl @@ -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" +} \ No newline at end of file diff --git a/plugins/review/src/main.rs b/plugins/review/src/main.rs new file mode 100644 index 00000000..0d6e4963 --- /dev/null +++ b/plugins/review/src/main.rs @@ -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, +} + +static CONFIG: OnceLock = 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 { + 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 = + 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).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 { + 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> { + 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 { + 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); + } +}