7
7
//! * `@rustbot release-assignment`: Removes the commenter's assignment.
8
8
//! * `r? @user`: Assigns to the given user (PRs only).
9
9
//!
10
+ //! Note: this module does not handle review assignments issued from the
11
+ //! GitHub "Assignees" dropdown menu
12
+ //!
10
13
//! This is capable of assigning to any user, even if they do not have write
11
14
//! access to the repo. It does this by fake-assigning the bot and adding a
12
15
//! "claimed by" section to the top-level comment.
20
23
use crate :: {
21
24
config:: AssignConfig ,
22
25
github:: { self , Event , FileDiff , Issue , IssuesAction , Selection } ,
23
- handlers:: { Context , GithubClient , IssuesEvent } ,
26
+ handlers:: { pr_tracking :: has_user_capacity , Context , GithubClient , IssuesEvent } ,
24
27
interactions:: EditIssueBody ,
25
28
} ;
26
29
use anyhow:: { bail, Context as _} ;
@@ -30,6 +33,7 @@ use rand::seq::IteratorRandom;
30
33
use rust_team_data:: v1:: Teams ;
31
34
use std:: collections:: { HashMap , HashSet } ;
32
35
use std:: fmt;
36
+ use tokio_postgres:: Client as DbClient ;
33
37
use tracing as log;
34
38
35
39
#[ cfg( test) ]
@@ -75,6 +79,27 @@ const NON_DEFAULT_BRANCH: &str =
75
79
76
80
const SUBMODULE_WARNING_MSG : & str = "These commits modify **submodules**." ;
77
81
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
+
78
103
fn on_vacation_msg ( user : & str ) -> String {
79
104
ON_VACATION_WARNING . replace ( "{username}" , user)
80
105
}
@@ -272,6 +297,8 @@ async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) {
272
297
/// Determines who to assign the PR to based on either an `r?` command, or
273
298
/// based on which files were modified.
274
299
///
300
+ /// Will also check if candidates have capacity in their work queue.
301
+ ///
275
302
/// Returns `(assignee, from_comment)` where `assignee` is who to assign to
276
303
/// (or None if no assignee could be found). `from_comment` is a boolean
277
304
/// indicating if the assignee came from an `r?` command (it is false if
@@ -282,13 +309,14 @@ async fn determine_assignee(
282
309
config : & AssignConfig ,
283
310
diff : & [ FileDiff ] ,
284
311
) -> anyhow:: Result < ( Option < String > , bool ) > {
312
+ let db_client = ctx. db . get ( ) . await ;
285
313
let teams = crate :: team_data:: teams ( & ctx. github ) . await ?;
286
314
if let Some ( name) = find_assign_command ( ctx, event) {
287
315
if is_self_assign ( & name, & event. issue . user . login ) {
288
316
return Ok ( ( Some ( name. to_string ( ) ) , true ) ) ;
289
317
}
290
318
// 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 {
292
320
Ok ( assignee) => return Ok ( ( Some ( assignee) , true ) ) ,
293
321
Err ( e) => {
294
322
event
@@ -302,7 +330,9 @@ async fn determine_assignee(
302
330
// Errors fall-through to try fallback group.
303
331
match find_reviewers_from_diff ( config, diff) {
304
332
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
+ {
306
336
Ok ( assignee) => return Ok ( ( Some ( assignee) , false ) ) ,
307
337
Err ( FindReviewerError :: TeamNotFound ( team) ) => log:: warn!(
308
338
"team {team} not found via diff from PR {}, \
@@ -312,7 +342,9 @@ async fn determine_assignee(
312
342
// TODO: post a comment on the PR if the reviewers were filtered due to being on vacation
313
343
Err (
314
344
e @ FindReviewerError :: NoReviewer { .. }
315
- | e @ FindReviewerError :: AllReviewersFiltered { .. } ,
345
+ | e @ FindReviewerError :: AllReviewersFiltered { .. }
346
+ | e @ FindReviewerError :: NoReviewerHasCapacity
347
+ | e @ FindReviewerError :: ReviewerHasNoCapacity { .. } ,
316
348
) => log:: trace!(
317
349
"no reviewer could be determined for PR {}: {e}" ,
318
350
event. issue. global_id( )
@@ -330,7 +362,7 @@ async fn determine_assignee(
330
362
}
331
363
332
364
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 {
334
366
Ok ( assignee) => return Ok ( ( Some ( assignee) , false ) ) ,
335
367
Err ( e) => {
336
368
log:: trace!(
@@ -492,7 +524,20 @@ pub(super) async fn handle_command(
492
524
// welcome message).
493
525
return Ok ( ( ) ) ;
494
526
}
527
+ let db_client = ctx. db . get ( ) . await ;
495
528
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
+ } ;
496
541
name. to_string ( )
497
542
} else {
498
543
let teams = crate :: team_data:: teams ( & ctx. github ) . await ?;
@@ -512,8 +557,14 @@ pub(super) async fn handle_command(
512
557
}
513
558
}
514
559
}
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
517
568
{
518
569
Ok ( assignee) => assignee,
519
570
Err ( e) => {
@@ -524,7 +575,11 @@ pub(super) async fn handle_command(
524
575
}
525
576
}
526
577
} ;
578
+
579
+ // This user is validated and can accept the PR
527
580
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
528
583
return Ok ( ( ) ) ;
529
584
}
530
585
@@ -582,6 +637,7 @@ pub(super) async fn handle_command(
582
637
583
638
e. apply ( & ctx. github , String :: new ( ) , & data) . await ?;
584
639
640
+ // Assign the PR: user's work queue has been checked and can accept this PR
585
641
match issue. set_assignee ( & ctx. github , & to_assign) . await {
586
642
Ok ( ( ) ) => return Ok ( ( ) ) , // we are done
587
643
Err ( github:: AssignmentError :: InvalidAssignee ) => {
@@ -603,7 +659,7 @@ pub(super) async fn handle_command(
603
659
}
604
660
605
661
#[ derive( PartialEq , Debug ) ]
606
- enum FindReviewerError {
662
+ pub enum FindReviewerError {
607
663
/// User specified something like `r? foo/bar` where that team name could
608
664
/// not be found.
609
665
TeamNotFound ( String ) ,
@@ -621,6 +677,11 @@ enum FindReviewerError {
621
677
initial : Vec < String > ,
622
678
filtered : Vec < String > ,
623
679
} ,
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 } ,
624
685
}
625
686
626
687
impl std:: error:: Error for FindReviewerError { }
@@ -650,13 +711,23 @@ impl fmt::Display for FindReviewerError {
650
711
write ! (
651
712
f,
652
713
"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.",
656
717
initial. join( "," ) ,
657
718
filtered. join( "," ) ,
658
719
)
659
720
}
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
+ }
660
731
}
661
732
}
662
733
}
@@ -667,7 +738,8 @@ impl fmt::Display for FindReviewerError {
667
738
/// `@octocat`, or names from the owners map. It can contain GitHub usernames,
668
739
/// auto-assign groups, or rust-lang team names. It must have at least one
669
740
/// entry.
670
- fn find_reviewer_from_names (
741
+ async fn find_reviewer_from_names (
742
+ db : & DbClient ,
671
743
teams : & Teams ,
672
744
config : & AssignConfig ,
673
745
issue : & Issue ,
@@ -693,14 +765,57 @@ fn find_reviewer_from_names(
693
765
//
694
766
// These are all ideas for improving the selection here. However, I'm not
695
767
// 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
697
784
. into_iter ( )
698
785
. 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" )
700
787
. to_string ( ) )
701
788
}
702
789
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.
704
819
fn candidate_reviewers_from_names < ' a > (
705
820
teams : & ' a Teams ,
706
821
config : & ' a AssignConfig ,
0 commit comments