-
Notifications
You must be signed in to change notification settings - Fork 104
Expand file tree
/
Copy pathassign.rs
More file actions
1365 lines (1264 loc) · 51.9 KB
/
assign.rs
File metadata and controls
1365 lines (1264 loc) · 51.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Handles PR and issue assignment.
//!
//! This supports several ways for setting issue/PR assignment:
//!
//! * `@rustbot assign @gh-user`: Assigns to the given user.
//! * `@rustbot claim`: Assigns to the comment author.
//! * `@rustbot release-assignment`: Removes the commenter's assignment.
//! * `r? @user`: Assigns to the given user (PRs only).
//! * `@rustbot reroll`: Re-run the automatic assignment logic based on PR diff and owner map that
//! is normally triggered when a PR is opened.
//!
//! Note: this module does not handle review assignments issued from the
//! GitHub "Assignees" dropdown menu
//!
//! This is capable of assigning to any user, even if they do not have write
//! access to the repo. It does this by fake-assigning the bot and adding a
//! "claimed by" section to the top-level comment.
//!
//! Configuration is done with the `[assign]` table.
//!
//! This also supports auto-assignment of new PRs. Based on rules in the
//! `assign.owners` config, it will auto-select an assignee based on the files
//! the PR modifies.
use crate::db::issue_data::IssueData;
use crate::db::review_prefs::{RotationMode, get_review_prefs_batch};
use crate::errors::{self, AssignmentError, user_error};
use crate::handlers::pr_tracking::ReviewerWorkqueue;
use crate::{
config::AssignConfig,
github::{self, Event, FileDiff, Issue, IssuesAction, utils::Selection},
handlers::{Context, GithubClient, IssuesEvent},
interactions::EditIssueBody,
};
use anyhow::{Context as _, bail};
use octocrab::models::AuthorAssociation;
use parser::command::assign::AssignCommand;
use parser::command::{Command, Input};
use rand::seq::IteratorRandom;
use rust_team_data::v1::Teams;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_postgres::Client as DbClient;
use tracing as log;
mod messages;
#[cfg(test)]
mod tests {
mod tests_candidates;
mod tests_from_diff;
}
// Special account that we use to prevent assignment.
const GHOST_ACCOUNT: &str = "ghost";
/// Key for the state in the database
const PREVIOUS_REVIEWERS_KEY: &str = "previous-reviewers";
/// State stored in the database
#[derive(Debug, Clone, PartialEq, Default, serde::Deserialize, serde::Serialize)]
struct Reviewers {
// names are stored in lowercase
names: HashSet<String>,
}
/// Assignment data stored in the issue/PR body.
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
struct AssignData {
user: Option<String>,
}
/// Returns the workqueue for the given repository, or an empty default workqueue
/// if the repository is not tracked.
fn get_repo_workqueue(ctx: &Context, repo: &str) -> Arc<RwLock<ReviewerWorkqueue>> {
ctx.workqueue_map
.get(repo)
.unwrap_or_else(|| Arc::new(RwLock::new(ReviewerWorkqueue::default())))
}
/// Input for auto-assignment when a PR is created or converted from draft.
#[derive(Debug)]
pub(super) enum AssignInput {
Opened { draft: bool },
ReadyForReview,
}
/// Prepares the input when a new PR is opened.
pub(super) async fn parse_input(
_ctx: &Context,
event: &IssuesEvent,
config: Option<&AssignConfig>,
) -> Result<Option<AssignInput>, String> {
if config.is_none() || !event.issue.is_pr() {
return Ok(None);
}
match event.action {
IssuesAction::Opened => Ok(Some(AssignInput::Opened {
draft: event.issue.draft,
})),
IssuesAction::ReadyForReview => Ok(Some(AssignInput::ReadyForReview)),
_ => Ok(None),
}
}
/// Handles the work of setting an assignment for a new PR and posting a
/// welcome message.
pub(super) async fn handle_input(
ctx: &Context,
config: &AssignConfig,
event: &IssuesEvent,
input: AssignInput,
) -> anyhow::Result<()> {
let assign_command = find_assign_command(ctx, event);
// Perform assignment when:
// - PR was opened normally
// - PR was opened as a draft with an explicit r? (but not r? ghost)
// - PR was converted from a draft and there are no current assignees
let should_assign = match input {
AssignInput::Opened { draft: false } => true,
AssignInput::Opened { draft: true } => {
// Even if the PR is opened as a draft, we still want to perform assignment if r?
// was used. However, historically, `r? ghost` was supposed to mean "do not
// perform assignment". So in that case, we skip the assignment and only perform it once
// the PR has been marked as being ready for review.
assign_command.as_ref().is_some_and(|a| a != GHOST_ACCOUNT)
}
AssignInput::ReadyForReview => event.issue.assignees.is_empty(),
};
if !should_assign {
log::info!("Skipping PR assignment, input: {input:?}, assign_command: {assign_command:?}");
return Ok(());
}
let Some(diff) = event.issue.diff(&ctx.github).await? else {
bail!(
"expected issue {} to be a PR, but the diff could not be determined",
event.issue.number
)
};
// Don't auto-assign or welcome if the user manually set the assignee when opening.
if event.issue.assignees.is_empty() {
let (assignee, from_comment) = determine_assignee(
ctx,
assign_command,
&event.issue,
&event.issue.user.login,
config,
diff,
)
.await?;
if assignee.as_ref().map(|r| r.name.as_str()) == Some(GHOST_ACCOUNT) {
// "ghost" is GitHub's placeholder account for deleted accounts.
// It is used here as a convenient way to prevent assignment. This
// is typically used for rollups or experiments where you don't
// want any assignments or noise.
return Ok(());
}
let welcome = if let Some(custom_messages) = &config.custom_messages {
if from_comment {
// No welcome is posted if they used `r?` in the opening body.
None
} else {
let mut welcome = match &assignee {
Some(assignee) => custom_messages
.auto_assign_someone
.as_ref()
.map(|wm| wm.trim().replace("{assignee}", &assignee.name)),
None => Some(custom_messages.auto_assign_no_one.trim().to_string()),
};
if let Some(ref mut welcome) = welcome
&& let Some(contrib) = &config.contributing_url
&& matches!(
event.issue.author_association,
AuthorAssociation::FirstTimer | AuthorAssociation::FirstTimeContributor
)
{
welcome.push_str("\n\n");
welcome.push_str(&messages::contribution_message(contrib, &ctx.username));
}
welcome
}
} else if matches!(
event.issue.author_association,
AuthorAssociation::FirstTimer | AuthorAssociation::FirstTimeContributor
) {
let assignee_text = match &assignee {
Some(assignee) => messages::welcome_with_reviewer(&assignee.name),
None => messages::WELCOME_WITHOUT_REVIEWER.to_string(),
};
let mut welcome = messages::new_user_welcome_message(&assignee_text);
if let Some(contrib) = &config.contributing_url {
welcome.push_str("\n\n");
welcome.push_str(&messages::contribution_message(contrib, &ctx.username));
}
Some(welcome)
} else if !from_comment {
match &assignee {
Some(assignee) => Some(messages::returning_user_welcome_message(
&assignee.name,
&ctx.username,
)),
None => {
// If the assign fallback group is empty, then we don't expect any automatic
// assignment, and this message would just be spam.
if config.fallback_review_group().is_some() {
Some(messages::returning_user_welcome_message_no_reviewer(
&event.issue.user.login,
))
} else {
None
}
}
}
} else {
// No welcome is posted if they are not new and they used `r?` in the opening body.
None
};
if let Some(assignee) = &assignee {
set_assignee(ctx, &event.issue, &ctx.github, assignee).await?;
}
if let Some(mut welcome) = welcome {
// Add some explanation of why the given reviewer was chosen
if let Some(assignee) = assignee
&& !assignee.selection_steps.is_empty()
{
fn format_candidates(candidates: &[String]) -> String {
if candidates.len() > 5 {
format!("{} candidates", candidates.len())
} else {
let mut candidates = candidates.to_vec();
candidates.sort();
candidates
.into_iter()
.map(|c| format!("`{c}`"))
.collect::<Vec<_>>()
.join(", ")
}
}
let mut explanation = String::new();
for step in &assignee.selection_steps {
let msg = match step {
SelectionStep::SelfAssign => "Self-assignment".to_string(),
SelectionStep::RandomlySelectedFrom(candidates) => {
format!("Random selection from {}", format_candidates(&candidates))
}
SelectionStep::Fallback(group) => {
format!("Fallback group: {}", format_candidates(&group))
}
SelectionStep::FileDiff(candidates) => format!(
"Owners of files modified in this PR: {}",
format_candidates(&candidates)
),
SelectionStep::Expansion { from, to } => {
format!(
"{} expanded to {}",
format_candidates(&from),
format_candidates(&to)
)
}
};
explanation.push_str(&format!("- {msg}\n"));
}
use std::fmt::Write;
writeln!(
welcome,
r#"
<details>
<summary>Why was this reviewer chosen?</summary>
The reviewer was selected based on:
{explanation}
</details>"#
)
.unwrap();
}
if let Err(e) = event.issue.post_comment(&ctx.github, &welcome).await {
log::warn!(
"failed to post welcome comment to {}: {e}",
event.issue.global_id()
);
}
}
}
Ok(())
}
/// Finds the `r?` command in the PR body.
///
/// Returns the name after the `r?` command, or None if not found.
fn find_assign_command(ctx: &Context, event: &IssuesEvent) -> Option<String> {
let mut input = Input::new(&event.issue.body, vec![&ctx.username]);
input.find_map(|command| match command {
Command::Assign(Ok(AssignCommand::RequestReview { name })) => Some(name),
_ => None,
})
}
fn is_self_assign(assignee: &str, pr_author: &str) -> bool {
assignee.to_lowercase() == pr_author.to_lowercase()
}
/// Sets the assignee of a PR, alerting any errors.
async fn set_assignee(
ctx: &Context,
issue: &Issue,
github: &GithubClient,
reviewer: &ReviewerSelection,
) -> anyhow::Result<()> {
let mut db = ctx.db.get().await;
let mut state: IssueData<'_, Reviewers> =
IssueData::load(&mut db, issue, PREVIOUS_REVIEWERS_KEY).await?;
// Don't re-assign if already assigned, e.g. on comment edit
if issue.contain_assignee(&reviewer.name) && issue.assignees.len() == 1 {
log::trace!(
"ignoring assign PR {} to {}, already assigned",
issue.global_id(),
reviewer.name,
);
return Ok(());
}
if let Err(err) = issue.set_assignee(github, &reviewer.name).await {
log::warn!(
"failed to set assignee of PR {} to {}: {err:?}",
issue.global_id(),
reviewer.name,
);
if let Err(e) = issue
.post_comment(
github,
&format!(
"Failed to set assignee to `{}`: {err}\n\
\n\
> **Note**: Only org members with at least the repository \"read\" role, \
users with write permissions, or people who have commented on the PR may \
be assigned.",
reviewer.name
),
)
.await
{
log::warn!("failed to post error comment: {e}");
return Err(e);
}
} else {
// If an error was suppressed, post a warning on the PR.
if let Some(suppressed_error) = &reviewer.suppressed_error {
let warning = match suppressed_error {
FindReviewerError::ReviewerOffRotation { username } => Some(format!(
r"`{username}` is not on the review rotation at the moment.
They may take a while to respond.
"
)),
FindReviewerError::ReviewerAtMaxCapacity { username } => Some(format!(
"`{username}` is currently at their maximum review capacity.
They may take a while to respond."
)),
_ => None,
};
if let Some(warning) = warning
&& let Err(err) = issue.post_comment(&ctx.github, &warning).await
{
// This is a best-effort warning, do not do anything apart from logging if it fails
log::warn!("failed to post reviewer warning comment: {err}");
}
}
}
// Record the reviewer in the database
state.data.names.insert(reviewer.name.to_lowercase());
state.save().await?;
Ok(())
}
/// Determines who to assign the PR to based on either an `r?` command, or
/// based on which files were modified.
///
/// Will also check if candidates have capacity in their work queue.
///
/// Returns `(assignee, from_comment)` where `assignee` is who to assign to
/// (or None if no assignee could be found). `from_comment` is a boolean
/// indicating if the assignee came from an `r?` command (it is false if
/// determined from the diff).
async fn determine_assignee(
ctx: &Context,
assign_command: Option<String>,
issue: &Issue,
requested_by: &str,
config: &AssignConfig,
diff: &[FileDiff],
) -> anyhow::Result<(Option<ReviewerSelection>, bool)> {
let mut db_client = ctx.db.get().await;
let workqueue = get_repo_workqueue(ctx, &issue.repository().full_repo_name());
let teams = &ctx.team.teams().await?;
if let Some(name) = assign_command {
// User included `r?` in the opening PR body.
match find_reviewer_from_names(
&mut db_client,
workqueue.clone(),
teams,
config,
issue,
requested_by,
&[name],
)
.await
{
Ok(assignee) => return Ok((Some(assignee), true)),
Err(e) => {
issue.post_comment(&ctx.github, &e.to_string()).await?;
// Fall through below for normal diff detection.
}
}
}
// Errors fall-through to try fallback group.
match find_reviewers_from_diff(config, diff) {
Ok(candidates) if !candidates.is_empty() => {
match find_reviewer_from_names(
&mut db_client,
workqueue.clone(),
teams,
config,
issue,
requested_by,
&candidates,
)
.await
{
Ok(assignee) => {
return Ok((
Some(assignee.prepend_selection_step(SelectionStep::FileDiff(candidates))),
false,
));
}
Err(FindReviewerError::TeamNotFound(team)) => log::warn!(
"team {team} not found via diff from PR {}, \
is there maybe a misconfigured group?",
issue.global_id()
),
Err(
e @ (FindReviewerError::NoReviewer { .. }
| FindReviewerError::ReviewerIsPrAuthor { .. }
| FindReviewerError::ReviewerAlreadyAssigned { .. }
| FindReviewerError::ReviewerPreviouslyAssigned { .. }
| FindReviewerError::ReviewerOffRotation { .. }
| FindReviewerError::ReviewerOffRotationThroughTeam { .. }
| FindReviewerError::DatabaseError(_)
| FindReviewerError::ReviewerAtMaxCapacity { .. }),
) => log::trace!(
"no reviewer could be determined for PR {}: {e}",
issue.global_id()
),
}
}
// If no owners matched the diff, fall-through.
Ok(_) => {}
Err(e) => {
log::warn!(
"failed to find candidate reviewer from diff due to error: {e}\n\
Is the triagebot.toml misconfigured?"
);
}
}
if let Some(fallback) = config.fallback_review_group() {
match find_reviewer_from_names(
&mut db_client,
workqueue.clone(),
teams,
config,
issue,
requested_by,
fallback,
)
.await
{
Ok(assignee) => {
return Ok((
Some(
assignee.prepend_selection_step(SelectionStep::Fallback(fallback.to_vec())),
),
false,
));
}
Err(e) => {
log::trace!(
"failed to select from fallback group for PR {}: {e}",
issue.global_id()
);
}
}
}
Ok((None, false))
}
/// Returns a list of candidate reviewers to use based on which files were changed.
///
/// May return an error if the owners map is misconfigured.
///
/// Beware this may return an empty list if nothing matches.
fn find_reviewers_from_diff(
config: &AssignConfig,
diff: &[FileDiff],
) -> anyhow::Result<Vec<String>> {
// Map of `owners` path to the number of changes found in that path.
// This weights the reviewer choice towards places where the most edits are done.
let mut counts: HashMap<&str, u32> = HashMap::new();
// Iterate over the diff, counting the number of modified lines in each
// file, and tracks those in the `counts` map.
for file_diff in diff {
// List of the longest `owners` patterns that match the current path. This
// prefers choosing reviewers from deeply nested paths over those defined
// for top-level paths, under the assumption that they are more
// specialized.
//
// This is a list to handle the situation if multiple paths of the same
// length match.
let mut longest_owner_patterns = Vec::new();
// Find the longest `owners` entries that match this path.
let mut longest = HashMap::new();
for owner_pattern in config.owners.keys() {
let ignore = ignore::gitignore::GitignoreBuilder::new("/")
.add_line(None, owner_pattern)
.with_context(|| format!("owner file pattern `{owner_pattern}` is not valid"))?
.build()?;
if ignore
.matched_path_or_any_parents(&file_diff.filename, false)
.is_ignore()
{
let owner_len = owner_pattern.split('/').count();
longest.insert(owner_pattern, owner_len);
}
}
let max_count = longest.values().copied().max().unwrap_or(0);
longest_owner_patterns.extend(
longest
.iter()
.filter(|(_, count)| **count == max_count)
.map(|x| *x.0),
);
// Give some weight to these patterns to start. This helps with
// files modified without any lines changed.
for owner_pattern in &longest_owner_patterns {
*counts.entry(owner_pattern).or_default() += 1;
}
// Count the modified lines.
for line in file_diff.patch.lines() {
if (!line.starts_with("+++") && line.starts_with('+'))
|| (!line.starts_with("---") && line.starts_with('-'))
{
for owner_path in &longest_owner_patterns {
*counts.entry(owner_path).or_default() += 1;
}
}
}
}
// Use the `owners` entry with the most number of modifications.
let max_count = counts.values().copied().max().unwrap_or(0);
let max_paths = counts
.iter()
.filter(|(_, count)| **count == max_count)
.map(|(path, _)| path);
let mut potential: Vec<_> = max_paths
.flat_map(|owner_path| &config.owners[*owner_path])
.cloned()
.collect();
// Dedupe. This isn't strictly necessary, as `find_reviewer_from_names` will deduplicate.
// However, this helps with testing.
potential.sort();
potential.dedup();
Ok(potential)
}
/// Handles a command posted in a comment.
pub(super) async fn handle_command(
ctx: &Context,
config: &AssignConfig,
event: &Event,
cmd: AssignCommand,
) -> anyhow::Result<()> {
let is_team_member = matches!(ctx.team.is_team_member(&event.user().login).await, Ok(true));
// Don't handle commands in comments from the bot. Some of the comments it
// posts contain commands to instruct the user, not things that the bot
// should respond to.
if event.user().login == ctx.username.as_str() {
return Ok(());
}
let issue = event.issue().unwrap();
if issue.is_pr() {
if !issue.is_open() {
return user_error!("Assignment is not allowed on a closed PR.");
}
if matches!(
event,
Event::Issue(IssuesEvent {
action: IssuesAction::Opened,
..
})
) {
// Don't handle review request comments on new PRs. Those will be
// handled by the new PR trigger (which also handles the
// welcome message).
return Ok(());
}
let teams = ctx.team.teams().await?;
let assignee = match cmd {
AssignCommand::Claim => event.user().login.clone(),
AssignCommand::AssignUser { username } => username,
AssignCommand::ReleaseAssignment => {
log::trace!(
"ignoring release on PR {:?}, must always have assignee",
issue.global_id()
);
return Ok(());
}
AssignCommand::RequestReview { name } => {
// Determine if assignee is a team. If yes, add the corresponding GH label.
if let Some(team_name) = get_team_name(&teams, issue, &name) {
let t_label = format!("T-{team_name}");
if let Err(err) = issue
.add_labels(&ctx.github, vec![github::Label { name: t_label }])
.await
{
if let Some(errors::UserError::UnknownLabels { .. }) = err.downcast_ref() {
log::warn!("error assigning team label: {err}");
} else {
return Err(err);
}
}
}
name
}
AssignCommand::Reroll => {
// We need to compute the PR diff here, but the IssuesEvent created from a
// comment webhook doesn't contain the required `base` and `head` fields.
// So we have to load the information about the pull request from the GitHub API
// explicitly.
let pr = ctx
.github
.pull_request(issue.repository(), issue.number)
.await
.context("Cannot load pull request from GitHub")?;
let Some(diff) = pr.diff(&ctx.github).await.context("Cannot load PR diff")? else {
bail!(
"expected issue {} to be a PR, but the diff could not be determined",
issue.number
);
};
let (assignee, _) =
determine_assignee(ctx, None, issue, &event.user().login, config, diff)
.await
.context("Cannot determine assignee when rerolling")?;
if let Some(assignee) = assignee {
set_assignee(ctx, issue, &ctx.github, &assignee)
.await
.context("Cannot set assignee when rerolling")?;
} else {
return user_error!(
"Cannot determine a new reviewer. Use `r? <username or team>` to request a specific reviewer or a team."
);
}
return Ok(());
}
};
// In the PR body, `r? ghost` means "do not assign anybody".
// When you send `r? ghost` in a PR comment, it should mean "unassign the current assignee".
// Only allow this for the PR author (usually when they forget to do `r? ghost` in the PR
// body), otherwise anyone could remove assignees from any PR.
if assignee == GHOST_ACCOUNT && issue.user.login == event.user().login {
issue.remove_assignees(&ctx.github, Selection::All).await?;
return Ok(());
}
let mut db_client = ctx.db.get().await;
let repo_workqueue = get_repo_workqueue(ctx, &event.repo().full_name);
let assignee = match find_reviewer_from_names(
&mut db_client,
repo_workqueue,
&teams,
config,
issue,
&event.user().login,
&[assignee.to_string()],
)
.await
{
Ok(assignee) => assignee,
Err(FindReviewerError::ReviewerAlreadyAssigned { username })
if issue.contain_assignee(&event.user().login) =>
{
// If one of the assignee tries to assign another already assigned user,
// let it pass, as it means they wanted to be removed from the assignee list.
ReviewerSelection::new(username, Vec::new())
}
Err(e) => {
issue.post_comment(&ctx.github, &e.to_string()).await?;
return Ok(());
}
};
set_assignee(ctx, issue, &ctx.github, &assignee).await?;
} else {
let mut client = ctx.db.get().await;
let mut e: EditIssueBody<'_, AssignData> =
EditIssueBody::load(&mut client, issue, "ASSIGN").await?;
let d = e.data_mut();
let to_assign = match cmd {
AssignCommand::Claim => event.user().login.clone(),
AssignCommand::AssignUser { username } => {
if !is_team_member && username != event.user().login {
return user_error!("Only Rust team members can assign other users");
}
username.clone()
}
AssignCommand::ReleaseAssignment => {
let current = &event.user().login;
if d.user.as_ref() == Some(current)
|| issue.contain_assignee(current)
|| is_team_member
{
issue.remove_assignees(&ctx.github, Selection::All).await?;
*d = AssignData { user: None };
e.apply(&ctx.github, String::new()).await?;
return Ok(());
} else if !issue.assignees.is_empty() {
return user_error!("Cannot release another user's assignment");
} else {
return user_error!("Cannot release unassigned issue");
}
}
AssignCommand::RequestReview { .. } => {
return user_error!("r? is only allowed on PRs.");
}
AssignCommand::Reroll { .. } => {
return user_error!("reroll is only allowed on PRs.");
}
};
// Don't re-assign if already assigned, e.g. on comment edit
if issue.contain_assignee(&to_assign) {
log::trace!(
"ignoring assign issue {issue} to {to_assign}, already assigned",
issue = issue.global_id(),
);
return Ok(());
}
*d = AssignData {
user: Some(to_assign.clone()),
};
match issue.set_assignee(&ctx.github, &to_assign).await {
Ok(()) => {
e.apply(&ctx.github, String::new()).await?;
return Ok(());
} // we are done
Err(AssignmentError::InvalidAssignee) => {
issue.set_assignee(&ctx.github, &ctx.username).await?;
let cmt_body = format!(
"This issue has been assigned to @{to_assign} via [this comment]({}).",
event.html_url().unwrap()
);
e.apply(&ctx.github, cmt_body).await?;
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
fn strip_organization_prefix<'a>(issue: &Issue, name: &'a str) -> &'a str {
let repo = issue.repository();
// @ is optional, so it is trimmed separately
// both @rust-lang/compiler and rust-lang/compiler should work
name.trim_start_matches('@')
.trim_start_matches(&format!("{}/", repo.organization))
}
/// Returns `Some(team_name)` if `name` corresponds to a name of a team.
fn get_team_name<'a>(teams: &Teams, issue: &Issue, name: &'a str) -> Option<&'a str> {
let team_name = strip_organization_prefix(issue, name);
// Remove "t-" or "T-" prefixes before checking if it's a team name
let team_name = team_name.trim_start_matches("t-").trim_start_matches("T-");
teams.teams.get(team_name).map(|_| team_name)
}
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
enum FindReviewerError {
/// User specified something like `r? foo/bar` where that team name could
/// not be found.
TeamNotFound(String),
/// No reviewer could be found.
///
/// This could happen if there is a cyclical group or other misconfiguration.
/// `initial` is the initial list of candidate names.
/// `unavailable_reviewers` is a number of reviewers that could not have been chosen, as they
/// are not available for a review at this time.
NoReviewer {
initial: Vec<String>,
unavailable_reviewers: u32,
},
/// Requested reviewer is off the review rotation (e.g. on a vacation).
/// Either the username is in [users_on_vacation] in `triagebot.toml` or the user has
/// configured [`RotationMode::OffRotation`] in their reviewer preferences.
ReviewerOffRotation { username: String },
/// Requested reviewer is off the review rotation for the specified team.
ReviewerOffRotationThroughTeam { username: String, team: String },
/// Requested reviewer is PR author
ReviewerIsPrAuthor { username: String },
/// Requested reviewer is already assigned to that PR
ReviewerAlreadyAssigned { username: String },
/// Requested reviewer was already assigned previously to that PR.
ReviewerPreviouslyAssigned { username: String },
/// Data required for assignment could not be loaded from the DB.
DatabaseError(String),
/// The reviewer has too many PRs already assigned.
ReviewerAtMaxCapacity { username: String },
}
impl std::error::Error for FindReviewerError {}
impl fmt::Display for FindReviewerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self {
FindReviewerError::TeamNotFound(team) => {
write!(
f,
"Team or group `{team}` not found.\n\
\n\
rust-lang team names can be found at https://github.com/rust-lang/team/tree/master/teams.\n\
Reviewer group names can be found in `triagebot.toml` in this repo."
)
}
FindReviewerError::NoReviewer {
initial,
unavailable_reviewers,
} => {
write!(
f,
"The review request `{}` corresponds to {unavailable_reviewers} reviewer(s).\n\
However, none of them are available for a review at this time.\n\
Use `r?` to specify someone else to assign.",
initial.join(",")
)
}
FindReviewerError::ReviewerOffRotation { username } => {
write!(f, "{}", messages::reviewer_off_rotation_message(username))
}
FindReviewerError::ReviewerOffRotationThroughTeam { username, team } => {
write!(
f,
"{}",
messages::reviewer_off_rotation_through_team_message(username, team)
)
}
FindReviewerError::ReviewerIsPrAuthor { .. } => {
write!(f, "{}", messages::REVIEWER_IS_PR_AUTHOR)
}
FindReviewerError::ReviewerAlreadyAssigned { .. } => {
write!(f, "{}", messages::REVIEWER_ALREADY_ASSIGNED)
}
FindReviewerError::ReviewerPreviouslyAssigned { username } => {
write!(f, "{}", messages::reviewer_assigned_before(username))
}
FindReviewerError::DatabaseError(error) => {
write!(f, "Database error: {error}")
}
FindReviewerError::ReviewerAtMaxCapacity { username } => {
write!(
f,
r"`{username}` has too many PRs assigned to them.
Please select a different reviewer.",
)
}
}
}
}
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone)]
enum SelectionStep {
/// The user assigned themselves.
SelfAssign,
/// Random selection amongst N candidates.
RandomlySelectedFrom(Vec<String>),
/// The reviewer was selected as a last resort from the adhoc fallback group.
Fallback(Vec<String>),
/// The reviewer was selected based on the PR diff that produced a set of initial candidates.
FileDiff(Vec<String>),
/// A set of groups or teams were expanded into a list of reviewer usernames.
Expansion { from: Vec<String>, to: Vec<String> },
}
/// Reviewer that was found to be eligible as a result of `r? <...>`.
/// In some cases, a reviewer selection error might have been suppressed.
/// We store it here to allow sending a comment with a warning about the suppressed error.
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
struct ReviewerSelection {
name: String,
suppressed_error: Option<FindReviewerError>,
/// Records a set of steps that were taken to select a given reviewer,
/// so that we can explain the selection to users.
selection_steps: Vec<SelectionStep>,
}
impl ReviewerSelection {
fn new(name: String, selection_steps: Vec<SelectionStep>) -> Self {
Self {
name,
suppressed_error: None,
selection_steps,
}
}
fn from_self_assign(name: String) -> Self {
Self {
name,
suppressed_error: None,
selection_steps: vec![SelectionStep::SelfAssign],
}
}
fn add_selection_step(mut self, step: SelectionStep) -> Self {
self.selection_steps.push(step);
self
}
fn prepend_selection_step(mut self, step: SelectionStep) -> Self {
self.selection_steps.insert(0, step);
self
}
}
/// Finds a reviewer to assign to a PR.
///
/// The `names` is a list of candidate reviewers `r?`, such as `compiler` or
/// `@octocat`, or names from the owners map. It can contain GitHub usernames,
/// auto-assign groups, or rust-lang team names. It must have at least one
/// entry.
async fn find_reviewer_from_names(
db: &mut DbClient,
workqueue: Arc<RwLock<ReviewerWorkqueue>>,
teams: &Teams,
config: &AssignConfig,
issue: &Issue,
requested_by: &str,
names: &[String],
) -> Result<ReviewerSelection, FindReviewerError> {
// Fast path for self-assign, which is always allowed.
if let [name] = names
&& is_self_assign(name, requested_by)
{
return Ok(ReviewerSelection::from_self_assign(name.clone()));
}
// Allow `me` as an alias for self-assign, which is always allowed.
if let [name] = names
&& name == "me"
{
return Ok(ReviewerSelection::from_self_assign(
requested_by.to_string(),
));
}
let candidates =
candidate_reviewers_from_names(db, workqueue, teams, config, issue, names).await?;
assert!(!candidates.is_empty());
// This uses a relatively primitive random choice algorithm.
// GitHub's CODEOWNERS supports much more sophisticated options, such as:
//
// - Round robin: Chooses reviewers based on who's received the least
// recent review request, focusing on alternating between all members of
// the team regardless of the number of outstanding reviews they
// currently have.
// - Load balance: Chooses reviewers based on each member's total number