Skip to content

Commit 8030301

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.
1 parent bb17381 commit 8030301

File tree

2 files changed

+133
-19
lines changed

2 files changed

+133
-19
lines changed

src/handlers/assign.rs

Lines changed: 130 additions & 15 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,
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)]
@@ -75,6 +79,27 @@ const NON_DEFAULT_BRANCH: &str =
7579

7680
const SUBMODULE_WARNING_MSG: &str = "These commits modify **submodules**.";
7781

82+
pub const SELF_ASSIGN_HAS_NO_CAPACITY: &str = "
83+
You have insufficient capacity to be assigned the pull request at this time. PR assignment has been reverted.
84+
85+
Please choose another assignee or increase your assignment limit.
86+
87+
(see [documentation](https://forge.rust-lang.org/triagebot/pr-assignment-tracking.html))";
88+
89+
pub const REVIEWER_HAS_NO_CAPACITY: &str = "
90+
`{username}` has insufficient capacity to be assigned the pull request at this time. PR assignment has been reverted.
91+
92+
Please choose another assignee.
93+
94+
(see [documentation](https://forge.rust-lang.org/triagebot/pr-assignment-tracking.html))";
95+
96+
const NO_REVIEWER_HAS_CAPACITY: &str = "
97+
Could not find a reviewer with enough capacity to be assigned at this time. This is a problem.
98+
99+
Please contact us on [#t-infra](https://rust-lang.zulipchat.com/#narrow/stream/242791-t-infra) on Zulip.
100+
101+
cc: @jackh726 @apiraino";
102+
78103
fn on_vacation_msg(user: &str) -> String {
79104
ON_VACATION_WARNING.replace("{username}", user)
80105
}
@@ -272,6 +297,8 @@ async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) {
272297
/// Determines who to assign the PR to based on either an `r?` command, or
273298
/// based on which files were modified.
274299
///
300+
/// Will also check if candidates have capacity in their work queue.
301+
///
275302
/// Returns `(assignee, from_comment)` where `assignee` is who to assign to
276303
/// (or None if no assignee could be found). `from_comment` is a boolean
277304
/// indicating if the assignee came from an `r?` command (it is false if
@@ -282,13 +309,14 @@ async fn determine_assignee(
282309
config: &AssignConfig,
283310
diff: &[FileDiff],
284311
) -> anyhow::Result<(Option<String>, bool)> {
312+
let db_client = ctx.db.get().await;
285313
let teams = crate::team_data::teams(&ctx.github).await?;
286314
if let Some(name) = find_assign_command(ctx, event) {
287315
if is_self_assign(&name, &event.issue.user.login) {
288316
return Ok((Some(name.to_string()), true));
289317
}
290318
// User included `r?` in the opening PR body.
291-
match find_reviewer_from_names(&teams, config, &event.issue, &[name]) {
319+
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &[name]).await {
292320
Ok(assignee) => return Ok((Some(assignee), true)),
293321
Err(e) => {
294322
event
@@ -302,7 +330,9 @@ async fn determine_assignee(
302330
// Errors fall-through to try fallback group.
303331
match find_reviewers_from_diff(config, diff) {
304332
Ok(candidates) if !candidates.is_empty() => {
305-
match find_reviewer_from_names(&teams, config, &event.issue, &candidates) {
333+
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &candidates)
334+
.await
335+
{
306336
Ok(assignee) => return Ok((Some(assignee), false)),
307337
Err(FindReviewerError::TeamNotFound(team)) => log::warn!(
308338
"team {team} not found via diff from PR {}, \
@@ -312,7 +342,9 @@ async fn determine_assignee(
312342
// TODO: post a comment on the PR if the reviewers were filtered due to being on vacation
313343
Err(
314344
e @ FindReviewerError::NoReviewer { .. }
315-
| e @ FindReviewerError::AllReviewersFiltered { .. },
345+
| e @ FindReviewerError::AllReviewersFiltered { .. }
346+
| e @ FindReviewerError::NoReviewerHasCapacity
347+
| e @ FindReviewerError::ReviewerHasNoCapacity { .. },
316348
) => log::trace!(
317349
"no reviewer could be determined for PR {}: {e}",
318350
event.issue.global_id()
@@ -330,7 +362,7 @@ async fn determine_assignee(
330362
}
331363

332364
if let Some(fallback) = config.adhoc_groups.get("fallback") {
333-
match find_reviewer_from_names(&teams, config, &event.issue, fallback) {
365+
match find_reviewer_from_names(&db_client, &teams, config, &event.issue, fallback).await {
334366
Ok(assignee) => return Ok((Some(assignee), false)),
335367
Err(e) => {
336368
log::trace!(
@@ -492,7 +524,20 @@ pub(super) async fn handle_command(
492524
// welcome message).
493525
return Ok(());
494526
}
527+
let db_client = ctx.db.get().await;
495528
if is_self_assign(&name, &event.user().login) {
529+
match has_user_capacity(&db_client, &name).await {
530+
Ok(work_queue) => work_queue.username,
531+
Err(_) => {
532+
issue
533+
.post_comment(
534+
&ctx.github,
535+
&REVIEWER_HAS_NO_CAPACITY.replace("{username}", &name),
536+
)
537+
.await?;
538+
return Ok(());
539+
}
540+
};
496541
name.to_string()
497542
} else {
498543
let teams = crate::team_data::teams(&ctx.github).await?;
@@ -512,8 +557,14 @@ pub(super) async fn handle_command(
512557
}
513558
}
514559
}
515-
516-
match find_reviewer_from_names(&teams, config, issue, &[team_name.to_string()])
560+
match find_reviewer_from_names(
561+
&db_client,
562+
&teams,
563+
config,
564+
issue,
565+
&[team_name.to_string()],
566+
)
567+
.await
517568
{
518569
Ok(assignee) => assignee,
519570
Err(e) => {
@@ -524,7 +575,11 @@ pub(super) async fn handle_command(
524575
}
525576
}
526577
};
578+
579+
// This user is validated and can accept the PR
527580
set_assignee(issue, &ctx.github, &username).await;
581+
// This PR will now be registered in the reviewer's work queue
582+
// by the `pr_tracking` handler
528583
return Ok(());
529584
}
530585

@@ -582,6 +637,7 @@ pub(super) async fn handle_command(
582637

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

640+
// Assign the PR: user's work queue has been checked and can accept this PR
585641
match issue.set_assignee(&ctx.github, &to_assign).await {
586642
Ok(()) => return Ok(()), // we are done
587643
Err(github::AssignmentError::InvalidAssignee) => {
@@ -603,7 +659,7 @@ pub(super) async fn handle_command(
603659
}
604660

605661
#[derive(PartialEq, Debug)]
606-
enum FindReviewerError {
662+
pub enum FindReviewerError {
607663
/// User specified something like `r? foo/bar` where that team name could
608664
/// not be found.
609665
TeamNotFound(String),
@@ -621,6 +677,11 @@ enum FindReviewerError {
621677
initial: Vec<String>,
622678
filtered: Vec<String>,
623679
},
680+
/// No reviewer has capacity to accept a pull request assignment at this time
681+
NoReviewerHasCapacity,
682+
/// The requested reviewer has no capacity to accept a pull request
683+
/// assignment at this time
684+
ReviewerHasNoCapacity { username: String },
624685
}
625686

626687
impl std::error::Error for FindReviewerError {}
@@ -650,13 +711,23 @@ impl fmt::Display for FindReviewerError {
650711
write!(
651712
f,
652713
"Could not assign reviewer from: `{}`.\n\
653-
User(s) `{}` are either the PR author, already assigned, or on vacation, \
654-
and there are no other candidates.\n\
655-
Use `r?` to specify someone else to assign.",
714+
User(s) `{}` are either the PR author, already assigned, or on vacation. \
715+
If it's a team, we could not find any candidates.\n\
716+
Please use `r?` to specify someone else to assign.",
656717
initial.join(","),
657718
filtered.join(","),
658719
)
659720
}
721+
FindReviewerError::ReviewerHasNoCapacity { username } => {
722+
write!(
723+
f,
724+
"{}",
725+
REVIEWER_HAS_NO_CAPACITY.replace("{username}", username)
726+
)
727+
}
728+
FindReviewerError::NoReviewerHasCapacity => {
729+
write!(f, "{}", NO_REVIEWER_HAS_CAPACITY)
730+
}
660731
}
661732
}
662733
}
@@ -667,7 +738,8 @@ impl fmt::Display for FindReviewerError {
667738
/// `@octocat`, or names from the owners map. It can contain GitHub usernames,
668739
/// auto-assign groups, or rust-lang team names. It must have at least one
669740
/// entry.
670-
fn find_reviewer_from_names(
741+
async fn find_reviewer_from_names(
742+
db: &DbClient,
671743
teams: &Teams,
672744
config: &AssignConfig,
673745
issue: &Issue,
@@ -693,14 +765,57 @@ fn find_reviewer_from_names(
693765
//
694766
// These are all ideas for improving the selection here. However, I'm not
695767
// sure they are really worth the effort.
696-
Ok(candidates
768+
769+
// filter out team members without capacity
770+
let filtered_candidates = filter_by_capacity(db, &candidates)
771+
.await
772+
.expect("Error while filtering out team members");
773+
774+
if filtered_candidates.is_empty() {
775+
return Err(FindReviewerError::AllReviewersFiltered {
776+
initial: names.to_vec(),
777+
filtered: names.to_vec(),
778+
});
779+
}
780+
781+
log::debug!("Filtered list of candidates: {:?}", filtered_candidates);
782+
783+
Ok(filtered_candidates
697784
.into_iter()
698785
.choose(&mut rand::thread_rng())
699-
.expect("candidate_reviewers_from_names always returns at least one entry")
786+
.expect("candidate_reviewers_from_names should return at least one entry")
700787
.to_string())
701788
}
702789

703-
/// Returns a list of candidate usernames to choose as a reviewer.
790+
/// Filter out candidates not having review capacity
791+
async fn filter_by_capacity(
792+
db: &DbClient,
793+
candidates: &HashSet<&str>,
794+
) -> anyhow::Result<HashSet<String>> {
795+
let usernames = candidates
796+
.iter()
797+
.map(|c| *c)
798+
.collect::<Vec<&str>>()
799+
.join(",");
800+
801+
let q = format!(
802+
"
803+
SELECT username
804+
FROM review_prefs r
805+
JOIN users on users.user_id=r.user_id
806+
AND username = ANY('{{ {} }}')
807+
AND ARRAY_LENGTH(r.assigned_prs, 1) < max_assigned_prs",
808+
usernames
809+
);
810+
let result = db.query(&q, &[]).await.context("Select DB error")?;
811+
let mut candidates: HashSet<String> = HashSet::new();
812+
for row in result {
813+
candidates.insert(row.get("username"));
814+
}
815+
Ok(candidates)
816+
}
817+
818+
/// returns a list of candidate usernames (from relevant teams) to choose as a reviewer.
704819
fn candidate_reviewers_from_names<'a>(
705820
teams: &'a Teams,
706821
config: &'a AssignConfig,

src/lib.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,9 @@ pub struct ReviewPrefs {
137137

138138
impl ReviewPrefs {
139139
fn to_string(&self) -> String {
140-
let capacity = if self.max_assigned_prs.is_some() {
141-
format!("{}", self.max_assigned_prs.expect("This can't fail"))
142-
} else {
143-
String::from("Not set (i.e. unlimited)")
140+
let capacity = match self.max_assigned_prs {
141+
Some(max) => format!("{}", max),
142+
None => String::from("Not set (i.e. unlimited)"),
144143
};
145144
let prs = self
146145
.assigned_prs

0 commit comments

Comments
 (0)