Skip to content

Commit bb815bb

Browse files
committed
Provide a better error message when r? fails
1 parent 0bd2fe0 commit bb815bb

File tree

2 files changed

+129
-53
lines changed

2 files changed

+129
-53
lines changed

src/handlers/assign.rs

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,11 @@ async fn determine_assignee(
282282
is there maybe a misconfigured group?",
283283
event.issue.global_id()
284284
),
285-
Err(FindReviewerError::NoReviewer(names)) => log::trace!(
286-
"no reviewer could be determined for PR {} with candidate name {names:?}",
285+
Err(
286+
e @ FindReviewerError::NoReviewer { .. }
287+
| e @ FindReviewerError::AllReviewersFiltered { .. },
288+
) => log::trace!(
289+
"no reviewer could be determined for PR {}: {e}",
287290
event.issue.global_id()
288291
),
289292
}
@@ -536,32 +539,61 @@ pub(super) async fn handle_command(
536539
Ok(())
537540
}
538541

539-
#[derive(Debug)]
542+
#[derive(PartialEq, Debug)]
540543
enum FindReviewerError {
541544
/// User specified something like `r? foo/bar` where that team name could
542545
/// not be found.
543546
TeamNotFound(String),
544-
/// No reviewer could be found. The field is the list of candidate names
545-
/// that were used to seed the selection. One example where this happens
546-
/// is if the given name was for a team where the PR author is the only
547-
/// member.
548-
NoReviewer(Vec<String>),
547+
/// No reviewer could be found.
548+
///
549+
/// This could happen if there is a cyclical group or other misconfiguration.
550+
/// `initial` is the initial list of candidate names.
551+
NoReviewer { initial: Vec<String> },
552+
/// All potential candidates were excluded. `initial` is the list of
553+
/// candidate names that were used to seed the selection. `filtered` is
554+
/// the users who were prevented from being assigned. One example where
555+
/// this happens is if the given name was for a team where the PR author
556+
/// is the only member.
557+
AllReviewersFiltered {
558+
initial: Vec<String>,
559+
filtered: Vec<String>,
560+
},
549561
}
550562

551563
impl std::error::Error for FindReviewerError {}
552564

553565
impl fmt::Display for FindReviewerError {
554566
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
555567
match self {
556-
FindReviewerError::TeamNotFound(team) => write!(f, "Team or group `{team}` not found.\n\
557-
\n\
558-
rust-lang team names can be found at https://github.com/rust-lang/team/tree/master/teams.\n\
559-
Reviewer group names can be found in `triagebot.toml` in this repo."),
560-
FindReviewerError::NoReviewer(names) => write!(
561-
f,
562-
"Could not determine reviewer from `{}`.",
563-
names.join(",")
564-
),
568+
FindReviewerError::TeamNotFound(team) => {
569+
write!(
570+
f,
571+
"Team or group `{team}` not found.\n\
572+
\n\
573+
rust-lang team names can be found at https://github.com/rust-lang/team/tree/master/teams.\n\
574+
Reviewer group names can be found in `triagebot.toml` in this repo."
575+
)
576+
}
577+
FindReviewerError::NoReviewer { initial } => {
578+
write!(
579+
f,
580+
"No reviewers could be found from initial choice `{}`\n\
581+
This repo may be misconfigured.\n\
582+
Use r? to specify someone else to assign.",
583+
initial.join(",")
584+
)
585+
}
586+
FindReviewerError::AllReviewersFiltered { initial, filtered } => {
587+
write!(
588+
f,
589+
"Could not assign reviewer from: `{}`.\n\
590+
User(s) `{}` are either the PR author or are already assigned, \
591+
and there are no other candidates.\n\
592+
Use r? to specify someone else to assign.",
593+
initial.join(","),
594+
filtered.join(","),
595+
)
596+
}
565597
}
566598
}
567599
}
@@ -598,12 +630,11 @@ fn find_reviewer_from_names(
598630
//
599631
// These are all ideas for improving the selection here. However, I'm not
600632
// sure they are really worth the effort.
601-
match candidates.into_iter().choose(&mut rand::thread_rng()) {
602-
Some(candidate) => Ok(candidate.to_string()),
603-
None => Err(FindReviewerError::NoReviewer(
604-
names.iter().map(|n| n.to_string()).collect(),
605-
)),
606-
}
633+
Ok(candidates
634+
.into_iter()
635+
.choose(&mut rand::thread_rng())
636+
.expect("candidate_reviewers_from_names always returns at least one entry")
637+
.to_string())
607638
}
608639

609640
/// Returns a list of candidate usernames to choose as a reviewer.
@@ -624,16 +655,22 @@ fn candidate_reviewers_from_names<'a>(
624655
// below will pop from this and then append the expanded results of teams.
625656
// Usernames will be added to `candidates`.
626657
let mut group_expansion: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
658+
// Keep track of which users get filtered out for a better error message.
659+
let mut filtered = Vec::new();
627660
let repo = issue.repository();
628661
let org_prefix = format!("{}/", repo.organization);
629662
// Don't allow groups or teams to include the current author or assignee.
630-
let filter = |name: &&str| -> bool {
663+
let mut filter = |name: &&str| -> bool {
631664
let name_lower = name.to_lowercase();
632-
name_lower != issue.user.login.to_lowercase()
665+
let ok = name_lower != issue.user.login.to_lowercase()
633666
&& !issue
634667
.assignees
635668
.iter()
636-
.any(|assignee| name_lower == assignee.login.to_lowercase())
669+
.any(|assignee| name_lower == assignee.login.to_lowercase());
670+
if !ok {
671+
filtered.push(name.to_string());
672+
}
673+
ok
637674
};
638675

639676
// Loop over groups to recursively expand them.
@@ -652,7 +689,7 @@ fn candidate_reviewers_from_names<'a>(
652689
group_members
653690
.iter()
654691
.map(|member| member.as_str())
655-
.filter(filter),
692+
.filter(&mut filter),
656693
);
657694
}
658695
continue;
@@ -672,7 +709,7 @@ fn candidate_reviewers_from_names<'a>(
672709
team.members
673710
.iter()
674711
.map(|member| member.github.as_str())
675-
.filter(filter),
712+
.filter(&mut filter),
676713
);
677714
continue;
678715
}
@@ -686,5 +723,14 @@ fn candidate_reviewers_from_names<'a>(
686723
candidates.insert(group_or_user);
687724
}
688725
}
689-
Ok(candidates)
726+
if candidates.is_empty() {
727+
let initial = names.iter().cloned().collect();
728+
if filtered.is_empty() {
729+
Err(FindReviewerError::NoReviewer { initial })
730+
} else {
731+
Err(FindReviewerError::AllReviewersFiltered { initial, filtered })
732+
}
733+
} else {
734+
Ok(candidates)
735+
}
690736
}

src/handlers/assign/tests/tests_candidates.rs

Lines changed: 54 additions & 24 deletions
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)