@@ -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 : & Reviewer ,
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 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 < Reviewer > , 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 Reviewer {
811
+ name : String ,
812
+ suppressed_error : Option < FindReviewerError > ,
813
+ }
814
+
815
+ impl Reviewer {
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 < Reviewer , 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 ( Reviewer :: from_name ( name. clone ( ) ) ) ;
802
842
}
803
843
}
804
844
@@ -827,17 +867,16 @@ 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
882
#[ derive( Eq , PartialEq , Hash , Debug ) ]
@@ -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 < Reviewer > , 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 ( ) ;
@@ -991,7 +1030,6 @@ async fn candidate_reviewers_from_names<'a>(
991
1030
. any ( |assignee| name_lower == assignee. login . to_lowercase ( ) ) ;
992
1031
993
1032
let is_previously_assigned = previous_reviewer_names. contains ( & name_lower) ;
994
- let is_directly_requested = reviewer_candidate. origin == ReviewerCandidateOrigin :: Direct ;
995
1033
996
1034
// Record the reason why the candidate was filtered out
997
1035
let reason = {
@@ -1000,7 +1038,7 @@ async fn candidate_reviewers_from_names<'a>(
1000
1038
username : candidate. clone ( ) ,
1001
1039
} )
1002
1040
// Allow r? <user> even for people on a vacation
1003
- } else if is_on_vacation && !is_directly_requested {
1041
+ } else if is_on_vacation {
1004
1042
Some ( FindReviewerError :: ReviewerOffRotation {
1005
1043
username : candidate. clone ( ) ,
1006
1044
} )
@@ -1051,12 +1089,6 @@ async fn candidate_reviewers_from_names<'a>(
1051
1089
let candidate = candidate?;
1052
1090
let username = & candidate. name ;
1053
1091
1054
- // If the reviewer was requested directly, do not consider their review preferences
1055
- let is_directly_requested = candidate. origin == ReviewerCandidateOrigin :: Direct ;
1056
- if is_directly_requested {
1057
- return Ok ( candidate) ;
1058
- }
1059
-
1060
1092
// If no review prefs were found, we assume the default unlimited
1061
1093
// review capacity and being on rotation.
1062
1094
let Some ( review_prefs) = review_prefs. get ( username. as_str ( ) ) else {
@@ -1096,13 +1128,24 @@ async fn candidate_reviewers_from_names<'a>(
1096
1128
) ;
1097
1129
1098
1130
if valid_candidates. is_empty ( ) {
1099
- // If we requested a single user for a review, we return a concrete error message
1100
- // describing why they couldn't be assigned.
1101
1131
if is_single_user {
1102
- Err ( candidates
1132
+ // If we requested a single user for a review, we may suppress some errors.
1133
+ // Check what error we got here.
1134
+ let error = candidates
1103
1135
. pop ( )
1104
1136
. unwrap ( )
1105
- . expect_err ( "valid_candidates is empty, so this should be an error" ) )
1137
+ . expect_err ( "valid_candidates is empty, so this should be an error" ) ;
1138
+ let username = match & error {
1139
+ // If the reviewer is at capacity or off rotation, allow them to be requested,
1140
+ // but store the suppressed error.
1141
+ FindReviewerError :: ReviewerOffRotation { username }
1142
+ | FindReviewerError :: ReviewerAtMaxCapacity { username } => username,
1143
+ _ => return Err ( error) ,
1144
+ } ;
1145
+ Ok ( HashSet :: from ( [ Reviewer {
1146
+ name : username. to_string ( ) ,
1147
+ suppressed_error : Some ( error) ,
1148
+ } ] ) )
1106
1149
} else {
1107
1150
// If it was a request for a team or a group, and no one is available, simply
1108
1151
// return `NoReviewer`.
@@ -1118,7 +1161,7 @@ async fn candidate_reviewers_from_names<'a>(
1118
1161
} else {
1119
1162
Ok ( valid_candidates
1120
1163
. into_iter ( )
1121
- . map ( |s| s. to_string ( ) )
1164
+ . map ( |s| Reviewer :: from_name ( s. to_string ( ) ) )
1122
1165
. collect ( ) )
1123
1166
}
1124
1167
}
0 commit comments