Skip to content

Commit f72b997

Browse files
authored
Merge pull request #2061 from Kobzol/team-stats
Add `team-stats` Zulip command
2 parents d93911b + 6876715 commit f72b997

File tree

2 files changed

+110
-2
lines changed

2 files changed

+110
-2
lines changed

src/zulip.rs

Lines changed: 105 additions & 2 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, PingGoalsArgs, 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;
@@ -201,6 +204,7 @@ async fn handle_command<'a>(
201204
ChatCommand::Work(cmd) => workqueue_commands(ctx, gh_id, cmd).await,
202205
ChatCommand::PingGoals(args) => ping_goals_cmd(ctx, gh_id, &args).await,
203206
ChatCommand::DocsUpdate => trigger_docs_update(message_data, &ctx.zulip),
207+
ChatCommand::TeamStats { name } => team_status_cmd(ctx, &name).await,
204208
};
205209

206210
let output = output?;
@@ -288,6 +292,104 @@ async fn ping_goals_cmd(
288292
}
289293
}
290294

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

src/zulip/commands.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ pub enum ChatCommand {
4040
PingGoals(PingGoalsArgs),
4141
/// Update docs
4242
DocsUpdate,
43+
/// Shows review queue statistics of members of the given Rust team.
44+
TeamStats {
45+
/// Name of the team to query.
46+
name: String,
47+
},
4348
}
4449

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

0 commit comments

Comments
 (0)