diff --git a/src/handlers/assign.rs b/src/handlers/assign.rs index 013c7c45..5e6cef62 100644 --- a/src/handlers/assign.rs +++ b/src/handlers/assign.rs @@ -22,6 +22,7 @@ use crate::{ config::AssignConfig, + db::issue_data::IssueData, github::{self, Event, FileDiff, Issue, IssuesAction, Selection}, handlers::{Context, GithubClient, IssuesEvent}, interactions::EditIssueBody, @@ -33,7 +34,6 @@ use rand::seq::IteratorRandom; use rust_team_data::v1::Teams; use std::collections::{HashMap, HashSet}; use std::fmt; -use tokio_postgres::Client as DbClient; use tracing as log; #[cfg(test)] @@ -87,9 +87,23 @@ const REVIEWER_ALREADY_ASSIGNED: &str = Please choose another assignee."; +const REVIEWER_ASSIGNED_BEFORE: &str = "Requested reviewers are assigned before. + +Please choose another assignee by using `r? @reviewer`."; + // Special account that we use to prevent assignment. const GHOST_ACCOUNT: &str = "ghost"; +/// Key for the state in the database +const PREVIOUS_REVIEWER_KEY: &str = "previous-reviewer"; + +/// State stored in the database +#[derive(Debug, Default, serde::Deserialize, serde::Serialize)] +struct Reviewer { + /// List of the last warnings in the most recent comment. + names: HashSet, +} + #[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct AssignData { user: Option, @@ -179,7 +193,7 @@ pub(super) async fn handle_input( None }; if let Some(assignee) = assignee { - set_assignee(&event.issue, &ctx.github, &assignee).await; + set_assignee(&ctx, &event.issue, &ctx.github, &assignee).await?; } if let Some(welcome) = welcome { @@ -211,7 +225,16 @@ fn is_self_assign(assignee: &str, pr_author: &str) -> bool { } /// Sets the assignee of a PR, alerting any errors. -async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) { +async fn set_assignee( + ctx: &Context, + issue: &Issue, + github: &GithubClient, + username: &str, +) -> anyhow::Result<()> { + let mut db = ctx.db.get().await; + let mut state: IssueData<'_, Reviewer> = + IssueData::load(&mut db, &issue, PREVIOUS_REVIEWER_KEY).await?; + // Don't re-assign if already assigned, e.g. on comment edit if issue.contain_assignee(&username) { log::trace!( @@ -219,7 +242,7 @@ async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) { issue.global_id(), username, ); - return; + return Ok(()); } if let Err(err) = issue.set_assignee(github, &username).await { log::warn!( @@ -242,8 +265,14 @@ async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) { .await { log::warn!("failed to post error comment: {e}"); + return Err(e); } } + + state.data.names.insert(username.to_string()); + state.save().await?; + + Ok(()) } /// Determines who to assign the PR to based on either an `r?` command, or @@ -261,11 +290,10 @@ async fn determine_assignee( config: &AssignConfig, diff: &[FileDiff], ) -> anyhow::Result<(Option, bool)> { - let db_client = ctx.db.get().await; let teams = crate::team_data::teams(&ctx.github).await?; if let Some(name) = find_assign_command(ctx, event) { // User included `r?` in the opening PR body. - match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &[name]).await { + match find_reviewer_from_names(&ctx, &teams, config, &event.issue, &[name]).await { Ok(assignee) => return Ok((Some(assignee), true)), Err(e) => { event @@ -279,7 +307,7 @@ async fn determine_assignee( // 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(&db_client, &teams, config, &event.issue, &candidates) + match find_reviewer_from_names(&ctx, &teams, config, &event.issue, &candidates) .await { Ok(assignee) => return Ok((Some(assignee), false)), @@ -290,9 +318,11 @@ async fn determine_assignee( ), Err( e @ FindReviewerError::NoReviewer { .. } + // TODO: only NoReviewer can be reached here! | e @ FindReviewerError::ReviewerIsPrAuthor { .. } | e @ FindReviewerError::ReviewerAlreadyAssigned { .. } - | e @ FindReviewerError::ReviewerOnVacation { .. }, + | e @ FindReviewerError::ReviewerOnVacation { .. } + | e @ FindReviewerError::ReviewerPreviouslyAssigned { .. }, ) => log::trace!( "no reviewer could be determined for PR {}: {e}", event.issue.global_id() @@ -310,7 +340,7 @@ async fn determine_assignee( } if let Some(fallback) = config.adhoc_groups.get("fallback") { - match find_reviewer_from_names(&db_client, &teams, config, &event.issue, fallback).await { + match find_reviewer_from_names(&ctx, &teams, config, &event.issue, fallback).await { Ok(assignee) => return Ok((Some(assignee), false)), Err(e) => { log::trace!( @@ -485,24 +515,18 @@ pub(super) async fn handle_command( return Ok(()); } - let db_client = ctx.db.get().await; - let assignee = match find_reviewer_from_names( - &db_client, - &teams, - config, - issue, - &[assignee.to_string()], - ) - .await - { - Ok(assignee) => assignee, - Err(e) => { - issue.post_comment(&ctx.github, &e.to_string()).await?; - return Ok(()); - } - }; + let assignee = + match find_reviewer_from_names(ctx, &teams, config, issue, &[assignee.to_string()]) + .await + { + Ok(assignee) => assignee, + Err(e) => { + issue.post_comment(&ctx.github, &e.to_string()).await?; + return Ok(()); + } + }; - set_assignee(issue, &ctx.github, &assignee).await; + set_assignee(ctx, issue, &ctx.github, &assignee).await?; } else { let e = EditIssueBody::new(&issue, "ASSIGN"); @@ -612,6 +636,8 @@ pub enum FindReviewerError { ReviewerIsPrAuthor { username: String }, /// Requested reviewer is already assigned to that PR ReviewerAlreadyAssigned { username: String }, + /// Requested reviewer is already assigned previously to that PR + ReviewerPreviouslyAssigned { username: String }, } impl std::error::Error for FindReviewerError {} @@ -654,6 +680,13 @@ impl fmt::Display for FindReviewerError { REVIEWER_ALREADY_ASSIGNED.replace("{username}", username) ) } + FindReviewerError::ReviewerPreviouslyAssigned { username } => { + write!( + f, + "{}", + REVIEWER_ASSIGNED_BEFORE.replace("{username}", username) + ) + } } } } @@ -665,7 +698,7 @@ impl fmt::Display for FindReviewerError { /// auto-assign groups, or rust-lang team names. It must have at least one /// entry. async fn find_reviewer_from_names( - _db: &DbClient, + ctx: &Context, teams: &Teams, config: &AssignConfig, issue: &Issue, @@ -678,7 +711,7 @@ async fn find_reviewer_from_names( } } - let candidates = candidate_reviewers_from_names(teams, config, issue, names)?; + let candidates = candidate_reviewers_from_names(ctx, teams, config, issue, names).await?; // This uses a relatively primitive random choice algorithm. // GitHub's CODEOWNERS supports much more sophisticated options, such as: // @@ -782,7 +815,8 @@ fn expand_teams_and_groups( /// Returns a list of candidate usernames (from relevant teams) to choose as a reviewer. /// If not reviewer is available, returns an error. -fn candidate_reviewers_from_names<'a>( +async fn candidate_reviewers_from_names<'a>( + ctx: &Context, teams: &'a Teams, config: &'a AssignConfig, issue: &Issue, @@ -804,6 +838,9 @@ fn candidate_reviewers_from_names<'a>( .iter() .any(|assignee| name_lower == assignee.login.to_lowercase()); + let previous_reviewer_names = get_previous_reviewer_names(ctx, issue).await; + let is_previously_assigned = previous_reviewer_names.contains(&candidate); + // Record the reason why the candidate was filtered out let reason = { if is_pr_author { @@ -818,6 +855,12 @@ fn candidate_reviewers_from_names<'a>( Some(FindReviewerError::ReviewerAlreadyAssigned { username: candidate.clone(), }) + } else if expansion_happened && is_previously_assigned { + // **Only** when r? group is expanded, we consider the reviewer previously assigned + // `r? @reviewer` will not consider the reviewer previously assigned + Some(FindReviewerError::ReviewerPreviouslyAssigned { + username: candidate.clone(), + }) } else { None } @@ -863,3 +906,19 @@ fn candidate_reviewers_from_names<'a>( Ok(valid_candidates) } } + +async fn get_previous_reviewer_names(ctx: &Context, issue: &Issue) -> HashSet { + if cfg!(test) { + return HashSet::new(); + } + + // Get the state of the warnings for this PR in the database. + let mut db = ctx.db.get().await; + let state: IssueData<'_, Reviewer> = + match IssueData::load(&mut db, &issue, PREVIOUS_REVIEWER_KEY).await { + Ok(state) => state, + Err(_) => return HashSet::new(), + }; + + state.data.names +} diff --git a/src/handlers/assign/tests/tests_candidates.rs b/src/handlers/assign/tests/tests_candidates.rs index 92d1d33d..aae2fcbe 100644 --- a/src/handlers/assign/tests/tests_candidates.rs +++ b/src/handlers/assign/tests/tests_candidates.rs @@ -1,15 +1,35 @@ //! Tests for `candidate_reviewers_from_names` use super::super::*; -use crate::tests::github::{issue, user}; +use crate::{ + handlers::pr_tracking::ReviewerWorkqueue, + tests::github::{issue, user}, +}; +use std::sync::Arc; -#[must_use] struct TestCtx { teams: Teams, config: AssignConfig, issue: Issue, } +fn create_test_context() -> crate::handlers::Context { + let github = GithubClient::new( + "fake-token-for-testing".to_string(), + "https://api.github.com".to_string(), + "https://api.github.com/graphql".to_string(), + "https://raw.githubusercontent.com".to_string(), + ); + + crate::handlers::Context { + github, + db: crate::db::ClientPool::new("".to_string()), + username: "triagebot".to_string(), + octocrab: octocrab::OctocrabBuilder::new().build().unwrap(), + workqueue: Arc::new(tokio::sync::RwLock::new(ReviewerWorkqueue::default())), + } +} + impl TestCtx { fn new(config: toml::Table, issue: Issue) -> Self { Self { @@ -41,10 +61,12 @@ impl TestCtx { self } - fn run(self, names: &[&str], expected: Result<&[&str], FindReviewerError>) { + async fn run(self, names: &[&str], expected: Result<&[&str], FindReviewerError>) { + let ctx = create_test_context(); let names: Vec<_> = names.iter().map(|n| n.to_string()).collect(); match ( - candidate_reviewers_from_names(&self.teams, &self.config, &self.issue, &names), + candidate_reviewers_from_names(&ctx, &self.teams, &self.config, &self.issue, &names) + .await, expected, ) { (Ok(candidates), Ok(expected)) => { @@ -67,24 +89,26 @@ fn test_candidates(config: toml::Table, issue: Issue) -> TestCtx { TestCtx::new(config, issue) } -#[test] -fn circular_groups() { +#[tokio::test] +async fn circular_groups() { // A cycle in the groups map. let config = toml::toml!( [adhoc_groups] compiler = ["other"] other = ["compiler"] ); - test_candidates(config, issue().call()).run( - &["compiler"], - Err(FindReviewerError::NoReviewer { - initial: vec!["compiler".to_string()], - }), - ); + test_candidates(config, issue().call()) + .run( + &["compiler"], + Err(FindReviewerError::NoReviewer { + initial: vec!["compiler".to_string()], + }), + ) + .await; } -#[test] -fn nested_groups() { +#[tokio::test] +async fn nested_groups() { // Test choosing a reviewer from group with nested groups. let config = toml::toml!( [adhoc_groups] @@ -92,26 +116,30 @@ fn nested_groups() { b = ["@nrc"] c = ["a", "b"] ); - test_candidates(config, issue().call()).run(&["c"], Ok(&["nrc", "pnkfelix"])); + test_candidates(config, issue().call()) + .run(&["c"], Ok(&["nrc", "pnkfelix"])) + .await; } -#[test] -fn candidate_filtered_author_only_candidate() { +#[tokio::test] +async fn candidate_filtered_author_only_candidate() { // When the author is the only candidate. let config = toml::toml!( [adhoc_groups] compiler = ["nikomatsakis"] ); - test_candidates(config, issue().author(user("nikomatsakis", 1)).call()).run( - &["compiler"], - Err(FindReviewerError::NoReviewer { - initial: vec!["compiler".to_string()], - }), - ); + test_candidates(config, issue().author(user("nikomatsakis", 1)).call()) + .run( + &["compiler"], + Err(FindReviewerError::NoReviewer { + initial: vec!["compiler".to_string()], + }), + ) + .await; } -#[test] -fn candidate_filtered_author() { +#[tokio::test] +async fn candidate_filtered_author() { // Filter out the author from the candidates. let config = toml::toml!( [adhoc_groups] @@ -119,11 +147,12 @@ fn candidate_filtered_author() { group2 = ["user2", "user4"] ); test_candidates(config, issue().author(user("user2", 1)).call()) - .run(&["compiler"], Ok(&["user1", "user3", "user4"])); + .run(&["compiler"], Ok(&["user1", "user3", "user4"])) + .await; } -#[test] -fn candidate_filtered_assignee() { +#[tokio::test] +async fn candidate_filtered_assignee() { // Filter out an existing assignee from the candidates. let config = toml::toml!( [adhoc_groups] @@ -133,11 +162,13 @@ fn candidate_filtered_assignee() { .author(user("user2", 2)) .assignees(vec![user("user1", 1), user("user3", 3)]) .call(); - test_candidates(config, issue).run(&["compiler"], Ok(&["user4"])); + test_candidates(config, issue) + .run(&["compiler"], Ok(&["user4"])) + .await; } -#[test] -fn groups_teams_users() { +#[tokio::test] +async fn groups_teams_users() { // Assortment of groups, teams, and users all selected at once. let teams = toml::toml!( team1 = ["t-user1"] @@ -147,14 +178,17 @@ fn groups_teams_users() { [adhoc_groups] group1 = ["user1", "rust-lang/team2"] ); - test_candidates(config, issue().call()).teams(&teams).run( - &["team1", "group1", "user3"], - Ok(&["t-user1", "t-user2", "user1", "user3"]), - ); + test_candidates(config, issue().call()) + .teams(&teams) + .run( + &["team1", "group1", "user3"], + Ok(&["t-user1", "t-user2", "user1", "user3"]), + ) + .await; } -#[test] -fn group_team_user_precedence() { +#[tokio::test] +async fn group_team_user_precedence() { // How it handles ambiguity when names overlap. let teams = toml::toml!(compiler = ["t-user1"]); let config = toml::toml!( @@ -163,14 +197,16 @@ fn group_team_user_precedence() { ); test_candidates(config.clone(), issue().call()) .teams(&teams) - .run(&["compiler"], Ok(&["user2"])); + .run(&["compiler"], Ok(&["user2"])) + .await; test_candidates(config, issue().call()) .teams(&teams) - .run(&["rust-lang/compiler"], Ok(&["user2"])); + .run(&["rust-lang/compiler"], Ok(&["user2"])) + .await; } -#[test] -fn what_do_slashes_mean() { +#[tokio::test] +async fn what_do_slashes_mean() { // How slashed names are handled. let teams = toml::toml!(compiler = ["t-user1"]); let config = toml::toml!( @@ -183,30 +219,35 @@ fn what_do_slashes_mean() { // Random slash names should work from groups. test_candidates(config.clone(), issue()) .teams(&teams) - .run(&["foo/bar"], Ok(&["foo-user"])); + .run(&["foo/bar"], Ok(&["foo-user"])) + .await; test_candidates(config, issue()) .teams(&teams) - .run(&["rust-lang-nursery/compiler"], Ok(&["user2"])); + .run(&["rust-lang-nursery/compiler"], Ok(&["user2"])) + .await; } -#[test] -fn invalid_org_doesnt_match() { +#[tokio::test] +async fn invalid_org_doesnt_match() { let teams = toml::toml!(compiler = ["t-user1"]); let config = toml::toml!( [adhoc_groups] compiler = ["user2"] ); - test_candidates(config, issue().call()).teams(&teams).run( - &["github/compiler"], - Err(FindReviewerError::TeamNotFound( - "github/compiler".to_string(), - )), - ); + test_candidates(config, issue().call()) + .teams(&teams) + .run( + &["github/compiler"], + Err(FindReviewerError::TeamNotFound( + "github/compiler".to_string(), + )), + ) + .await; } -#[test] -fn vacation() { +#[tokio::test] +async fn vacation() { let teams = toml::toml!(bootstrap = ["jyn514", "Mark-Simulacrum"]); let config = toml::toml!(users_on_vacation = ["jyn514"]); @@ -218,9 +259,11 @@ fn vacation() { Err(FindReviewerError::ReviewerOnVacation { username: "jyn514".to_string(), }), - ); + ) + .await; test_candidates(config.clone(), issue().call()) .teams(&teams) - .run(&["bootstrap"], Ok(&["Mark-Simulacrum"])); + .run(&["bootstrap"], Ok(&["Mark-Simulacrum"])) + .await; }