assign.rs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  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. fn is_self_assign(assignee: &str, pr_author: &str) -> bool {
  170. assignee.to_lowercase() == pr_author.to_lowercase()
  171. }
  172. /// Returns a message if the PR is opened against the non-default branch.
  173. fn non_default_branch(event: &IssuesEvent) -> Option<String> {
  174. let target_branch = &event.issue.base.as_ref().unwrap().git_ref;
  175. let default_branch = &event.repository.default_branch;
  176. if target_branch == default_branch {
  177. return None;
  178. }
  179. Some(
  180. NON_DEFAULT_BRANCH
  181. .replace("{default}", default_branch)
  182. .replace("{target}", target_branch),
  183. )
  184. }
  185. /// Returns a message if the PR modifies a git submodule.
  186. fn modifies_submodule(diff: &str) -> Option<String> {
  187. let re = regex::Regex::new(r"\+Subproject\scommit\s").unwrap();
  188. if re.is_match(diff) {
  189. Some(SUBMODULE_WARNING_MSG.to_string())
  190. } else {
  191. None
  192. }
  193. }
  194. /// Sets the assignee of a PR, alerting any errors.
  195. async fn set_assignee(issue: &Issue, github: &GithubClient, username: &str) {
  196. // Don't re-assign if already assigned, e.g. on comment edit
  197. if issue.contain_assignee(&username) {
  198. log::trace!(
  199. "ignoring assign PR {} to {}, already assigned",
  200. issue.global_id(),
  201. username,
  202. );
  203. return;
  204. }
  205. if let Err(err) = issue.set_assignee(github, &username).await {
  206. log::warn!(
  207. "failed to set assignee of PR {} to {}: {:?}",
  208. issue.global_id(),
  209. username,
  210. err
  211. );
  212. if let Err(e) = issue
  213. .post_comment(
  214. github,
  215. &format!(
  216. "Failed to set assignee to `{username}`: {err}\n\
  217. \n\
  218. > **Note**: Only org members, users with write \
  219. permissions, or people who have commented on the PR may \
  220. be assigned."
  221. ),
  222. )
  223. .await
  224. {
  225. log::warn!("failed to post error comment: {e}");
  226. }
  227. }
  228. }
  229. /// Determines who to assign the PR to based on either an `r?` command, or
  230. /// based on which files were modified.
  231. ///
  232. /// Returns `(assignee, from_comment)` where `assignee` is who to assign to
  233. /// (or None if no assignee could be found). `from_comment` is a boolean
  234. /// indicating if the assignee came from an `r?` command (it is false if
  235. /// determined from the diff).
  236. async fn determine_assignee(
  237. ctx: &Context,
  238. event: &IssuesEvent,
  239. config: &AssignConfig,
  240. input: &AssignInput,
  241. ) -> anyhow::Result<(Option<String>, bool)> {
  242. let teams = crate::team_data::teams(&ctx.github).await?;
  243. if let Some(name) = find_assign_command(ctx, event) {
  244. if is_self_assign(&name, &event.issue.user.login) {
  245. return Ok((Some(name.to_string()), true));
  246. }
  247. // User included `r?` in the opening PR body.
  248. match find_reviewer_from_names(&teams, config, &event.issue, &[name]) {
  249. Ok(assignee) => return Ok((Some(assignee), true)),
  250. Err(e) => {
  251. event
  252. .issue
  253. .post_comment(&ctx.github, &e.to_string())
  254. .await?;
  255. // Fall through below for normal diff detection.
  256. }
  257. }
  258. }
  259. // Errors fall-through to try fallback group.
  260. match find_reviewers_from_diff(config, &input.git_diff) {
  261. Ok(candidates) if !candidates.is_empty() => {
  262. match find_reviewer_from_names(&teams, config, &event.issue, &candidates) {
  263. Ok(assignee) => return Ok((Some(assignee), false)),
  264. Err(FindReviewerError::TeamNotFound(team)) => log::warn!(
  265. "team {team} not found via diff from PR {}, \
  266. is there maybe a misconfigured group?",
  267. event.issue.global_id()
  268. ),
  269. Err(
  270. e @ FindReviewerError::NoReviewer { .. }
  271. | e @ FindReviewerError::AllReviewersFiltered { .. },
  272. ) => log::trace!(
  273. "no reviewer could be determined for PR {}: {e}",
  274. event.issue.global_id()
  275. ),
  276. }
  277. }
  278. // If no owners matched the diff, fall-through.
  279. Ok(_) => {}
  280. Err(e) => {
  281. log::warn!(
  282. "failed to find candidate reviewer from diff due to error: {e}\n\
  283. Is the triagebot.toml misconfigured?"
  284. );
  285. }
  286. }
  287. if let Some(fallback) = config.adhoc_groups.get("fallback") {
  288. match find_reviewer_from_names(&teams, config, &event.issue, fallback) {
  289. Ok(assignee) => return Ok((Some(assignee), false)),
  290. Err(e) => {
  291. log::trace!(
  292. "failed to select from fallback group for PR {}: {e}",
  293. event.issue.global_id()
  294. );
  295. }
  296. }
  297. }
  298. Ok((None, false))
  299. }
  300. /// Returns a list of candidate reviewers to use based on which files were changed.
  301. ///
  302. /// May return an error if the owners map is misconfigured.
  303. ///
  304. /// Beware this may return an empty list if nothing matches.
  305. fn find_reviewers_from_diff(config: &AssignConfig, diff: &str) -> anyhow::Result<Vec<String>> {
  306. // Map of `owners` path to the number of changes found in that path.
  307. // This weights the reviewer choice towards places where the most edits are done.
  308. let mut counts: HashMap<&str, u32> = HashMap::new();
  309. // List of the longest `owners` patterns that match the current path. This
  310. // prefers choosing reviewers from deeply nested paths over those defined
  311. // for top-level paths, under the assumption that they are more
  312. // specialized.
  313. //
  314. // This is a list to handle the situation if multiple paths of the same
  315. // length match.
  316. let mut longest_owner_patterns = Vec::new();
  317. // Iterate over the diff, finding the start of each file. After each file
  318. // is found, it counts the number of modified lines in that file, and
  319. // tracks those in the `counts` map.
  320. for line in diff.split('\n') {
  321. if line.starts_with("diff --git ") {
  322. // Start of a new file.
  323. longest_owner_patterns.clear();
  324. let path = line[line.find(" b/").unwrap()..]
  325. .strip_prefix(" b/")
  326. .unwrap();
  327. // Find the longest `owners` entries that match this path.
  328. let mut longest = HashMap::new();
  329. for owner_pattern in config.owners.keys() {
  330. let ignore = ignore::gitignore::GitignoreBuilder::new("/")
  331. .add_line(None, owner_pattern)
  332. .with_context(|| format!("owner file pattern `{owner_pattern}` is not valid"))?
  333. .build()?;
  334. if ignore.matched_path_or_any_parents(path, false).is_ignore() {
  335. let owner_len = owner_pattern.split('/').count();
  336. longest.insert(owner_pattern, owner_len);
  337. }
  338. }
  339. let max_count = longest.values().copied().max().unwrap_or(0);
  340. longest_owner_patterns.extend(
  341. longest
  342. .iter()
  343. .filter(|(_, count)| **count == max_count)
  344. .map(|x| *x.0),
  345. );
  346. // Give some weight to these patterns to start. This helps with
  347. // files modified without any lines changed.
  348. for owner_pattern in &longest_owner_patterns {
  349. *counts.entry(owner_pattern).or_default() += 1;
  350. }
  351. continue;
  352. }
  353. // Check for a modified line.
  354. if (!line.starts_with("+++") && line.starts_with('+'))
  355. || (!line.starts_with("---") && line.starts_with('-'))
  356. {
  357. for owner_path in &longest_owner_patterns {
  358. *counts.entry(owner_path).or_default() += 1;
  359. }
  360. }
  361. }
  362. // Use the `owners` entry with the most number of modifications.
  363. let max_count = counts.values().copied().max().unwrap_or(0);
  364. let max_paths = counts
  365. .iter()
  366. .filter(|(_, count)| **count == max_count)
  367. .map(|(path, _)| path);
  368. let mut potential: Vec<_> = max_paths
  369. .flat_map(|owner_path| &config.owners[*owner_path])
  370. .map(|owner| owner.to_string())
  371. .collect();
  372. // Dedupe. This isn't strictly necessary, as `find_reviewer_from_names` will deduplicate.
  373. // However, this helps with testing.
  374. potential.sort();
  375. potential.dedup();
  376. Ok(potential)
  377. }
  378. /// Handles a command posted in a comment.
  379. pub(super) async fn handle_command(
  380. ctx: &Context,
  381. config: &AssignConfig,
  382. event: &Event,
  383. cmd: AssignCommand,
  384. ) -> anyhow::Result<()> {
  385. let is_team_member = if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await
  386. {
  387. false
  388. } else {
  389. true
  390. };
  391. // Don't handle commands in comments from the bot. Some of the comments it
  392. // posts contain commands to instruct the user, not things that the bot
  393. // should respond to.
  394. if event.user().login == ctx.username.as_str() {
  395. return Ok(());
  396. }
  397. let issue = event.issue().unwrap();
  398. if issue.is_pr() {
  399. if !issue.is_open() {
  400. issue
  401. .post_comment(&ctx.github, "Assignment is not allowed on a closed PR.")
  402. .await?;
  403. return Ok(());
  404. }
  405. let username = match cmd {
  406. AssignCommand::Own => event.user().login.clone(),
  407. AssignCommand::User { username } => username,
  408. AssignCommand::Release => {
  409. log::trace!(
  410. "ignoring release on PR {:?}, must always have assignee",
  411. issue.global_id()
  412. );
  413. return Ok(());
  414. }
  415. AssignCommand::ReviewName { name } => {
  416. if config.owners.is_empty() {
  417. // To avoid conflicts with the highfive bot while transitioning,
  418. // r? is ignored if `owners` is not configured in triagebot.toml.
  419. return Ok(());
  420. }
  421. if matches!(
  422. event,
  423. Event::Issue(IssuesEvent {
  424. action: IssuesAction::Opened,
  425. ..
  426. })
  427. ) {
  428. // Don't handle r? comments on new PRs. Those will be
  429. // handled by the new PR trigger (which also handles the
  430. // welcome message).
  431. return Ok(());
  432. }
  433. if is_self_assign(&name, &event.user().login) {
  434. name.to_string()
  435. } else {
  436. let teams = crate::team_data::teams(&ctx.github).await?;
  437. match find_reviewer_from_names(&teams, config, issue, &[name]) {
  438. Ok(assignee) => assignee,
  439. Err(e) => {
  440. issue.post_comment(&ctx.github, &e.to_string()).await?;
  441. return Ok(());
  442. }
  443. }
  444. }
  445. }
  446. };
  447. set_assignee(issue, &ctx.github, &username).await;
  448. return Ok(());
  449. }
  450. let e = EditIssueBody::new(&issue, "ASSIGN");
  451. let to_assign = match cmd {
  452. AssignCommand::Own => event.user().login.clone(),
  453. AssignCommand::User { username } => {
  454. if !is_team_member && username != event.user().login {
  455. bail!("Only Rust team members can assign other users");
  456. }
  457. username.clone()
  458. }
  459. AssignCommand::Release => {
  460. if let Some(AssignData {
  461. user: Some(current),
  462. }) = e.current_data()
  463. {
  464. if current == event.user().login || is_team_member {
  465. issue.remove_assignees(&ctx.github, Selection::All).await?;
  466. e.apply(&ctx.github, String::new(), AssignData { user: None })
  467. .await?;
  468. return Ok(());
  469. } else {
  470. bail!("Cannot release another user's assignment");
  471. }
  472. } else {
  473. let current = &event.user().login;
  474. if issue.contain_assignee(current) {
  475. issue
  476. .remove_assignees(&ctx.github, Selection::One(&current))
  477. .await?;
  478. e.apply(&ctx.github, String::new(), AssignData { user: None })
  479. .await?;
  480. return Ok(());
  481. } else {
  482. bail!("Cannot release unassigned issue");
  483. }
  484. };
  485. }
  486. AssignCommand::ReviewName { .. } => bail!("r? is only allowed on PRs."),
  487. };
  488. // Don't re-assign if aleady assigned, e.g. on comment edit
  489. if issue.contain_assignee(&to_assign) {
  490. log::trace!(
  491. "ignoring assign issue {} to {}, already assigned",
  492. issue.global_id(),
  493. to_assign,
  494. );
  495. return Ok(());
  496. }
  497. let data = AssignData {
  498. user: Some(to_assign.clone()),
  499. };
  500. e.apply(&ctx.github, String::new(), &data).await?;
  501. match issue.set_assignee(&ctx.github, &to_assign).await {
  502. Ok(()) => return Ok(()), // we are done
  503. Err(github::AssignmentError::InvalidAssignee) => {
  504. issue
  505. .set_assignee(&ctx.github, &ctx.username)
  506. .await
  507. .context("self-assignment failed")?;
  508. let cmt_body = format!(
  509. "This issue has been assigned to @{} via [this comment]({}).",
  510. to_assign,
  511. event.html_url().unwrap()
  512. );
  513. e.apply(&ctx.github, cmt_body, &data).await?;
  514. }
  515. Err(e) => return Err(e.into()),
  516. }
  517. Ok(())
  518. }
  519. #[derive(PartialEq, Debug)]
  520. enum FindReviewerError {
  521. /// User specified something like `r? foo/bar` where that team name could
  522. /// not be found.
  523. TeamNotFound(String),
  524. /// No reviewer could be found.
  525. ///
  526. /// This could happen if there is a cyclical group or other misconfiguration.
  527. /// `initial` is the initial list of candidate names.
  528. NoReviewer { initial: Vec<String> },
  529. /// All potential candidates were excluded. `initial` is the list of
  530. /// candidate names that were used to seed the selection. `filtered` is
  531. /// the users who were prevented from being assigned. One example where
  532. /// this happens is if the given name was for a team where the PR author
  533. /// is the only member.
  534. AllReviewersFiltered {
  535. initial: Vec<String>,
  536. filtered: Vec<String>,
  537. },
  538. }
  539. impl std::error::Error for FindReviewerError {}
  540. impl fmt::Display for FindReviewerError {
  541. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
  542. match self {
  543. FindReviewerError::TeamNotFound(team) => {
  544. write!(
  545. f,
  546. "Team or group `{team}` not found.\n\
  547. \n\
  548. rust-lang team names can be found at https://github.com/rust-lang/team/tree/master/teams.\n\
  549. Reviewer group names can be found in `triagebot.toml` in this repo."
  550. )
  551. }
  552. FindReviewerError::NoReviewer { initial } => {
  553. write!(
  554. f,
  555. "No reviewers could be found from initial request `{}`\n\
  556. This repo may be misconfigured.\n\
  557. Use r? to specify someone else to assign.",
  558. initial.join(",")
  559. )
  560. }
  561. FindReviewerError::AllReviewersFiltered { initial, filtered } => {
  562. write!(
  563. f,
  564. "Could not assign reviewer from: `{}`.\n\
  565. User(s) `{}` are either the PR author or are already assigned, \
  566. and there are no other candidates.\n\
  567. Use r? to specify someone else to assign.",
  568. initial.join(","),
  569. filtered.join(","),
  570. )
  571. }
  572. }
  573. }
  574. }
  575. /// Finds a reviewer to assign to a PR.
  576. ///
  577. /// The `names` is a list of candidate reviewers `r?`, such as `compiler` or
  578. /// `@octocat`, or names from the owners map. It can contain GitHub usernames,
  579. /// auto-assign groups, or rust-lang team names. It must have at least one
  580. /// entry.
  581. fn find_reviewer_from_names(
  582. teams: &Teams,
  583. config: &AssignConfig,
  584. issue: &Issue,
  585. names: &[String],
  586. ) -> Result<String, FindReviewerError> {
  587. let candidates = candidate_reviewers_from_names(teams, config, issue, names)?;
  588. // This uses a relatively primitive random choice algorithm.
  589. // GitHub's CODEOWNERS supports much more sophisticated options, such as:
  590. //
  591. // - Round robin: Chooses reviewers based on who's received the least
  592. // recent review request, focusing on alternating between all members of
  593. // the team regardless of the number of outstanding reviews they
  594. // currently have.
  595. // - Load balance: Chooses reviewers based on each member's total number
  596. // of recent review requests and considers the number of outstanding
  597. // reviews for each member. The load balance algorithm tries to ensure
  598. // that each team member reviews an equal number of pull requests in any
  599. // 30 day period.
  600. //
  601. // Additionally, with CODEOWNERS, users marked as "Busy" in the GitHub UI
  602. // will not be selected for reviewer. There are several other options for
  603. // configuring CODEOWNERS as well.
  604. //
  605. // These are all ideas for improving the selection here. However, I'm not
  606. // sure they are really worth the effort.
  607. Ok(candidates
  608. .into_iter()
  609. .choose(&mut rand::thread_rng())
  610. .expect("candidate_reviewers_from_names always returns at least one entry")
  611. .to_string())
  612. }
  613. /// Returns a list of candidate usernames to choose as a reviewer.
  614. fn candidate_reviewers_from_names<'a>(
  615. teams: &'a Teams,
  616. config: &'a AssignConfig,
  617. issue: &Issue,
  618. names: &'a [String],
  619. ) -> Result<HashSet<&'a str>, FindReviewerError> {
  620. // Set of candidate usernames to choose from. This uses a set to
  621. // deduplicate entries so that someone in multiple teams isn't
  622. // over-weighted.
  623. let mut candidates: HashSet<&str> = HashSet::new();
  624. // Keep track of groups seen to avoid cycles and avoid expanding the same
  625. // team multiple times.
  626. let mut seen = HashSet::new();
  627. // This is a queue of potential groups or usernames to expand. The loop
  628. // below will pop from this and then append the expanded results of teams.
  629. // Usernames will be added to `candidates`.
  630. let mut group_expansion: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
  631. // Keep track of which users get filtered out for a better error message.
  632. let mut filtered = Vec::new();
  633. let repo = issue.repository();
  634. let org_prefix = format!("{}/", repo.organization);
  635. // Don't allow groups or teams to include the current author or assignee.
  636. let mut filter = |name: &&str| -> bool {
  637. let name_lower = name.to_lowercase();
  638. let ok = name_lower != issue.user.login.to_lowercase()
  639. && !issue
  640. .assignees
  641. .iter()
  642. .any(|assignee| name_lower == assignee.login.to_lowercase());
  643. if !ok {
  644. filtered.push(name.to_string());
  645. }
  646. ok
  647. };
  648. // Loop over groups to recursively expand them.
  649. while let Some(group_or_user) = group_expansion.pop() {
  650. let group_or_user = group_or_user.strip_prefix('@').unwrap_or(group_or_user);
  651. // Try ad-hoc groups first.
  652. // Allow `rust-lang/compiler` to match `compiler`.
  653. let maybe_group = group_or_user
  654. .strip_prefix(&org_prefix)
  655. .unwrap_or(group_or_user);
  656. if let Some(group_members) = config.adhoc_groups.get(maybe_group) {
  657. // If a group has already been expanded, don't expand it again.
  658. if seen.insert(maybe_group) {
  659. group_expansion.extend(
  660. group_members
  661. .iter()
  662. .map(|member| member.as_str())
  663. .filter(&mut filter),
  664. );
  665. }
  666. continue;
  667. }
  668. // Check for a team name.
  669. // Allow either a direct team name like `rustdoc` or a GitHub-style
  670. // team name of `rust-lang/rustdoc` (though this does not check if
  671. // that is a real GitHub team name).
  672. //
  673. // This ignores subteam relationships (it only uses direct members).
  674. let maybe_team = group_or_user
  675. .strip_prefix("rust-lang/")
  676. .unwrap_or(group_or_user);
  677. if let Some(team) = teams.teams.get(maybe_team) {
  678. candidates.extend(
  679. team.members
  680. .iter()
  681. .map(|member| member.github.as_str())
  682. .filter(&mut filter),
  683. );
  684. continue;
  685. }
  686. if group_or_user.contains('/') {
  687. return Err(FindReviewerError::TeamNotFound(group_or_user.to_string()));
  688. }
  689. // Assume it is a user.
  690. if filter(&group_or_user) {
  691. candidates.insert(group_or_user);
  692. }
  693. }
  694. if candidates.is_empty() {
  695. let initial = names.iter().cloned().collect();
  696. if filtered.is_empty() {
  697. Err(FindReviewerError::NoReviewer { initial })
  698. } else {
  699. Err(FindReviewerError::AllReviewersFiltered { initial, filtered })
  700. }
  701. } else {
  702. Ok(candidates)
  703. }
  704. }