2222
2323use crate :: {
2424 config:: AssignConfig ,
25+ db:: issue_data:: IssueData ,
2526 github:: { self , Event , FileDiff , Issue , IssuesAction , Selection } ,
2627 handlers:: { Context , GithubClient , IssuesEvent } ,
2728 interactions:: EditIssueBody ,
@@ -33,7 +34,6 @@ use rand::seq::IteratorRandom;
3334use rust_team_data:: v1:: Teams ;
3435use std:: collections:: { HashMap , HashSet } ;
3536use std:: fmt;
36- use tokio_postgres:: Client as DbClient ;
3737use tracing as log;
3838
3939#[ cfg( test) ]
@@ -87,9 +87,23 @@ const REVIEWER_ALREADY_ASSIGNED: &str =
8787
8888Please choose another assignee." ;
8989
90+ const REVIEWER_ASSIGNED_BEFORE : & str = "Requested reviewers are assigned before.
91+
92+ Please choose another assignee by using `r? @reviewer`." ;
93+
9094// Special account that we use to prevent assignment.
9195const GHOST_ACCOUNT : & str = "ghost" ;
9296
97+ /// Key for the state in the database
98+ const PREVIOUS_REVIEWER_KEY : & str = "previous-reviewer" ;
99+
100+ /// State stored in the database
101+ #[ derive( Debug , Default , serde:: Deserialize , serde:: Serialize ) ]
102+ struct Reviewer {
103+ /// List of the last warnings in the most recent comment.
104+ names : HashSet < String > ,
105+ }
106+
93107#[ derive( Debug , PartialEq , Eq , serde:: Serialize , serde:: Deserialize ) ]
94108struct AssignData {
95109 user : Option < String > ,
@@ -179,7 +193,7 @@ pub(super) async fn handle_input(
179193 None
180194 } ;
181195 if let Some ( assignee) = assignee {
182- set_assignee ( & event. issue , & ctx. github , & assignee) . await ;
196+ set_assignee ( & ctx , & event. issue , & ctx. github , & assignee) . await ? ;
183197 }
184198
185199 if let Some ( welcome) = welcome {
@@ -211,15 +225,24 @@ fn is_self_assign(assignee: &str, pr_author: &str) -> bool {
211225}
212226
213227/// Sets the assignee of a PR, alerting any errors.
214- async fn set_assignee ( issue : & Issue , github : & GithubClient , username : & str ) {
228+ async fn set_assignee (
229+ ctx : & Context ,
230+ issue : & Issue ,
231+ github : & GithubClient ,
232+ username : & str ,
233+ ) -> anyhow:: Result < ( ) > {
234+ let mut db = ctx. db . get ( ) . await ;
235+ let mut state: IssueData < ' _ , Reviewer > =
236+ IssueData :: load ( & mut db, & issue, PREVIOUS_REVIEWER_KEY ) . await ?;
237+
215238 // Don't re-assign if already assigned, e.g. on comment edit
216239 if issue. contain_assignee ( & username) {
217240 log:: trace!(
218241 "ignoring assign PR {} to {}, already assigned" ,
219242 issue. global_id( ) ,
220243 username,
221244 ) ;
222- return ;
245+ return Ok ( ( ) ) ;
223246 }
224247 if let Err ( err) = issue. set_assignee ( github, & username) . await {
225248 log:: warn!(
@@ -242,8 +265,12 @@ async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) {
242265 . await
243266 {
244267 log:: warn!( "failed to post error comment: {e}" ) ;
268+ return Err ( e) ;
245269 }
246270 }
271+ state. data . names . insert ( username. to_string ( ) ) ;
272+ state. save ( ) . await ?;
273+ Ok ( ( ) )
247274}
248275
249276/// Determines who to assign the PR to based on either an `r?` command, or
@@ -261,11 +288,10 @@ async fn determine_assignee(
261288 config : & AssignConfig ,
262289 diff : & [ FileDiff ] ,
263290) -> anyhow:: Result < ( Option < String > , bool ) > {
264- let db_client = ctx. db . get ( ) . await ;
265291 let teams = crate :: team_data:: teams ( & ctx. github ) . await ?;
266292 if let Some ( name) = find_assign_command ( ctx, event) {
267293 // User included `r?` in the opening PR body.
268- match find_reviewer_from_names ( & db_client , & teams, config, & event. issue , & [ name] ) . await {
294+ match find_reviewer_from_names ( & ctx , & teams, config, & event. issue , & [ name] ) . await {
269295 Ok ( assignee) => return Ok ( ( Some ( assignee) , true ) ) ,
270296 Err ( e) => {
271297 event
@@ -279,7 +305,7 @@ async fn determine_assignee(
279305 // Errors fall-through to try fallback group.
280306 match find_reviewers_from_diff ( config, diff) {
281307 Ok ( candidates) if !candidates. is_empty ( ) => {
282- match find_reviewer_from_names ( & db_client , & teams, config, & event. issue , & candidates)
308+ match find_reviewer_from_names ( & ctx , & teams, config, & event. issue , & candidates)
283309 . await
284310 {
285311 Ok ( assignee) => return Ok ( ( Some ( assignee) , false ) ) ,
@@ -290,9 +316,11 @@ async fn determine_assignee(
290316 ) ,
291317 Err (
292318 e @ FindReviewerError :: NoReviewer { .. }
319+ // TODO: only NoReviewer can be reached here!
293320 | e @ FindReviewerError :: ReviewerIsPrAuthor { .. }
294321 | e @ FindReviewerError :: ReviewerAlreadyAssigned { .. }
295- | e @ FindReviewerError :: ReviewerOnVacation { .. } ,
322+ | e @ FindReviewerError :: ReviewerOnVacation { .. }
323+ | e @ FindReviewerError :: ReviewerPreviouslyAssigned { .. } ,
296324 ) => log:: trace!(
297325 "no reviewer could be determined for PR {}: {e}" ,
298326 event. issue. global_id( )
@@ -310,7 +338,7 @@ async fn determine_assignee(
310338 }
311339
312340 if let Some ( fallback) = config. adhoc_groups . get ( "fallback" ) {
313- match find_reviewer_from_names ( & db_client , & teams, config, & event. issue , fallback) . await {
341+ match find_reviewer_from_names ( & ctx , & teams, config, & event. issue , fallback) . await {
314342 Ok ( assignee) => return Ok ( ( Some ( assignee) , false ) ) ,
315343 Err ( e) => {
316344 log:: trace!(
@@ -485,24 +513,18 @@ pub(super) async fn handle_command(
485513 return Ok ( ( ) ) ;
486514 }
487515
488- let db_client = ctx. db . get ( ) . await ;
489- let assignee = match find_reviewer_from_names (
490- & db_client,
491- & teams,
492- config,
493- issue,
494- & [ assignee. to_string ( ) ] ,
495- )
496- . await
497- {
498- Ok ( assignee) => assignee,
499- Err ( e) => {
500- issue. post_comment ( & ctx. github , & e. to_string ( ) ) . await ?;
501- return Ok ( ( ) ) ;
502- }
503- } ;
516+ let assignee =
517+ match find_reviewer_from_names ( ctx, & teams, config, issue, & [ assignee. to_string ( ) ] )
518+ . await
519+ {
520+ Ok ( assignee) => assignee,
521+ Err ( e) => {
522+ issue. post_comment ( & ctx. github , & e. to_string ( ) ) . await ?;
523+ return Ok ( ( ) ) ;
524+ }
525+ } ;
504526
505- set_assignee ( issue, & ctx. github , & assignee) . await ;
527+ set_assignee ( ctx , issue, & ctx. github , & assignee) . await ? ;
506528 } else {
507529 let e = EditIssueBody :: new ( & issue, "ASSIGN" ) ;
508530
@@ -612,6 +634,8 @@ pub enum FindReviewerError {
612634 ReviewerIsPrAuthor { username : String } ,
613635 /// Requested reviewer is already assigned to that PR
614636 ReviewerAlreadyAssigned { username : String } ,
637+ /// Requested reviewer is already assigned previously to that PR
638+ ReviewerPreviouslyAssigned { username : String } ,
615639}
616640
617641impl std:: error:: Error for FindReviewerError { }
@@ -654,6 +678,13 @@ impl fmt::Display for FindReviewerError {
654678 REVIEWER_ALREADY_ASSIGNED . replace( "{username}" , username)
655679 )
656680 }
681+ FindReviewerError :: ReviewerPreviouslyAssigned { username } => {
682+ write ! (
683+ f,
684+ "{}" ,
685+ REVIEWER_ASSIGNED_BEFORE . replace( "{username}" , username)
686+ )
687+ }
657688 }
658689 }
659690}
@@ -665,7 +696,7 @@ impl fmt::Display for FindReviewerError {
665696/// auto-assign groups, or rust-lang team names. It must have at least one
666697/// entry.
667698async fn find_reviewer_from_names (
668- _db : & DbClient ,
699+ ctx : & Context ,
669700 teams : & Teams ,
670701 config : & AssignConfig ,
671702 issue : & Issue ,
@@ -678,7 +709,7 @@ async fn find_reviewer_from_names(
678709 }
679710 }
680711
681- let candidates = candidate_reviewers_from_names ( teams, config, issue, names) ?;
712+ let candidates = candidate_reviewers_from_names ( ctx , teams, config, issue, names) . await ?;
682713 // This uses a relatively primitive random choice algorithm.
683714 // GitHub's CODEOWNERS supports much more sophisticated options, such as:
684715 //
@@ -782,7 +813,8 @@ fn expand_teams_and_groups(
782813
783814/// Returns a list of candidate usernames (from relevant teams) to choose as a reviewer.
784815/// If not reviewer is available, returns an error.
785- fn candidate_reviewers_from_names < ' a > (
816+ async fn candidate_reviewers_from_names < ' a > (
817+ ctx : & Context ,
786818 teams : & ' a Teams ,
787819 config : & ' a AssignConfig ,
788820 issue : & Issue ,
@@ -804,6 +836,9 @@ fn candidate_reviewers_from_names<'a>(
804836 . iter ( )
805837 . any ( |assignee| name_lower == assignee. login . to_lowercase ( ) ) ;
806838
839+ let previous_reviewer_names = get_previous_reviewer_names ( ctx, issue) . await ;
840+ let is_previously_assigned = previous_reviewer_names. contains ( & candidate) ;
841+
807842 // Record the reason why the candidate was filtered out
808843 let reason = {
809844 if is_pr_author {
@@ -818,6 +853,12 @@ fn candidate_reviewers_from_names<'a>(
818853 Some ( FindReviewerError :: ReviewerAlreadyAssigned {
819854 username : candidate. clone ( ) ,
820855 } )
856+ } else if expansion_happened && is_previously_assigned {
857+ // **Only** when r? group is expanded, we consider the reviewer previously assigned
858+ // `r? @reviewer` will not consider the reviewer previously assigned
859+ Some ( FindReviewerError :: ReviewerPreviouslyAssigned {
860+ username : candidate. clone ( ) ,
861+ } )
821862 } else {
822863 None
823864 }
@@ -863,3 +904,15 @@ fn candidate_reviewers_from_names<'a>(
863904 Ok ( valid_candidates)
864905 }
865906}
907+
908+ async fn get_previous_reviewer_names ( ctx : & Context , issue : & Issue ) -> HashSet < String > {
909+ // Get the state of the warnings for this PR in the database.
910+ let mut db = ctx. db . get ( ) . await ;
911+ let state: IssueData < ' _ , Reviewer > =
912+ match IssueData :: load ( & mut db, & issue, PREVIOUS_REVIEWER_KEY ) . await {
913+ Ok ( state) => state,
914+ Err ( _) => return HashSet :: new ( ) ,
915+ } ;
916+
917+ state. data . names
918+ }
0 commit comments