no_merges.rs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. //! Purpose: When opening a PR, or pushing new changes, check for merge commits
  2. //! and notify the user of our no-merge policy.
  3. use crate::{
  4. config::NoMergesConfig,
  5. db::issue_data::IssueData,
  6. github::{IssuesAction, IssuesEvent, Label},
  7. handlers::Context,
  8. };
  9. use anyhow::Context as _;
  10. use serde::{Deserialize, Serialize};
  11. use std::collections::HashSet;
  12. use std::fmt::Write;
  13. use tracing as log;
  14. const NO_MERGES_KEY: &str = "no_merges";
  15. pub(super) struct NoMergesInput {
  16. /// Hashes of merge commits in the pull request.
  17. merge_commits: HashSet<String>,
  18. }
  19. #[derive(Debug, Default, Deserialize, Serialize)]
  20. struct NoMergesState {
  21. /// Hashes of merge commits that have already been mentioned by triagebot in a comment.
  22. mentioned_merge_commits: HashSet<String>,
  23. /// Labels that the bot added as part of the no-merges check.
  24. #[serde(default)]
  25. added_labels: Vec<String>,
  26. }
  27. pub(super) async fn parse_input(
  28. ctx: &Context,
  29. event: &IssuesEvent,
  30. config: Option<&NoMergesConfig>,
  31. ) -> Result<Option<NoMergesInput>, String> {
  32. if !matches!(
  33. event.action,
  34. IssuesAction::Opened | IssuesAction::Synchronize | IssuesAction::ReadyForReview
  35. ) {
  36. return Ok(None);
  37. }
  38. // Require a `[no_merges]` configuration block to enable no-merges notifications.
  39. let Some(config) = config else {
  40. return Ok(None);
  41. };
  42. // Don't ping on rollups or draft PRs.
  43. if event.issue.title.starts_with("Rollup of") || event.issue.draft {
  44. return Ok(None);
  45. }
  46. // Don't trigger if the PR has any of the excluded title segments.
  47. if config
  48. .exclude_titles
  49. .iter()
  50. .any(|s| event.issue.title.contains(s))
  51. {
  52. return Ok(None);
  53. }
  54. let mut merge_commits = HashSet::new();
  55. let commits = event
  56. .issue
  57. .commits(&ctx.github)
  58. .await
  59. .map_err(|e| {
  60. log::error!("failed to fetch commits: {:?}", e);
  61. })
  62. .unwrap_or_default();
  63. for commit in commits {
  64. if commit.parents.len() > 1 {
  65. merge_commits.insert(commit.sha.clone());
  66. }
  67. }
  68. // Run the handler even if we have no merge commits,
  69. // so we can take an action if some were removed.
  70. Ok(Some(NoMergesInput { merge_commits }))
  71. }
  72. const DEFAULT_MESSAGE: &str = "
  73. There are merge commits (commits with multiple parents) in your changes. We have a \
  74. [no merge policy](https://rustc-dev-guide.rust-lang.org/git.html#no-merge-policy) \
  75. so these commits will need to be removed for this pull request to be merged.
  76. You can start a rebase with the following commands:
  77. ```shell-session
  78. $ # rebase
  79. $ git rebase -i master
  80. $ # delete any merge commits in the editor that appears
  81. $ git push --force-with-lease
  82. ```
  83. ";
  84. pub(super) async fn handle_input(
  85. ctx: &Context,
  86. config: &NoMergesConfig,
  87. event: &IssuesEvent,
  88. input: NoMergesInput,
  89. ) -> anyhow::Result<()> {
  90. let mut client = ctx.db.get().await;
  91. let mut state: IssueData<'_, NoMergesState> =
  92. IssueData::load(&mut client, &event.issue, NO_MERGES_KEY).await?;
  93. // No merge commits.
  94. if input.merge_commits.is_empty() {
  95. if state.data.mentioned_merge_commits.is_empty() {
  96. // No merge commits from before, so do nothing.
  97. return Ok(());
  98. }
  99. // Merge commits were removed, so remove the labels we added.
  100. for name in state.data.added_labels.iter() {
  101. event
  102. .issue
  103. .remove_label(&ctx.github, name)
  104. .await
  105. .context("failed to remove label")?;
  106. }
  107. // FIXME: Minimize prior no_merges comments.
  108. // Clear from state.
  109. state.data.mentioned_merge_commits.clear();
  110. state.data.added_labels.clear();
  111. state.save().await?;
  112. return Ok(());
  113. }
  114. let first_time = state.data.mentioned_merge_commits.is_empty();
  115. let mut message = config
  116. .message
  117. .as_deref()
  118. .unwrap_or(DEFAULT_MESSAGE)
  119. .to_string();
  120. let since_last_posted = if first_time {
  121. ""
  122. } else {
  123. " (since this message was last posted)"
  124. };
  125. writeln!(
  126. message,
  127. "The following commits are merge commits{since_last_posted}:"
  128. )
  129. .unwrap();
  130. let mut should_send = false;
  131. for commit in &input.merge_commits {
  132. if state.data.mentioned_merge_commits.contains(commit) {
  133. continue;
  134. }
  135. should_send = true;
  136. state.data.mentioned_merge_commits.insert((*commit).clone());
  137. writeln!(message, "- {commit}").unwrap();
  138. }
  139. if should_send {
  140. if !first_time {
  141. // Check if the labels are still set.
  142. // Otherwise, they were probably removed manually.
  143. let any_removed = state.data.added_labels.iter().any(|label| {
  144. // No label on the issue matches.
  145. event.issue.labels().iter().all(|l| &l.name != label)
  146. });
  147. if any_removed {
  148. // Assume it was a false positive, so don't
  149. // re-add the labels or send a message this time.
  150. state.save().await?;
  151. return Ok(());
  152. }
  153. }
  154. let existing_labels = event.issue.labels();
  155. let mut labels = Vec::new();
  156. for name in config.labels.iter() {
  157. // Only add labels not already on the issue.
  158. if existing_labels.iter().all(|l| &l.name != name) {
  159. state.data.added_labels.push(name.clone());
  160. labels.push(Label { name: name.clone() });
  161. }
  162. }
  163. // Set labels
  164. event
  165. .issue
  166. .add_labels(&ctx.github, labels)
  167. .await
  168. .context("failed to set no_merges labels")?;
  169. // Post comment
  170. event
  171. .issue
  172. .post_comment(&ctx.github, &message)
  173. .await
  174. .context("failed to post no_merges comment")?;
  175. state.save().await?;
  176. }
  177. Ok(())
  178. }
  179. #[cfg(test)]
  180. mod test {
  181. use super::*;
  182. #[test]
  183. fn message() {
  184. let mut message = DEFAULT_MESSAGE.to_string();
  185. writeln!(message, "The following commits are merge commits:").unwrap();
  186. for n in 1..5 {
  187. writeln!(message, "- commit{n}").unwrap();
  188. }
  189. assert_eq!(
  190. message,
  191. "
  192. There are merge commits (commits with multiple parents) in your changes. We have a [no merge policy](https://rustc-dev-guide.rust-lang.org/git.html#no-merge-policy) so these commits will need to be removed for this pull request to be merged.
  193. You can start a rebase with the following commands:
  194. ```shell-session
  195. $ # rebase
  196. $ git rebase -i master
  197. $ # delete any merge commits in the editor that appears
  198. $ git push --force-with-lease
  199. ```
  200. The following commits are merge commits:
  201. - commit1
  202. - commit2
  203. - commit3
  204. - commit4
  205. "
  206. );
  207. }
  208. }