Skip to content

Commit c64ed00

Browse files
committed
Take review preferences into account when assigning reviews
1 parent 09bab14 commit c64ed00

File tree

3 files changed

+128
-7
lines changed

3 files changed

+128
-7
lines changed

src/db/review_prefs.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::db::users::record_username;
22
use crate::github::{User, UserId};
33
use anyhow::Context;
44
use serde::Serialize;
5+
use std::collections::HashMap;
56

67
#[derive(Debug, Serialize)]
78
pub struct ReviewPrefs {
@@ -37,6 +38,50 @@ WHERE review_prefs.user_id = $1;";
3738
Ok(row.map(|r| r.into()))
3839
}
3940

41+
/// Returns a set of review preferences for all passed usernames.
42+
/// Usernames are matched regardless of case.
43+
///
44+
/// Usernames that are not present in the resulting map have no review preferences configured
45+
/// in the database.
46+
pub async fn get_review_prefs_batch<'a>(
47+
db: &tokio_postgres::Client,
48+
users: &[&'a str],
49+
) -> anyhow::Result<HashMap<&'a str, ReviewPrefs>> {
50+
// We need to make sure that we match users regardless of case, but at the
51+
// same time we need to return the originally-cased usernames in the final hashmap.
52+
// At the same time, we can't depend on the order of results returned by the DB.
53+
// So we need to do some additional bookkeeping here.
54+
let lowercase_map: HashMap<String, &str> = users
55+
.iter()
56+
.map(|name| (name.to_lowercase(), *name))
57+
.collect();
58+
let lowercase_users: Vec<&str> = lowercase_map.keys().map(|s| s.as_str()).collect();
59+
60+
// The id/user_id/max_assigned_prs columns have to match the names used in
61+
// `From<tokio_postgres::row::Row> for ReviewPrefs`.
62+
let query = "
63+
SELECT lower(u.username) AS username, r.id AS id, r.user_id AS user_id, r.max_assigned_prs AS max_assigned_prs
64+
FROM review_prefs AS r
65+
JOIN users AS u ON u.user_id = r.user_id
66+
WHERE lower(u.username) = ANY($1);";
67+
68+
Ok(db
69+
.query(query, &[&lowercase_users])
70+
.await
71+
.context("Error retrieving review preferences from usernames")?
72+
.into_iter()
73+
.map(|row| {
74+
// Map back from the lowercase username to the original username.
75+
let username_lower: &str = row.get("username");
76+
let username = lowercase_map
77+
.get(username_lower)
78+
.expect("Lowercase username not found");
79+
let prefs: ReviewPrefs = row.into();
80+
(*username, prefs)
81+
})
82+
.collect())
83+
}
84+
4085
/// Updates review preferences of the specified user, or creates them
4186
/// if they do not exist yet.
4287
pub async fn upsert_review_prefs(

src/handlers/assign.rs

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
//! `assign.owners` config, it will auto-select an assignee based on the files
2121
//! the PR modifies.
2222
23+
use crate::db::review_prefs::get_review_prefs_batch;
24+
use crate::github::UserId;
2325
use crate::handlers::pr_tracking::ReviewerWorkqueue;
2426
use crate::{
2527
config::AssignConfig,
@@ -345,7 +347,9 @@ async fn determine_assignee(
345347
e @ FindReviewerError::NoReviewer { .. }
346348
| e @ FindReviewerError::ReviewerIsPrAuthor { .. }
347349
| e @ FindReviewerError::ReviewerAlreadyAssigned { .. }
348-
| e @ FindReviewerError::ReviewerOnVacation { .. },
350+
| e @ FindReviewerError::ReviewerOnVacation { .. }
351+
| e @ FindReviewerError::DatabaseError(_)
352+
| e @ FindReviewerError::ReviewerAtMaxCapacity { .. },
349353
) => log::trace!(
350354
"no reviewer could be determined for PR {}: {e}",
351355
event.issue.global_id()
@@ -675,6 +679,10 @@ enum FindReviewerError {
675679
ReviewerIsPrAuthor { username: String },
676680
/// Requested reviewer is already assigned to that PR
677681
ReviewerAlreadyAssigned { username: String },
682+
/// Data required for assignment could not be loaded from the DB.
683+
DatabaseError(String),
684+
/// The reviewer has too many PRs alreayd assigned.
685+
ReviewerAtMaxCapacity { username: String },
678686
}
679687

680688
impl std::error::Error for FindReviewerError {}
@@ -717,6 +725,17 @@ impl fmt::Display for FindReviewerError {
717725
REVIEWER_ALREADY_ASSIGNED.replace("{username}", username)
718726
)
719727
}
728+
FindReviewerError::DatabaseError(error) => {
729+
write!(f, "Database error: {error}")
730+
}
731+
FindReviewerError::ReviewerAtMaxCapacity { username } => {
732+
write!(
733+
f,
734+
r"`{username}` has too many PRs assigned to them.
735+
736+
Please select a different reviewer.",
737+
)
738+
}
720739
}
721740
}
722741
}
@@ -728,7 +747,7 @@ impl fmt::Display for FindReviewerError {
728747
/// auto-assign groups, or rust-lang team names. It must have at least one
729748
/// entry.
730749
async fn find_reviewer_from_names(
731-
_db: &DbClient,
750+
db: &DbClient,
732751
workqueue: Arc<RwLock<ReviewerWorkqueue>>,
733752
teams: &Teams,
734753
config: &AssignConfig,
@@ -742,7 +761,10 @@ async fn find_reviewer_from_names(
742761
}
743762
}
744763

745-
let candidates = candidate_reviewers_from_names(workqueue, teams, config, issue, names)?;
764+
let candidates =
765+
candidate_reviewers_from_names(db, workqueue, teams, config, issue, names).await?;
766+
assert!(!candidates.is_empty());
767+
746768
// This uses a relatively primitive random choice algorithm.
747769
// GitHub's CODEOWNERS supports much more sophisticated options, such as:
748770
//
@@ -846,20 +868,23 @@ fn expand_teams_and_groups(
846868

847869
/// Returns a list of candidate usernames (from relevant teams) to choose as a reviewer.
848870
/// If not reviewer is available, returns an error.
849-
fn candidate_reviewers_from_names<'a>(
871+
async fn candidate_reviewers_from_names<'a>(
872+
db: &DbClient,
850873
workqueue: Arc<RwLock<ReviewerWorkqueue>>,
851874
teams: &'a Teams,
852875
config: &'a AssignConfig,
853876
issue: &Issue,
854877
names: &'a [String],
855878
) -> Result<HashSet<String>, FindReviewerError> {
879+
// Step 1: expand teams and groups into candidate names
856880
let (expanded, expansion_happened) = expand_teams_and_groups(teams, issue, config, names)?;
857881
let expanded_count = expanded.len();
858882

859883
// Set of candidate usernames to choose from.
860884
// We go through each expanded candidate and store either success or an error for them.
861885
let mut candidates: Vec<Result<String, FindReviewerError>> = Vec::new();
862886

887+
// Step 2: pre-filter candidates based on checks that we can perform quickly
863888
for candidate in expanded {
864889
let name_lower = candidate.to_lowercase();
865890
let is_pr_author = name_lower == issue.user.login.to_lowercase();
@@ -896,9 +921,50 @@ fn candidate_reviewers_from_names<'a>(
896921
}
897922
assert_eq!(candidates.len(), expanded_count);
898923

899-
let valid_candidates: HashSet<String> = candidates
924+
if config.review_prefs.is_some() {
925+
// Step 3: gather potential usernames to form a DB query for review preferences
926+
let usernames: Vec<String> = candidates
927+
.iter()
928+
.filter_map(|res| res.as_deref().ok().map(|s| s.to_string()))
929+
.collect();
930+
let usernames: Vec<&str> = usernames.iter().map(|s| s.as_str()).collect();
931+
let review_prefs = get_review_prefs_batch(db, &usernames)
932+
.await
933+
.context("cannot fetch review preferences")
934+
.map_err(|e| FindReviewerError::DatabaseError(e.to_string()))?;
935+
936+
let workqueue = workqueue.read().await;
937+
938+
// Step 4: check review preferences
939+
candidates = candidates
940+
.into_iter()
941+
.map(|username| {
942+
// Only consider candidates that did not have an earlier error
943+
let username = username?;
944+
945+
// If no review prefs were found, we assume the default unlimited
946+
// review capacity.
947+
let Some(review_prefs) = review_prefs.get(username.as_str()) else {
948+
return Ok(username);
949+
};
950+
let Some(capacity) = review_prefs.max_assigned_prs else {
951+
return Ok(username);
952+
};
953+
let assigned_prs = workqueue.assigned_pr_count(review_prefs.user_id as UserId);
954+
// Can we assign one more PR?
955+
if (assigned_prs as i32) < capacity {
956+
Ok(username)
957+
} else {
958+
Err(FindReviewerError::ReviewerAtMaxCapacity { username })
959+
}
960+
})
961+
.collect();
962+
}
963+
assert_eq!(candidates.len(), expanded_count);
964+
965+
let valid_candidates: HashSet<&str> = candidates
900966
.iter()
901-
.filter_map(|res| res.as_ref().ok().cloned())
967+
.filter_map(|res| res.as_deref().ok())
902968
.collect();
903969

904970
if valid_candidates.is_empty() {
@@ -925,6 +991,9 @@ fn candidate_reviewers_from_names<'a>(
925991
})
926992
}
927993
} else {
928-
Ok(valid_candidates)
994+
Ok(valid_candidates
995+
.into_iter()
996+
.map(|s| s.to_string())
997+
.collect())
929998
}
930999
}

src/handlers/pr_tracking.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ impl ReviewerWorkqueue {
3636
pub fn new(reviewers: HashMap<UserId, HashSet<PullRequestNumber>>) -> Self {
3737
Self { reviewers }
3838
}
39+
40+
pub fn assigned_pr_count(&self, user_id: UserId) -> u64 {
41+
self.reviewers
42+
.get(&user_id)
43+
.map(|prs| prs.len() as u64)
44+
.unwrap_or(0)
45+
}
3946
}
4047

4148
pub(super) enum ReviewPrefsInput {

0 commit comments

Comments
 (0)