Skip to content

Commit 987ad86

Browse files
Merge pull request #1668 from ehuss/better-filter-err
Provide a better error message when r? fails
2 parents 98afddb + efb7148 commit 987ad86

File tree

2 files changed

+129
-53
lines changed

2 files changed

+129
-53
lines changed

src/handlers/assign.rs

+75-29
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,11 @@ async fn determine_assignee(
289289
is there maybe a misconfigured group?",
290290
event.issue.global_id()
291291
),
292-
Err(FindReviewerError::NoReviewer(names)) => log::trace!(
293-
"no reviewer could be determined for PR {} with candidate name {names:?}",
292+
Err(
293+
e @ FindReviewerError::NoReviewer { .. }
294+
| e @ FindReviewerError::AllReviewersFiltered { .. },
295+
) => log::trace!(
296+
"no reviewer could be determined for PR {}: {e}",
294297
event.issue.global_id()
295298
),
296299
}
@@ -547,32 +550,61 @@ pub(super) async fn handle_command(
547550
Ok(())
548551
}
549552

550-
#[derive(Debug)]
553+
#[derive(PartialEq, Debug)]
551554
enum FindReviewerError {
552555
/// User specified something like `r? foo/bar` where that team name could
553556
/// not be found.
554557
TeamNotFound(String),
555-
/// No reviewer could be found. The field is the list of candidate names
556-
/// that were used to seed the selection. One example where this happens
557-
/// is if the given name was for a team where the PR author is the only
558-
/// member.
559-
NoReviewer(Vec<String>),
558+
/// No reviewer could be found.
559+
///
560+
/// This could happen if there is a cyclical group or other misconfiguration.
561+
/// `initial` is the initial list of candidate names.
562+
NoReviewer { initial: Vec<String> },
563+
/// All potential candidates were excluded. `initial` is the list of
564+
/// candidate names that were used to seed the selection. `filtered` is
565+
/// the users who were prevented from being assigned. One example where
566+
/// this happens is if the given name was for a team where the PR author
567+
/// is the only member.
568+
AllReviewersFiltered {
569+
initial: Vec<String>,
570+
filtered: Vec<String>,
571+
},
560572
}
561573

562574
impl std::error::Error for FindReviewerError {}
563575

564576
impl fmt::Display for FindReviewerError {
565577
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
566578
match self {
567-
FindReviewerError::TeamNotFound(team) => write!(f, "Team or group `{team}` not found.\n\
568-
\n\
569-
rust-lang team names can be found at https://github.com/rust-lang/team/tree/master/teams.\n\
570-
Reviewer group names can be found in `triagebot.toml` in this repo."),
571-
FindReviewerError::NoReviewer(names) => write!(
572-
f,
573-
"Could not determine reviewer from `{}`.",
574-
names.join(",")
575-
),
579+
FindReviewerError::TeamNotFound(team) => {
580+
write!(
581+
f,
582+
"Team or group `{team}` not found.\n\
583+
\n\
584+
rust-lang team names can be found at https://github.com/rust-lang/team/tree/master/teams.\n\
585+
Reviewer group names can be found in `triagebot.toml` in this repo."
586+
)
587+
}
588+
FindReviewerError::NoReviewer { initial } => {
589+
write!(
590+
f,
591+
"No reviewers could be found from initial request `{}`\n\
592+
This repo may be misconfigured.\n\
593+
Use r? to specify someone else to assign.",
594+
initial.join(",")
595+
)
596+
}
597+
FindReviewerError::AllReviewersFiltered { initial, filtered } => {
598+
write!(
599+
f,
600+
"Could not assign reviewer from: `{}`.\n\
601+
User(s) `{}` are either the PR author or are already assigned, \
602+
and there are no other candidates.\n\
603+
Use r? to specify someone else to assign.",
604+
initial.join(","),
605+
filtered.join(","),
606+
)
607+
}
576608
}
577609
}
578610
}
@@ -609,12 +641,11 @@ fn find_reviewer_from_names(
609641
//
610642
// These are all ideas for improving the selection here. However, I'm not
611643
// sure they are really worth the effort.
612-
match candidates.into_iter().choose(&mut rand::thread_rng()) {
613-
Some(candidate) => Ok(candidate.to_string()),
614-
None => Err(FindReviewerError::NoReviewer(
615-
names.iter().map(|n| n.to_string()).collect(),
616-
)),
617-
}
644+
Ok(candidates
645+
.into_iter()
646+
.choose(&mut rand::thread_rng())
647+
.expect("candidate_reviewers_from_names always returns at least one entry")
648+
.to_string())
618649
}
619650

620651
/// Returns a list of candidate usernames to choose as a reviewer.
@@ -635,16 +666,22 @@ fn candidate_reviewers_from_names<'a>(
635666
// below will pop from this and then append the expanded results of teams.
636667
// Usernames will be added to `candidates`.
637668
let mut group_expansion: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
669+
// Keep track of which users get filtered out for a better error message.
670+
let mut filtered = Vec::new();
638671
let repo = issue.repository();
639672
let org_prefix = format!("{}/", repo.organization);
640673
// Don't allow groups or teams to include the current author or assignee.
641-
let filter = |name: &&str| -> bool {
674+
let mut filter = |name: &&str| -> bool {
642675
let name_lower = name.to_lowercase();
643-
name_lower != issue.user.login.to_lowercase()
676+
let ok = name_lower != issue.user.login.to_lowercase()
644677
&& !issue
645678
.assignees
646679
.iter()
647-
.any(|assignee| name_lower == assignee.login.to_lowercase())
680+
.any(|assignee| name_lower == assignee.login.to_lowercase());
681+
if !ok {
682+
filtered.push(name.to_string());
683+
}
684+
ok
648685
};
649686

650687
// Loop over groups to recursively expand them.
@@ -663,7 +700,7 @@ fn candidate_reviewers_from_names<'a>(
663700
group_members
664701
.iter()
665702
.map(|member| member.as_str())
666-
.filter(filter),
703+
.filter(&mut filter),
667704
);
668705
}
669706
continue;
@@ -683,7 +720,7 @@ fn candidate_reviewers_from_names<'a>(
683720
team.members
684721
.iter()
685722
.map(|member| member.github.as_str())
686-
.filter(filter),
723+
.filter(&mut filter),
687724
);
688725
continue;
689726
}
@@ -697,5 +734,14 @@ fn candidate_reviewers_from_names<'a>(
697734
candidates.insert(group_or_user);
698735
}
699736
}
700-
Ok(candidates)
737+
if candidates.is_empty() {
738+
let initial = names.iter().cloned().collect();
739+
if filtered.is_empty() {
740+
Err(FindReviewerError::NoReviewer { initial })
741+
} else {
742+
Err(FindReviewerError::AllReviewersFiltered { initial, filtered })
743+
}
744+
} else {
745+
Ok(candidates)
746+
}
701747
}

src/handlers/assign/tests/tests_candidates.rs

+54-24
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,26 @@ fn test_from_names(
88
config: toml::Value,
99
issue: serde_json::Value,
1010
names: &[&str],
11-
expected: &[&str],
11+
expected: Result<&[&str], FindReviewerError>,
1212
) {
1313
let (teams, config, issue) = convert_simplified(teams, config, issue);
1414
let names: Vec<_> = names.iter().map(|n| n.to_string()).collect();
15-
let candidates = candidate_reviewers_from_names(&teams, &config, &issue, &names).unwrap();
16-
let mut candidates: Vec<_> = candidates.into_iter().collect();
17-
candidates.sort();
18-
let expected: Vec<_> = expected.iter().map(|x| *x).collect();
19-
assert_eq!(candidates, expected);
15+
match (
16+
candidate_reviewers_from_names(&teams, &config, &issue, &names),
17+
expected,
18+
) {
19+
(Ok(candidates), Ok(expected)) => {
20+
let mut candidates: Vec<_> = candidates.into_iter().collect();
21+
candidates.sort();
22+
let expected: Vec<_> = expected.iter().map(|x| *x).collect();
23+
assert_eq!(candidates, expected);
24+
}
25+
(Err(actual), Err(expected)) => {
26+
assert_eq!(actual, expected)
27+
}
28+
(Ok(candidates), Err(_)) => panic!("expected Err, got Ok: {candidates:?}"),
29+
(Err(e), Ok(_)) => panic!("expected Ok, got Err: {e}"),
30+
}
2031
}
2132

2233
/// Convert the simplified input in preparation for `candidate_reviewers_from_names`.
@@ -78,7 +89,15 @@ fn circular_groups() {
7889
other = ["compiler"]
7990
);
8091
let issue = generic_issue("octocat", "rust-lang/rust");
81-
test_from_names(None, config, issue, &["compiler"], &[]);
92+
test_from_names(
93+
None,
94+
config,
95+
issue,
96+
&["compiler"],
97+
Err(FindReviewerError::NoReviewer {
98+
initial: vec!["compiler".to_string()],
99+
}),
100+
);
82101
}
83102

84103
#[test]
@@ -91,7 +110,7 @@ fn nested_groups() {
91110
c = ["a", "b"]
92111
);
93112
let issue = generic_issue("octocat", "rust-lang/rust");
94-
test_from_names(None, config, issue, &["c"], &["nrc", "pnkfelix"]);
113+
test_from_names(None, config, issue, &["c"], Ok(&["nrc", "pnkfelix"]));
95114
}
96115

97116
#[test]
@@ -102,7 +121,16 @@ fn candidate_filtered_author_only_candidate() {
102121
compiler = ["nikomatsakis"]
103122
);
104123
let issue = generic_issue("nikomatsakis", "rust-lang/rust");
105-
test_from_names(None, config, issue, &["compiler"], &[]);
124+
test_from_names(
125+
None,
126+
config,
127+
issue,
128+
&["compiler"],
129+
Err(FindReviewerError::AllReviewersFiltered {
130+
initial: vec!["compiler".to_string()],
131+
filtered: vec!["nikomatsakis".to_string()],
132+
}),
133+
);
106134
}
107135

108136
#[test]
@@ -119,7 +147,7 @@ fn candidate_filtered_author() {
119147
config,
120148
issue,
121149
&["compiler"],
122-
&["user1", "user3", "user4"],
150+
Ok(&["user1", "user3", "user4"]),
123151
);
124152
}
125153

@@ -135,7 +163,7 @@ fn candidate_filtered_assignee() {
135163
{"login": "user1", "id": 1},
136164
{"login": "user3", "id": 3},
137165
]);
138-
test_from_names(None, config, issue, &["compiler"], &["user4"]);
166+
test_from_names(None, config, issue, &["compiler"], Ok(&["user4"]));
139167
}
140168

141169
#[test]
@@ -155,7 +183,7 @@ fn groups_teams_users() {
155183
config,
156184
issue,
157185
&["team1", "group1", "user3"],
158-
&["t-user1", "t-user2", "user1", "user3"],
186+
Ok(&["t-user1", "t-user2", "user1", "user3"]),
159187
);
160188
}
161189

@@ -173,14 +201,14 @@ fn group_team_user_precedence() {
173201
config.clone(),
174202
issue.clone(),
175203
&["compiler"],
176-
&["user2"],
204+
Ok(&["user2"]),
177205
);
178206
test_from_names(
179207
Some(teams.clone()),
180208
config.clone(),
181209
issue.clone(),
182210
&["rust-lang/compiler"],
183-
&["user2"],
211+
Ok(&["user2"]),
184212
);
185213
}
186214

@@ -200,22 +228,22 @@ fn what_do_slashes_mean() {
200228
config.clone(),
201229
issue.clone(),
202230
&["foo/bar"],
203-
&["foo-user"],
231+
Ok(&["foo-user"]),
204232
);
205233
// Since this is rust-lang-nursery, it uses the rust-lang team, not the group.
206234
test_from_names(
207235
Some(teams.clone()),
208236
config.clone(),
209237
issue.clone(),
210238
&["rust-lang/compiler"],
211-
&["t-user1"],
239+
Ok(&["t-user1"]),
212240
);
213241
test_from_names(
214242
Some(teams.clone()),
215243
config.clone(),
216244
issue.clone(),
217245
&["rust-lang-nursery/compiler"],
218-
&["user2"],
246+
Ok(&["user2"]),
219247
);
220248
}
221249

@@ -227,11 +255,13 @@ fn invalid_org_doesnt_match() {
227255
compiler = ["user2"]
228256
);
229257
let issue = generic_issue("octocat", "rust-lang/rust");
230-
let (teams, config, issue) = convert_simplified(Some(teams), config, issue);
231-
let names = vec!["github/compiler".to_string()];
232-
match candidate_reviewers_from_names(&teams, &config, &issue, &names) {
233-
Ok(x) => panic!("expected err, got {x:?}"),
234-
Err(FindReviewerError::TeamNotFound(_)) => {}
235-
Err(e) => panic!("unexpected error {e:?}"),
236-
}
258+
test_from_names(
259+
Some(teams),
260+
config,
261+
issue,
262+
&["github/compiler"],
263+
Err(FindReviewerError::TeamNotFound(
264+
"github/compiler".to_string(),
265+
)),
266+
);
237267
}

0 commit comments

Comments
 (0)