Skip to content

Commit 15539b7

Browse files
committed
PR assignment based on work queue availability (take #2)
This is a version of #1786 that fixes the two following bugs: There were 2 bugs: 1. the initial migration wasn't formatted correctly and the new schema changes weren't applied 2. The SELECT to find a reviewer would return wrong results when a team member had NULL in the table `review_prefs.max_assigned_prs`
1 parent a0ad972 commit 15539b7

File tree

5 files changed

+199
-25
lines changed

5 files changed

+199
-25
lines changed

src/db.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,11 @@ CREATE table review_prefs (
332332
assigned_prs INT[] NOT NULL DEFAULT array[]::INT[]
333333
);",
334334
"
335-
CREATE EXTENSION intarray;
336-
CREATE UNIQUE INDEX review_prefs_user_id ON review_prefs(user_id);
335+
CREATE EXTENSION IF NOT EXISTS intarray;",
336+
"
337+
CREATE UNIQUE INDEX IF NOT EXISTS review_prefs_user_id ON review_prefs(user_id);
337338
",
339+
"
340+
ALTER TABLE review_prefs ADD COLUMN IF NOT EXISTS max_assigned_prs INTEGER DEFAULT NULL;
341+
",
338342
];

src/handlers.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ macro_rules! issue_handlers {
159159

160160
// Handle events that happened on issues
161161
//
162-
// This is for events that happen only on issues (e.g. label changes).
162+
// This is for events that happen only on issues or pull requests (e.g. label changes or assignments).
163163
// Each module in the list must contain the functions `parse_input` and `handle_input`.
164164
issue_handlers! {
165165
assign,
@@ -280,7 +280,7 @@ macro_rules! command_handlers {
280280
//
281281
// This is for handlers for commands parsed by the `parser` crate.
282282
// Each variant of `parser::command::Command` must be in this list,
283-
// preceded by the module containing the coresponding `handle_command` function
283+
// preceded by the module containing the corresponding `handle_command` function
284284
command_handlers! {
285285
assign: Assign,
286286
glacier: Glacier,

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,

0 commit comments

Comments
 (0)