From 4bf6442868de89f720a666620d3f5406513bab5b Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:34:57 +0200 Subject: [PATCH] feat(testutil): port verifypr PR template verification Closes #160. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- Cargo.lock | 5 + crates/testutil/Cargo.toml | 5 + crates/testutil/src/bin/verifypr.rs | 22 ++ crates/testutil/src/lib.rs | 3 + crates/testutil/src/verifypr.rs | 528 ++++++++++++++++++++++++++++ 5 files changed, 563 insertions(+) create mode 100644 crates/testutil/src/bin/verifypr.rs create mode 100644 crates/testutil/src/verifypr.rs diff --git a/Cargo.lock b/Cargo.lock index 58e0e5c1..e55f7696 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5709,15 +5709,20 @@ dependencies = [ "pluto-crypto", "pluto-eth2api", "pluto-eth2util", + "pluto-featureset", "rand 0.8.7", + "regex", "reqwest 0.13.4", "serde", "serde_json", + "test-case", "thiserror 2.0.20", "tokio", "tokio-util", "tracing", + "tracing-subscriber", "tree_hash", + "url", "wiremock", ] diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index b36dd447..5e4b5d11 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -21,7 +21,9 @@ pluto-core.workspace = true pluto-crypto.workspace = true pluto-eth2api.workspace = true pluto-eth2util.workspace = true +pluto-featureset.workspace = true rand.workspace = true +regex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true @@ -29,11 +31,14 @@ thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true tracing.workspace = true +tracing-subscriber.workspace = true tree_hash.workspace = true +url.workspace = true wiremock.workspace = true [dev-dependencies] assert-json-diff.workspace = true +test-case.workspace = true [lints] workspace = true diff --git a/crates/testutil/src/bin/verifypr.rs b/crates/testutil/src/bin/verifypr.rs new file mode 100644 index 00000000..77b5de1f --- /dev/null +++ b/crates/testutil/src/bin/verifypr.rs @@ -0,0 +1,22 @@ +//! Command `verifypr` verifies a GitHub pull request against the contribution +//! template. +//! +//! The PR is read as JSON from the `GITHUB_PR` env variable. + +use std::{io, process::ExitCode}; + +use pluto_testutil::verifypr::verify; + +fn main() -> ExitCode { + tracing_subscriber::fmt().with_writer(io::stderr).init(); + + if let Err(err) = verify() { + tracing::error!(%err, "❌ Verification failed"); + + return ExitCode::FAILURE; + } + + tracing::info!("✅ Verification success"); + + ExitCode::SUCCESS +} diff --git a/crates/testutil/src/lib.rs b/crates/testutil/src/lib.rs index 8f572e56..67ee63ec 100644 --- a/crates/testutil/src/lib.rs +++ b/crates/testutil/src/lib.rs @@ -17,6 +17,9 @@ pub mod beaconmock; /// Validator mock — drives validator-side duties against a [`BeaconMock`]. pub mod validatormock; +/// Pull-request template verification. +pub mod verifypr; + pub use beaconmock::{BeaconMock, MockState, Validator, ValidatorSet}; pub use random::{ random_deneb_versioned_attestation, random_eth2_signature, random_eth2_signature_bytes, diff --git a/crates/testutil/src/verifypr.rs b/crates/testutil/src/verifypr.rs new file mode 100644 index 00000000..80523097 --- /dev/null +++ b/crates/testutil/src/verifypr.rs @@ -0,0 +1,528 @@ +//! # Verify PR +//! +//! Verifies a GitHub pull request against the contribution template: a +//! `package[/subpackage]: subject` title plus a body carrying `category:`, +//! `ticket:` and optional `feature_flag:` tags. +//! +//! The PR is read as JSON from the `GITHUB_PR` env variable, which is how the +//! GitHub Actions `pull_request` event payload is handed to the tool. + +use std::{env, sync::LazyLock}; + +use pluto_featureset::{Config, Feature, FeatureSet, FeaturesetError, Status}; +use regex::Regex; +use serde::Deserialize; +use thiserror::Error; +use url::Url; + +/// The env variable carrying the PR as JSON. +const PR_ENV: &str = "GITHUB_PR"; + +/// The maximum length of a PR title. +const MAX_TITLE_LEN: usize = 60; + +/// The minimum length of the title suffix, the `: subject` part. +const MIN_TITLE_SUFFIX_LEN: usize = 5; + +/// The values accepted by the `category:` body tag. +const CATEGORIES: [&str; 7] = [ + "feature", "bug", "refactor", "docs", "test", "fixbuild", "misc", +]; + +/// The `category:` body tag. +const CAT_TAG: &str = "category:"; + +/// The `ticket:` body tag. +const TICKET_TAG: &str = "ticket:"; + +/// The `feature_flag:` body tag. +const FEATURE_TAG: &str = "feature_flag:"; + +/// Matches the `package[/subpackage]` title prefix. +/// +/// The `\w` class is spelled out since Go's `regexp` treats it as ASCII-only +/// while Rust's `regex` treats it as Unicode. +static TITLE_PREFIX: LazyLock = + LazyLock::new(|| Regex::new(r"^[*0-9A-Za-z_]+(/[*0-9A-Za-z_]+)?$").expect("invalid regex")); + +/// Errors returned when a PR doesn't match the template. +#[derive(Debug, Error)] +pub enum Error { + /// The env variable carrying the PR isn't set, or is blank. + #[error("env variable not set: {var}")] + EnvVarNotSet { + /// Name of the missing env variable. + var: &'static str, + }, + + /// The PR JSON couldn't be deserialised. + #[error("unmarshal PR body")] + UnmarshalPr(#[source] serde_json::Error), + + /// One of the required PR fields is empty. + #[error("pr field not set")] + PrFieldNotSet, + + /// The feature set couldn't be resolved. + #[error(transparent)] + Featureset(#[from] FeaturesetError), + + /// The title exceeds the maximum length. + #[error("title too long: max {max}, actual {actual}")] + TitleTooLong { + /// Maximum allowed title length. + max: usize, + /// Length of the offending title. + actual: usize, + }, + + /// The title has no `package[/subpackage]:` prefix. + #[error("title isn't prefixed with 'package[/subpackage]:'")] + TitleNotPrefixed, + + /// The title prefix isn't a `package[/subpackage]` pair. + #[error("title prefix doesn't match regex")] + TitlePrefixRegex, + + /// The title suffix is shorter than the minimum length. + #[error("title suffix too short")] + TitleSuffixTooShort, + + /// The colon after the title prefix isn't followed by a space. + #[error("title prefix not followed by space")] + TitleNotFollowedBySpace, + + /// The title suffix starts with a capital. + #[error("title suffix shouldn't start with a capital")] + TitleSuffixCapital, + + /// The title suffix ends with punctuation. + #[error("title suffix shouldn't end with punctuation")] + TitleSuffixPunctuation, + + /// The body is empty. + #[error("body empty")] + BodyEmpty, + + /// The body still carries the template's markdown comments. + #[error("instructions not deleted (markdown comments present)")] + InstructionsNotDeleted, + + /// The first body line is empty. + #[error("first line empty")] + FirstLineEmpty, + + /// The body carries more than one `category:` line. + #[error("multiple category tag lines")] + MultipleCategoryTags, + + /// The `category:` line isn't preceded by an empty line. + #[error("category tag not preceded by empty line")] + CategoryTagNotPrecededByEmptyLine, + + /// The `category:` tag has no value. + #[error("category tag empty")] + CategoryTagEmpty, + + /// The `category:` value isn't one of the accepted categories. + #[error("invalid category: {category}, allows: {allows}")] + InvalidCategory { + /// The rejected category. + category: String, + /// The accepted categories. + allows: String, + }, + + /// The body carries more than one `ticket:` line. + #[error("multiple ticket tag lines")] + MultipleTicketTags, + + /// The `ticket:` tag has no value. + #[error("ticket tag empty")] + TicketTagEmpty, + + /// The `ticket:` value is still the template's placeholder. + #[error("invalid #000 ticket")] + InvalidPlaceholderTicket, + + /// The `ticket:` value isn't a valid URL. + #[error("ticket tag invalid url")] + TicketTagInvalidUrl, + + /// The `ticket:` value isn't a GitHub issue reference. + #[error("ticket tag not a valid github link, #123")] + TicketTagNotGithubLink, + + /// The `ticket:` value is neither a URL, `none`, nor `#123`. + #[error("invalid ticket tag")] + InvalidTicketTag, + + /// The body is missing the `category:` tag. + #[error("missing category tag")] + MissingCategoryTag, + + /// The body is missing the `ticket:` tag. + #[error("missing ticket tag")] + MissingTicketTag, + + /// The body carries more than one `feature_flag:` line. + #[error("multiple feature_flag tag lines")] + MultipleFeatureFlagTags, + + /// The `feature_flag:` tag has no value. + #[error("feature_flag tag empty")] + FeatureFlagTagEmpty, + + /// The `feature_flag:` value is still the template's placeholder. + #[error("invalid ? feature_flag")] + InvalidPlaceholderFeatureFlag, + + /// The `feature_flag:` value isn't snake case. + #[error("feature flags are snake case, see crates/featureset/src/lib.rs")] + FeatureFlagNotSnakeCase, + + /// The `feature_flag:` value isn't a known, enabled feature. + #[error("unknown feature flag, see crates/featureset/src/lib.rs")] + UnknownFeatureFlag, +} + +/// Result alias for PR verification. +pub type Result = std::result::Result; + +/// A GitHub pull request. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Pr { + /// The PR title. + #[serde(default)] + pub title: String, + /// The PR body. + #[serde(default)] + pub body: String, + /// The PR node ID. + #[serde(default, rename = "node_id")] + pub id: String, +} + +impl Pr { + /// Returns the PR parsed from the `GITHUB_PR` env variable. + pub fn from_env() -> Result { + let pr_json = env::var(PR_ENV).map_err(|_| Error::EnvVarNotSet { var: PR_ENV })?; + + if pr_json.trim().is_empty() { + return Err(Error::EnvVarNotSet { var: PR_ENV }); + } + + let pr: Self = serde_json::from_str(&pr_json).map_err(Error::UnmarshalPr)?; + + if pr.title.is_empty() || pr.body.is_empty() || pr.id.is_empty() { + return Err(Error::PrFieldNotSet); + } + + Ok(pr) + } +} + +/// Returns an error if the PR in the `GITHUB_PR` env variable doesn't match the +/// template. +pub fn verify() -> Result<()> { + let features = FeatureSet::from_config(Config { + min_status: Status::Alpha, + ..Default::default() + })?; + + let pr = Pr::from_env()?; + + // Skip dependabot PRs. + if pr.title.contains("build(deps)") && pr.body.contains("dependabot") { + return Ok(()); + } + + // Skip Renovate PRs. + if pr.title.contains("chore(deps)") && pr.body.contains("Renovate") { + return Ok(()); + } + + tracing::info!(title = %pr.title, "Verifying PR against template"); + tracing::info!("## PR body:\n{}\n####", pr.body); + + verify_title(&pr.title)?; + verify_body(&pr.body, &features)?; + + Ok(()) +} + +/// Returns an error if the PR title doesn't match the template. +pub fn verify_title(title: &str) -> Result<()> { + if title.len() > MAX_TITLE_LEN { + return Err(Error::TitleTooLong { + max: MAX_TITLE_LEN, + actual: title.len(), + }); + } + + let Some((prefix, suffix)) = title.split_once(':') else { + return Err(Error::TitleNotPrefixed); + }; + + if !TITLE_PREFIX.is_match(prefix) { + return Err(Error::TitlePrefixRegex); + } + + if suffix.len() < MIN_TITLE_SUFFIX_LEN { + return Err(Error::TitleSuffixTooShort); + } + + let Some(suffix) = suffix.strip_prefix(' ') else { + return Err(Error::TitleNotFollowedBySpace); + }; + + if suffix.chars().next().is_some_and(char::is_uppercase) { + return Err(Error::TitleSuffixCapital); + } + + if suffix.chars().next_back().is_some_and(is_punct) { + return Err(Error::TitleSuffixPunctuation); + } + + Ok(()) +} + +/// Returns an error if the PR body doesn't match the template. +/// +/// `features` resolves the `feature_flag:` tag; a flag naming a feature that +/// isn't enabled there is rejected. +pub fn verify_body(body: &str, features: &FeatureSet) -> Result<()> { + if body.trim().is_empty() { + return Err(Error::BodyEmpty); + } + + if body.contains("