Skip to content

Commit a1ebd23

Browse files
committed
Add logic for determining if a PR is waiting for a review or not
PRs that do not pass this check will not be considered for the workqueue.
1 parent bed6858 commit a1ebd23

File tree

2 files changed

+192
-73
lines changed

2 files changed

+192
-73
lines changed

src/handlers/pr_tracking.rs

Lines changed: 180 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,20 @@
66
//! - Adds the PR to the workqueue of one team member (after the PR has been assigned or reopened)
77
//! - Removes the PR from the workqueue of one team member (after the PR has been unassigned or closed)
88
9-
use crate::github::PullRequestNumber;
9+
use crate::github::{Label, PullRequestNumber};
1010
use crate::github::{User, UserId};
1111
use crate::{
1212
config::ReviewPrefsConfig,
1313
github::{IssuesAction, IssuesEvent},
1414
handlers::Context,
1515
};
1616
use futures::TryStreamExt;
17+
use octocrab::models::IssueState;
1718
use octocrab::params::pulls::Sort;
1819
use octocrab::params::{Direction, State};
1920
use octocrab::Octocrab;
2021
use std::collections::{HashMap, HashSet};
22+
use tokio::sync::RwLockWriteGuard;
2123
use tracing as log;
2224

2325
/// Maps users to a set of currently assigned open non-draft pull requests.
@@ -39,8 +41,7 @@ impl ReviewerWorkqueue {
3941
pub(super) enum ReviewPrefsInput {
4042
Assigned { assignee: User },
4143
Unassigned { assignee: User },
42-
Reopened,
43-
Closed,
44+
OtherChange,
4445
}
4546

4647
pub(super) async fn parse_input(
@@ -68,10 +69,12 @@ pub(super) async fn parse_input(
6869
assignee: assignee.clone(),
6970
})),
7071
// We don't need to handle Opened explicitly, because that will trigger the Assigned event
71-
IssuesAction::Reopened => Ok(Some(ReviewPrefsInput::Reopened)),
72-
IssuesAction::Closed | IssuesAction::Deleted | IssuesAction::Transferred => {
73-
Ok(Some(ReviewPrefsInput::Closed))
74-
}
72+
IssuesAction::Reopened
73+
| IssuesAction::Closed
74+
| IssuesAction::Deleted
75+
| IssuesAction::Transferred
76+
| IssuesAction::Labeled { .. }
77+
| IssuesAction::Unlabeled { .. } => Ok(Some(ReviewPrefsInput::OtherChange)),
7578
_ => Ok(None),
7679
}
7780
}
@@ -84,45 +87,52 @@ pub(super) async fn handle_input<'a>(
8487
) -> anyhow::Result<()> {
8588
log::info!("Handling event action {:?} in PR tracking", event.action);
8689

90+
let pr = &event.issue;
91+
let pr_number = event.issue.number;
92+
93+
let mut workqueue = ctx.workqueue.write().await;
94+
95+
// If the PR doesn't wait for a review, remove it from the workqueue completely.
96+
// This handles situations such as labels being modified, which make the PR no longer to be
97+
// in the "waiting for a review" state, or the PR being closed/merged.
98+
if !waits_for_a_review(&pr.labels, pr.is_open(), pr.draft) {
99+
log::info!(
100+
"Removing PR {pr_number} from workqueue, because it is not waiting for a review.",
101+
);
102+
delete_pr_from_all_queues(&mut workqueue, pr_number);
103+
return Ok(());
104+
}
105+
87106
match input {
88-
// This handler is reached also when assigning a PR using the Github UI
89-
// (i.e. from the "Assignees" dropdown menu).
90-
// We need to also check assignee availability here.
107+
// The PR was assigned to a specific user, and it is waiting for a review.
91108
ReviewPrefsInput::Assigned { assignee } => {
92-
let pr_number = event.issue.number;
93109
log::info!(
94-
"Adding PR {pr_number} from workqueue of {} because they were assigned.",
110+
"Adding PR {pr_number} to workqueue of {} because they were assigned.",
95111
assignee.login
96112
);
97113

98-
upsert_pr_into_workqueue(ctx, assignee.id, pr_number).await;
114+
upsert_pr_into_user_queue(&mut workqueue, assignee.id, pr_number);
99115
}
100116
ReviewPrefsInput::Unassigned { assignee } => {
101-
let pr_number = event.issue.number;
102117
log::info!(
103118
"Removing PR {pr_number} from workqueue of {} because they were unassigned.",
104119
assignee.login
105120
);
106-
delete_pr_from_workqueue(ctx, assignee.id, pr_number).await;
121+
delete_pr_from_user_queue(&mut workqueue, assignee.id, pr_number);
107122
}
108-
ReviewPrefsInput::Closed => {
123+
// Some other change has happened (e.g. labels changed or the PR being reopened).
124+
// Make sure that all assigned users have the PR in their queue.
125+
// When a PR is opened, it might not yet contain all the information needed to determine
126+
// whether it waits for a reviewer or not. For example, when you open a PR,
127+
// triagebot might apply the "S-waiting-on-review" (or similar) label to it, which we
128+
// currently use to determine whether a PR is truly assigned to someone or not.
129+
// We thus need to refresh the queue state after every relevant state change that we
130+
// receive.
131+
ReviewPrefsInput::OtherChange => {
109132
for assignee in &event.issue.assignees {
110-
let pr_number = event.issue.number;
111-
log::info!(
112-
"Removing PR {pr_number} from workqueue of {} because it was closed or merged.",
113-
assignee.login
114-
);
115-
delete_pr_from_workqueue(ctx, assignee.id, pr_number).await;
116-
}
117-
}
118-
ReviewPrefsInput::Reopened => {
119-
for assignee in &event.issue.assignees {
120-
let pr_number = event.issue.number;
121-
log::info!(
122-
"Re-adding PR {pr_number} to workqueue of {} because it was (re)opened.",
123-
assignee.login
124-
);
125-
upsert_pr_into_workqueue(ctx, assignee.id, pr_number).await;
133+
if upsert_pr_into_user_queue(&mut workqueue, assignee.id, pr_number) {
134+
log::info!("Adding PR {pr_number} to workqueue of {}.", assignee.login);
135+
}
126136
}
127137
}
128138
}
@@ -147,8 +157,10 @@ pub async fn load_workqueue(client: &Octocrab) -> anyhow::Result<ReviewerWorkque
147157
}
148158

149159
/// Retrieve tuples of (user, PR number) where
150-
/// the given user is assigned as a reviewer for that PR.
151-
/// Only non-draft, non-rollup and open PRs are taken into account.
160+
/// the given user is assigned as a reviewer for that PR
161+
/// and the PR is considered to be "waiting for a review", according to the semantics
162+
/// of the reviewer workqueue.
163+
/// See the [`waits_for_a_review`] function.
152164
pub async fn retrieve_pull_request_assignments(
153165
owner: &str,
154166
repository: &str,
@@ -170,26 +182,26 @@ pub async fn retrieve_pull_request_assignments(
170182
.into_stream(client);
171183
let mut stream = std::pin::pin!(stream);
172184
while let Some(pr) = stream.try_next().await? {
173-
if pr.draft == Some(true) {
174-
continue;
175-
}
176-
// exclude rollup PRs
177-
if pr
185+
let labels = pr
178186
.labels
179187
.unwrap_or_default()
180-
.iter()
181-
.any(|label| label.name == "rollup")
182-
{
183-
continue;
184-
}
185-
for user in pr.assignees.unwrap_or_default() {
186-
assignments.push((
187-
User {
188-
login: user.login,
189-
id: (*user.id).into(),
190-
},
191-
pr.number,
192-
));
188+
.into_iter()
189+
.map(|l| Label { name: l.name })
190+
.collect::<Vec<Label>>();
191+
if waits_for_a_review(
192+
&labels,
193+
pr.state == Some(IssueState::Open),
194+
pr.draft.unwrap_or_default(),
195+
) {
196+
for user in pr.assignees.unwrap_or_default() {
197+
assignments.push((
198+
User {
199+
login: user.login,
200+
id: (*user.id).into(),
201+
},
202+
pr.number,
203+
));
204+
}
193205
}
194206
}
195207
assignments.sort_by(|a, b| a.0.id.cmp(&b.0.id));
@@ -210,30 +222,57 @@ pub async fn get_assigned_prs(ctx: &Context, user_id: UserId) -> HashSet<PullReq
210222

211223
/// Add a PR to the workqueue of a team member.
212224
/// Ensures no accidental PR duplicates.
213-
async fn upsert_pr_into_workqueue(ctx: &Context, user_id: UserId, pr: PullRequestNumber) {
214-
ctx.workqueue
215-
.write()
216-
.await
217-
.reviewers
218-
.entry(user_id)
219-
.or_default()
220-
.insert(pr);
225+
///
226+
/// Returns true if the PR was actually inserted.
227+
fn upsert_pr_into_user_queue(
228+
workqueue: &mut RwLockWriteGuard<ReviewerWorkqueue>,
229+
user_id: UserId,
230+
pr: PullRequestNumber,
231+
) -> bool {
232+
workqueue.reviewers.entry(user_id).or_default().insert(pr)
221233
}
222234

223-
/// Delete a PR from the workqueue of a team member
224-
async fn delete_pr_from_workqueue(ctx: &Context, user_id: UserId, pr: PullRequestNumber) {
225-
let mut queue = ctx.workqueue.write().await;
226-
if let Some(reviewer) = queue.reviewers.get_mut(&user_id) {
227-
reviewer.remove(&pr);
235+
/// Delete a PR from the workqueue of a team member.
236+
fn delete_pr_from_user_queue(
237+
workqueue: &mut ReviewerWorkqueue,
238+
user_id: UserId,
239+
pr: PullRequestNumber,
240+
) {
241+
if let Some(queue) = workqueue.reviewers.get_mut(&user_id) {
242+
queue.remove(&pr);
228243
}
229244
}
230245

246+
/// Delete a PR from the workqueue completely.
247+
fn delete_pr_from_all_queues(workqueue: &mut ReviewerWorkqueue, pr: PullRequestNumber) {
248+
for queue in workqueue.reviewers.values_mut() {
249+
queue.retain(|pr_number| *pr_number != pr);
250+
}
251+
}
252+
253+
/// Returns true if the workqueue should assume that this PR is actually waiting for a reviewer.
254+
/// The function receives atomic attributes so that it is compatible both with triagebot's
255+
/// `Issue` struct (used for incremental updates) and octocrab's `PullRequest` struct (used for
256+
/// batch PR loads).
257+
///
258+
/// Note: this functionality is currently hardcoded for rust-lang/rust, other repos might use
259+
/// different labels.
260+
fn waits_for_a_review(labels: &[Label], is_open: bool, is_draft: bool) -> bool {
261+
let is_blocked = labels
262+
.iter()
263+
.any(|l| l.name == "S-blocked" || l.name == "S-inactive");
264+
let is_rollup = labels.iter().any(|l| l.name == "rollup");
265+
let is_waiting_for_reviewer = labels.iter().any(|l| l.name == "S-waiting-on-review");
266+
267+
is_open && !is_draft && !is_blocked && !is_rollup && is_waiting_for_reviewer
268+
}
269+
231270
#[cfg(test)]
232271
mod tests {
233272
use crate::config::Config;
234-
use crate::github::PullRequestNumber;
235273
use crate::github::{Issue, IssuesAction, IssuesEvent, Repository, User};
236-
use crate::handlers::pr_tracking::{handle_input, parse_input, upsert_pr_into_workqueue};
274+
use crate::github::{Label, PullRequestNumber};
275+
use crate::handlers::pr_tracking::{handle_input, parse_input, upsert_pr_into_user_queue};
237276
use crate::tests::github::{default_test_user, issue, pull_request, user};
238277
use crate::tests::{run_test, TestContext};
239278

@@ -247,7 +286,10 @@ mod tests {
247286
IssuesAction::Assigned {
248287
assignee: user.clone(),
249288
},
250-
pull_request().number(10).call(),
289+
pull_request()
290+
.number(10)
291+
.labels(vec!["S-waiting-on-review"])
292+
.call(),
251293
)
252294
.await;
253295

@@ -258,6 +300,29 @@ mod tests {
258300
.await;
259301
}
260302

303+
#[tokio::test]
304+
async fn ignore_blocked_pr() {
305+
run_test(|ctx| async move {
306+
let user = user("Martin", 2);
307+
308+
run_handler(
309+
&ctx,
310+
IssuesAction::Assigned {
311+
assignee: user.clone(),
312+
},
313+
pull_request()
314+
.labels(vec!["S-waiting-on-review", "S-blocked"])
315+
.call(),
316+
)
317+
.await;
318+
319+
check_assigned_prs(&ctx, &user, &[]).await;
320+
321+
Ok(ctx)
322+
})
323+
.await;
324+
}
325+
261326
#[tokio::test]
262327
async fn remove_pr_from_workqueue_on_unassign() {
263328
run_test(|ctx| async move {
@@ -269,7 +334,10 @@ mod tests {
269334
IssuesAction::Unassigned {
270335
assignee: user.clone(),
271336
},
272-
pull_request().number(10).call(),
337+
pull_request()
338+
.number(10)
339+
.labels(vec!["S-waiting-on-review"])
340+
.call(),
273341
)
274342
.await;
275343

@@ -280,6 +348,43 @@ mod tests {
280348
.await;
281349
}
282350

351+
#[tokio::test]
352+
async fn add_pr_to_workqueue_on_label() {
353+
run_test(|ctx| async move {
354+
let user = user("Martin", 2);
355+
356+
run_handler(
357+
&ctx,
358+
IssuesAction::Assigned {
359+
assignee: user.clone(),
360+
},
361+
pull_request().number(10).call(),
362+
)
363+
.await;
364+
check_assigned_prs(&ctx, &user, &[]).await;
365+
366+
run_handler(
367+
&ctx,
368+
IssuesAction::Labeled {
369+
label: Label {
370+
name: "S-waiting-on-review".to_string(),
371+
},
372+
},
373+
pull_request()
374+
.number(10)
375+
.labels(vec!["S-waiting-on-review"])
376+
.assignees(vec![user.clone()])
377+
.call(),
378+
)
379+
.await;
380+
381+
check_assigned_prs(&ctx, &user, &[10]).await;
382+
383+
Ok(ctx)
384+
})
385+
.await;
386+
}
387+
283388
#[tokio::test]
284389
async fn remove_pr_from_workqueue_on_pr_closed() {
285390
run_test(|ctx| async move {
@@ -314,6 +419,7 @@ mod tests {
314419
IssuesAction::Reopened,
315420
pull_request()
316421
.number(10)
422+
.labels(vec!["S-waiting-on-review"])
317423
.assignees(vec![user.clone()])
318424
.call(),
319425
)
@@ -369,8 +475,11 @@ mod tests {
369475
}
370476

371477
async fn set_assigned_prs(ctx: &TestContext, user: &User, prs: &[PullRequestNumber]) {
372-
for &pr in prs {
373-
upsert_pr_into_workqueue(ctx.handler_ctx(), user.id, pr).await;
478+
{
479+
let mut workqueue = ctx.handler_ctx().workqueue.write().await;
480+
for &pr in prs {
481+
upsert_pr_into_user_queue(&mut workqueue, user.id, pr);
482+
}
374483
}
375484
check_assigned_prs(&ctx, user, prs).await;
376485
}

0 commit comments

Comments
 (0)