Skip to content

Commit 30bdd2f

Browse files
authored
Merge pull request #367 from rust-lang/pr-approval-labels
Forbid approving PRs with labels blocking approval
2 parents f1135d2 + 4861da4 commit 30bdd2f

File tree

8 files changed

+184
-28
lines changed

8 files changed

+184
-28
lines changed

rust-bors.example.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ approve = ["+approved"]
1919
try = ["+foo", "-bar"]
2020
try_succeed = ["+foobar", "+foo", "+baz"]
2121
try_failed = []
22+
23+
# Labels that will block approval when present on a PR
24+
# (Optional)
25+
labels_blocking_approval = ["final-comment-period", "proposed-final-comment-period"]

src/bors/comment.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ handled during merge and rebase. This is normal, and you should still perform st
215215
}
216216

217217
pub fn approve_non_open_pr_comment() -> Comment {
218-
Comment::new("Only open, non-draft PRs can be approved".to_string())
218+
Comment::new(":clipboard: Only open, non-draft PRs can be approved.".to_string())
219219
}
220220

221221
pub fn approve_wip_title(keyword: &str) -> Comment {
@@ -227,6 +227,17 @@ Hint: Remove **{keyword}** from this PR's title when it is ready for review.
227227
))
228228
}
229229

230+
pub fn approve_blocking_labels_present(blocking_labels: &[&str]) -> Comment {
231+
let labels = blocking_labels
232+
.iter()
233+
.map(|label| format!("`{label}`"))
234+
.join(", ");
235+
Comment::new(format!(
236+
":clipboard: This PR cannot be approved because it currently has the following {}: {labels}.",
237+
pluralize("label", blocking_labels.len())
238+
))
239+
}
240+
230241
pub fn delegate_try_builds_comment(delegatee: &str, bot_prefix: &CommandPrefix) -> Comment {
231242
Comment::new(format!(
232243
r":v: @{delegatee}, you can now perform try builds on this pull request!

src/bors/handlers/review.rs

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use crate::bors::RepositoryState;
55
use crate::bors::command::RollupMode;
66
use crate::bors::command::{Approver, CommandPrefix};
77
use crate::bors::comment::{
8-
approve_non_open_pr_comment, approve_wip_title, delegate_comment, delegate_try_builds_comment,
8+
approve_blocking_labels_present, approve_non_open_pr_comment, approve_wip_title,
9+
delegate_comment, delegate_try_builds_comment,
910
};
1011
use crate::bors::handlers::has_permission;
1112
use crate::bors::handlers::labels::handle_label_trigger;
@@ -38,7 +39,7 @@ pub(super) async fn command_approve(
3839
return Ok(());
3940
};
4041

41-
if let Some(error_comment) = check_pr_approval_validity(pr).await? {
42+
if let Some(error_comment) = check_pr_approval_validity(pr, &repo_state).await? {
4243
repo_state
4344
.client
4445
.post_comment(pr.number(), error_comment)
@@ -69,7 +70,10 @@ const WIP_KEYWORDS: &[&str] = &["wip", "[do not merge]"];
6970
/// Check that the given PR can be approved in its current state.
7071
/// Returns `Ok(Some(comment))` if it **cannot** be approved; the comment should be sent to the
7172
/// pull request.
72-
async fn check_pr_approval_validity(pr: &PullRequestData) -> anyhow::Result<Option<Comment>> {
73+
async fn check_pr_approval_validity(
74+
pr: &PullRequestData,
75+
repo: &RepositoryState,
76+
) -> anyhow::Result<Option<Comment>> {
7377
// Check PR status
7478
if !matches!(pr.github.status, PullRequestStatus::Open) {
7579
return Ok(Some(approve_non_open_pr_comment()));
@@ -81,6 +85,24 @@ async fn check_pr_approval_validity(pr: &PullRequestData) -> anyhow::Result<Opti
8185
return Ok(Some(approve_wip_title(wip_kw)));
8286
}
8387

88+
// Check blocking labels
89+
let config = repo.config.load();
90+
let blocking_labels: Vec<&str> = pr
91+
.github
92+
.labels
93+
.iter()
94+
.map(|label| label.as_str())
95+
.filter(|label| {
96+
config
97+
.labels_blocking_approval
98+
.iter()
99+
.any(|blocking_label| blocking_label == label)
100+
})
101+
.collect();
102+
if !blocking_labels.is_empty() {
103+
return Ok(Some(approve_blocking_labels_present(&blocking_labels)));
104+
}
105+
84106
Ok(None)
85107
}
86108

@@ -1102,7 +1124,7 @@ mod tests {
11021124
.set_pr_status_draft(default_repo_name(), default_pr_number())
11031125
.await?;
11041126
tester.post_comment("@bors r+").await?;
1105-
insta::assert_snapshot!(tester.get_comment().await?, @"Only open, non-draft PRs can be approved");
1127+
insta::assert_snapshot!(tester.get_comment().await?, @":clipboard: Only open, non-draft PRs can be approved.");
11061128
tester.default_pr().await.expect_unapproved();
11071129
Ok(())
11081130
})
@@ -1116,7 +1138,7 @@ mod tests {
11161138
.set_pr_status_closed(default_repo_name(), default_pr_number())
11171139
.await?;
11181140
tester.post_comment("@bors r+").await?;
1119-
insta::assert_snapshot!(tester.get_comment().await?, @"Only open, non-draft PRs can be approved");
1141+
insta::assert_snapshot!(tester.get_comment().await?, @":clipboard: Only open, non-draft PRs can be approved.");
11201142
tester.default_pr().await.expect_unapproved();
11211143
Ok(())
11221144
})
@@ -1130,7 +1152,7 @@ mod tests {
11301152
.set_pr_status_merged(default_repo_name(), default_pr_number())
11311153
.await?;
11321154
tester.post_comment("@bors r+").await?;
1133-
insta::assert_snapshot!(tester.get_comment().await?, @"Only open, non-draft PRs can be approved");
1155+
insta::assert_snapshot!(tester.get_comment().await?, @":clipboard: Only open, non-draft PRs can be approved.");
11341156
tester.default_pr().await.expect_unapproved();
11351157
Ok(())
11361158
})
@@ -1156,4 +1178,56 @@ mod tests {
11561178
})
11571179
.await;
11581180
}
1181+
1182+
#[sqlx::test]
1183+
async fn approve_pr_with_blocked_label(pool: sqlx::PgPool) {
1184+
BorsBuilder::new(pool)
1185+
.github(GitHubState::default().with_default_config(
1186+
r#"
1187+
labels_blocking_approval = ["proposed-final-comment-period"]
1188+
"#,
1189+
))
1190+
.run_test(async |tester: &mut BorsTester| {
1191+
tester
1192+
.edit_pr(default_repo_name(), default_pr_number(), |pr| {
1193+
pr.labels = vec![
1194+
"S-waiting-on-review".to_string(),
1195+
"proposed-final-comment-period".to_string(),
1196+
];
1197+
})
1198+
.await?;
1199+
tester.post_comment("@bors r+").await?;
1200+
insta::assert_snapshot!(tester.get_comment().await?, @":clipboard: This PR cannot be approved because it currently has the following label: `proposed-final-comment-period`.");
1201+
tester.default_pr().await.expect_unapproved();
1202+
Ok(())
1203+
})
1204+
.await;
1205+
}
1206+
1207+
#[sqlx::test]
1208+
async fn approve_pr_with_blocked_labels(pool: sqlx::PgPool) {
1209+
BorsBuilder::new(pool)
1210+
.github(GitHubState::default().with_default_config(
1211+
r#"
1212+
labels_blocking_approval = ["proposed-final-comment-period", "final-comment-period"]
1213+
"#,
1214+
))
1215+
.run_test(async |tester: &mut BorsTester| {
1216+
tester
1217+
.edit_pr(default_repo_name(), default_pr_number(), |pr| {
1218+
pr.labels = vec![
1219+
"S-waiting-on-review".to_string(),
1220+
"proposed-final-comment-period".to_string(),
1221+
"final-comment-period".to_string(),
1222+
"S-blocked".to_string(),
1223+
];
1224+
})
1225+
.await?;
1226+
tester.post_comment("@bors r+").await?;
1227+
insta::assert_snapshot!(tester.get_comment().await?, @":clipboard: This PR cannot be approved because it currently has the following labels: `proposed-final-comment-period`, `final-comment-period`.");
1228+
tester.default_pr().await.expect_unapproved();
1229+
Ok(())
1230+
})
1231+
.await;
1232+
}
11591233
}

src/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub const CONFIG_FILE_PATH: &str = "rust-bors.toml";
1111
/// Configuration of a repository loaded from a `rust-bors.toml`
1212
/// file located in the root of the repository file tree.
1313
#[derive(serde::Deserialize, Debug)]
14+
#[serde(deny_unknown_fields)]
1415
pub struct RepositoryConfig {
1516
/// Maximum duration (in seconds) to wait for CI checks to complete before timing out.
1617
/// Defaults to 3600 seconds (1 hour).
@@ -24,6 +25,9 @@ pub struct RepositoryConfig {
2425
/// Format: `trigger = ["+label_to_add", "-label_to_remove"]`
2526
#[serde(default, deserialize_with = "deserialize_labels")]
2627
pub labels: HashMap<LabelTrigger, Vec<LabelModification>>,
28+
/// Labels that will block a PR from being approved when present on the PR.
29+
#[serde(default)]
30+
pub labels_blocking_approval: Vec<String>,
2731
/// Minimum time (in seconds) to wait for CI checks to complete before proceeding.
2832
/// Defaults to `None` (no minimum wait time).
2933
#[serde(default, deserialize_with = "deserialize_duration_from_secs_opt")]
@@ -253,6 +257,25 @@ try = ["foo"]
253257
load_config(content);
254258
}
255259

260+
#[test]
261+
fn deserialize_labels_blocking_approval() {
262+
let content = r#"labels_blocking_approval = ["foo", "bar"]"#;
263+
let config = load_config(content);
264+
insta::assert_debug_snapshot!(config.labels_blocking_approval, @r#"
265+
[
266+
"foo",
267+
"bar",
268+
]
269+
"#);
270+
}
271+
272+
#[test]
273+
#[should_panic(expected = "unknown field `labels-blocking-approval`")]
274+
fn deserialize_unknown_key_fail() {
275+
let content = r#"labels-blocking-approval = ["foo", "bar"]"#;
276+
load_config(content);
277+
}
278+
256279
fn load_config(config: &str) -> RepositoryConfig {
257280
toml::from_str(config).unwrap()
258281
}

src/github/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ pub struct PullRequest {
121121
pub author: GithubUser,
122122
pub assignees: Vec<GithubUser>,
123123
pub status: PullRequestStatus,
124+
pub labels: Vec<String>,
124125
}
125126

126127
impl From<octocrab::models::pulls::PullRequest> for PullRequest {
@@ -157,6 +158,12 @@ impl From<octocrab::models::pulls::PullRequest> for PullRequest {
157158
} else {
158159
PullRequestStatus::Open
159160
},
161+
labels: pr
162+
.labels
163+
.unwrap_or_default()
164+
.into_iter()
165+
.map(|l| l.name)
166+
.collect(),
160167
}
161168
}
162169
}

src/github/webhook.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,11 @@ mod tests {
607607
},
608608
assignees: [],
609609
status: Open,
610+
labels: [
611+
"foo",
612+
"foobar",
613+
"baz",
614+
],
610615
},
611616
from_base_sha: Some(
612617
CommitSha(
@@ -679,6 +684,11 @@ mod tests {
679684
},
680685
assignees: [],
681686
status: Open,
687+
labels: [
688+
"foo",
689+
"foobar",
690+
"baz",
691+
],
682692
},
683693
},
684694
),
@@ -795,6 +805,7 @@ mod tests {
795805
},
796806
assignees: [],
797807
status: Open,
808+
labels: [],
798809
},
799810
draft: false,
800811
},
@@ -863,6 +874,9 @@ mod tests {
863874
},
864875
assignees: [],
865876
status: Closed,
877+
labels: [
878+
"foo",
879+
],
866880
},
867881
},
868882
),
@@ -930,6 +944,7 @@ mod tests {
930944
},
931945
assignees: [],
932946
status: Merged,
947+
labels: [],
933948
},
934949
},
935950
),
@@ -997,6 +1012,9 @@ mod tests {
9971012
},
9981013
assignees: [],
9991014
status: Open,
1015+
labels: [
1016+
"foo",
1017+
],
10001018
},
10011019
},
10021020
),
@@ -1064,6 +1082,7 @@ mod tests {
10641082
},
10651083
assignees: [],
10661084
status: Draft,
1085+
labels: [],
10671086
},
10681087
draft: true,
10691088
},
@@ -1132,6 +1151,7 @@ mod tests {
11321151
},
11331152
assignees: [],
11341153
status: Draft,
1154+
labels: [],
11351155
},
11361156
},
11371157
),
@@ -1199,6 +1219,7 @@ mod tests {
11991219
},
12001220
assignees: [],
12011221
status: Open,
1222+
labels: [],
12021223
},
12031224
},
12041225
),
@@ -1337,6 +1358,7 @@ mod tests {
13371358
},
13381359
],
13391360
status: Open,
1361+
labels: [],
13401362
},
13411363
},
13421364
),
@@ -1404,6 +1426,7 @@ mod tests {
14041426
},
14051427
assignees: [],
14061428
status: Open,
1429+
labels: [],
14071430
},
14081431
},
14091432
),

0 commit comments

Comments
 (0)