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.
2023use 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} ;
2629use anyhow:: { bail, Context as _} ;
@@ -30,6 +33,7 @@ use rand::seq::IteratorRandom;
3033use rust_team_data:: v1:: Teams ;
3134use std:: collections:: { HashMap , HashSet } ;
3235use std:: fmt;
36+ use tokio_postgres:: Client as DbClient ;
3337use tracing as log;
3438
3539#[ cfg( test) ]
@@ -80,6 +84,27 @@ const NON_DEFAULT_BRANCH_EXCEPTION: &str =
8084
8185const 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+
83108fn 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
641703impl 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.
719831fn candidate_reviewers_from_names < ' a > (
720832 teams : & ' a Teams ,
721833 config : & ' a AssignConfig ,
0 commit comments