Skip to content

Commit 4a9b71f

Browse files
authored
Merge pull request #1947 from Kobzol/assignment-take-limit-into-account
Take review preferences into account when determining reviewers
2 parents 76b673c + a2d136a commit 4a9b71f

File tree

7 files changed

+537
-130
lines changed

7 files changed

+537
-130
lines changed

src/config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ pub(crate) struct PingTeamConfig {
9292
pub(crate) label: Option<String>,
9393
}
9494

95+
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
96+
#[serde(deny_unknown_fields)]
97+
pub(crate) struct AssignReviewPrefsConfig {}
98+
9599
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
96100
#[serde(deny_unknown_fields)]
97101
pub(crate) struct AssignConfig {
@@ -111,6 +115,9 @@ pub(crate) struct AssignConfig {
111115
pub(crate) owners: HashMap<String, Vec<String>>,
112116
#[serde(default)]
113117
pub(crate) users_on_vacation: HashSet<String>,
118+
/// Should review preferences be taken into account when deciding who to assign to a PR?
119+
#[serde(default)]
120+
pub(crate) review_prefs: Option<AssignReviewPrefsConfig>,
114121
}
115122

116123
impl AssignConfig {
@@ -656,6 +663,7 @@ mod tests {
656663
adhoc_groups: HashMap::new(),
657664
owners: HashMap::new(),
658665
users_on_vacation: HashSet::from(["jyn514".into()]),
666+
review_prefs: None,
659667
}),
660668
note: Some(NoteConfig { _empty: () }),
661669
ping: Some(PingConfig { teams: ping_teams }),
@@ -736,6 +744,7 @@ mod tests {
736744
adhoc_groups: HashMap::new(),
737745
owners: HashMap::new(),
738746
users_on_vacation: HashSet::new(),
747+
review_prefs: None,
739748
}),
740749
note: None,
741750
ping: None,

src/db/review_prefs.rs

Lines changed: 57 additions & 14 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(
@@ -67,17 +112,14 @@ mod tests {
67112
use crate::db::review_prefs::{get_review_prefs, upsert_review_prefs};
68113
use crate::db::users::get_user;
69114
use crate::tests::github::user;
70-
use crate::tests::run_test;
115+
use crate::tests::run_db_test;
71116

72117
#[tokio::test]
73118
async fn insert_prefs_create_user() {
74-
run_test(|ctx| async {
75-
let db = ctx.db_client().await;
76-
119+
run_db_test(|ctx| async {
77120
let user = user("Martin", 1);
78-
upsert_review_prefs(&db, user.clone(), Some(1)).await?;
79-
80-
assert_eq!(get_user(&db, user.id).await?.unwrap(), user);
121+
upsert_review_prefs(&ctx.db_client(), user.clone(), Some(1)).await?;
122+
assert_eq!(get_user(&ctx.db_client(), user.id).await?.unwrap(), user);
81123

82124
Ok(ctx)
83125
})
@@ -86,12 +128,13 @@ mod tests {
86128

87129
#[tokio::test]
88130
async fn insert_max_assigned_prs() {
89-
run_test(|ctx| async {
90-
let db = ctx.db_client().await;
91-
92-
upsert_review_prefs(&db, user("Martin", 1), Some(5)).await?;
131+
run_db_test(|ctx| async {
132+
upsert_review_prefs(&ctx.db_client(), user("Martin", 1), Some(5)).await?;
93133
assert_eq!(
94-
get_review_prefs(&db, 1).await?.unwrap().max_assigned_prs,
134+
get_review_prefs(&ctx.db_client(), 1)
135+
.await?
136+
.unwrap()
137+
.max_assigned_prs,
95138
Some(5)
96139
);
97140

@@ -102,8 +145,8 @@ mod tests {
102145

103146
#[tokio::test]
104147
async fn update_max_assigned_prs() {
105-
run_test(|ctx| async {
106-
let db = ctx.db_client().await;
148+
run_db_test(|ctx| async {
149+
let db = ctx.db_client();
107150

108151
upsert_review_prefs(&db, user("Martin", 1), Some(5)).await?;
109152
assert_eq!(

src/db/users.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ WHERE user_id = $1;",
4141
#[cfg(test)]
4242
mod tests {
4343
use crate::db::users::{get_user, record_username};
44-
use crate::tests::run_test;
44+
use crate::tests::run_db_test;
4545

4646
#[tokio::test]
4747
async fn update_username_on_conflict() {
48-
run_test(|ctx| async {
49-
let db = ctx.db_client().await;
48+
run_db_test(|ctx| async {
49+
let db = ctx.db_client();
5050

5151
record_username(&db, 1, "Foo").await?;
5252
record_username(&db, 1, "Bar").await?;

src/handlers/assign.rs

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
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;
25+
use crate::handlers::pr_tracking::ReviewerWorkqueue;
2326
use crate::{
2427
config::AssignConfig,
2528
github::{self, Event, FileDiff, Issue, IssuesAction, Selection},
@@ -33,6 +36,8 @@ use rand::seq::IteratorRandom;
3336
use rust_team_data::v1::Teams;
3437
use std::collections::{HashMap, HashSet};
3538
use std::fmt;
39+
use std::sync::Arc;
40+
use tokio::sync::RwLock;
3641
use tokio_postgres::Client as DbClient;
3742
use tracing as log;
3843

@@ -299,7 +304,16 @@ async fn determine_assignee(
299304
let teams = crate::team_data::teams(&ctx.github).await?;
300305
if let Some(name) = assign_command {
301306
// User included `r?` in the opening PR body.
302-
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &[name]).await {
307+
match find_reviewer_from_names(
308+
&db_client,
309+
ctx.workqueue.clone(),
310+
&teams,
311+
config,
312+
&event.issue,
313+
&[name],
314+
)
315+
.await
316+
{
303317
Ok(assignee) => return Ok((Some(assignee), true)),
304318
Err(e) => {
305319
event
@@ -313,8 +327,15 @@ async fn determine_assignee(
313327
// Errors fall-through to try fallback group.
314328
match find_reviewers_from_diff(config, diff) {
315329
Ok(candidates) if !candidates.is_empty() => {
316-
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &candidates)
317-
.await
330+
match find_reviewer_from_names(
331+
&db_client,
332+
ctx.workqueue.clone(),
333+
&teams,
334+
config,
335+
&event.issue,
336+
&candidates,
337+
)
338+
.await
318339
{
319340
Ok(assignee) => return Ok((Some(assignee), false)),
320341
Err(FindReviewerError::TeamNotFound(team)) => log::warn!(
@@ -326,7 +347,9 @@ async fn determine_assignee(
326347
e @ FindReviewerError::NoReviewer { .. }
327348
| e @ FindReviewerError::ReviewerIsPrAuthor { .. }
328349
| e @ FindReviewerError::ReviewerAlreadyAssigned { .. }
329-
| e @ FindReviewerError::ReviewerOnVacation { .. },
350+
| e @ FindReviewerError::ReviewerOnVacation { .. }
351+
| e @ FindReviewerError::DatabaseError(_)
352+
| e @ FindReviewerError::ReviewerAtMaxCapacity { .. },
330353
) => log::trace!(
331354
"no reviewer could be determined for PR {}: {e}",
332355
event.issue.global_id()
@@ -344,7 +367,16 @@ async fn determine_assignee(
344367
}
345368

346369
if let Some(fallback) = config.adhoc_groups.get("fallback") {
347-
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, fallback).await {
370+
match find_reviewer_from_names(
371+
&db_client,
372+
ctx.workqueue.clone(),
373+
&teams,
374+
config,
375+
&event.issue,
376+
fallback,
377+
)
378+
.await
379+
{
348380
Ok(assignee) => return Ok((Some(assignee), false)),
349381
Err(e) => {
350382
log::trace!(
@@ -522,6 +554,7 @@ pub(super) async fn handle_command(
522554
let db_client = ctx.db.get().await;
523555
let assignee = match find_reviewer_from_names(
524556
&db_client,
557+
ctx.workqueue.clone(),
525558
&teams,
526559
config,
527560
issue,
@@ -646,6 +679,10 @@ enum FindReviewerError {
646679
ReviewerIsPrAuthor { username: String },
647680
/// Requested reviewer is already assigned to that PR
648681
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 },
649686
}
650687

651688
impl std::error::Error for FindReviewerError {}
@@ -688,6 +725,17 @@ impl fmt::Display for FindReviewerError {
688725
REVIEWER_ALREADY_ASSIGNED.replace("{username}", username)
689726
)
690727
}
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+
}
691739
}
692740
}
693741
}
@@ -699,7 +747,8 @@ impl fmt::Display for FindReviewerError {
699747
/// auto-assign groups, or rust-lang team names. It must have at least one
700748
/// entry.
701749
async fn find_reviewer_from_names(
702-
_db: &DbClient,
750+
db: &DbClient,
751+
workqueue: Arc<RwLock<ReviewerWorkqueue>>,
703752
teams: &Teams,
704753
config: &AssignConfig,
705754
issue: &Issue,
@@ -712,7 +761,10 @@ async fn find_reviewer_from_names(
712761
}
713762
}
714763

715-
let candidates = candidate_reviewers_from_names(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+
716768
// This uses a relatively primitive random choice algorithm.
717769
// GitHub's CODEOWNERS supports much more sophisticated options, such as:
718770
//
@@ -816,19 +868,23 @@ fn expand_teams_and_groups(
816868

817869
/// Returns a list of candidate usernames (from relevant teams) to choose as a reviewer.
818870
/// If not reviewer is available, returns an error.
819-
fn candidate_reviewers_from_names<'a>(
871+
async fn candidate_reviewers_from_names<'a>(
872+
db: &DbClient,
873+
workqueue: Arc<RwLock<ReviewerWorkqueue>>,
820874
teams: &'a Teams,
821875
config: &'a AssignConfig,
822876
issue: &Issue,
823877
names: &'a [String],
824878
) -> Result<HashSet<String>, FindReviewerError> {
879+
// Step 1: expand teams and groups into candidate names
825880
let (expanded, expansion_happened) = expand_teams_and_groups(teams, issue, config, names)?;
826881
let expanded_count = expanded.len();
827882

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

887+
// Step 2: pre-filter candidates based on checks that we can perform quickly
832888
for candidate in expanded {
833889
let name_lower = candidate.to_lowercase();
834890
let is_pr_author = name_lower == issue.user.login.to_lowercase();
@@ -865,9 +921,50 @@ fn candidate_reviewers_from_names<'a>(
865921
}
866922
assert_eq!(candidates.len(), expanded_count);
867923

868-
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
869966
.iter()
870-
.filter_map(|res| res.as_ref().ok().cloned())
967+
.filter_map(|res| res.as_deref().ok())
871968
.collect();
872969

873970
if valid_candidates.is_empty() {
@@ -894,6 +991,9 @@ fn candidate_reviewers_from_names<'a>(
894991
})
895992
}
896993
} else {
897-
Ok(valid_candidates)
994+
Ok(valid_candidates
995+
.into_iter()
996+
.map(|s| s.to_string())
997+
.collect())
898998
}
899999
}

0 commit comments

Comments
 (0)