@@ -188,7 +188,7 @@ pub(super) async fn handle_input(
188
188
if event. issue . assignees . is_empty ( ) {
189
189
let ( assignee, from_comment) =
190
190
determine_assignee ( ctx, assign_command, event, config, & diff) . await ?;
191
- if assignee. as_deref ( ) == Some ( GHOST_ACCOUNT ) {
191
+ if assignee. as_ref ( ) . map ( |r| r . name . as_str ( ) ) == Some ( GHOST_ACCOUNT ) {
192
192
// "ghost" is GitHub's placeholder account for deleted accounts.
193
193
// It is used here as a convenient way to prevent assignment. This
194
194
// is typically used for rollups or experiments where you don't
@@ -205,7 +205,7 @@ pub(super) async fn handle_input(
205
205
. await
206
206
{
207
207
let who_text = match & assignee {
208
- Some ( assignee) => WELCOME_WITH_REVIEWER . replace ( "{assignee}" , assignee) ,
208
+ Some ( assignee) => WELCOME_WITH_REVIEWER . replace ( "{assignee}" , & assignee. name ) ,
209
209
None => WELCOME_WITHOUT_REVIEWER . to_string ( ) ,
210
210
} ;
211
211
let mut welcome = NEW_USER_WELCOME_MESSAGE . replace ( "{who}" , & who_text) ;
@@ -221,7 +221,7 @@ pub(super) async fn handle_input(
221
221
} else if !from_comment {
222
222
let welcome = match & assignee {
223
223
Some ( assignee) => RETURNING_USER_WELCOME_MESSAGE
224
- . replace ( "{assignee}" , assignee)
224
+ . replace ( "{assignee}" , & assignee. name )
225
225
. replace ( "{bot}" , & ctx. username ) ,
226
226
None => RETURNING_USER_WELCOME_MESSAGE_NO_REVIEWER
227
227
. replace ( "{author}" , & event. issue . user . login ) ,
@@ -268,37 +268,38 @@ async fn set_assignee(
268
268
ctx : & Context ,
269
269
issue : & Issue ,
270
270
github : & GithubClient ,
271
- username : & str ,
271
+ reviewer : & ReviewerSelection ,
272
272
) -> anyhow:: Result < ( ) > {
273
273
let mut db = ctx. db . get ( ) . await ;
274
274
let mut state: IssueData < ' _ , Reviewers > =
275
275
IssueData :: load ( & mut db, & issue, PREVIOUS_REVIEWERS_KEY ) . await ?;
276
276
277
277
// Don't re-assign if already assigned, e.g. on comment edit
278
- if issue. contain_assignee ( & username ) {
278
+ if issue. contain_assignee ( & reviewer . name ) {
279
279
log:: trace!(
280
280
"ignoring assign PR {} to {}, already assigned" ,
281
281
issue. global_id( ) ,
282
- username ,
282
+ reviewer . name ,
283
283
) ;
284
284
return Ok ( ( ) ) ;
285
285
}
286
- if let Err ( err) = issue. set_assignee ( github, & username ) . await {
286
+ if let Err ( err) = issue. set_assignee ( github, & reviewer . name ) . await {
287
287
log:: warn!(
288
288
"failed to set assignee of PR {} to {}: {:?}" ,
289
289
issue. global_id( ) ,
290
- username ,
290
+ reviewer . name ,
291
291
err
292
292
) ;
293
293
if let Err ( e) = issue
294
294
. post_comment (
295
295
github,
296
296
& format ! (
297
- "Failed to set assignee to `{username }`: {err}\n \
297
+ "Failed to set assignee to `{}`: {err}\n \
298
298
\n \
299
299
> **Note**: Only org members with at least the repository \" read\" role, \
300
300
users with write permissions, or people who have commented on the PR may \
301
- be assigned."
301
+ be assigned.",
302
+ reviewer. name
302
303
) ,
303
304
)
304
305
. await
@@ -308,9 +309,30 @@ async fn set_assignee(
308
309
}
309
310
}
310
311
311
- // Record the reviewer in the database
312
+ // If an error was suppressed, post a warning on the PR.
313
+ if let Some ( suppressed_error) = & reviewer. suppressed_error {
314
+ let warning = match suppressed_error {
315
+ FindReviewerError :: ReviewerOffRotation { username } => Some ( format ! (
316
+ r"`{username}` is not on the review rotation at the moment.
317
+ They may take a while to respond.
318
+ "
319
+ ) ) ,
320
+ FindReviewerError :: ReviewerAtMaxCapacity { username } => Some ( format ! (
321
+ "`{username}` is currently at their maximum review capacity.
322
+ They may take a while to respond."
323
+ ) ) ,
324
+ _ => None ,
325
+ } ;
326
+ if let Some ( warning) = warning {
327
+ if let Err ( err) = issue. post_comment ( & ctx. github , & warning) . await {
328
+ // This is a best-effort warning, do not do anything apart from logging if it fails
329
+ log:: warn!( "failed to post reviewer warning comment: {err}" ) ;
330
+ }
331
+ }
332
+ }
312
333
313
- state. data . names . insert ( username. to_lowercase ( ) ) ;
334
+ // Record the reviewer in the database
335
+ state. data . names . insert ( reviewer. name . to_lowercase ( ) ) ;
314
336
state. save ( ) . await ?;
315
337
Ok ( ( ) )
316
338
}
@@ -330,7 +352,7 @@ async fn determine_assignee(
330
352
event : & IssuesEvent ,
331
353
config : & AssignConfig ,
332
354
diff : & [ FileDiff ] ,
333
- ) -> anyhow:: Result < ( Option < String > , bool ) > {
355
+ ) -> anyhow:: Result < ( Option < ReviewerSelection > , bool ) > {
334
356
let mut db_client = ctx. db . get ( ) . await ;
335
357
let teams = crate :: team_data:: teams ( & ctx. github ) . await ?;
336
358
if let Some ( name) = assign_command {
@@ -693,7 +715,7 @@ fn get_team_name<'a>(teams: &Teams, issue: &Issue, name: &'a str) -> Option<&'a
693
715
teams. teams . get ( team_name) . map ( |_| team_name)
694
716
}
695
717
696
- #[ derive( PartialEq , Debug ) ]
718
+ #[ derive( PartialEq , Eq , PartialOrd , Ord , Hash , Debug ) ]
697
719
enum FindReviewerError {
698
720
/// User specified something like `r? foo/bar` where that team name could
699
721
/// not be found.
@@ -715,7 +737,7 @@ enum FindReviewerError {
715
737
ReviewerPreviouslyAssigned { username : String } ,
716
738
/// Data required for assignment could not be loaded from the DB.
717
739
DatabaseError ( String ) ,
718
- /// The reviewer has too many PRs alreayd assigned.
740
+ /// The reviewer has too many PRs already assigned.
719
741
ReviewerAtMaxCapacity { username : String } ,
720
742
}
721
743
@@ -781,6 +803,24 @@ Please select a different reviewer.",
781
803
}
782
804
}
783
805
806
+ /// Reviewer that was found to be eligible as a result of `r? <...>`.
807
+ /// In some cases, a reviewer selection error might have been suppressed.
808
+ /// We store it here to allow sending a comment with a warning about the suppressed error.
809
+ #[ derive( PartialEq , Eq , PartialOrd , Ord , Hash , Debug ) ]
810
+ struct ReviewerSelection {
811
+ name : String ,
812
+ suppressed_error : Option < FindReviewerError > ,
813
+ }
814
+
815
+ impl ReviewerSelection {
816
+ fn from_name ( name : String ) -> Self {
817
+ Self {
818
+ name,
819
+ suppressed_error : None ,
820
+ }
821
+ }
822
+ }
823
+
784
824
/// Finds a reviewer to assign to a PR.
785
825
///
786
826
/// The `names` is a list of candidate reviewers `r?`, such as `compiler` or
@@ -794,11 +834,11 @@ async fn find_reviewer_from_names(
794
834
config : & AssignConfig ,
795
835
issue : & Issue ,
796
836
names : & [ String ] ,
797
- ) -> Result < String , FindReviewerError > {
837
+ ) -> Result < ReviewerSelection , FindReviewerError > {
798
838
// Fast path for self-assign, which is always allowed.
799
839
if let [ name] = names {
800
840
if is_self_assign ( & name, & issue. user . login ) {
801
- return Ok ( name. clone ( ) ) ;
841
+ return Ok ( ReviewerSelection :: from_name ( name. clone ( ) ) ) ;
802
842
}
803
843
}
804
844
@@ -827,26 +867,25 @@ async fn find_reviewer_from_names(
827
867
// sure they are really worth the effort.
828
868
829
869
log:: info!(
830
- "[#{}] Initial unfiltered list of candidates: {:?}" ,
870
+ "[#{}] Filtered list of candidates: {:?}" ,
831
871
issue. number,
832
872
candidates
833
873
) ;
834
874
835
- // Return unfiltered list of candidates
875
+ // Select a random reviewer from the filtered list
836
876
Ok ( candidates
837
877
. into_iter ( )
838
878
. choose ( & mut rand:: thread_rng ( ) )
839
- . expect ( "candidate_reviewers_from_names should return at least one entry" )
840
- . to_string ( ) )
879
+ . expect ( "candidate_reviewers_from_names should return at least one entry" ) )
841
880
}
842
881
843
- #[ derive( Eq , PartialEq , Hash ) ]
882
+ #[ derive( Eq , PartialEq , Hash , Debug ) ]
844
883
struct ReviewerCandidate {
845
884
name : String ,
846
885
origin : ReviewerCandidateOrigin ,
847
886
}
848
887
849
- #[ derive( Eq , PartialEq , Hash , Copy , Clone ) ]
888
+ #[ derive( Eq , PartialEq , Hash , Copy , Clone , Debug ) ]
850
889
enum ReviewerCandidateOrigin {
851
890
/// This reviewer was directly requested for a review.
852
891
Direct ,
@@ -962,7 +1001,7 @@ async fn candidate_reviewers_from_names<'a>(
962
1001
config : & ' a AssignConfig ,
963
1002
issue : & Issue ,
964
1003
names : & ' a [ String ] ,
965
- ) -> Result < HashSet < String > , FindReviewerError > {
1004
+ ) -> Result < HashSet < ReviewerSelection > , FindReviewerError > {
966
1005
// Step 1: expand teams and groups into candidate names
967
1006
let expanded = expand_teams_and_groups ( teams, issue, config, names) ?;
968
1007
let expanded_count = expanded. len ( ) ;
@@ -976,7 +1015,7 @@ async fn candidate_reviewers_from_names<'a>(
976
1015
977
1016
// Set of candidate usernames to choose from.
978
1017
// We go through each expanded candidate and store either success or an error for them.
979
- let mut candidates: Vec < Result < String , FindReviewerError > > = Vec :: new ( ) ;
1018
+ let mut candidates: Vec < Result < ReviewerCandidate , FindReviewerError > > = Vec :: new ( ) ;
980
1019
let previous_reviewer_names = get_previous_reviewer_names ( db, issue) . await ;
981
1020
982
1021
// Step 2: pre-filter candidates based on checks that we can perform quickly
@@ -1022,7 +1061,7 @@ async fn candidate_reviewers_from_names<'a>(
1022
1061
if let Some ( error_reason) = reason {
1023
1062
candidates. push ( Err ( error_reason) ) ;
1024
1063
} else {
1025
- candidates. push ( Ok ( reviewer_candidate. name ) ) ;
1064
+ candidates. push ( Ok ( reviewer_candidate) ) ;
1026
1065
}
1027
1066
}
1028
1067
assert_eq ! ( candidates. len( ) , expanded_count) ;
@@ -1031,7 +1070,7 @@ async fn candidate_reviewers_from_names<'a>(
1031
1070
// Step 3: gather potential usernames to form a DB query for review preferences
1032
1071
let usernames: Vec < String > = candidates
1033
1072
. iter ( )
1034
- . filter_map ( |res| res. as_deref ( ) . ok ( ) . map ( |s| s. to_string ( ) ) )
1073
+ . filter_map ( |res| res. as_ref ( ) . ok ( ) . map ( |s| s. name . to_string ( ) ) )
1035
1074
. collect ( ) ;
1036
1075
let usernames: Vec < & str > = usernames. iter ( ) . map ( |s| s. as_str ( ) ) . collect ( ) ;
1037
1076
let review_prefs = get_review_prefs_batch ( db, & usernames)
@@ -1044,35 +1083,40 @@ async fn candidate_reviewers_from_names<'a>(
1044
1083
// Step 4: check review preferences
1045
1084
candidates = candidates
1046
1085
. into_iter ( )
1047
- . map ( |username | {
1086
+ . map ( |candidate | {
1048
1087
// Only consider candidates that did not have an earlier error
1049
- let username = username?;
1088
+ let candidate = candidate?;
1089
+ let username = & candidate. name ;
1050
1090
1051
1091
// If no review prefs were found, we assume the default unlimited
1052
1092
// review capacity and being on rotation.
1053
1093
let Some ( review_prefs) = review_prefs. get ( username. as_str ( ) ) else {
1054
- return Ok ( username ) ;
1094
+ return Ok ( candidate ) ;
1055
1095
} ;
1056
1096
if let Some ( capacity) = review_prefs. max_assigned_prs {
1057
1097
let assigned_prs = workqueue. assigned_pr_count ( review_prefs. user_id as UserId ) ;
1058
1098
// Is the reviewer at max capacity?
1059
1099
if ( assigned_prs as i32 ) >= capacity {
1060
- return Err ( FindReviewerError :: ReviewerAtMaxCapacity { username } ) ;
1100
+ return Err ( FindReviewerError :: ReviewerAtMaxCapacity {
1101
+ username : username. clone ( ) ,
1102
+ } ) ;
1061
1103
}
1062
1104
}
1063
1105
if review_prefs. rotation_mode == RotationMode :: OffRotation {
1064
- return Err ( FindReviewerError :: ReviewerOffRotation { username } ) ;
1106
+ return Err ( FindReviewerError :: ReviewerOffRotation {
1107
+ username : username. clone ( ) ,
1108
+ } ) ;
1065
1109
}
1066
1110
1067
- return Ok ( username ) ;
1111
+ return Ok ( candidate ) ;
1068
1112
} )
1069
1113
. collect ( ) ;
1070
1114
}
1071
1115
assert_eq ! ( candidates. len( ) , expanded_count) ;
1072
1116
1073
1117
let valid_candidates: HashSet < & str > = candidates
1074
1118
. iter ( )
1075
- . filter_map ( |res| res. as_deref ( ) . ok ( ) )
1119
+ . filter_map ( |res| res. as_ref ( ) . ok ( ) . map ( |c| c . name . as_str ( ) ) )
1076
1120
. collect ( ) ;
1077
1121
1078
1122
log:: debug!(
@@ -1083,13 +1127,24 @@ async fn candidate_reviewers_from_names<'a>(
1083
1127
) ;
1084
1128
1085
1129
if valid_candidates. is_empty ( ) {
1086
- // If we requested a single user for a review, we return a concrete error message
1087
- // describing why they couldn't be assigned.
1088
1130
if is_single_user {
1089
- Err ( candidates
1131
+ // If we requested a single user for a review, we may suppress some errors.
1132
+ // Check what error we got here.
1133
+ let error = candidates
1090
1134
. pop ( )
1091
1135
. unwrap ( )
1092
- . expect_err ( "valid_candidates is empty, so this should be an error" ) )
1136
+ . expect_err ( "valid_candidates is empty, so this should be an error" ) ;
1137
+ let username = match & error {
1138
+ // If the reviewer is at capacity or off rotation, allow them to be requested,
1139
+ // but store the suppressed error.
1140
+ FindReviewerError :: ReviewerOffRotation { username }
1141
+ | FindReviewerError :: ReviewerAtMaxCapacity { username } => username,
1142
+ _ => return Err ( error) ,
1143
+ } ;
1144
+ Ok ( HashSet :: from ( [ ReviewerSelection {
1145
+ name : username. to_string ( ) ,
1146
+ suppressed_error : Some ( error) ,
1147
+ } ] ) )
1093
1148
} else {
1094
1149
// If it was a request for a team or a group, and no one is available, simply
1095
1150
// return `NoReviewer`.
@@ -1105,7 +1160,7 @@ async fn candidate_reviewers_from_names<'a>(
1105
1160
} else {
1106
1161
Ok ( valid_candidates
1107
1162
. into_iter ( )
1108
- . map ( |s| s. to_string ( ) )
1163
+ . map ( |s| ReviewerSelection :: from_name ( s. to_string ( ) ) )
1109
1164
. collect ( ) )
1110
1165
}
1111
1166
}
0 commit comments