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,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.
719835fn candidate_reviewers_from_names < ' a > (
720836 teams : & ' a Teams ,
721837 config : & ' a AssignConfig ,
0 commit comments