assign.rs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. //! Handles PR and issue assignment.
  2. //!
  3. //! This supports several ways for setting issue/PR assignment:
  4. //!
  5. //! * `@rustbot assign @gh-user`: Assigns to the given user.
  6. //! * `@rustbot claim`: Assigns to the comment author.
  7. //! * `@rustbot release-assignment`: Removes the commenter's assignment.
  8. //! * `r? @user`: Assigns to the given user (PRs only).
  9. //!
  10. //! This is capable of assigning to any user, even if they do not have write
  11. //! access to the repo. It does this by fake-assigning the bot and adding a
  12. //! "claimed by" section to the top-level comment.
  13. //!
  14. //! Configuration is done with the `[assign]` table.
  15. //!
  16. //! This also supports auto-assignment of new PRs. Based on rules in the
  17. //! `assign.owners` config, it will auto-select an assignee based on the files
  18. //! the PR modifies.
  19. use crate::{
  20. config::AssignConfig,
  21. github::{self, Event, Issue, IssuesAction, Selection},
  22. handlers::{Context, GithubClient, IssuesEvent},
  23. interactions::EditIssueBody,
  24. };
  25. use anyhow::{bail, Context as _};
  26. use parser::command::assign::AssignCommand;
  27. use parser::command::{Command, Input};
  28. use rand::seq::IteratorRandom;
  29. use rust_team_data::v1::Teams;
  30. use std::collections::{HashMap, HashSet};
  31. use std::fmt;
  32. use tracing as log;
  33. #[cfg(test)]
  34. mod tests {
  35. mod tests_candidates;
  36. mod tests_from_diff;
  37. }
  38. const NEW_USER_WELCOME_MESSAGE: &str = "Thanks for the pull request, and welcome! \
  39. The Rust team is excited to review your changes, and you should hear from {who} soon.";
  40. const CONTRIBUTION_MESSAGE: &str = "\
  41. Please see [the contribution instructions]({contributing_url}) for more information.";
  42. const WELCOME_WITH_REVIEWER: &str = "@{assignee} (or someone else)";
  43. const WELCOME_WITHOUT_REVIEWER: &str = "@Mark-Simulacrum (NB. this repo may be misconfigured)";
  44. const RETURNING_USER_WELCOME_MESSAGE: &str = "r? @{assignee}
  45. (rustbot has picked a reviewer for you, use r? to override)";
  46. const RETURNING_USER_WELCOME_MESSAGE_NO_REVIEWER: &str =
  47. "@{author}: no appropriate reviewer found, use r? to override";
  48. const NON_DEFAULT_BRANCH: &str =
  49. "Pull requests are usually filed against the {default} branch for this repo, \
  50. but this one is against {target}. \
  51. Please double check that you specified the right target!";
  52. const SUBMODULE_WARNING_MSG: &str = "These commits modify **submodules**.";
  53. #[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
  54. struct AssignData {
  55. user: Option<String>,
  56. }
  57. /// Input for auto-assignment when a PR is created.
  58. pub(super) struct AssignInput {
  59. git_diff: String,
  60. }
  61. /// Prepares the input when a new PR is opened.
  62. pub(super) async fn parse_input(
  63. ctx: &Context,
  64. event: &IssuesEvent,
  65. config: Option<&AssignConfig>,
  66. ) -> Result<Option<AssignInput>, String> {
  67. let config = match config {
  68. Some(config) => config,
  69. None => return Ok(None),
  70. };
  71. if config.owners.is_empty()
  72. || !matches!(event.action, IssuesAction::Opened)
  73. || !event.issue.is_pr()
  74. {
  75. return Ok(None);
  76. }
  77. let git_diff = match event.issue.diff(&ctx.github).await {
  78. Ok(None) => return Ok(None),
  79. Err(e) => {
  80. log::error!("failed to fetch diff: {:?}", e);
  81. return Ok(None);
  82. }
  83. Ok(Some(diff)) => diff,
  84. };
  85. Ok(Some(AssignInput { git_diff }))
  86. }
  87. /// Handles the work of setting an assignment for a new PR and posting a
  88. /// welcome message.
  89. pub(super) async fn handle_input(
  90. ctx: &Context,
  91. config: &AssignConfig,
  92. event: &IssuesEvent,
  93. input: AssignInput,
  94. ) -> anyhow::Result<()> {
  95. // Don't auto-assign or welcome if the user manually set the assignee when opening.
  96. if event.issue.assignees.is_empty() {
  97. let (assignee, from_comment) = determine_assignee(ctx, event, config, &input).await?;
  98. if assignee.as_deref() == Some("ghost") {
  99. // "ghost" is GitHub's placeholder account for deleted accounts.
  100. // It is used here as a convenient way to prevent assignment. This
  101. // is typically used for rollups or experiments where you don't
  102. // want any assignments or noise.
  103. return Ok(());
  104. }
  105. let welcome = if ctx
  106. .github
  107. .is_new_contributor(&event.repository, &event.issue.user.login)
  108. .await
  109. {
  110. let who_text = match &assignee {
  111. Some(assignee) => WELCOME_WITH_REVIEWER.replace("{assignee}", assignee),
  112. None => WELCOME_WITHOUT_REVIEWER.to_string(),
  113. };
  114. let mut welcome = NEW_USER_WELCOME_MESSAGE.replace("{who}", &who_text);
  115. if let Some(contrib) = &config.contributing_url {
  116. welcome.push_str("\n\n");
  117. welcome.push_str(&CONTRIBUTION_MESSAGE.replace("{contributing_url}", contrib));
  118. }
  119. Some(welcome)
  120. } else if !from_comment {
  121. let welcome = match &assignee {
  122. Some(assignee) => RETURNING_USER_WELCOME_MESSAGE.replace("{assignee}", assignee),
  123. None => RETURNING_USER_WELCOME_MESSAGE_NO_REVIEWER
  124. .replace("{author}", &event.issue.user.login),
  125. };
  126. Some(welcome)
  127. } else {
  128. // No welcome is posted if they are not new and they used `r?` in the opening body.
  129. None
  130. };
  131. if let Some(assignee) = assignee {
  132. set_assignee(&event.issue, &ctx.github, &assignee).await;
  133. }
  134. if let Some(welcome) = welcome {
  135. if let Err(e) = event.issue.post_comment(&ctx.github, &welcome).await {
  136. log::warn!(
  137. "failed to post welcome comment to {}: {e}",
  138. event.issue.global_id()
  139. );
  140. }
  141. }
  142. }
  143. // Compute some warning messages to post to new PRs.
  144. let mut warnings = Vec::new();
  145. if config.warn_non_default_branch {
  146. warnings.extend(non_default_branch(event));
  147. }
  148. warnings.extend(modifies_submodule(&input.git_diff));
  149. if !warnings.is_empty() {
  150. let warnings: Vec<_> = warnings
  151. .iter()
  152. .map(|warning| format!("* {warning}"))
  153. .collect();
  154. let warning = format!(":warning: **Warning** :warning:\n\n{}", warnings.join("\n"));
  155. event.issue.post_comment(&ctx.github, &warning).await?;
  156. };
  157. Ok(())
  158. }
  159. /// Finds the `r?` command in the PR body.
  160. ///
  161. /// Returns the name after the `r?` command, or None if not found.
  162. fn find_assign_command(ctx: &Context, event: &IssuesEvent) -> Option<String> {
  163. let mut input = Input::new(&event.issue.body, vec![&ctx.username]);
  164. input.find_map(|command| match command {
  165. Command::Assign(Ok(AssignCommand::ReviewName { name })) => Some(name),
  166. _ => None,
  167. })
  168. }
  169. /// Returns a message if the PR is opened against the non-default branch.
  170. fn non_default_branch(event: &IssuesEvent) -> Option<String> {
  171. let target_branch = &event.issue.base.as_ref().unwrap().git_ref;
  172. let default_branch = &event.repository.default_branch;
  173. if target_branch == default_branch {
  174. return None;
  175. }
  176. Some(
  177. NON_DEFAULT_BRANCH
  178. .replace("{default}", default_branch)
  179. .replace("{target}", target_branch),
  180. )
  181. }
  182. /// Returns a message if the PR modifies a git submodule.
  183. fn modifies_submodule(diff: &str) -> Option<String> {
  184. let re = regex::Regex::new(r"\+Subproject\scommit\s").unwrap();
  185. if re.is_match(diff) {
  186. Some(SUBMODULE_WARNING_MSG.to_string())
  187. } else {
  188. None
  189. }
  190. }
  191. /// Sets the assignee of a PR, alerting any errors.
  192. async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) {
  193. // Don't re-assign if already assigned, e.g. on comment edit
  194. if issue.contain_assignee(&username) {
  195. log::trace!(
  196. "ignoring assign PR {} to {}, already assigned",
  197. issue.global_id(),
  198. username,
  199. );
  200. return;
  201. }
  202. if let Err(err) = issue.set_assignee(github, &username).await {
  203. log::warn!(
  204. "failed to set assignee of PR {} to {}: {:?}",
  205. issue.global_id(),
  206. username,
  207. err
  208. );
  209. if let Err(e) = issue
  210. .post_comment(
  211. github,
  212. &format!(
  213. "Failed to set assignee to `{username}`: {err}\n\
  214. \n\
  215. > **Note**: Only org members, users with write \
  216. permissions, or people who have commented on the PR may \
  217. be assigned."
  218. ),
  219. )
  220. .await
  221. {
  222. log::warn!("failed to post error comment: {e}");
  223. }
  224. }
  225. }
  226. /// Determines who to assign the PR to based on either an `r?` command, or
  227. /// based on which files were modified.
  228. ///
  229. /// Returns `(assignee, from_comment)` where `assignee` is who to assign to
  230. /// (or None if no assignee could be found). `from_comment` is a boolean
  231. /// indicating if the assignee came from an `r?` command (it is false if
  232. /// determined from the diff).
  233. async fn determine_assignee(
  234. ctx: &Context,
  235. event: &IssuesEvent,
  236. config: &AssignConfig,
  237. input: &AssignInput,
  238. ) -> anyhow::Result<(Option<String>, bool)> {
  239. let teams = crate::team_data::teams(&ctx.github).await?;
  240. if let Some(name) = find_assign_command(ctx, event) {
  241. // User included `r?` in the opening PR body.
  242. match find_reviewer_from_names(&teams, config, &event.issue, &[name]) {
  243. Ok(assignee) => return Ok((Some(assignee), true)),
  244. Err(e) => {
  245. event
  246. .issue
  247. .post_comment(&ctx.github, &e.to_string())
  248. .await?;
  249. // Fall through below for normal diff detection.
  250. }
  251. }
  252. }
  253. // Errors fall-through to try fallback group.
  254. match find_reviewers_from_diff(config, &input.git_diff) {
  255. Ok(candidates) if !candidates.is_empty() => {
  256. match find_reviewer_from_names(&teams, config, &event.issue, &candidates) {
  257. Ok(assignee) => return Ok((Some(assignee), false)),
  258. Err(FindReviewerError::TeamNotFound(team)) => log::warn!(
  259. "team {team} not found via diff from PR {}, \
  260. is there maybe a misconfigured group?",
  261. event.issue.global_id()
  262. ),
  263. Err(FindReviewerError::NoReviewer(names)) => log::trace!(
  264. "no reviewer could be determined for PR {} with candidate name {names:?}",
  265. event.issue.global_id()
  266. ),
  267. }
  268. }
  269. // If no owners matched the diff, fall-through.
  270. Ok(_) => {}
  271. Err(e) => {
  272. log::warn!(
  273. "failed to find candidate reviewer from diff due to error: {e}\n\
  274. Is the triagebot.toml misconfigured?"
  275. );
  276. }
  277. }
  278. if let Some(fallback) = config.adhoc_groups.get("fallback") {
  279. match find_reviewer_from_names(&teams, config, &event.issue, fallback) {
  280. Ok(assignee) => return Ok((Some(assignee), false)),
  281. Err(e) => {
  282. log::trace!(
  283. "failed to select from fallback group for PR {}: {e}",
  284. event.issue.global_id()
  285. );
  286. }
  287. }
  288. }
  289. Ok((None, false))
  290. }
  291. /// Returns a list of candidate reviewers to use based on which files were changed.
  292. ///
  293. /// May return an error if the owners map is misconfigured.
  294. ///
  295. /// Beware this may return an empty list if nothing matches.
  296. fn find_reviewers_from_diff(config: &AssignConfig, diff: &str) -> anyhow::Result<Vec<String>> {
  297. // Map of `owners` path to the number of changes found in that path.
  298. // This weights the reviewer choice towards places where the most edits are done.
  299. let mut counts: HashMap<&str, u32> = HashMap::new();
  300. // List of the longest `owners` patterns that match the current path. This
  301. // prefers choosing reviewers from deeply nested paths over those defined
  302. // for top-level paths, under the assumption that they are more
  303. // specialized.
  304. //
  305. // This is a list to handle the situation if multiple paths of the same
  306. // length match.
  307. let mut longest_owner_patterns = Vec::new();
  308. // Iterate over the diff, finding the start of each file. After each file
  309. // is found, it counts the number of modified lines in that file, and
  310. // tracks those in the `counts` map.
  311. for line in diff.split('\n') {
  312. if line.starts_with("diff --git ") {
  313. // Start of a new file.
  314. longest_owner_patterns.clear();
  315. let path = line[line.find(" b/").unwrap()..]
  316. .strip_prefix(" b/")
  317. .unwrap();
  318. // Find the longest `owners` entries that match this path.
  319. let mut longest = HashMap::new();
  320. for owner_pattern in config.owners.keys() {
  321. let ignore = ignore::gitignore::GitignoreBuilder::new("/")
  322. .add_line(None, owner_pattern)
  323. .with_context(|| format!("owner file pattern `{owner_pattern}` is not valid"))?
  324. .build()?;
  325. if ignore.matched_path_or_any_parents(path, false).is_ignore() {
  326. let owner_len = owner_pattern.split('/').count();
  327. longest.insert(owner_pattern, owner_len);
  328. }
  329. }
  330. let max_count = longest.values().copied().max().unwrap_or(0);
  331. longest_owner_patterns.extend(
  332. longest
  333. .iter()
  334. .filter(|(_, count)| **count == max_count)
  335. .map(|x| *x.0),
  336. );
  337. // Give some weight to these patterns to start. This helps with
  338. // files modified without any lines changed.
  339. for owner_pattern in &longest_owner_patterns {
  340. *counts.entry(owner_pattern).or_default() += 1;
  341. }
  342. continue;
  343. }
  344. // Check for a modified line.
  345. if (!line.starts_with("+++") && line.starts_with('+'))
  346. || (!line.starts_with("---") && line.starts_with('-'))
  347. {
  348. for owner_path in &longest_owner_patterns {
  349. *counts.entry(owner_path).or_default() += 1;
  350. }
  351. }
  352. }
  353. // Use the `owners` entry with the most number of modifications.
  354. let max_count = counts.values().copied().max().unwrap_or(0);
  355. let max_paths = counts
  356. .iter()
  357. .filter(|(_, count)| **count == max_count)
  358. .map(|(path, _)| path);
  359. let mut potential: Vec<_> = max_paths
  360. .flat_map(|owner_path| &config.owners[*owner_path])
  361. .map(|owner| owner.to_string())
  362. .collect();
  363. // Dedupe. This isn't strictly necessary, as `find_reviewer_from_names` will deduplicate.
  364. // However, this helps with testing.
  365. potential.sort();
  366. potential.dedup();
  367. Ok(potential)
  368. }
  369. /// Handles a command posted in a comment.
  370. pub(super) async fn handle_command(
  371. ctx: &Context,
  372. config: &AssignConfig,
  373. event: &Event,
  374. cmd: AssignCommand,
  375. ) -> anyhow::Result<()> {
  376. let is_team_member = if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await
  377. {
  378. false
  379. } else {
  380. true
  381. };
  382. // Don't handle commands in comments from the bot. Some of the comments it
  383. // posts contain commands to instruct the user, not things that the bot
  384. // should respond to.
  385. if event.user().login == ctx.username.as_str() {
  386. return Ok(());
  387. }
  388. let issue = event.issue().unwrap();
  389. if issue.is_pr() {
  390. if !issue.is_open() {
  391. issue
  392. .post_comment(&ctx.github, "Assignment is not allowed on a closed PR.")
  393. .await?;
  394. return Ok(());
  395. }
  396. let username = match cmd {
  397. AssignCommand::Own => event.user().login.clone(),
  398. AssignCommand::User { username } => username,
  399. AssignCommand::Release => {
  400. log::trace!(
  401. "ignoring release on PR {:?}, must always have assignee",
  402. issue.global_id()
  403. );
  404. return Ok(());
  405. }
  406. AssignCommand::ReviewName { name } => {
  407. if config.owners.is_empty() {
  408. // To avoid conflicts with the highfive bot while transitioning,
  409. // r? is ignored if `owners` is not configured in triagebot.toml.
  410. return Ok(());
  411. }
  412. if matches!(
  413. event,
  414. Event::Issue(IssuesEvent {
  415. action: IssuesAction::Opened,
  416. ..
  417. })
  418. ) {
  419. // Don't handle r? comments on new PRs. Those will be
  420. // handled by the new PR trigger (which also handles the
  421. // welcome message).
  422. return Ok(());
  423. }
  424. let teams = crate::team_data::teams(&ctx.github).await?;
  425. match find_reviewer_from_names(&teams, config, issue, &[name]) {
  426. Ok(assignee) => assignee,
  427. Err(e) => {
  428. issue.post_comment(&ctx.github, &e.to_string()).await?;
  429. return Ok(());
  430. }
  431. }
  432. }
  433. };
  434. set_assignee(issue, &ctx.github, &username).await;
  435. return Ok(());
  436. }
  437. let e = EditIssueBody::new(&issue, "ASSIGN");
  438. let to_assign = match cmd {
  439. AssignCommand::Own => event.user().login.clone(),
  440. AssignCommand::User { username } => {
  441. if !is_team_member && username != event.user().login {
  442. bail!("Only Rust team members can assign other users");
  443. }
  444. username.clone()
  445. }
  446. AssignCommand::Release => {
  447. if let Some(AssignData {
  448. user: Some(current),
  449. }) = e.current_data()
  450. {
  451. if current == event.user().login || is_team_member {
  452. issue.remove_assignees(&ctx.github, Selection::All).await?;
  453. e.apply(&ctx.github, String::new(), AssignData { user: None })
  454. .await?;
  455. return Ok(());
  456. } else {
  457. bail!("Cannot release another user's assignment");
  458. }
  459. } else {
  460. let current = &event.user().login;
  461. if issue.contain_assignee(current) {
  462. issue
  463. .remove_assignees(&ctx.github, Selection::One(&current))
  464. .await?;
  465. e.apply(&ctx.github, String::new(), AssignData { user: None })
  466. .await?;
  467. return Ok(());
  468. } else {
  469. bail!("Cannot release unassigned issue");
  470. }
  471. };
  472. }
  473. AssignCommand::ReviewName { .. } => bail!("r? is only allowed on PRs."),
  474. };
  475. // Don't re-assign if aleady assigned, e.g. on comment edit
  476. if issue.contain_assignee(&to_assign) {
  477. log::trace!(
  478. "ignoring assign issue {} to {}, already assigned",
  479. issue.global_id(),
  480. to_assign,
  481. );
  482. return Ok(());
  483. }
  484. let data = AssignData {
  485. user: Some(to_assign.clone()),
  486. };
  487. e.apply(&ctx.github, String::new(), &data).await?;
  488. match issue.set_assignee(&ctx.github, &to_assign).await {
  489. Ok(()) => return Ok(()), // we are done
  490. Err(github::AssignmentError::InvalidAssignee) => {
  491. issue
  492. .set_assignee(&ctx.github, &ctx.username)
  493. .await
  494. .context("self-assignment failed")?;
  495. let cmt_body = format!(
  496. "This issue has been assigned to @{} via [this comment]({}).",
  497. to_assign,
  498. event.html_url().unwrap()
  499. );
  500. e.apply(&ctx.github, cmt_body, &data).await?;
  501. }
  502. Err(e) => return Err(e.into()),
  503. }
  504. Ok(())
  505. }
  506. #[derive(Debug)]
  507. enum FindReviewerError {
  508. /// User specified something like `r? foo/bar` where that team name could
  509. /// not be found.
  510. TeamNotFound(String),
  511. /// No reviewer could be found. The field is the list of candidate names
  512. /// that were used to seed the selection. One example where this happens
  513. /// is if the given name was for a team where the PR author is the only
  514. /// member.
  515. NoReviewer(Vec<String>),
  516. }
  517. impl std::error::Error for FindReviewerError {}
  518. impl fmt::Display for FindReviewerError {
  519. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
  520. match self {
  521. FindReviewerError::TeamNotFound(team) => write!(f, "Team or group `{team}` not found.\n\
  522. \n\
  523. rust-lang team names can be found at https://github.com/rust-lang/team/tree/master/teams.\n\
  524. Reviewer group names can be found in `triagebot.toml` in this repo."),
  525. FindReviewerError::NoReviewer(names) => write!(
  526. f,
  527. "Could not determine reviewer from `{}`.",
  528. names.join(",")
  529. ),
  530. }
  531. }
  532. }
  533. /// Finds a reviewer to assign to a PR.
  534. ///
  535. /// The `names` is a list of candidate reviewers `r?`, such as `compiler` or
  536. /// `@octocat`, or names from the owners map. It can contain GitHub usernames,
  537. /// auto-assign groups, or rust-lang team names. It must have at least one
  538. /// entry.
  539. fn find_reviewer_from_names(
  540. teams: &Teams,
  541. config: &AssignConfig,
  542. issue: &Issue,
  543. names: &[String],
  544. ) -> Result<String, FindReviewerError> {
  545. let candidates = candidate_reviewers_from_names(teams, config, issue, names)?;
  546. // This uses a relatively primitive random choice algorithm.
  547. // GitHub's CODEOWNERS supports much more sophisticated options, such as:
  548. //
  549. // - Round robin: Chooses reviewers based on who's received the least
  550. // recent review request, focusing on alternating between all members of
  551. // the team regardless of the number of outstanding reviews they
  552. // currently have.
  553. // - Load balance: Chooses reviewers based on each member's total number
  554. // of recent review requests and considers the number of outstanding
  555. // reviews for each member. The load balance algorithm tries to ensure
  556. // that each team member reviews an equal number of pull requests in any
  557. // 30 day period.
  558. //
  559. // Additionally, with CODEOWNERS, users marked as "Busy" in the GitHub UI
  560. // will not be selected for reviewer. There are several other options for
  561. // configuring CODEOWNERS as well.
  562. //
  563. // These are all ideas for improving the selection here. However, I'm not
  564. // sure they are really worth the effort.
  565. match candidates.into_iter().choose(&mut rand::thread_rng()) {
  566. Some(candidate) => Ok(candidate.to_string()),
  567. None => Err(FindReviewerError::NoReviewer(
  568. names.iter().map(|n| n.to_string()).collect(),
  569. )),
  570. }
  571. }
  572. /// Returns a list of candidate usernames to choose as a reviewer.
  573. fn candidate_reviewers_from_names<'a>(
  574. teams: &'a Teams,
  575. config: &'a AssignConfig,
  576. issue: &Issue,
  577. names: &'a [String],
  578. ) -> Result<HashSet<&'a str>, FindReviewerError> {
  579. // Set of candidate usernames to choose from. This uses a set to
  580. // deduplicate entries so that someone in multiple teams isn't
  581. // over-weighted.
  582. let mut candidates: HashSet<&str> = HashSet::new();
  583. // Keep track of groups seen to avoid cycles and avoid expanding the same
  584. // team multiple times.
  585. let mut seen = HashSet::new();
  586. // This is a queue of potential groups or usernames to expand. The loop
  587. // below will pop from this and then append the expanded results of teams.
  588. // Usernames will be added to `candidates`.
  589. let mut group_expansion: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
  590. let repo = issue.repository();
  591. let org_prefix = format!("{}/", repo.organization);
  592. // Don't allow groups or teams to include the current author or assignee.
  593. let filter = |name: &&str| -> bool {
  594. let name_lower = name.to_lowercase();
  595. name_lower != issue.user.login.to_lowercase()
  596. && !issue
  597. .assignees
  598. .iter()
  599. .any(|assignee| name_lower == assignee.login.to_lowercase())
  600. };
  601. // Loop over groups to recursively expand them.
  602. while let Some(group_or_user) = group_expansion.pop() {
  603. let group_or_user = group_or_user.strip_prefix('@').unwrap_or(group_or_user);
  604. // Try ad-hoc groups first.
  605. // Allow `rust-lang/compiler` to match `compiler`.
  606. let maybe_group = group_or_user
  607. .strip_prefix(&org_prefix)
  608. .unwrap_or(group_or_user);
  609. if let Some(group_members) = config.adhoc_groups.get(maybe_group) {
  610. // If a group has already been expanded, don't expand it again.
  611. if seen.insert(maybe_group) {
  612. group_expansion.extend(
  613. group_members
  614. .iter()
  615. .map(|member| member.as_str())
  616. .filter(filter),
  617. );
  618. }
  619. continue;
  620. }
  621. // Check for a team name.
  622. // Allow either a direct team name like `rustdoc` or a GitHub-style
  623. // team name of `rust-lang/rustdoc` (though this does not check if
  624. // that is a real GitHub team name).
  625. //
  626. // This ignores subteam relationships (it only uses direct members).
  627. let maybe_team = group_or_user
  628. .strip_prefix("rust-lang/")
  629. .unwrap_or(group_or_user);
  630. if let Some(team) = teams.teams.get(maybe_team) {
  631. candidates.extend(
  632. team.members
  633. .iter()
  634. .map(|member| member.github.as_str())
  635. .filter(filter),
  636. );
  637. continue;
  638. }
  639. if group_or_user.contains('/') {
  640. return Err(FindReviewerError::TeamNotFound(group_or_user.to_string()));
  641. }
  642. // Assume it is a user.
  643. if filter(&group_or_user) {
  644. candidates.insert(group_or_user);
  645. }
  646. }
  647. Ok(candidates)
  648. }