Skip to content

Commit 543a98a

Browse files
committed
Add team-stats Zulip command to show review statistics of a given team
1 parent 0ee7587 commit 543a98a

File tree

2 files changed

+111
-4
lines changed

2 files changed

+111
-4
lines changed

src/zulip.rs

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ mod commands;
44

55
use crate::db::notifications::add_metadata;
66
use crate::db::notifications::{self, delete_ping, move_indices, record_ping, Identifier};
7-
use crate::db::review_prefs::{get_review_prefs, upsert_review_prefs, RotationMode};
7+
use crate::db::review_prefs::{
8+
get_review_prefs, get_review_prefs_batch, upsert_review_prefs, RotationMode,
9+
};
810
use crate::github::User;
911
use crate::handlers::docs_update::docs_update;
1012
use crate::handlers::pr_tracking::get_assigned_prs;
@@ -17,7 +19,8 @@ use crate::zulip::commands::{
1719
parse_cli, ChatCommand, LookupCmd, StreamCommand, WorkqueueCmd, WorkqueueLimit,
1820
};
1921
use anyhow::{format_err, Context as _};
20-
use rust_team_data::v1::TeamKind;
22+
use rust_team_data::v1::{TeamKind, TeamMember};
23+
use std::cmp::Reverse;
2124
use std::fmt::Write as _;
2225
use subtle::ConstantTimeEq;
2326
use tracing as log;
@@ -196,6 +199,7 @@ async fn handle_command<'a>(
196199
ChatCommand::Whoami => whoami_cmd(&ctx, gh_id).await,
197200
ChatCommand::Lookup(cmd) => lookup_cmd(&ctx, cmd).await,
198201
ChatCommand::Work(cmd) => workqueue_commands(ctx, gh_id, cmd).await,
202+
ChatCommand::TeamStats { name } => team_status_cmd(ctx, &name).await,
199203
};
200204

201205
let output = output?;
@@ -281,6 +285,104 @@ async fn handle_command<'a>(
281285
}
282286
}
283287

288+
async fn team_status_cmd(ctx: &Context, team_name: &str) -> anyhow::Result<Option<String>> {
289+
use std::fmt::Write;
290+
291+
let Some(team) = ctx.team_api.get_team(team_name).await? else {
292+
return Ok(Some(format!("Team {team_name} not found")));
293+
};
294+
295+
let mut members = team.members;
296+
members.sort_by(|a, b| a.github.cmp(&b.github));
297+
298+
let usernames: Vec<&str> = members
299+
.iter()
300+
.map(|member| member.github.as_str())
301+
.collect();
302+
303+
let db = ctx.db.get().await;
304+
let review_prefs = get_review_prefs_batch(&db, &usernames)
305+
.await
306+
.context("cannot load review preferences")?;
307+
308+
let workqueue = ctx.workqueue.read().await;
309+
let total_assigned: u64 = members
310+
.iter()
311+
.map(|member| workqueue.assigned_pr_count(member.github_id))
312+
.sum();
313+
314+
let table_header = |title: &str| {
315+
format!(
316+
r"### {title}
317+
| Username | Name | Assigned PRs | Review capacity |
318+
|----------|------|-------------:|----------------:|"
319+
)
320+
};
321+
322+
let format_member_row = |member: &TeamMember| {
323+
let review_prefs = review_prefs.get(member.github.as_str());
324+
let max_capacity = review_prefs
325+
.as_ref()
326+
.and_then(|prefs| prefs.max_assigned_prs);
327+
let assigned_prs = workqueue.assigned_pr_count(member.github_id);
328+
329+
let max_capacity = max_capacity
330+
.map(|c| c.to_string())
331+
.unwrap_or_else(|| "unlimited".to_string());
332+
format!(
333+
"| `{}` | {} | `{assigned_prs}` | `{max_capacity}` |",
334+
member.github, member.name
335+
)
336+
};
337+
338+
let (mut on_rotation, mut off_rotation): (Vec<&TeamMember>, Vec<&TeamMember>) =
339+
members.iter().partition(|member| {
340+
let rotation_mode = review_prefs
341+
.get(member.github.as_str())
342+
.map(|prefs| prefs.rotation_mode)
343+
.unwrap_or_default();
344+
matches!(rotation_mode, RotationMode::OnRotation)
345+
});
346+
on_rotation.sort_by_key(|member| Reverse(workqueue.assigned_pr_count(member.github_id)));
347+
off_rotation.sort_by_key(|member| Reverse(workqueue.assigned_pr_count(member.github_id)));
348+
349+
let on_rotation = on_rotation
350+
.into_iter()
351+
.map(format_member_row)
352+
.collect::<Vec<_>>();
353+
let off_rotation = off_rotation
354+
.into_iter()
355+
.map(format_member_row)
356+
.collect::<Vec<_>>();
357+
358+
// e.g. 2 members, 5 PRs assigned
359+
let mut msg = format!(
360+
"{} {}, {} {} assigned\n\n",
361+
members.len(),
362+
pluralize("member", members.len()),
363+
total_assigned,
364+
pluralize("PR", total_assigned as usize)
365+
);
366+
if !on_rotation.is_empty() {
367+
writeln!(
368+
msg,
369+
"{}",
370+
table_header(&format!("ON rotation ({})", on_rotation.len()))
371+
)?;
372+
writeln!(msg, "{}\n", on_rotation.join("\n"))?;
373+
}
374+
if !off_rotation.is_empty() {
375+
writeln!(
376+
msg,
377+
"{}",
378+
table_header(&format!("OFF rotation ({})", on_rotation.len()))
379+
)?;
380+
writeln!(msg, "{}\n", off_rotation.join("\n"))?;
381+
}
382+
383+
Ok(Some(msg))
384+
}
385+
284386
/// Returns true if we should notify user who was impersonated by someone who executed this command.
285387
/// More or less, the following holds: `sensitive` == `not read-only`.
286388
fn is_sensitive_command(cmd: &ChatCommand) -> bool {
@@ -289,8 +391,7 @@ fn is_sensitive_command(cmd: &ChatCommand) -> bool {
289391
| ChatCommand::Add { .. }
290392
| ChatCommand::Move { .. }
291393
| ChatCommand::Meta { .. } => true,
292-
ChatCommand::Whoami => false,
293-
ChatCommand::Lookup(_) => false,
394+
ChatCommand::Whoami | ChatCommand::Lookup(_) | ChatCommand::TeamStats { .. } => false,
294395
ChatCommand::Work(cmd) => match cmd {
295396
WorkqueueCmd::Show => false,
296397
WorkqueueCmd::SetPrLimit { .. } => true,

src/zulip/commands.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ pub enum ChatCommand {
3636
/// Inspect or modify your reviewer workqueue.
3737
#[clap(subcommand)]
3838
Work(WorkqueueCmd),
39+
/// Print the review statistics of the given Rust team.
40+
/// Shows the reviewer queue contents of the team members.
41+
TeamStats {
42+
/// Name of the team to query.
43+
name: String,
44+
},
3945
}
4046

4147
#[derive(clap::Parser, Debug, PartialEq)]

0 commit comments

Comments
 (0)