Skip to content

Commit 73653c5

Browse files
committed
PR assignment based on work queue availability
Add checks in multiple points when identifying or finding an assignee. Filter out team members currently not having capacity, return messages instructing what to do in case the assignment is rejected. Signed-off-by: apiraino <apiraino@users.noreply.github.com>
1 parent 124e287 commit 73653c5

File tree

1 file changed

+130
-14
lines changed

1 file changed

+130
-14
lines changed

src/handlers/assign.rs

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
//! * `@rustbot release-assignment`: Removes the commenter's assignment.
88
//! * `r? @user`: Assigns to the given user (PRs only).
99
//!
10+
//! Note: this module does not handle review assignments issued from the
11+
//! GitHub "Assignees" dropdown menu
12+
//!
1013
//! This is capable of assigning to any user, even if they do not have write
1114
//! access to the repo. It does this by fake-assigning the bot and adding a
1215
//! "claimed by" section to the top-level comment.
@@ -20,7 +23,7 @@
2023
use crate::{
2124
config::{AssignConfig, WarnNonDefaultBranchException},
2225
github::{self, Event, FileDiff, Issue, IssuesAction, Selection},
23-
handlers::{Context, GithubClient, IssuesEvent},
26+
handlers::{pr_tracking::has_user_capacity, Context, GithubClient, IssuesEvent},
2427
interactions::EditIssueBody,
2528
};
2629
use anyhow::{bail, Context as _};
@@ -30,6 +33,7 @@ use rand::seq::IteratorRandom;
3033
use rust_team_data::v1::Teams;
3134
use std::collections::{HashMap, HashSet};
3235
use std::fmt;
36+
use tokio_postgres::Client as DbClient;
3337
use tracing as log;
3438

3539
#[cfg(test)]
@@ -80,6 +84,27 @@ const NON_DEFAULT_BRANCH_EXCEPTION: &str =
8084

8185
const SUBMODULE_WARNING_MSG: &str = "These commits modify **submodules**.";
8286

87+
pub const SELF_ASSIGN_HAS_NO_CAPACITY: &str = "
88+
You have insufficient capacity to be assigned the pull request at this time. PR assignment has been reverted.
89+
90+
Please choose another assignee or increase your assignment limit.
91+
92+
(see [documentation](https://forge.rust-lang.org/triagebot/pr-assignment-tracking.html))";
93+
94+
pub const REVIEWER_HAS_NO_CAPACITY: &str = "
95+
`{username}` has insufficient capacity to be assigned the pull request at this time. PR assignment has been reverted.
96+
97+
Please choose another assignee.
98+
99+
(see [documentation](https://forge.rust-lang.org/triagebot/pr-assignment-tracking.html))";
100+
101+
const NO_REVIEWER_HAS_CAPACITY: &str = "
102+
Could not find a reviewer with enough capacity to be assigned at this time. This is a problem.
103+
104+
Please contact us on [#t-infra](https://rust-lang.zulipchat.com/#narrow/stream/242791-t-infra) on Zulip.
105+
106+
cc: @jackh726 @apiraino";
107+
83108
fn on_vacation_msg(user: &str) -> String {
84109
ON_VACATION_WARNING.replace("{username}", user)
85110
}
@@ -287,6 +312,8 @@ async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) {
287312
/// Determines who to assign the PR to based on either an `r?` command, or
288313
/// based on which files were modified.
289314
///
315+
/// Will also check if candidates have capacity in their work queue.
316+
///
290317
/// Returns `(assignee, from_comment)` where `assignee` is who to assign to
291318
/// (or None if no assignee could be found). `from_comment` is a boolean
292319
/// indicating if the assignee came from an `r?` command (it is false if
@@ -297,13 +324,14 @@ async fn determine_assignee(
297324
config: &AssignConfig,
298325
diff: &[FileDiff],
299326
) -> anyhow::Result<(Option<String>, bool)> {
327+
let db_client = ctx.db.get().await;
300328
let teams = crate::team_data::teams(&ctx.github).await?;
301329
if let Some(name) = find_assign_command(ctx, event) {
302330
if is_self_assign(&name, &event.issue.user.login) {
303331
return Ok((Some(name.to_string()), true));
304332
}
305333
// User included `r?` in the opening PR body.
306-
match find_reviewer_from_names(&teams, config, &event.issue, &[name]) {
334+
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &[name]).await {
307335
Ok(assignee) => return Ok((Some(assignee), true)),
308336
Err(e) => {
309337
event
@@ -317,7 +345,9 @@ async fn determine_assignee(
317345
// Errors fall-through to try fallback group.
318346
match find_reviewers_from_diff(config, diff) {
319347
Ok(candidates) if !candidates.is_empty() => {
320-
match find_reviewer_from_names(&teams, config, &event.issue, &candidates) {
348+
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &candidates)
349+
.await
350+
{
321351
Ok(assignee) => return Ok((Some(assignee), false)),
322352
Err(FindReviewerError::TeamNotFound(team)) => log::warn!(
323353
"team {team} not found via diff from PR {}, \
@@ -327,7 +357,9 @@ async fn determine_assignee(
327357
// TODO: post a comment on the PR if the reviewers were filtered due to being on vacation
328358
Err(
329359
e @ FindReviewerError::NoReviewer { .. }
330-
| e @ FindReviewerError::AllReviewersFiltered { .. },
360+
| e @ FindReviewerError::AllReviewersFiltered { .. }
361+
| e @ FindReviewerError::NoReviewerHasCapacity
362+
| e @ FindReviewerError::ReviewerHasNoCapacity { .. },
331363
) => log::trace!(
332364
"no reviewer could be determined for PR {}: {e}",
333365
event.issue.global_id()
@@ -345,7 +377,7 @@ async fn determine_assignee(
345377
}
346378

347379
if let Some(fallback) = config.adhoc_groups.get("fallback") {
348-
match find_reviewer_from_names(&teams, config, &event.issue, fallback) {
380+
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, fallback).await {
349381
Ok(assignee) => return Ok((Some(assignee), false)),
350382
Err(e) => {
351383
log::trace!(
@@ -507,7 +539,20 @@ pub(super) async fn handle_command(
507539
// welcome message).
508540
return Ok(());
509541
}
542+
let db_client = ctx.db.get().await;
510543
if is_self_assign(&name, &event.user().login) {
544+
match has_user_capacity(&db_client, &name).await {
545+
Ok(work_queue) => work_queue.username,
546+
Err(_) => {
547+
issue
548+
.post_comment(
549+
&ctx.github,
550+
&REVIEWER_HAS_NO_CAPACITY.replace("{username}", &name),
551+
)
552+
.await?;
553+
return Ok(());
554+
}
555+
};
511556
name.to_string()
512557
} else {
513558
let teams = crate::team_data::teams(&ctx.github).await?;
@@ -528,7 +573,14 @@ pub(super) async fn handle_command(
528573
}
529574
}
530575

531-
match find_reviewer_from_names(&teams, config, issue, &[team_name.to_string()])
576+
match find_reviewer_from_names(
577+
&db_client,
578+
&teams,
579+
config,
580+
issue,
581+
&[team_name.to_string()],
582+
)
583+
.await
532584
{
533585
Ok(assignee) => assignee,
534586
Err(e) => {
@@ -539,7 +591,11 @@ pub(super) async fn handle_command(
539591
}
540592
}
541593
};
594+
595+
// This user is validated and can accept the PR
542596
set_assignee(issue, &ctx.github, &username).await;
597+
// This PR will now be registered in the reviewer's work queue
598+
// by the `pr_tracking` handler
543599
return Ok(());
544600
}
545601

@@ -597,6 +653,7 @@ pub(super) async fn handle_command(
597653

598654
e.apply(&ctx.github, String::new(), &data).await?;
599655

656+
// Assign the PR: user's work queue has been checked and can accept this PR
600657
match issue.set_assignee(&ctx.github, &to_assign).await {
601658
Ok(()) => return Ok(()), // we are done
602659
Err(github::AssignmentError::InvalidAssignee) => {
@@ -618,7 +675,7 @@ pub(super) async fn handle_command(
618675
}
619676

620677
#[derive(PartialEq, Debug)]
621-
enum FindReviewerError {
678+
pub enum FindReviewerError {
622679
/// User specified something like `r? foo/bar` where that team name could
623680
/// not be found.
624681
TeamNotFound(String),
@@ -636,6 +693,11 @@ enum FindReviewerError {
636693
initial: Vec<String>,
637694
filtered: Vec<String>,
638695
},
696+
/// No reviewer has capacity to accept a pull request assignment at this time
697+
NoReviewerHasCapacity,
698+
/// The requested reviewer has no capacity to accept a pull request
699+
/// assignment at this time
700+
ReviewerHasNoCapacity { username: String },
639701
}
640702

641703
impl std::error::Error for FindReviewerError {}
@@ -665,13 +727,23 @@ impl fmt::Display for FindReviewerError {
665727
write!(
666728
f,
667729
"Could not assign reviewer from: `{}`.\n\
668-
User(s) `{}` are either the PR author, already assigned, or on vacation, \
669-
and there are no other candidates.\n\
670-
Use `r?` to specify someone else to assign.",
730+
User(s) `{}` are either the PR author, already assigned, or on vacation. \
731+
If it's a team, we could not find any candidates.\n\
732+
Please use `r?` to specify someone else to assign.",
671733
initial.join(","),
672734
filtered.join(","),
673735
)
674736
}
737+
FindReviewerError::ReviewerHasNoCapacity { username } => {
738+
write!(
739+
f,
740+
"{}",
741+
REVIEWER_HAS_NO_CAPACITY.replace("{username}", username)
742+
)
743+
}
744+
FindReviewerError::NoReviewerHasCapacity => {
745+
write!(f, "{}", NO_REVIEWER_HAS_CAPACITY)
746+
}
675747
}
676748
}
677749
}
@@ -682,7 +754,8 @@ impl fmt::Display for FindReviewerError {
682754
/// `@octocat`, or names from the owners map. It can contain GitHub usernames,
683755
/// auto-assign groups, or rust-lang team names. It must have at least one
684756
/// entry.
685-
fn find_reviewer_from_names(
757+
async fn find_reviewer_from_names(
758+
db: &DbClient,
686759
teams: &Teams,
687760
config: &AssignConfig,
688761
issue: &Issue,
@@ -708,14 +781,57 @@ fn find_reviewer_from_names(
708781
//
709782
// These are all ideas for improving the selection here. However, I'm not
710783
// sure they are really worth the effort.
711-
Ok(candidates
784+
785+
// filter out team members without capacity
786+
let filtered_candidates = filter_by_capacity(db, &candidates)
787+
.await
788+
.expect("Error while filtering out team members");
789+
790+
if filtered_candidates.is_empty() {
791+
return Err(FindReviewerError::AllReviewersFiltered {
792+
initial: names.to_vec(),
793+
filtered: names.to_vec(),
794+
});
795+
}
796+
797+
log::debug!("Filtered list of candidates: {:?}", filtered_candidates);
798+
799+
Ok(filtered_candidates
712800
.into_iter()
713801
.choose(&mut rand::thread_rng())
714-
.expect("candidate_reviewers_from_names always returns at least one entry")
802+
.expect("candidate_reviewers_from_names should return at least one entry")
715803
.to_string())
716804
}
717805

718-
/// Returns a list of candidate usernames to choose as a reviewer.
806+
/// Filter out candidates not having review capacity
807+
async fn filter_by_capacity(
808+
db: &DbClient,
809+
candidates: &HashSet<&str>,
810+
) -> anyhow::Result<HashSet<String>> {
811+
let usernames = candidates
812+
.iter()
813+
.map(|c| *c)
814+
.collect::<Vec<&str>>()
815+
.join(",");
816+
817+
let q = format!(
818+
"
819+
SELECT username
820+
FROM review_prefs r
821+
JOIN users on users.user_id=r.user_id
822+
AND username = ANY('{{ {} }}')
823+
AND ARRAY_LENGTH(r.assigned_prs, 1) < max_assigned_prs",
824+
usernames
825+
);
826+
let result = db.query(&q, &[]).await.context("Select DB error")?;
827+
let mut candidates: HashSet<String> = HashSet::new();
828+
for row in result {
829+
candidates.insert(row.get("username"));
830+
}
831+
Ok(candidates)
832+
}
833+
834+
/// Returns a list of candidate usernames (from relevant teams) to choose as a reviewer.
719835
fn candidate_reviewers_from_names<'a>(
720836
teams: &'a Teams,
721837
config: &'a AssignConfig,

0 commit comments

Comments
 (0)