Skip to content

Commit 3c31528

Browse files
authored
Merge pull request #1936 from Urgau/no_merges-refactor
Refactor `no_merges` handler into the `check_commits` super-handler
2 parents 00286ef + 38c7a26 commit 3c31528

File tree

4 files changed

+242
-275
lines changed

4 files changed

+242
-275
lines changed

src/handlers.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ mod major_change;
3737
mod mentions;
3838
mod merge_conflicts;
3939
mod milestone_prs;
40-
mod no_merges;
4140
mod nominate;
4241
mod note;
4342
mod notification;
@@ -228,7 +227,6 @@ issue_handlers! {
228227
issue_links,
229228
major_change,
230229
mentions,
231-
no_merges,
232230
notify_zulip,
233231
review_requested,
234232
pr_tracking,

src/handlers/check_commits.rs

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
use std::collections::HashSet;
2+
13
use anyhow::bail;
4+
use anyhow::Context as _;
25

36
use super::Context;
47
use crate::{
58
config::Config,
69
db::issue_data::IssueData,
7-
github::{Event, IssuesAction, IssuesEvent, ReportedContentClassifiers},
10+
github::{Event, IssuesAction, IssuesEvent, Label, ReportedContentClassifiers},
811
};
912

1013
#[cfg(test)]
@@ -13,6 +16,7 @@ use crate::github::GithubCommit;
1316
mod issue_links;
1417
mod modified_submodule;
1518
mod no_mentions;
19+
mod no_merges;
1620
mod non_default_branch;
1721

1822
/// Key for the state in the database
@@ -25,6 +29,8 @@ struct CheckCommitsWarningsState {
2529
last_warnings: Vec<String>,
2630
/// ID of the most recent warning comment.
2731
last_warned_comment: Option<String>,
32+
/// List of the last labels added.
33+
last_labels: Vec<String>,
2834
}
2935

3036
pub(super) async fn handle(ctx: &Context, event: &Event, config: &Config) -> anyhow::Result<()> {
@@ -34,12 +40,17 @@ pub(super) async fn handle(ctx: &Context, event: &Event, config: &Config) -> any
3440

3541
if !matches!(
3642
event.action,
37-
IssuesAction::Opened | IssuesAction::Synchronize
43+
IssuesAction::Opened | IssuesAction::Synchronize | IssuesAction::ReadyForReview
3844
) || !event.issue.is_pr()
3945
{
4046
return Ok(());
4147
}
4248

49+
// Don't ping on rollups or draft PRs.
50+
if event.issue.title.starts_with("Rollup of") || event.issue.draft {
51+
return Ok(());
52+
}
53+
4354
let Some(diff) = event.issue.diff(&ctx.github).await? else {
4455
bail!(
4556
"expected issue {} to be a PR, but the diff could not be determined",
@@ -49,6 +60,7 @@ pub(super) async fn handle(ctx: &Context, event: &Event, config: &Config) -> any
4960
let commits = event.issue.commits(&ctx.github).await?;
5061

5162
let mut warnings = Vec::new();
63+
let mut labels = Vec::new();
5264

5365
// Compute the warnings
5466
if let Some(assign_config) = &config.assign {
@@ -72,14 +84,24 @@ pub(super) async fn handle(ctx: &Context, event: &Event, config: &Config) -> any
7284
warnings.extend(issue_links::issue_links_in_commits(issue_links, &commits));
7385
}
7486

75-
handle_warnings(ctx, event, warnings).await
87+
if let Some(no_merges) = &config.no_merges {
88+
if let Some(warn) =
89+
no_merges::merges_in_commits(&event.issue.title, &event.repository, no_merges, &commits)
90+
{
91+
warnings.push(warn.0);
92+
labels.extend(warn.1);
93+
}
94+
}
95+
96+
handle_warnings_and_labels(ctx, event, warnings, labels).await
7697
}
7798

7899
// Add, hide or hide&add a comment with the warnings.
79-
async fn handle_warnings(
100+
async fn handle_warnings_and_labels(
80101
ctx: &Context,
81102
event: &IssuesEvent,
82103
warnings: Vec<String>,
104+
labels: Vec<String>,
83105
) -> anyhow::Result<()> {
84106
// Get the state of the warnings for this PR in the database.
85107
let mut db = ctx.db.get().await;
@@ -105,10 +127,8 @@ async fn handle_warnings(
105127
let warning = warning_from_warnings(&warnings);
106128
let comment = event.issue.post_comment(&ctx.github, &warning).await?;
107129

108-
// Save new state in the database
109130
state.data.last_warnings = warnings;
110131
state.data.last_warned_comment = Some(comment.node_id);
111-
state.save().await?;
112132
} else if warnings.is_empty() {
113133
// No warnings to be shown, let's resolve a previous warnings comment, if there was one.
114134
if let Some(last_warned_comment_id) = state.data.last_warned_comment {
@@ -123,10 +143,46 @@ async fn handle_warnings(
123143

124144
state.data.last_warnings = Vec::new();
125145
state.data.last_warned_comment = None;
126-
state.save().await?;
127146
}
128147
}
129148

149+
// Handle the labels, add the new ones, remove the one no longer required, or don't do anything
150+
if !state.data.last_labels.is_empty() || !labels.is_empty() {
151+
let (labels_to_remove, labels_to_add) =
152+
calculate_label_changes(&state.data.last_labels, &labels);
153+
154+
// Remove the labels no longer required
155+
if !labels_to_remove.is_empty() {
156+
for label in labels_to_remove {
157+
event
158+
.issue
159+
.remove_label(&ctx.github, &label)
160+
.await
161+
.context("failed to remove a label in check_commits")?;
162+
}
163+
}
164+
165+
// Add the labels that are now required
166+
if !labels_to_add.is_empty() {
167+
event
168+
.issue
169+
.add_labels(
170+
&ctx.github,
171+
labels_to_add
172+
.into_iter()
173+
.map(|name| Label { name })
174+
.collect(),
175+
)
176+
.await
177+
.context("failed to add labels in check_commits")?;
178+
}
179+
180+
state.data.last_labels = labels;
181+
}
182+
183+
// Save new state in the database
184+
state.save().await?;
185+
130186
Ok(())
131187
}
132188

@@ -139,6 +195,20 @@ fn warning_from_warnings(warnings: &[String]) -> String {
139195
format!(":warning: **Warning** :warning:\n\n{}", warnings.join("\n"))
140196
}
141197

198+
// Calculate the label changes
199+
fn calculate_label_changes(
200+
previous: &Vec<String>,
201+
current: &Vec<String>,
202+
) -> (Vec<String>, Vec<String>) {
203+
let previous_set: HashSet<String> = previous.into_iter().cloned().collect();
204+
let current_set: HashSet<String> = current.into_iter().cloned().collect();
205+
206+
let removals = previous_set.difference(&current_set).cloned().collect();
207+
let additions = current_set.difference(&previous_set).cloned().collect();
208+
209+
(removals, additions)
210+
}
211+
142212
#[cfg(test)]
143213
fn dummy_commit_from_body(sha: &str, body: &str) -> GithubCommit {
144214
use chrono::{DateTime, FixedOffset};
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
4+
use crate::{
5+
config::NoMergesConfig,
6+
github::{GithubCommit, Repository},
7+
};
8+
use std::collections::BTreeSet;
9+
use std::fmt::Write;
10+
11+
pub(super) fn merges_in_commits(
12+
issue_title: &str,
13+
repository: &Repository,
14+
config: &NoMergesConfig,
15+
commits: &Vec<GithubCommit>,
16+
) -> Option<(String, Vec<String>)> {
17+
// Don't trigger if the PR has any of the excluded title segments.
18+
if config
19+
.exclude_titles
20+
.iter()
21+
.any(|s| issue_title.contains(s))
22+
{
23+
return None;
24+
}
25+
26+
let mut merge_commits = BTreeSet::new();
27+
for commit in commits {
28+
if commit.parents.len() > 1 {
29+
merge_commits.insert(&*commit.sha);
30+
}
31+
}
32+
33+
// No merge commits.
34+
if merge_commits.is_empty() {
35+
return None;
36+
}
37+
38+
let message = config
39+
.message
40+
.as_deref()
41+
.unwrap_or(&get_default_message(
42+
&repository.full_name,
43+
&repository.default_branch,
44+
merge_commits.into_iter(),
45+
))
46+
.to_string();
47+
48+
Some((message, config.labels.clone()))
49+
}
50+
51+
fn get_default_message<'a>(
52+
repository_name: &str,
53+
default_branch: &str,
54+
commits: impl IntoIterator<Item = &'a str>,
55+
) -> String {
56+
let mut message = format!(
57+
"
58+
The following commits have merge commits (commits with multiple parents) in your changes. \
59+
We have a [no merge policy](https://rustc-dev-guide.rust-lang.org/git.html#no-merge-policy) \
60+
so these commits will need to be removed for this pull request to be merged.
61+
"
62+
);
63+
64+
for commit in commits {
65+
writeln!(message, " - {commit}").unwrap();
66+
}
67+
68+
writeln!(
69+
message,
70+
"
71+
You can start a rebase with the following commands:
72+
```shell-session
73+
$ # rebase
74+
$ git pull --rebase https://github.com/{repository_name}.git {default_branch}
75+
$ git push --force-with-lease
76+
```"
77+
)
78+
.unwrap();
79+
80+
message
81+
}
82+
83+
#[test]
84+
fn end_to_end() {
85+
use super::dummy_commit_from_body;
86+
use crate::github::Parent;
87+
88+
let config = NoMergesConfig {
89+
exclude_titles: vec!["Subtree update".to_string()],
90+
labels: vec!["merge-commits".to_string()],
91+
message: None,
92+
};
93+
94+
let title = "This a PR!";
95+
let repository = Repository {
96+
full_name: "rust-lang/rust".to_string(),
97+
default_branch: "master".to_string(),
98+
fork: false,
99+
parent: None,
100+
};
101+
102+
let commit_with_merge = || {
103+
let mut commit_with_merge =
104+
dummy_commit_from_body("9cc6dce67c917fe5937e984f58f5003ccbb5c37e", "+ main.rs");
105+
commit_with_merge.parents = vec![
106+
Parent {
107+
sha: "4fb1228e72cf76549e275c38c226c1c2326ca991".to_string(),
108+
},
109+
Parent {
110+
sha: "febd545030008f13541064895ae36e19d929a043".to_string(),
111+
},
112+
];
113+
commit_with_merge
114+
};
115+
116+
// contains merges
117+
{
118+
let Some((warning, labels)) = merges_in_commits(
119+
&title,
120+
&repository,
121+
&config,
122+
&vec![
123+
commit_with_merge(),
124+
dummy_commit_from_body("499bdd2d766f98420c66a80a02b7d3ceba4d06ba", "+ nothing.rs"),
125+
],
126+
) else {
127+
unreachable!()
128+
};
129+
assert_eq!(warning, "
130+
The following commits have 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.
131+
- 9cc6dce67c917fe5937e984f58f5003ccbb5c37e
132+
133+
You can start a rebase with the following commands:
134+
```shell-session
135+
$ # rebase
136+
$ git pull --rebase https://github.com/rust-lang/rust.git master
137+
$ git push --force-with-lease
138+
```
139+
");
140+
assert_eq!(labels, vec!["merge-commits"]);
141+
}
142+
143+
// doesn't contains merges
144+
{
145+
let commit = dummy_commit_from_body("67c917fe5937e984f58f5003ccbb5c37e", "+ main.rs");
146+
147+
assert_eq!(
148+
merges_in_commits(&title, &repository, &config, &vec![commit]),
149+
None
150+
);
151+
}
152+
153+
// contains merges, but excluded by title
154+
{
155+
assert_eq!(
156+
merges_in_commits(
157+
"Subtree update of rustc_custom_codegen",
158+
&repository,
159+
&config,
160+
&vec![commit_with_merge()]
161+
),
162+
None
163+
);
164+
}
165+
}

0 commit comments

Comments
 (0)