Skip to content

Commit da64ec5

Browse files
authored
Merge pull request #1919 from Kobzol/setting-review-assignment-limit
Implement setting review assignment limit
2 parents ad8d3dc + e8e7e33 commit da64ec5

File tree

6 files changed

+229
-44
lines changed

6 files changed

+229
-44
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,32 @@ Where the value in `--secret` is the secret value you place in `GITHUB_WEBHOOK_S
9999

100100
If you are modifying code that sends message to Zulip and want to test your changes, you can register a [new free Zulip instance](https://zulip.com/new/). Before launching the triagebot locally, set the Zulip env vars to connect to your test instance (see example in `.env.sample`).
101101

102+
You can also test Zulip webhooks locally with `curl`. For example, to test the Zulip hooks (commands sent to the
103+
Triagebot from the Rust lang Zulip), you start the triagebot on `localhost:8000` and then simulate a
104+
Zulip hook payload:
105+
``` sh
106+
curl http://localhost:8000/zulip-hook \
107+
-H "Content-Type: application/json" \
108+
-d '{
109+
"data": "<CMD>",
110+
"token": "<ZULIP_TOKEN>",
111+
"message": {
112+
"sender_id": <YOUR_ID>,
113+
"recipient_id": <YOUR_ID>,
114+
"sender_full_name": "Randolph Carter",
115+
"sender_email": "[email protected]",
116+
"type": "stream"
117+
}
118+
}'
119+
```
120+
121+
Where:
122+
- `CMD` is the exact command you would issue @triagebot on Zulip (ex. open a direct chat with the
123+
bot and send "work show")
124+
- `ZULIP_TOKEN`: can be anything. Must correspond to the env var `$ZULIP_TOKEN` on your workstation
125+
- `YOUR_ID`: your GitHub user ID. Must be existing in your local triagebot database (table `users` and as
126+
foreign key also in `review_prefs`)
127+
102128
#### ngrok
103129

104130
The following is an example of using <https://ngrok.com/> to provide webhook forwarding.

src/db.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use tokio_postgres::Client as DbClient;
1010
pub mod issue_data;
1111
pub mod jobs;
1212
pub mod notifications;
13+
pub mod review_prefs;
1314
pub mod rustc_commits;
1415
pub mod users;
1516

src/db/review_prefs.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use crate::db::users::record_username;
2+
use crate::github::{User, UserId};
3+
use anyhow::Context;
4+
use serde::Serialize;
5+
6+
#[derive(Debug, Serialize)]
7+
pub struct ReviewPrefs {
8+
pub id: uuid::Uuid,
9+
pub user_id: i64,
10+
pub max_assigned_prs: Option<i32>,
11+
}
12+
13+
impl From<tokio_postgres::row::Row> for ReviewPrefs {
14+
fn from(row: tokio_postgres::row::Row) -> Self {
15+
Self {
16+
id: row.get("id"),
17+
user_id: row.get("user_id"),
18+
max_assigned_prs: row.get("max_assigned_prs"),
19+
}
20+
}
21+
}
22+
23+
/// Get team member review preferences.
24+
/// If they are missing, returns `Ok(None)`.
25+
pub async fn get_review_prefs(
26+
db: &tokio_postgres::Client,
27+
user_id: UserId,
28+
) -> anyhow::Result<Option<ReviewPrefs>> {
29+
let query = "
30+
SELECT id, user_id, max_assigned_prs
31+
FROM review_prefs
32+
WHERE review_prefs.user_id = $1;";
33+
let row = db
34+
.query_opt(query, &[&(user_id as i64)])
35+
.await
36+
.context("Error retrieving review preferences")?;
37+
Ok(row.map(|r| r.into()))
38+
}
39+
40+
/// Updates review preferences of the specified user, or creates them
41+
/// if they do not exist yet.
42+
pub async fn upsert_review_prefs(
43+
db: &tokio_postgres::Client,
44+
user: User,
45+
max_assigned_prs: Option<u32>,
46+
) -> anyhow::Result<u64, anyhow::Error> {
47+
// We need to have the user stored in the DB to have a valid FK link in review_prefs
48+
record_username(db, user.id, &user.login).await?;
49+
50+
let max_assigned_prs = max_assigned_prs.map(|v| v as i32);
51+
let query = "
52+
INSERT INTO review_prefs(user_id, max_assigned_prs)
53+
VALUES ($1, $2)
54+
ON CONFLICT (user_id)
55+
DO UPDATE
56+
SET max_assigned_prs = excluded.max_assigned_prs";
57+
58+
let res = db
59+
.execute(query, &[&(user.id as i64), &max_assigned_prs])
60+
.await
61+
.context("Error upserting review preferences")?;
62+
Ok(res)
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use crate::db::review_prefs::{get_review_prefs, upsert_review_prefs};
68+
use crate::db::users::get_user;
69+
use crate::tests::github::user;
70+
use crate::tests::run_test;
71+
72+
#[tokio::test]
73+
async fn insert_prefs_create_user() {
74+
run_test(|ctx| async {
75+
let db = ctx.db_client().await;
76+
77+
let user = user("Martin", 1);
78+
upsert_review_prefs(&db, user.clone(), Some(1)).await?;
79+
80+
assert_eq!(get_user(&db, user.id).await?.unwrap(), user);
81+
82+
Ok(ctx)
83+
})
84+
.await;
85+
}
86+
87+
#[tokio::test]
88+
async fn insert_max_assigned_prs() {
89+
run_test(|ctx| async {
90+
let db = ctx.db_client().await;
91+
92+
upsert_review_prefs(&db, user("Martin", 1), Some(5)).await?;
93+
assert_eq!(
94+
get_review_prefs(&db, 1).await?.unwrap().max_assigned_prs,
95+
Some(5)
96+
);
97+
98+
Ok(ctx)
99+
})
100+
.await;
101+
}
102+
103+
#[tokio::test]
104+
async fn update_max_assigned_prs() {
105+
run_test(|ctx| async {
106+
let db = ctx.db_client().await;
107+
108+
upsert_review_prefs(&db, user("Martin", 1), Some(5)).await?;
109+
assert_eq!(
110+
get_review_prefs(&db, 1).await?.unwrap().max_assigned_prs,
111+
Some(5)
112+
);
113+
upsert_review_prefs(&db, user("Martin", 1), Some(10)).await?;
114+
assert_eq!(
115+
get_review_prefs(&db, 1).await?.unwrap().max_assigned_prs,
116+
Some(10)
117+
);
118+
119+
upsert_review_prefs(&db, user("Martin", 1), None).await?;
120+
assert_eq!(
121+
get_review_prefs(&db, 1).await?.unwrap().max_assigned_prs,
122+
None
123+
);
124+
125+
Ok(ctx)
126+
})
127+
.await;
128+
}
129+
}

src/lib.rs

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
#![allow(clippy::new_without_default)]
22

3-
use crate::github::{PullRequestDetails, UserId};
3+
use crate::github::PullRequestDetails;
44

55
use anyhow::Context;
66
use handlers::HandlerError;
77
use interactions::ErrorComment;
8-
use serde::Serialize;
98
use std::fmt;
109
use tracing as log;
1110

@@ -125,40 +124,6 @@ impl From<anyhow::Error> for WebhookError {
125124
}
126125
}
127126

128-
#[derive(Debug, Serialize)]
129-
pub struct ReviewPrefs {
130-
pub id: uuid::Uuid,
131-
pub user_id: i64,
132-
pub max_assigned_prs: Option<i32>,
133-
}
134-
135-
impl From<tokio_postgres::row::Row> for ReviewPrefs {
136-
fn from(row: tokio_postgres::row::Row) -> Self {
137-
Self {
138-
id: row.get("id"),
139-
user_id: row.get("user_id"),
140-
max_assigned_prs: row.get("max_assigned_prs"),
141-
}
142-
}
143-
}
144-
145-
/// Get team member review preferences.
146-
/// If they are missing, returns `Ok(None)`.
147-
pub async fn get_review_prefs(
148-
db: &tokio_postgres::Client,
149-
user_id: UserId,
150-
) -> anyhow::Result<Option<ReviewPrefs>> {
151-
let query = "
152-
SELECT id, user_id, max_assigned_prs
153-
FROM review_prefs
154-
WHERE review_prefs.user_id = $1;";
155-
let row = db
156-
.query_opt(query, &[&(user_id as i64)])
157-
.await
158-
.context("Error retrieving review preferences")?;
159-
Ok(row.map(|r| r.into()))
160-
}
161-
162127
pub fn deserialize_payload<T: serde::de::DeserializeOwned>(v: &str) -> anyhow::Result<T> {
163128
let mut deserializer = serde_json::Deserializer::from_str(&v);
164129
let res: Result<T, _> = serde_path_to_error::deserialize(&mut deserializer);

src/team_data.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::github::GithubClient;
22
use anyhow::Context as _;
3-
use rust_team_data::v1::{Teams, ZulipMapping, BASE_URL};
3+
use rust_team_data::v1::{People, Teams, ZulipMapping, BASE_URL};
44
use serde::de::DeserializeOwned;
55

66
async fn by_url<T: DeserializeOwned>(client: &GithubClient, path: &str) -> anyhow::Result<T> {
@@ -36,3 +36,9 @@ pub async fn teams(client: &GithubClient) -> anyhow::Result<Teams> {
3636
.await
3737
.context("team-api: teams.json")
3838
}
39+
40+
pub async fn people(client: &GithubClient) -> anyhow::Result<People> {
41+
by_url(client, "/people.json")
42+
.await
43+
.context("team-api: people.json")
44+
}

src/zulip.rs

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::db::notifications::add_metadata;
22
use crate::db::notifications::{self, delete_ping, move_indices, record_ping, Identifier};
3-
use crate::get_review_prefs;
4-
use crate::github::{get_id_for_username, GithubClient};
3+
use crate::db::review_prefs::{get_review_prefs, upsert_review_prefs};
4+
use crate::github::{get_id_for_username, GithubClient, User};
55
use crate::handlers::docs_update::docs_update;
66
use crate::handlers::pr_tracking::get_assigned_prs;
77
use crate::handlers::project_goals::{self, ping_project_goals_owners};
@@ -84,6 +84,19 @@ pub async fn to_github_id(client: &GithubClient, zulip_id: u64) -> anyhow::Resul
8484
Ok(map.users.get(&zulip_id).copied())
8585
}
8686

87+
pub async fn username_from_gh_id(
88+
client: &GithubClient,
89+
gh_id: u64,
90+
) -> anyhow::Result<Option<String>> {
91+
let people_map = crate::team_data::people(client).await?;
92+
Ok(people_map
93+
.people
94+
.into_iter()
95+
.filter(|(_, p)| p.github_id == gh_id)
96+
.map(|p| p.0)
97+
.next())
98+
}
99+
87100
pub async fn to_zulip_id(client: &GithubClient, github_id: u64) -> anyhow::Result<Option<u64>> {
88101
let map = crate::team_data::zulip_map(client).await?;
89102
Ok(map
@@ -164,7 +177,7 @@ fn handle_command<'a>(
164177
.map_err(|e| format_err!("Failed to parse movement, expected `move <from> <to>`: {e:?}.")),
165178
Some("meta") => add_meta_notification(&ctx, gh_id, words).await
166179
.map_err(|e| format_err!("Failed to parse `meta` command. Synopsis: meta <num> <text>: Add <text> to your notification identified by <num> (>0)\n\nError: {e:?}")),
167-
Some("work") => query_pr_assignments(ctx, gh_id, words).await
180+
Some("work") => workqueue_commands(ctx, gh_id, words).await
168181
.map_err(|e| format_err!("Failed to parse `work` command. Synopsis: work <show>: shows your current PRs assignment\n\nError: {e:?}")),
169182
_ => {
170183
while let Some(word) = next {
@@ -242,7 +255,9 @@ fn handle_command<'a>(
242255
})
243256
}
244257

245-
async fn query_pr_assignments(
258+
/// Commands for working with the workqueue, e.g. showing how many PRs are assigned
259+
/// or modifying the PR review assignment limit.
260+
async fn workqueue_commands(
246261
ctx: &Context,
247262
gh_id: u64,
248263
mut words: impl Iterator<Item = &str>,
@@ -268,16 +283,59 @@ async fn query_pr_assignments(
268283
.collect::<Vec<String>>()
269284
.join(", ");
270285

271-
let review_prefs = get_review_prefs(&db_client, gh_id).await?;
286+
let review_prefs = get_review_prefs(&db_client, gh_id)
287+
.await
288+
.context("cannot get review preferences")?;
272289
let capacity = match review_prefs.and_then(|p| p.max_assigned_prs) {
273-
Some(max) => format!("{max}"),
290+
Some(max) => max.to_string(),
274291
None => String::from("Not set (i.e. unlimited)"),
275292
};
276293

277-
let mut response = format!("Assigned PRs: {prs}\n");
294+
let mut response = format!("Assigned rust-lang/rust PRs: {prs}\n");
278295
writeln!(response, "Review capacity: {capacity}")?;
279296
response
280297
}
298+
"set-pr-limit" => {
299+
let max_assigned_prs = match words.next() {
300+
Some(value) => {
301+
if words.next().is_some() {
302+
anyhow::bail!("Too many parameters.");
303+
}
304+
if value == "unlimited" {
305+
None
306+
} else {
307+
Some(value.parse::<u32>().context(
308+
"Wrong parameter format. Must be a positive integer or `unlimited` to unset the limit.",
309+
)?)
310+
}
311+
}
312+
None => anyhow::bail!("Missing parameter."),
313+
};
314+
let gh_username = username_from_gh_id(&ctx.github, gh_id)
315+
.await?
316+
.ok_or_else(|| {
317+
anyhow::anyhow!("Cannot find your GitHub username in the team database")
318+
})?;
319+
320+
let user = User {
321+
login: gh_username.clone(),
322+
id: gh_id,
323+
};
324+
upsert_review_prefs(&db_client, user, max_assigned_prs)
325+
.await
326+
.context("Error occurred while setting review preferences.")?;
327+
tracing::info!("Setting max assignment PRs of `{gh_username}` to {max_assigned_prs:?}");
328+
format!(
329+
"Review capacity set to {}",
330+
match max_assigned_prs {
331+
Some(v) => v.to_string(),
332+
None => "unlimited".to_string(),
333+
}
334+
)
335+
}
336+
"help" => r"work show => show your assigned PRs
337+
work set-pr-limit <number>|unlimited => set the maximum number of PRs you can be assigned to"
338+
.to_string(),
281339
_ => anyhow::bail!("Invalid subcommand."),
282340
};
283341

0 commit comments

Comments
 (0)