Repository: rust-lang/triagebot
Pull request #1939: Refactor and unify assignment logic
Author: login: Kobzol
Created: 2025-04-16T10:24:40Z | Merged: 2025-04-17T08:10:07Z

Follow-up to https://github.com/rust-lang/triagebot/pull/1935.

This PR refactors the reviewer candidate selection logic with the following goals:
- Unify the reviewer selection logic inside `find_reviewer_from_names` and `candidate_reviewers_from_names`, so that various special cases are not scattered amongst other functions in the assign handler.
- Make the candidate selection logic easier to understand (and extend, in preparation of using review preferences for determining reviewer candidates).
- Improve error messages, by providing a specific error message when exactly one reviewer is requested. Before, we would just show a generic message like "the user is on vacation or something or something", even though we knew the exact cause of why they couldn't be assigned.
- Refactor tests to make them easier to be extended in the future. This is again in preparation of review preferences, where the tests will start needing access to the database.

Best reviewed commit-by-commit.


Diff:
diff --git a/src/handlers/assign.rs b/src/handlers/assign.rs
index 718db40e4..b1dae5959 100644
--- a/src/handlers/assign.rs
+++ b/src/handlers/assign.rs
@@ -78,27 +78,6 @@ Please choose another assignee."
     )
 }
 
-pub const SELF_ASSIGN_HAS_NO_CAPACITY: &str = "
-You have insufficient capacity to be assigned the pull request at this time. PR assignment has been reverted.
-
-Please choose another assignee or increase your assignment limit.
-
-(see [documentation](https://forge.rust-lang.org/triagebot/pr-assignment-tracking.html))";
-
-pub const REVIEWER_HAS_NO_CAPACITY: &str = "
-`{username}` has insufficient capacity to be assigned the pull request at this time. PR assignment has been reverted.
-
-Please choose another assignee.
-
-(see [documentation](https://forge.rust-lang.org/triagebot/pr-assignment-tracking.html))";
-
-const NO_REVIEWER_HAS_CAPACITY: &str = "
-Could not find a reviewer with enough capacity to be assigned at this time. This is a problem.
-
-Please contact us on [#t-infra](https://rust-lang.zulipchat.com/#narrow/stream/242791-t-infra) on Zulip.
-
-cc: @jackh726 @apiraino";
-
 const REVIEWER_IS_PR_AUTHOR: &str = "Pull request author cannot be assigned as reviewer.
 
 Please choose another assignee.";
@@ -282,9 +261,6 @@ async fn determine_assignee(
     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) {
-        if is_self_assign(&name, &event.issue.user.login) {
-            return Ok((Some(name.to_string()), true));
-        }
         // User included `r?` in the opening PR body.
         match find_reviewer_from_names(&db_client, &teams, config, &event.issue, &[name]).await {
             Ok(assignee) => return Ok((Some(assignee), true)),
@@ -311,22 +287,13 @@ async fn determine_assignee(
                 ),
                 Err(
                     e @ FindReviewerError::NoReviewer { .. }
-                    | e @ FindReviewerError::AllReviewersFiltered { .. }
-                    | e @ FindReviewerError::NoReviewerHasCapacity
-                    | e @ FindReviewerError::ReviewerHasNoCapacity { .. }
                     | e @ FindReviewerError::ReviewerIsPrAuthor { .. }
-                    | e @ FindReviewerError::ReviewerAlreadyAssigned { .. },
+                    | e @ FindReviewerError::ReviewerAlreadyAssigned { .. }
+                    | e @ FindReviewerError::ReviewerOnVacation { .. },
                 ) => log::trace!(
                     "no reviewer could be determined for PR {}: {e}",
                     event.issue.global_id()
                 ),
-                Err(e @ FindReviewerError::ReviewerOnVacation { .. }) => {
-                    // TODO: post a comment on the PR if the reviewer(s) were filtered due to being on vacation
-                    log::trace!(
-                        "no reviewer could be determined for PR {}: {e}",
-                        event.issue.global_id()
-                    )
-                }
             }
         }
         // If no owners matched the diff, fall-through.
@@ -475,21 +442,11 @@ pub(super) async fn handle_command(
             return Ok(());
         }
 
-        let requested_name = match cmd {
+        let teams = crate::team_data::teams(&ctx.github).await?;
+
+        let assignee = match cmd {
             AssignCommand::Claim => event.user().login.clone(),
-            AssignCommand::AssignUser { username } => {
-                // Allow users on vacation to assign themselves to a PR, but not anyone else.
-                if config.is_on_vacation(&username)
-                    && event.user().login.to_lowercase() != username.to_lowercase()
-                {
-                    // This is a comment, so there must already be a reviewer assigned. No need to assign anyone else.
-                    issue
-                        .post_comment(&ctx.github, &on_vacation_warning(&username))
-                        .await?;
-                    return Ok(());
-                }
-                username
-            }
+            AssignCommand::AssignUser { username } => username,
             AssignCommand::ReleaseAssignment => {
                 log::trace!(
                     "ignoring release on PR {:?}, must always have assignee",
@@ -498,128 +455,134 @@ pub(super) async fn handle_command(
                 return Ok(());
             }
             AssignCommand::RequestReview { name } => {
-                let db_client = ctx.db.get().await;
-                if is_self_assign(&name, &event.user().login) {
-                    name.to_string()
-                } else {
-                    let teams = crate::team_data::teams(&ctx.github).await?;
-                    // remove "t-" or "T-" prefixes before checking if it's a team name
-                    let team_name = name.trim_start_matches("t-").trim_start_matches("T-");
-                    // Determine if assignee is a team. If yes, add the corresponding GH label.
-                    if teams.teams.get(team_name).is_some() {
-                        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(github::UnknownLabels { .. }) = err.downcast_ref() {
-                                log::warn!("Error assigning label: {}", err);
-                            } else {
-                                return Err(err);
-                            }
-                        }
-                    }
-
-                    match find_reviewer_from_names(
-                        &db_client,
-                        &teams,
-                        config,
-                        issue,
-                        &[team_name.to_string()],
-                    )
-                    .await
+                // 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
                     {
-                        Ok(assignee) => assignee,
-                        Err(e) => {
-                            issue.post_comment(&ctx.github, &e.to_string()).await?;
-                            return Ok(());
+                        if let Some(github::UnknownLabels { .. }) = err.downcast_ref() {
+                            log::warn!("Error assigning label: {}", err);
+                        } else {
+                            return Err(err);
                         }
                     }
                 }
+                name
             }
         };
 
-        // This user is validated and can accept the PR
-        set_assignee(issue, &ctx.github, &requested_name).await;
-        // This PR will now be registered in the reviewer's work queue
-        // by the `pr_tracking` handler
-        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 e = EditIssueBody::new(&issue, "ASSIGN");
+        set_assignee(issue, &ctx.github, &assignee).await;
+    } else {
+        let e = EditIssueBody::new(&issue, "ASSIGN");
 
-    let to_assign = match cmd {
-        AssignCommand::Claim => event.user().login.clone(),
-        AssignCommand::AssignUser { username } => {
-            if !is_team_member && username != event.user().login {
-                bail!("Only Rust team members can assign other users");
-            }
-            username.clone()
-        }
-        AssignCommand::ReleaseAssignment => {
-            if let Some(AssignData {
-                user: Some(current),
-            }) = e.current_data()
-            {
-                if current == event.user().login || is_team_member {
-                    issue.remove_assignees(&ctx.github, Selection::All).await?;
-                    e.apply(&ctx.github, String::new(), AssignData { user: None })
-                        .await?;
-                    return Ok(());
-                } else {
-                    bail!("Cannot release another user's assignment");
+        let to_assign = match cmd {
+            AssignCommand::Claim => event.user().login.clone(),
+            AssignCommand::AssignUser { username } => {
+                if !is_team_member && username != event.user().login {
+                    bail!("Only Rust team members can assign other users");
                 }
-            } else {
-                let current = &event.user().login;
-                if issue.contain_assignee(current) {
-                    issue
-                        .remove_assignees(&ctx.github, Selection::One(&current))
-                        .await?;
-                    e.apply(&ctx.github, String::new(), AssignData { user: None })
-                        .await?;
-                    return Ok(());
+                username.clone()
+            }
+            AssignCommand::ReleaseAssignment => {
+                if let Some(AssignData {
+                    user: Some(current),
+                }) = e.current_data()
+                {
+                    if current == event.user().login || is_team_member {
+                        issue.remove_assignees(&ctx.github, Selection::All).await?;
+                        e.apply(&ctx.github, String::new(), AssignData { user: None })
+                            .await?;
+                        return Ok(());
+                    } else {
+                        bail!("Cannot release another user's assignment");
+                    }
                 } else {
-                    bail!("Cannot release unassigned issue");
-                }
-            };
+                    let current = &event.user().login;
+                    if issue.contain_assignee(current) {
+                        issue
+                            .remove_assignees(&ctx.github, Selection::One(&current))
+                            .await?;
+                        e.apply(&ctx.github, String::new(), AssignData { user: None })
+                            .await?;
+                        return Ok(());
+                    } else {
+                        bail!("Cannot release unassigned issue");
+                    }
+                };
+            }
+            AssignCommand::RequestReview { .. } => bail!("r? is only allowed on PRs."),
+        };
+        // Don't re-assign if aleady assigned, e.g. on comment edit
+        if issue.contain_assignee(&to_assign) {
+            log::trace!(
+                "ignoring assign issue {} to {}, already assigned",
+                issue.global_id(),
+                to_assign,
+            );
+            return Ok(());
         }
-        AssignCommand::RequestReview { .. } => bail!("r? is only allowed on PRs."),
-    };
-    // Don't re-assign if aleady assigned, e.g. on comment edit
-    if issue.contain_assignee(&to_assign) {
-        log::trace!(
-            "ignoring assign issue {} to {}, already assigned",
-            issue.global_id(),
-            to_assign,
-        );
-        return Ok(());
-    }
-    let data = AssignData {
-        user: Some(to_assign.clone()),
-    };
+        let data = AssignData {
+            user: Some(to_assign.clone()),
+        };
 
-    e.apply(&ctx.github, String::new(), &data).await?;
+        e.apply(&ctx.github, String::new(), &data).await?;
 
-    match issue.set_assignee(&ctx.github, &to_assign).await {
-        Ok(()) => return Ok(()), // we are done
-        Err(github::AssignmentError::InvalidAssignee) => {
-            issue
-                .set_assignee(&ctx.github, &ctx.username)
-                .await
-                .context("self-assignment failed")?;
-            let cmt_body = format!(
-                "This issue has been assigned to @{} via [this comment]({}).",
-                to_assign,
-                event.html_url().unwrap()
-            );
-            e.apply(&ctx.github, cmt_body, &data).await?;
+        match issue.set_assignee(&ctx.github, &to_assign).await {
+            Ok(()) => return Ok(()), // we are done
+            Err(github::AssignmentError::InvalidAssignee) => {
+                issue
+                    .set_assignee(&ctx.github, &ctx.username)
+                    .await
+                    .context("self-assignment failed")?;
+                let cmt_body = format!(
+                    "This issue has been assigned to @{} via [this comment]({}).",
+                    to_assign,
+                    event.html_url().unwrap()
+                );
+                e.apply(&ctx.github, cmt_body, &data).await?;
+            }
+            Err(e) => return Err(e.into()),
         }
-        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, Debug)]
 pub enum FindReviewerError {
     /// User specified something like `r? foo/bar` where that team name could
@@ -630,20 +593,6 @@ pub enum FindReviewerError {
     /// This could happen if there is a cyclical group or other misconfiguration.
     /// `initial` is the initial list of candidate names.
     NoReviewer { initial: Vec<String> },
-    /// All potential candidates were excluded. `initial` is the list of
-    /// candidate names that were used to seed the selection. `filtered` is
-    /// the users who were prevented from being assigned. One example where
-    /// this happens is if the given name was for a team where the PR author
-    /// is the only member.
-    AllReviewersFiltered {
-        initial: Vec<String>,
-        filtered: Vec<String>,
-    },
-    /// No reviewer has capacity to accept a pull request assignment at this time
-    NoReviewerHasCapacity,
-    /// The requested reviewer has no capacity to accept a pull request
-    /// assignment at this time
-    ReviewerHasNoCapacity { username: String },
     /// Requested reviewer is on vacation
     /// (i.e. username is in [users_on_vacation] in the triagebot.toml)
     ReviewerOnVacation { username: String },
@@ -676,26 +625,6 @@ impl fmt::Display for FindReviewerError {
                     initial.join(",")
                 )
             }
-            FindReviewerError::AllReviewersFiltered { initial, filtered } => {
-                write!(
-                    f,
-                    "Could not assign reviewer from: `{}`.\n\
-                     User(s) `{}` are either the PR author, already assigned, or on vacation. \
-                     Please use `r?` to specify someone else to assign.",
-                    initial.join(","),
-                    filtered.join(","),
-                )
-            }
-            FindReviewerError::ReviewerHasNoCapacity { username } => {
-                write!(
-                    f,
-                    "{}",
-                    REVIEWER_HAS_NO_CAPACITY.replace("{username}", username)
-                )
-            }
-            FindReviewerError::NoReviewerHasCapacity => {
-                write!(f, "{}", NO_REVIEWER_HAS_CAPACITY)
-            }
             FindReviewerError::ReviewerOnVacation { username } => {
                 write!(f, "{}", on_vacation_warning(username))
             }
@@ -730,6 +659,13 @@ async fn find_reviewer_from_names(
     issue: &Issue,
     names: &[String],
 ) -> Result<String, FindReviewerError> {
+    // Fast path for self-assign, which is always allowed.
+    if let [name] = names {
+        if is_self_assign(&name, &issue.user.login) {
+            return Ok(name.clone());
+        }
+    }
+
     let candidates = candidate_reviewers_from_names(teams, config, issue, names)?;
     // This uses a relatively primitive random choice algorithm.
     // GitHub's CODEOWNERS supports much more sophisticated options, such as:
@@ -762,26 +698,6 @@ async fn find_reviewer_from_names(
         return Ok("ghost".to_string());
     }
 
-    // filter out team members without capacity
-    // let filtered_candidates = filter_by_capacity(db, &candidates)
-    //     .await
-    //     .expect("Error while filtering out team members");
-    //
-    // if filtered_candidates.is_empty() {
-    //     // NOTE: disabled for now, just log
-    //     log::info!("[#{}] Filtered list of PR assignee is empty", issue.number);
-    //     // return Err(FindReviewerError::AllReviewersFiltered {
-    //     //     initial: names.to_vec(),
-    //     //     filtered: names.to_vec(),
-    //     // });
-    // }
-    //
-    // log::info!(
-    //     "[#{}] Filtered list of candidates: {:?}",
-    //     issue.number,
-    //     filtered_candidates
-    // );
-
     // Return unfiltered list of candidates
     Ok(candidates
         .into_iter()
@@ -790,35 +706,92 @@ async fn find_reviewer_from_names(
         .to_string())
 }
 
+/// Recursively expand all teams and adhoc groups found within `names`.
+/// Returns a set of expanded usernames.
+/// Also normalizes usernames from `@user` to `user`.
+///
+/// Returns `(set of expanded users, expansion_happened)`.
+/// `expansion_happened` signals if any expansion has been performed.
+fn expand_teams_and_groups(
+    teams: &Teams,
+    issue: &Issue,
+    config: &AssignConfig,
+    names: &[String],
+) -> Result<(HashSet<String>, bool), FindReviewerError> {
+    let mut expanded = HashSet::new();
+    let mut expansion_happened = false;
+
+    // Keep track of groups seen to avoid cycles and avoid expanding the same
+    // team multiple times.
+    let mut seen_names = HashSet::new();
+
+    // This is a queue of potential groups or usernames to expand. The loop
+    // below will pop from this and then append the expanded results of teams.
+    // Usernames will be added to `expanded`.
+    let mut to_be_expanded: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
+
+    // Loop over names to recursively expand them.
+    while let Some(name_to_expand) = to_be_expanded.pop() {
+        // `name_to_expand` could be a team name, an adhoc group name or a username.
+        let maybe_team = get_team_name(teams, issue, name_to_expand);
+        let maybe_group = strip_organization_prefix(issue, name_to_expand);
+        let maybe_user = name_to_expand.strip_prefix('@').unwrap_or(name_to_expand);
+
+        // Try ad-hoc groups first.
+        if let Some(group_members) = config.adhoc_groups.get(maybe_group) {
+            expansion_happened = true;
+
+            // If a group has already been expanded, don't expand it again.
+            if seen_names.insert(maybe_group) {
+                to_be_expanded.extend(group_members.iter().map(|s| s.as_str()));
+            }
+            continue;
+        }
+
+        // Check for a team name.
+        // Allow either a direct team name like `rustdoc` or a GitHub-style
+        // team name of `rust-lang/rustdoc` (though this does not check if
+        // that is a real GitHub team name).
+        //
+        // This ignores subteam relationships (it only uses direct members).
+        if let Some(team) = maybe_team.and_then(|t| teams.teams.get(t)) {
+            expansion_happened = true;
+            expanded.extend(team.members.iter().map(|member| member.github.clone()));
+            continue;
+        }
+
+        // Here we know it's not a known team nor a group.
+        // If the username contains a slash, assume that it is an unknown team.
+        if maybe_user.contains('/') {
+            return Err(FindReviewerError::TeamNotFound(maybe_user.to_string()));
+        }
+
+        // Assume it is a user.
+        expanded.insert(maybe_user.to_string());
+    }
+
+    Ok((expanded, expansion_happened))
+}
+
 /// 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>(
     teams: &'a Teams,
     config: &'a AssignConfig,
     issue: &Issue,
     names: &'a [String],
-) -> Result<HashSet<&'a str>, FindReviewerError> {
-    // Set of candidate usernames to choose from. This uses a set to
-    // deduplicate entries so that someone in multiple teams isn't
-    // over-weighted.
-    let mut candidates: HashSet<&str> = HashSet::new();
-    // Keep track of groups seen to avoid cycles and avoid expanding the same
-    // team multiple times.
-    let mut seen = HashSet::new();
-    // This is a queue of potential groups or usernames to expand. The loop
-    // below will pop from this and then append the expanded results of teams.
-    // Usernames will be added to `candidates`.
-    let mut group_expansion: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
-    // Keep track of which users get filtered out for a better error message.
-    let mut filtered = Vec::new();
-    // For debugging purposes, keep track about /why/ candidates were filtered out
-    let mut filtered_debug: HashMap<String, Option<FindReviewerError>> = HashMap::new();
-    let repo = issue.repository();
-    let org_prefix = format!("{}/", repo.organization);
-    // Don't allow groups or teams to include the current author or assignee.
-    let mut filter = |name: &&str| -> bool {
-        let name_lower = name.to_lowercase();
+) -> Result<HashSet<String>, FindReviewerError> {
+    let (expanded, expansion_happened) = expand_teams_and_groups(teams, issue, config, names)?;
+    let expanded_count = expanded.len();
+
+    // Set of candidate usernames to choose from.
+    // We go through each expanded candidate and store either success or an error for them.
+    let mut candidates: Vec<Result<String, FindReviewerError>> = Vec::new();
+
+    for candidate in expanded {
+        let name_lower = candidate.to_lowercase();
         let is_pr_author = name_lower == issue.user.login.to_lowercase();
-        let is_on_vacation = config.is_on_vacation(name);
+        let is_on_vacation = config.is_on_vacation(&candidate);
         let is_already_assigned = issue
             .assignees
             .iter()
@@ -828,93 +801,58 @@ fn candidate_reviewers_from_names<'a>(
         let reason = {
             if is_pr_author {
                 Some(FindReviewerError::ReviewerIsPrAuthor {
-                    username: name.to_string(),
+                    username: candidate.clone(),
                 })
             } else if is_on_vacation {
                 Some(FindReviewerError::ReviewerOnVacation {
-                    username: name.to_string(),
+                    username: candidate.clone(),
                 })
             } else if is_already_assigned {
                 Some(FindReviewerError::ReviewerAlreadyAssigned {
-                    username: name.to_string(),
+                    username: candidate.clone(),
                 })
             } else {
                 None
             }
         };
 
-        let can_be_assigned = !is_pr_author && !is_on_vacation && !is_already_assigned;
-        if !can_be_assigned {
-            filtered.push(name.to_string());
-            filtered_debug.insert(name.to_string(), reason);
-        }
-        can_be_assigned
-    };
-
-    // Loop over groups to recursively expand them.
-    while let Some(group_or_user) = group_expansion.pop() {
-        let group_or_user = group_or_user.strip_prefix('@').unwrap_or(group_or_user);
-
-        // Try ad-hoc groups first.
-        // Allow `rust-lang/compiler` to match `compiler`.
-        let maybe_group = group_or_user
-            .strip_prefix(&org_prefix)
-            .unwrap_or(group_or_user);
-        if let Some(group_members) = config.adhoc_groups.get(maybe_group) {
-            // If a group has already been expanded, don't expand it again.
-            if seen.insert(maybe_group) {
-                group_expansion.extend(
-                    group_members
-                        .iter()
-                        .map(|member| member.as_str())
-                        .filter(&mut filter),
-                );
-            }
-            continue;
-        }
-
-        // Check for a team name.
-        // Allow either a direct team name like `rustdoc` or a GitHub-style
-        // team name of `rust-lang/rustdoc` (though this does not check if
-        // that is a real GitHub team name).
-        //
-        // This ignores subteam relationships (it only uses direct members).
-        let maybe_team = group_or_user
-            .strip_prefix("rust-lang/")
-            .unwrap_or(group_or_user);
-        if let Some(team) = teams.teams.get(maybe_team) {
-            candidates.extend(
-                team.members
-                    .iter()
-                    .map(|member| member.github.as_str())
-                    .filter(&mut filter),
-            );
-            continue;
+        if let Some(error_reason) = reason {
+            candidates.push(Err(error_reason));
+        } else {
+            candidates.push(Ok(candidate));
         }
+    }
+    assert_eq!(candidates.len(), expanded_count);
 
-        if group_or_user.contains('/') {
-            return Err(FindReviewerError::TeamNotFound(group_or_user.to_string()));
-        }
+    let valid_candidates: HashSet<String> = candidates
+        .iter()
+        .filter_map(|res| res.as_ref().ok().cloned())
+        .collect();
 
-        // Assume it is a user.
-        if filter(&group_or_user) {
-            candidates.insert(group_or_user);
-        }
-    }
-    if candidates.is_empty() {
-        let initial = names.iter().cloned().collect();
-        if filtered.is_empty() {
-            Err(FindReviewerError::NoReviewer { initial })
+    if valid_candidates.is_empty() {
+        // Was it a request for a single user, i.e. `r? @username`?
+        let is_single_user = names.len() == 1 && !expansion_happened;
+
+        // If we requested a single user for a review, we return a concrete error message
+        // describing why they couldn't be assigned.
+        if is_single_user {
+            Err(candidates
+                .pop()
+                .unwrap()
+                .expect_err("valid_candidates is empty, so this should be an error"))
         } else {
+            // If it was a request for a team or a group, and no one is available, simply
+            // return `NoReviewer`.
             log::warn!(
-                "[#{}] Initial list of candidates {:?}, filtered-out with reasons: {:?}",
-                issue.number,
-                initial,
-                filtered_debug
+                "No valid candidates found for review request on {}. Reasons: {:?}",
+                issue.global_id(),
+                candidates
             );
-            Err(FindReviewerError::AllReviewersFiltered { initial, filtered })
+            Err(FindReviewerError::NoReviewer {
+                initial: names.to_vec(),
+            })
         }
     } else {
-        Ok(candidates)
+        Ok(valid_candidates)
     }
 }
diff --git a/src/handlers/assign/tests/tests_candidates.rs b/src/handlers/assign/tests/tests_candidates.rs
index 3febb1033..92d1d33d5 100644
--- a/src/handlers/assign/tests/tests_candidates.rs
+++ b/src/handlers/assign/tests/tests_candidates.rs
@@ -1,84 +1,70 @@
 //! Tests for `candidate_reviewers_from_names`
 
 use super::super::*;
+use crate::tests::github::{issue, user};
 
-/// Basic test function for testing `candidate_reviewers_from_names`.
-fn test_from_names(
-    teams: Option<toml::Table>,
-    config: toml::Table,
-    issue: serde_json::Value,
-    names: &[&str],
-    expected: Result<&[&str], FindReviewerError>,
-) {
-    let (teams, config, issue) = convert_simplified(teams, config, issue);
-    let names: Vec<_> = names.iter().map(|n| n.to_string()).collect();
-    match (
-        candidate_reviewers_from_names(&teams, &config, &issue, &names),
-        expected,
-    ) {
-        (Ok(candidates), Ok(expected)) => {
-            let mut candidates: Vec<_> = candidates.into_iter().collect();
-            candidates.sort();
-            let expected: Vec<_> = expected.iter().map(|x| *x).collect();
-            assert_eq!(candidates, expected);
+#[must_use]
+struct TestCtx {
+    teams: Teams,
+    config: AssignConfig,
+    issue: Issue,
+}
+
+impl TestCtx {
+    fn new(config: toml::Table, issue: Issue) -> Self {
+        Self {
+            teams: Teams {
+                teams: Default::default(),
+            },
+            config: config.try_into().unwrap(),
+            issue,
         }
-        (Err(actual), Err(expected)) => {
-            assert_eq!(actual, expected)
+    }
+
+    fn teams(mut self, table: &toml::Table) -> Self {
+        let teams: serde_json::Value = table.clone().try_into().unwrap();
+        let mut teams_config = serde_json::json!({});
+        for (team_name, members) in teams.as_object().unwrap() {
+            let members: Vec<_> = members.as_array().unwrap().iter().map(|member| {
+                serde_json::json!({"name": member, "github": member, "github_id": 100, "is_lead": false})
+            }).collect();
+            teams_config[team_name] = serde_json::json!({
+                "name": team_name,
+                "kind": "team",
+                "members": serde_json::Value::Array(members),
+                "alumni": [],
+                "discord": [],
+                "roles": [],
+            });
         }
-        (Ok(candidates), Err(_)) => panic!("expected Err, got Ok: {candidates:?}"),
-        (Err(e), Ok(_)) => panic!("expected Ok, got Err: {e}"),
+        self.teams = serde_json::value::from_value(teams_config).unwrap();
+        self
     }
-}
 
-/// Convert the simplified input in preparation for `candidate_reviewers_from_names`.
-fn convert_simplified(
-    teams: Option<toml::Table>,
-    config: toml::Table,
-    issue: serde_json::Value,
-) -> (Teams, AssignConfig, Issue) {
-    // Convert the simplified team config to a real team config.
-    // This uses serde_json since it is easier to manipulate than toml.
-    let teams: serde_json::Value = match teams {
-        Some(teams) => teams.try_into().unwrap(),
-        None => serde_json::json!({}),
-    };
-    let mut teams_config = serde_json::json!({});
-    for (team_name, members) in teams.as_object().unwrap() {
-        let members: Vec<_> = members.as_array().unwrap().iter().map(|member| {
-            serde_json::json!({"name": member, "github": member, "github_id": 1, "is_lead": false})
-        }).collect();
-        teams_config[team_name] = serde_json::json!({
-            "name": team_name,
-            "kind": "team",
-            "members": serde_json::Value::Array(members),
-            "alumni": [],
-            "discord": [],
-            "roles": [],
-        });
+    fn run(self, names: &[&str], expected: Result<&[&str], FindReviewerError>) {
+        let names: Vec<_> = names.iter().map(|n| n.to_string()).collect();
+        match (
+            candidate_reviewers_from_names(&self.teams, &self.config, &self.issue, &names),
+            expected,
+        ) {
+            (Ok(candidates), Ok(expected)) => {
+                let mut candidates: Vec<_> = candidates.into_iter().collect();
+                candidates.sort();
+                let expected: Vec<_> = expected.iter().map(|x| *x).collect();
+                assert_eq!(candidates, expected);
+            }
+            (Err(actual), Err(expected)) => {
+                assert_eq!(actual, expected)
+            }
+            (Ok(candidates), Err(_)) => panic!("expected Err, got Ok: {candidates:?}"),
+            (Err(e), Ok(_)) => panic!("expected Ok, got Err: {e}"),
+        }
     }
-    let teams = serde_json::value::from_value(teams_config).unwrap();
-    let config = config.try_into().unwrap();
-    let issue = serde_json::value::from_value(issue).unwrap();
-    (teams, config, issue)
 }
 
-fn generic_issue(author: &str, repo: &str) -> serde_json::Value {
-    serde_json::json!({
-        "number": 1234,
-        "created_at": "2022-06-26T21:31:31Z",
-        "updated_at": "2022-06-26T21:31:31Z",
-        "title": "Example PR",
-        "body": "PR body",
-        "html_url": "https://github.com/rust-lang/rust/pull/1234",
-        "user": {
-            "login": author,
-            "id": 583231,
-        },
-        "labels": [],
-        "assignees": [],
-        "comments_url": format!("https://api.github.com/repos/{repo}/pull/1234/comments"),
-        "state": "open",
-    })
+/// Basic test function for testing `candidate_reviewers_from_names`.
+fn test_candidates(config: toml::Table, issue: Issue) -> TestCtx {
+    TestCtx::new(config, issue)
 }
 
 #[test]
@@ -89,11 +75,7 @@ fn circular_groups() {
         compiler = ["other"]
         other = ["compiler"]
     );
-    let issue = generic_issue("octocat", "rust-lang/rust");
-    test_from_names(
-        None,
-        config,
-        issue,
+    test_candidates(config, issue().call()).run(
         &["compiler"],
         Err(FindReviewerError::NoReviewer {
             initial: vec!["compiler".to_string()],
@@ -110,8 +92,7 @@ fn nested_groups() {
         b = ["@nrc"]
         c = ["a", "b"]
     );
-    let issue = generic_issue("octocat", "rust-lang/rust");
-    test_from_names(None, config, issue, &["c"], Ok(&["nrc", "pnkfelix"]));
+    test_candidates(config, issue().call()).run(&["c"], Ok(&["nrc", "pnkfelix"]));
 }
 
 #[test]
@@ -121,15 +102,10 @@ fn candidate_filtered_author_only_candidate() {
         [adhoc_groups]
         compiler = ["nikomatsakis"]
     );
-    let issue = generic_issue("nikomatsakis", "rust-lang/rust");
-    test_from_names(
-        None,
-        config,
-        issue,
+    test_candidates(config, issue().author(user("nikomatsakis", 1)).call()).run(
         &["compiler"],
-        Err(FindReviewerError::AllReviewersFiltered {
+        Err(FindReviewerError::NoReviewer {
             initial: vec!["compiler".to_string()],
-            filtered: vec!["nikomatsakis".to_string()],
         }),
     );
 }
@@ -142,14 +118,8 @@ fn candidate_filtered_author() {
         compiler = ["user1", "user2", "user3", "group2"]
         group2 = ["user2", "user4"]
     );
-    let issue = generic_issue("user2", "rust-lang/rust");
-    test_from_names(
-        None,
-        config,
-        issue,
-        &["compiler"],
-        Ok(&["user1", "user3", "user4"]),
-    );
+    test_candidates(config, issue().author(user("user2", 1)).call())
+        .run(&["compiler"], Ok(&["user1", "user3", "user4"]));
 }
 
 #[test]
@@ -159,12 +129,11 @@ fn candidate_filtered_assignee() {
         [adhoc_groups]
         compiler = ["user1", "user2", "user3", "user4"]
     );
-    let mut issue = generic_issue("user2", "rust-lang/rust");
-    issue["assignees"] = serde_json::json!([
-        {"login": "user1", "id": 1},
-        {"login": "user3", "id": 3},
-    ]);
-    test_from_names(None, config, issue, &["compiler"], Ok(&["user4"]));
+    let issue = issue()
+        .author(user("user2", 2))
+        .assignees(vec![user("user1", 1), user("user3", 3)])
+        .call();
+    test_candidates(config, issue).run(&["compiler"], Ok(&["user4"]));
 }
 
 #[test]
@@ -178,11 +147,7 @@ fn groups_teams_users() {
         [adhoc_groups]
         group1 = ["user1", "rust-lang/team2"]
     );
-    let issue = generic_issue("octocat", "rust-lang/rust");
-    test_from_names(
-        Some(teams),
-        config,
-        issue,
+    test_candidates(config, issue().call()).teams(&teams).run(
         &["team1", "group1", "user3"],
         Ok(&["t-user1", "t-user2", "user1", "user3"]),
     );
@@ -196,21 +161,12 @@ fn group_team_user_precedence() {
         [adhoc_groups]
         compiler = ["user2"]
     );
-    let issue = generic_issue("octocat", "rust-lang/rust");
-    test_from_names(
-        Some(teams.clone()),
-        config.clone(),
-        issue.clone(),
-        &["compiler"],
-        Ok(&["user2"]),
-    );
-    test_from_names(
-        Some(teams.clone()),
-        config.clone(),
-        issue.clone(),
-        &["rust-lang/compiler"],
-        Ok(&["user2"]),
-    );
+    test_candidates(config.clone(), issue().call())
+        .teams(&teams)
+        .run(&["compiler"], Ok(&["user2"]));
+    test_candidates(config, issue().call())
+        .teams(&teams)
+        .run(&["rust-lang/compiler"], Ok(&["user2"]));
 }
 
 #[test]
@@ -222,30 +178,16 @@ fn what_do_slashes_mean() {
         compiler = ["user2"]
         "foo/bar" = ["foo-user"]
     );
-    let issue = generic_issue("octocat", "rust-lang-nursery/rust");
+    let issue = || issue().org("rust-lang-nursery").call();
+
     // Random slash names should work from groups.
-    test_from_names(
-        Some(teams.clone()),
-        config.clone(),
-        issue.clone(),
-        &["foo/bar"],
-        Ok(&["foo-user"]),
-    );
-    // Since this is rust-lang-nursery, it uses the rust-lang team, not the group.
-    test_from_names(
-        Some(teams.clone()),
-        config.clone(),
-        issue.clone(),
-        &["rust-lang/compiler"],
-        Ok(&["t-user1"]),
-    );
-    test_from_names(
-        Some(teams.clone()),
-        config.clone(),
-        issue.clone(),
-        &["rust-lang-nursery/compiler"],
-        Ok(&["user2"]),
-    );
+    test_candidates(config.clone(), issue())
+        .teams(&teams)
+        .run(&["foo/bar"], Ok(&["foo-user"]));
+
+    test_candidates(config, issue())
+        .teams(&teams)
+        .run(&["rust-lang-nursery/compiler"], Ok(&["user2"]));
 }
 
 #[test]
@@ -255,11 +197,7 @@ fn invalid_org_doesnt_match() {
         [adhoc_groups]
         compiler = ["user2"]
     );
-    let issue = generic_issue("octocat", "rust-lang/rust");
-    test_from_names(
-        Some(teams),
-        config,
-        issue,
+    test_candidates(config, issue().call()).teams(&teams).run(
         &["github/compiler"],
         Err(FindReviewerError::TeamNotFound(
             "github/compiler".to_string(),
@@ -271,28 +209,18 @@ fn invalid_org_doesnt_match() {
 fn vacation() {
     let teams = toml::toml!(bootstrap = ["jyn514", "Mark-Simulacrum"]);
     let config = toml::toml!(users_on_vacation = ["jyn514"]);
-    let issue = generic_issue("octocat", "rust-lang/rust");
 
-    // Test that `r? user` falls through to assigning from the team.
-    // See `determine_assignee` - ideally we would test that function directly instead of indirectly through `find_reviewer_from_names`.
-    let err_names = vec!["jyn514".into()];
-    test_from_names(
-        Some(teams.clone()),
-        config.clone(),
-        issue.clone(),
-        &["jyn514"],
-        Err(FindReviewerError::AllReviewersFiltered {
-            initial: err_names.clone(),
-            filtered: err_names,
-        }),
-    );
-
-    // Test that `r? bootstrap` doesn't assign from users on vacation.
-    test_from_names(
-        Some(teams.clone()),
-        config.clone(),
-        issue,
-        &["bootstrap"],
-        Ok(&["Mark-Simulacrum"]),
-    );
+    // Test that `r? user` returns a specific error about the user being on vacation.
+    test_candidates(config.clone(), issue().call())
+        .teams(&teams)
+        .run(
+            &["jyn514"],
+            Err(FindReviewerError::ReviewerOnVacation {
+                username: "jyn514".to_string(),
+            }),
+        );
+
+    test_candidates(config.clone(), issue().call())
+        .teams(&teams)
+        .run(&["bootstrap"], Ok(&["Mark-Simulacrum"]));
 }
diff --git a/src/tests/github.rs b/src/tests/github.rs
index 0a2983db6..e9c36d626 100644
--- a/src/tests/github.rs
+++ b/src/tests/github.rs
@@ -24,6 +24,8 @@ pub fn issue(
     body: Option<&str>,
     assignees: Option<Vec<User>>,
     pr: Option<bool>,
+    org: Option<&str>,
+    repo: Option<&str>,
 ) -> Issue {
     let number = number.unwrap_or(1);
     let state = state.unwrap_or(IssueState::Open);
@@ -35,6 +37,8 @@ pub fn issue(
     } else {
         None
     };
+    let org = org.unwrap_or("rust-lang");
+    let repo = repo.unwrap_or("rust");
 
     Issue {
         number,
@@ -43,7 +47,7 @@ pub fn issue(
         updated_at: Utc::now(),
         merge_commit_sha: None,
         title: format!("Issue #{number}"),
-        html_url: "<html-url>".to_string(),
+        html_url: format!("https://github.com/{org}/{repo}/pull/{number}"),
         user: author,
         labels: vec![],
         assignees,
@@ -51,7 +55,8 @@ pub fn issue(
         merged: false,
         draft: false,
         comments: None,
-        comments_url: "".to_string(),
+        // The repository is parsed from comments_url, so this field is important
+        comments_url: format!("https://api.github.com/repos/{org}/{repo}/issues/{number}/comments"),
         repository: Default::default(),
         base: None,
         head: None,


Reviews:
user: login: Urgau
body: r=me on the first four, will look at the other ones later
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T17:15:23Z
user: login: Urgau
body: Look good to me. Only a small remark/question.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T19:22:59Z
user: login: Kobzol
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T07:48:37Z
user: login: Kobzol
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T07:53:37Z
user: login: Kobzol
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T07:58:58Z

Review comments:
diff_hunk: @@ -739,6 +736,13 @@ async fn find_reviewer_from_names(
     issue: &Issue,
     names: &[String],
 ) -> Result<String, FindReviewerError> {
+    // Fast path for self-assign, which is always allowed.
+    if let [name] = names {
+        if is_self_assign(&name, &issue.user.login) {
+            return Ok(name.clone());
+        }
+    }
path: src/handlers/assign.rs
user: login: Urgau
body: I'm a bit surprised by this.

Intuitively, I would expect only team member (to be more precise, only people who have write access) to be able to be assigned on a pull request. Issue, no problem.
created_at: 2025-04-16T19:20:46Z
author_association: MEMBER
diff_hunk: @@ -739,6 +736,13 @@ async fn find_reviewer_from_names(
     issue: &Issue,
     names: &[String],
 ) -> Result<String, FindReviewerError> {
+    // Fast path for self-assign, which is always allowed.
+    if let [name] = names {
+        if is_self_assign(&name, &issue.user.login) {
+            return Ok(name.clone());
+        }
+    }
path: src/handlers/assign.rs
user: login: Kobzol
body: I don't think that there's a problem with the PR author being assigned to their own PR. In any case, this was allowed before, both in [PR body](https://github.com/rust-lang/triagebot/blob/c3fb152735ceb95bf78724e991bdc27a42df9794/src/handlers/assign.rs#L285) and [comments](https://github.com/rust-lang/triagebot/blob/c3fb152735ceb95bf78724e991bdc27a42df9794/src/handlers/assign.rs#L502), so this just keeps the original behavior.
created_at: 2025-04-17T07:48:37Z
author_association: MEMBER
diff_hunk: @@ -739,6 +736,13 @@ async fn find_reviewer_from_names(
     issue: &Issue,
     names: &[String],
 ) -> Result<String, FindReviewerError> {
+    // Fast path for self-assign, which is always allowed.
+    if let [name] = names {
+        if is_self_assign(&name, &issue.user.login) {
+            return Ok(name.clone());
+        }
+    }
path: src/handlers/assign.rs
user: login: Kobzol
body: That being said, we have the `ReviewerIsPrAuthor` error, so it's not actually possible for *other people* to assign the PR author :laughing: That's inconsistent, yeah.
created_at: 2025-04-17T07:53:37Z
author_association: MEMBER
diff_hunk: @@ -739,6 +736,13 @@ async fn find_reviewer_from_names(
     issue: &Issue,
     names: &[String],
 ) -> Result<String, FindReviewerError> {
+    // Fast path for self-assign, which is always allowed.
+    if let [name] = names {
+        if is_self_assign(&name, &issue.user.login) {
+            return Ok(name.clone());
+        }
+    }
path: src/handlers/assign.rs
user: login: Kobzol
body: I guess we can do that in a separate PR, asked about it on Zulip.
created_at: 2025-04-17T07:58:58Z
author_association: MEMBER

PR comments:
user: login: Kobzol
created_at: 2025-04-17T07:52:14Z
body: Rebased.
author_association: MEMBER
user: login: Kobzol
created_at: 2025-04-17T07:59:05Z
body: Let's see what happens.
author_association: MEMBER