assign.rs 29 KB

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