Skip to content

Commit 34e9b54

Browse files
authored
Merge pull request #2014 from Kobzol/always-allow-direct-review-request
Always allow direct review requests
2 parents 93d9985 + 97f989a commit 34e9b54

File tree

4 files changed

+205
-134
lines changed

4 files changed

+205
-134
lines changed

src/handlers/assign.rs

Lines changed: 94 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ pub(super) async fn handle_input(
188188
if event.issue.assignees.is_empty() {
189189
let (assignee, from_comment) =
190190
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) {
192192
// "ghost" is GitHub's placeholder account for deleted accounts.
193193
// It is used here as a convenient way to prevent assignment. This
194194
// is typically used for rollups or experiments where you don't
@@ -205,7 +205,7 @@ pub(super) async fn handle_input(
205205
.await
206206
{
207207
let who_text = match &assignee {
208-
Some(assignee) => WELCOME_WITH_REVIEWER.replace("{assignee}", assignee),
208+
Some(assignee) => WELCOME_WITH_REVIEWER.replace("{assignee}", &assignee.name),
209209
None => WELCOME_WITHOUT_REVIEWER.to_string(),
210210
};
211211
let mut welcome = NEW_USER_WELCOME_MESSAGE.replace("{who}", &who_text);
@@ -221,7 +221,7 @@ pub(super) async fn handle_input(
221221
} else if !from_comment {
222222
let welcome = match &assignee {
223223
Some(assignee) => RETURNING_USER_WELCOME_MESSAGE
224-
.replace("{assignee}", assignee)
224+
.replace("{assignee}", &assignee.name)
225225
.replace("{bot}", &ctx.username),
226226
None => RETURNING_USER_WELCOME_MESSAGE_NO_REVIEWER
227227
.replace("{author}", &event.issue.user.login),
@@ -268,37 +268,38 @@ async fn set_assignee(
268268
ctx: &Context,
269269
issue: &Issue,
270270
github: &GithubClient,
271-
username: &str,
271+
reviewer: &ReviewerSelection,
272272
) -> anyhow::Result<()> {
273273
let mut db = ctx.db.get().await;
274274
let mut state: IssueData<'_, Reviewers> =
275275
IssueData::load(&mut db, &issue, PREVIOUS_REVIEWERS_KEY).await?;
276276

277277
// 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) {
279279
log::trace!(
280280
"ignoring assign PR {} to {}, already assigned",
281281
issue.global_id(),
282-
username,
282+
reviewer.name,
283283
);
284284
return Ok(());
285285
}
286-
if let Err(err) = issue.set_assignee(github, &username).await {
286+
if let Err(err) = issue.set_assignee(github, &reviewer.name).await {
287287
log::warn!(
288288
"failed to set assignee of PR {} to {}: {:?}",
289289
issue.global_id(),
290-
username,
290+
reviewer.name,
291291
err
292292
);
293293
if let Err(e) = issue
294294
.post_comment(
295295
github,
296296
&format!(
297-
"Failed to set assignee to `{username}`: {err}\n\
297+
"Failed to set assignee to `{}`: {err}\n\
298298
\n\
299299
> **Note**: Only org members with at least the repository \"read\" role, \
300300
users with write permissions, or people who have commented on the PR may \
301-
be assigned."
301+
be assigned.",
302+
reviewer.name
302303
),
303304
)
304305
.await
@@ -308,9 +309,30 @@ async fn set_assignee(
308309
}
309310
}
310311

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+
}
312333

313-
state.data.names.insert(username.to_lowercase());
334+
// Record the reviewer in the database
335+
state.data.names.insert(reviewer.name.to_lowercase());
314336
state.save().await?;
315337
Ok(())
316338
}
@@ -330,7 +352,7 @@ async fn determine_assignee(
330352
event: &IssuesEvent,
331353
config: &AssignConfig,
332354
diff: &[FileDiff],
333-
) -> anyhow::Result<(Option<String>, bool)> {
355+
) -> anyhow::Result<(Option<ReviewerSelection>, bool)> {
334356
let mut db_client = ctx.db.get().await;
335357
let teams = crate::team_data::teams(&ctx.github).await?;
336358
if let Some(name) = assign_command {
@@ -693,7 +715,7 @@ fn get_team_name<'a>(teams: &Teams, issue: &Issue, name: &'a str) -> Option<&'a
693715
teams.teams.get(team_name).map(|_| team_name)
694716
}
695717

696-
#[derive(PartialEq, Debug)]
718+
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
697719
enum FindReviewerError {
698720
/// User specified something like `r? foo/bar` where that team name could
699721
/// not be found.
@@ -715,7 +737,7 @@ enum FindReviewerError {
715737
ReviewerPreviouslyAssigned { username: String },
716738
/// Data required for assignment could not be loaded from the DB.
717739
DatabaseError(String),
718-
/// The reviewer has too many PRs alreayd assigned.
740+
/// The reviewer has too many PRs already assigned.
719741
ReviewerAtMaxCapacity { username: String },
720742
}
721743

@@ -781,6 +803,24 @@ Please select a different reviewer.",
781803
}
782804
}
783805

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+
784824
/// Finds a reviewer to assign to a PR.
785825
///
786826
/// The `names` is a list of candidate reviewers `r?`, such as `compiler` or
@@ -794,11 +834,11 @@ async fn find_reviewer_from_names(
794834
config: &AssignConfig,
795835
issue: &Issue,
796836
names: &[String],
797-
) -> Result<String, FindReviewerError> {
837+
) -> Result<ReviewerSelection, FindReviewerError> {
798838
// Fast path for self-assign, which is always allowed.
799839
if let [name] = names {
800840
if is_self_assign(&name, &issue.user.login) {
801-
return Ok(name.clone());
841+
return Ok(ReviewerSelection::from_name(name.clone()));
802842
}
803843
}
804844

@@ -827,26 +867,25 @@ async fn find_reviewer_from_names(
827867
// sure they are really worth the effort.
828868

829869
log::info!(
830-
"[#{}] Initial unfiltered list of candidates: {:?}",
870+
"[#{}] Filtered list of candidates: {:?}",
831871
issue.number,
832872
candidates
833873
);
834874

835-
// Return unfiltered list of candidates
875+
// Select a random reviewer from the filtered list
836876
Ok(candidates
837877
.into_iter()
838878
.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"))
841880
}
842881

843-
#[derive(Eq, PartialEq, Hash)]
882+
#[derive(Eq, PartialEq, Hash, Debug)]
844883
struct ReviewerCandidate {
845884
name: String,
846885
origin: ReviewerCandidateOrigin,
847886
}
848887

849-
#[derive(Eq, PartialEq, Hash, Copy, Clone)]
888+
#[derive(Eq, PartialEq, Hash, Copy, Clone, Debug)]
850889
enum ReviewerCandidateOrigin {
851890
/// This reviewer was directly requested for a review.
852891
Direct,
@@ -962,7 +1001,7 @@ async fn candidate_reviewers_from_names<'a>(
9621001
config: &'a AssignConfig,
9631002
issue: &Issue,
9641003
names: &'a [String],
965-
) -> Result<HashSet<String>, FindReviewerError> {
1004+
) -> Result<HashSet<ReviewerSelection>, FindReviewerError> {
9661005
// Step 1: expand teams and groups into candidate names
9671006
let expanded = expand_teams_and_groups(teams, issue, config, names)?;
9681007
let expanded_count = expanded.len();
@@ -976,7 +1015,7 @@ async fn candidate_reviewers_from_names<'a>(
9761015

9771016
// Set of candidate usernames to choose from.
9781017
// 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();
9801019
let previous_reviewer_names = get_previous_reviewer_names(db, issue).await;
9811020

9821021
// Step 2: pre-filter candidates based on checks that we can perform quickly
@@ -1022,7 +1061,7 @@ async fn candidate_reviewers_from_names<'a>(
10221061
if let Some(error_reason) = reason {
10231062
candidates.push(Err(error_reason));
10241063
} else {
1025-
candidates.push(Ok(reviewer_candidate.name));
1064+
candidates.push(Ok(reviewer_candidate));
10261065
}
10271066
}
10281067
assert_eq!(candidates.len(), expanded_count);
@@ -1031,7 +1070,7 @@ async fn candidate_reviewers_from_names<'a>(
10311070
// Step 3: gather potential usernames to form a DB query for review preferences
10321071
let usernames: Vec<String> = candidates
10331072
.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()))
10351074
.collect();
10361075
let usernames: Vec<&str> = usernames.iter().map(|s| s.as_str()).collect();
10371076
let review_prefs = get_review_prefs_batch(db, &usernames)
@@ -1044,35 +1083,40 @@ async fn candidate_reviewers_from_names<'a>(
10441083
// Step 4: check review preferences
10451084
candidates = candidates
10461085
.into_iter()
1047-
.map(|username| {
1086+
.map(|candidate| {
10481087
// Only consider candidates that did not have an earlier error
1049-
let username = username?;
1088+
let candidate = candidate?;
1089+
let username = &candidate.name;
10501090

10511091
// If no review prefs were found, we assume the default unlimited
10521092
// review capacity and being on rotation.
10531093
let Some(review_prefs) = review_prefs.get(username.as_str()) else {
1054-
return Ok(username);
1094+
return Ok(candidate);
10551095
};
10561096
if let Some(capacity) = review_prefs.max_assigned_prs {
10571097
let assigned_prs = workqueue.assigned_pr_count(review_prefs.user_id as UserId);
10581098
// Is the reviewer at max capacity?
10591099
if (assigned_prs as i32) >= capacity {
1060-
return Err(FindReviewerError::ReviewerAtMaxCapacity { username });
1100+
return Err(FindReviewerError::ReviewerAtMaxCapacity {
1101+
username: username.clone(),
1102+
});
10611103
}
10621104
}
10631105
if review_prefs.rotation_mode == RotationMode::OffRotation {
1064-
return Err(FindReviewerError::ReviewerOffRotation { username });
1106+
return Err(FindReviewerError::ReviewerOffRotation {
1107+
username: username.clone(),
1108+
});
10651109
}
10661110

1067-
return Ok(username);
1111+
return Ok(candidate);
10681112
})
10691113
.collect();
10701114
}
10711115
assert_eq!(candidates.len(), expanded_count);
10721116

10731117
let valid_candidates: HashSet<&str> = candidates
10741118
.iter()
1075-
.filter_map(|res| res.as_deref().ok())
1119+
.filter_map(|res| res.as_ref().ok().map(|c| c.name.as_str()))
10761120
.collect();
10771121

10781122
log::debug!(
@@ -1083,13 +1127,24 @@ async fn candidate_reviewers_from_names<'a>(
10831127
);
10841128

10851129
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.
10881130
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
10901134
.pop()
10911135
.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+
}]))
10931148
} else {
10941149
// If it was a request for a team or a group, and no one is available, simply
10951150
// return `NoReviewer`.
@@ -1105,7 +1160,7 @@ async fn candidate_reviewers_from_names<'a>(
11051160
} else {
11061161
Ok(valid_candidates
11071162
.into_iter()
1108-
.map(|s| s.to_string())
1163+
.map(|s| ReviewerSelection::from_name(s.to_string()))
11091164
.collect())
11101165
}
11111166
}

0 commit comments

Comments
 (0)