Skip to content

Commit ba5e479

Browse files
authored
Merge pull request #1879 from apiraino/assign-prs-based-on-work-queue-take-3
Add work queue tracking in triagebot DB
2 parents 38b904f + 5afc243 commit ba5e479

File tree

6 files changed

+198
-23
lines changed

6 files changed

+198
-23
lines changed

.env.sample

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ GITHUB_WEBHOOK_SECRET=MUST_BE_CONFIGURED
2020
# ZULIP_API_TOKEN=yyy
2121

2222
# Authenticates inbound webhooks from Github
23-
# ZULIP_TOKEN=xxx
23+
# ZULIP_TOKEN=xxx
24+
25+
# Use another endpoint to retrieve teams of the Rust project (useful for local testing)
26+
# default: https://team-api.infra.rust-lang.org/v1
27+
# TEAMS_API_URL=http://localhost:8080

src/db.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,7 @@ CREATE EXTENSION IF NOT EXISTS intarray;",
344344
"
345345
CREATE UNIQUE INDEX IF NOT EXISTS review_prefs_user_id ON review_prefs(user_id);
346346
",
347+
"
348+
ALTER TABLE review_prefs ADD COLUMN max_assigned_prs INTEGER DEFAULT NULL;
349+
",
347350
];

src/handlers.rs

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

208208
// Handle events that happened on issues
209209
//
210-
// This is for events that happen only on issues (e.g. label changes).
210+
// This is for events that happen only on issues or pull requests (e.g. label changes or assignments).
211211
// Each module in the list must contain the functions `parse_input` and `handle_input`.
212212
issue_handlers! {
213213
assign,
@@ -328,7 +328,7 @@ macro_rules! command_handlers {
328328
//
329329
// This is for handlers for commands parsed by the `parser` crate.
330330
// Each variant of `parser::command::Command` must be in this list,
331-
// preceded by the module containing the coresponding `handle_command` function
331+
// preceded by the module containing the corresponding `handle_command` function
332332
command_handlers! {
333333
assign: Assign,
334334
glacier: Glacier,

src/handlers/assign.rs

Lines changed: 126 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,22 @@ 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+
Please use `r?` to specify someone else to assign.",
671732
initial.join(","),
672733
filtered.join(","),
673734
)
674735
}
736+
FindReviewerError::ReviewerHasNoCapacity { username } => {
737+
write!(
738+
f,
739+
"{}",
740+
REVIEWER_HAS_NO_CAPACITY.replace("{username}", username)
741+
)
742+
}
743+
FindReviewerError::NoReviewerHasCapacity => {
744+
write!(f, "{}", NO_REVIEWER_HAS_CAPACITY)
745+
}
675746
}
676747
}
677748
}
@@ -682,7 +753,8 @@ impl fmt::Display for FindReviewerError {
682753
/// `@octocat`, or names from the owners map. It can contain GitHub usernames,
683754
/// auto-assign groups, or rust-lang team names. It must have at least one
684755
/// entry.
685-
fn find_reviewer_from_names(
756+
async fn find_reviewer_from_names(
757+
db: &DbClient,
686758
teams: &Teams,
687759
config: &AssignConfig,
688760
issue: &Issue,
@@ -708,14 +780,54 @@ fn find_reviewer_from_names(
708780
//
709781
// These are all ideas for improving the selection here. However, I'm not
710782
// sure they are really worth the effort.
711-
Ok(candidates
783+
784+
// filter out team members without capacity
785+
let filtered_candidates = filter_by_capacity(db, &candidates)
786+
.await
787+
.expect("Error while filtering out team members");
788+
789+
if filtered_candidates.is_empty() {
790+
return Err(FindReviewerError::AllReviewersFiltered {
791+
initial: names.to_vec(),
792+
filtered: names.to_vec(),
793+
});
794+
}
795+
796+
log::debug!("Filtered list of candidates: {:?}", filtered_candidates);
797+
798+
Ok(filtered_candidates
712799
.into_iter()
713800
.choose(&mut rand::thread_rng())
714-
.expect("candidate_reviewers_from_names always returns at least one entry")
801+
.expect("candidate_reviewers_from_names should return at least one entry")
715802
.to_string())
716803
}
717804

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

0 commit comments

Comments
 (0)