Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
989 changes: 453 additions & 536 deletions Cargo.lock

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions docs/config/.gitvote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,41 @@ profiles:
#
duration: 5m

# Pass threshold (required)
# Pass rule (required)
#
# Percentage of votes in favor required to pass the vote
# A profile must define exactly one of `pass_threshold` or `pass_rule`.
#
# `pass_threshold` is the percentage of votes in favor required to pass.
#
# The percentage is calculated based on the number of votes in favor and the
# pass threshold base, which is the number of allowed voters by default (see
# the pass_threshold_base and allowed_voters fields below for more details).
# It must be greater than 0 and not greater than 100.
pass_threshold: 50
#
# `pass_threshold` is the legacy format and remains the default percentage
# rule when `pass_rule` is not provided. The equivalent typed rule is:
#
# pass_rule:
# type: percentage
# threshold: 50
#
# Alternatively, the `vote_count` rule passes a vote based on the number of
# binding votes cast. The example below requires at least three approvals
# and no rejections. Abstentions and users who have not voted do not affect
# either count.
#
# pass_rule:
# type: vote_count
# minimum_approvals: 3
# maximum_rejections: 0

# Pass threshold base (optional)
#
# Number the in favor and against percentages are calculated over, which is
# also used to decide if the vote passes. Only binding votes are counted.
# This option applies to percentage rules only; vote_count rules use the
# configured approval and rejection counts directly.
#
# - allowed_voters (default): all users with binding votes, so users who
# do not vote count towards the percentage calculation too.
Expand Down Expand Up @@ -143,6 +164,10 @@ profiles:
# are direct collaborators, organization members with access through team
# memberships, organization members with access through default organization
# permissions, and organization owners.
# When the `vote_count` pass rule is used, only binding votes from these
# allowed users count toward `minimum_approvals` and `maximum_rejections`.
# This makes it possible to require, for example, three maintainer approvals
# and no maintainer rejections by listing the maintainers team below.
#
# By default, teams' members with the maintainer role are allowed to vote
# as well. By using the `exclude_team_maintainers` option, it's possible to
Expand Down Expand Up @@ -208,6 +233,10 @@ profiles:
# the users who have not voted yet cannot make it fail. When using
# votes_cast, this means that a vote that is currently passing may stay open
# until its duration ends.
#
# With a vote_count rule, a vote is only closed early once the minimum
# approvals have been reached and votes from all users who have not voted
# yet could not exceed maximum_rejections.
# 
# close_on_passing: true
#
Expand Down
59 changes: 55 additions & 4 deletions src/cfg_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const ERR_INVALID_PASS_THRESHOLD: &str = "pass threshold must be greater than 0
/// Error message used when teams are listed in the allowed voters section on a
/// repository that does not belong to an organization.
const ERR_TEAMS_NOT_ALLOWED: &str = "teams in allowed voters can only be used in organizations";
const ERR_PASS_RULES_MUTUALLY_EXCLUSIVE: &str = "pass_threshold and pass_rule cannot be used together";
const ERR_MINIMUM_APPROVALS_INVALID: &str = "minimum_approvals must be greater than zero";

/// Type alias to represent a profile name.
type ProfileName = String;
Expand Down Expand Up @@ -93,8 +95,11 @@ impl AutomationRule {
pub(crate) struct CfgProfile {
#[serde(with = "humantime_serde")]
pub duration: Duration,
#[serde(default)]
pub pass_threshold: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub pass_rule: Option<PassRule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_voters: Option<AllowedVoters>,
#[serde(skip_serializing_if = "Option::is_none")]
pub announcements: Option<Announcements>,
Expand Down Expand Up @@ -138,10 +143,24 @@ impl CfgProfile {

/// Check if the configuration profile is valid.
fn validate(&self, is_org: bool) -> Result<()> {
// The pass threshold must be a percentage in the (0, 100] range. Written
// as a negated range check so that NaN values are rejected as well.
if !(self.pass_threshold > 0.0 && self.pass_threshold <= 100.0) {
bail!(ERR_INVALID_PASS_THRESHOLD);
if self.pass_rule.is_some() && self.pass_threshold != 0.0 {
bail!(ERR_PASS_RULES_MUTUALLY_EXCLUSIVE);
Comment on lines +146 to +147
}

match &self.pass_rule {
Some(PassRule::VoteCount {
minimum_approvals: 0, ..
}) => bail!(ERR_MINIMUM_APPROVALS_INVALID),
Some(PassRule::VoteCount { .. }) => {}
pass_rule => {
let percentage_threshold = pass_rule
.as_ref()
.and_then(PassRule::percentage_threshold)
.unwrap_or(self.pass_threshold);
if !(percentage_threshold > 0.0 && percentage_threshold <= 100.0) {
bail!(ERR_INVALID_PASS_THRESHOLD);
}
}
}

// Only repositories that belong to some organization can use teams in
Expand All @@ -158,6 +177,38 @@ impl CfgProfile {
}
}

/// Rule used to determine whether a vote passes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum PassRule {
Percentage {
threshold: f64,
},
VoteCount {
minimum_approvals: u64,
maximum_rejections: u64,
},
}

impl PassRule {
pub(crate) fn percentage_threshold(&self) -> Option<f64> {
match self {
Self::Percentage { threshold } => Some(*threshold),
Self::VoteCount { .. } => None,
}
}

pub(crate) fn vote_count_thresholds(&self) -> Option<(u64, u64)> {
match self {
Self::Percentage { .. } => None,
Self::VoteCount {
minimum_approvals,
maximum_rejections,
} => Some((*minimum_approvals, *maximum_rejections)),
}
}
}

/// Represents the teams and users allowed to vote.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub(crate) struct AllowedVoters {
Expand Down
61 changes: 61 additions & 0 deletions src/cfg_repo/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,30 @@ fn cfg_profile_deserialize_pass_threshold_base_invalid() {
}
}

#[test]
fn cfg_profile_deserialize_pass_rules() {
let percentage: CfgProfile =
serde_yaml::from_str("duration: 5m\npass_rule:\n type: percentage\n threshold: 50\n").unwrap();
assert_eq!(
percentage.pass_rule,
Some(PassRule::Percentage { threshold: 50.0 })
);
percentage.validate(OWNER_IS_ORG).unwrap();

let vote_count: CfgProfile = serde_yaml::from_str(
"duration: 5m\npass_rule:\n type: vote_count\n minimum_approvals: 3\n maximum_rejections: 0\n",
)
.unwrap();
assert_eq!(
vote_count.pass_rule,
Some(PassRule::VoteCount {
minimum_approvals: 3,
maximum_rejections: 0,
})
);
vote_count.validate(OWNER_IS_ORG).unwrap();
}

#[test]
fn cfg_profile_deserialize_stored_json() {
// Setup JSON as stored in the vote cfg column
Expand Down Expand Up @@ -240,6 +264,7 @@ fn cfg_profile_deserialize_stored_json() {
CfgProfile {
duration: Duration::from_secs(2_630_016 + 2 * 86_400 + 3 * 3_600),
pass_threshold: 66.5,
pass_rule: None,
allowed_voters: Some(AllowedVoters {
teams: Some(vec![TEAM1.to_string()]),
users: Some(vec![USER1.to_string()]),
Expand Down Expand Up @@ -331,6 +356,7 @@ fn cfg_profile_serialize_full() {
let cfg = CfgProfile {
duration: Duration::from_hours(49),
pass_threshold: 75.0,
pass_rule: None,
allowed_voters: Some(AllowedVoters {
teams: Some(vec![TEAM1.to_string()]),
users: Some(vec![USER1.to_string()]),
Expand Down Expand Up @@ -418,6 +444,40 @@ fn cfg_profile_validate_invalid_pass_threshold() {
}
}

#[test]
fn cfg_profile_validate_pass_rule_conflicts_and_limits() {
let conflicting = CfgProfile {
pass_threshold: 50.0,
pass_rule: Some(PassRule::Percentage { threshold: 50.0 }),
..Default::default()
};
assert_eq!(
conflicting.validate(OWNER_IS_ORG).unwrap_err().to_string(),
ERR_PASS_RULES_MUTUALLY_EXCLUSIVE
);

let invalid_percentage = CfgProfile {
pass_rule: Some(PassRule::Percentage { threshold: 0.0 }),
..Default::default()
};
assert_eq!(
invalid_percentage.validate(OWNER_IS_ORG).unwrap_err().to_string(),
ERR_INVALID_PASS_THRESHOLD
);

let invalid_vote_count = CfgProfile {
pass_rule: Some(PassRule::VoteCount {
minimum_approvals: 0,
maximum_rejections: 0,
}),
..Default::default()
};
assert_eq!(
invalid_vote_count.validate(OWNER_IS_ORG).unwrap_err().to_string(),
ERR_MINIMUM_APPROVALS_INVALID
);
}

#[test]
fn cfg_profile_validate_owner_not_org_with_empty_teams() {
// Setup profile with empty teams
Expand Down Expand Up @@ -545,6 +605,7 @@ profiles:
CfgProfile {
duration: Duration::from_hours(14 * 24),
pass_threshold: 66.6,
pass_rule: None,
allowed_voters: Some(AllowedVoters {
teams: Some(vec![TEAM1.to_string()]),
users: Some(vec![USER1.to_string()]),
Expand Down
40 changes: 37 additions & 3 deletions src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use tokio_postgres::{Row, types::Json};
use uuid::Uuid;

use crate::{
cfg_repo::{CfgProfile, PassThresholdBase},
cfg_repo::{CfgProfile, PassRule, PassThresholdBase},
github::{DynGH, UserName},
};

Expand Down Expand Up @@ -105,6 +105,8 @@ pub(crate) struct VoteResults {
pub passed: bool,
pub in_favor_percentage: f64,
pub pass_threshold: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pass_rule: Option<PassRule>,
pub in_favor: i64,
pub against: i64,
pub against_percentage: f64,
Expand All @@ -122,7 +124,31 @@ impl VoteResults {
/// that further votes from pending voters cannot make it fail (assuming the
/// votes already cast and the allowed voters do not change).
pub(crate) fn passes_with_all_allowed_voters(&self) -> bool {
threshold_reached(self.in_favor, self.pass_threshold, self.allowed_voters)
match &self.pass_rule {
Some(PassRule::VoteCount {
minimum_approvals,
maximum_rejections,
}) => {
self.in_favor as u64 >= *minimum_approvals
&& (self.against + self.not_voted) as u64 <= *maximum_rejections
}
Some(PassRule::Percentage { threshold }) => {
threshold_reached(self.in_favor, *threshold, self.allowed_voters)
}
None => threshold_reached(self.in_favor, self.pass_threshold, self.allowed_voters),
}
}

pub(crate) fn percentage_threshold(&self) -> Option<f64> {
match &self.pass_rule {
Some(PassRule::Percentage { threshold }) => Some(*threshold),
Some(PassRule::VoteCount { .. }) => None,
None => Some(self.pass_threshold),
}
}

pub(crate) fn vote_count_thresholds(&self) -> Option<(u64, u64)> {
self.pass_rule.as_ref().and_then(PassRule::vote_count_thresholds)
}
}

Expand Down Expand Up @@ -230,12 +256,20 @@ pub(crate) async fn calculate<'a>(
.collect();

// Check if the vote passed
let passed = threshold_reached(in_favor, vote.cfg.pass_threshold, threshold_base);
let passed = match &vote.cfg.pass_rule {
Some(PassRule::Percentage { threshold }) => threshold_reached(in_favor, *threshold, threshold_base),
Some(PassRule::VoteCount {
minimum_approvals,
maximum_rejections,
}) => in_favor as u64 >= *minimum_approvals && against as u64 <= *maximum_rejections,
None => threshold_reached(in_favor, vote.cfg.pass_threshold, threshold_base),
};

Ok(VoteResults {
passed,
in_favor_percentage,
pass_threshold: vote.cfg.pass_threshold,
pass_rule: vote.cfg.pass_rule.clone(),
in_favor,
against,
against_percentage,
Expand Down
Loading