Skip to content

Commit 4d731b3

Browse files
authored
Merge pull request #2271 from Kobzol/recent-comments
Add Zulip command for showing recent GitHub comments of a user
2 parents 1ca5cd0 + 83e37dd commit 4d731b3

File tree

3 files changed

+160
-45
lines changed

3 files changed

+160
-45
lines changed

src/handlers/report_user_bans.rs

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
//! Handler that reports GitHub bans and unbans to a Zulip channel.
22
3-
use crate::github::{OrgBlockAction, OrgBlockEvent, UserComment};
3+
use crate::github::{OrgBlockAction, OrgBlockEvent};
44
use crate::handlers::Context;
5+
use crate::zulip;
56
use crate::zulip::MessageApiRequest;
67
use crate::zulip::api::Recipient;
78
use tracing as log;
@@ -12,9 +13,6 @@ const ZULIP_STREAM_ID: u64 = 464799;
1213
/// Maximum number of recent comments to include in ban reports.
1314
const MAX_RECENT_COMMENTS: usize = 10;
1415

15-
/// Maximum length of a comment snippet in the report.
16-
const MAX_COMMENT_SNIPPET_LEN: usize = 200;
17-
1816
pub async fn handle(ctx: &Context, event: &OrgBlockEvent) -> anyhow::Result<()> {
1917
let topic = format!("github user {}", event.blocked_user.login);
2018

@@ -44,7 +42,7 @@ pub async fn handle(ctx: &Context, event: &OrgBlockEvent) -> anyhow::Result<()>
4442
Ok(comments) if !comments.is_empty() => {
4543
message.push_str("\n\n**Recent comments:**\n");
4644
for comment in comments {
47-
message.push_str(&format_user_comment(&comment));
45+
message.push_str(&zulip::format_user_comment(&comment));
4846
}
4947
}
5048
Ok(_) => {
@@ -85,30 +83,3 @@ pub async fn handle(ctx: &Context, event: &OrgBlockEvent) -> anyhow::Result<()>
8583

8684
Ok(())
8785
}
88-
89-
/// Formats user's comment for display in the Zulip message.
90-
fn format_user_comment(comment: &UserComment) -> String {
91-
let snippet = truncate_comment(&comment.body, MAX_COMMENT_SNIPPET_LEN);
92-
let date = comment
93-
.created_at
94-
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
95-
.unwrap_or_else(|| "unknown date".to_string());
96-
97-
format!(
98-
"- [{title}]({comment_url}) ({date}):\n > {snippet}\n",
99-
title = truncate_comment(&comment.issue_title, 60),
100-
comment_url = comment.comment_url,
101-
)
102-
}
103-
104-
/// Truncates a comment to the specified length, adding ellipsis if needed.
105-
fn truncate_comment(text: &str, max_len: usize) -> String {
106-
let normalized: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
107-
108-
if normalized.len() <= max_len {
109-
normalized
110-
} else {
111-
let truncated: String = normalized.chars().take(max_len - 3).collect();
112-
format!("{truncated}...")
113-
}
114-
}

src/zulip.rs

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::db::notifications::{self, Identifier, delete_ping, move_indices, reco
77
use crate::db::review_prefs::{
88
RotationMode, get_review_prefs, get_review_prefs_batch, upsert_review_prefs,
99
};
10-
use crate::github::User;
10+
use crate::github::{User, UserComment};
1111
use crate::handlers::Context;
1212
use crate::handlers::docs_update::docs_update;
1313
use crate::handlers::pr_tracking::get_assigned_prs;
@@ -220,7 +220,7 @@ async fn handle_command<'a>(
220220
if message_data.stream_id.is_none() {
221221
let mut words: Vec<&str> = message.split_whitespace().collect();
222222

223-
// Handle impersonation
223+
// Parse impersonation
224224
let mut impersonated = false;
225225
#[expect(clippy::get_first, reason = "for symmetry with `get(1)`")]
226226
if let Some(&"as") = words.get(0) {
@@ -248,6 +248,13 @@ async fn handle_command<'a>(
248248
}
249249

250250
let cmd = parse_cli::<ChatCommand, _>(words.into_iter())?;
251+
let impersonation_mode = get_cmd_impersonation_mode(&cmd);
252+
if impersonated && matches!(impersonation_mode, ImpersonationMode::Disabled) {
253+
return Err(anyhow::anyhow!(
254+
"This command cannot be used with impersonation. Remove the `as <user>` prefix."
255+
));
256+
}
257+
251258
tracing::info!("command parsed to {cmd:?} (impersonated: {impersonated})");
252259

253260
let output = match &cmd {
@@ -268,15 +275,21 @@ async fn handle_command<'a>(
268275
ping_goals_cmd(ctx.clone(), gh_id, message_data, args).await
269276
}
270277
ChatCommand::DocsUpdate => trigger_docs_update(message_data, &ctx.zulip),
278+
ChatCommand::Comments {
279+
username,
280+
organization,
281+
} => recent_comments_cmd(&ctx, gh_id, username, &organization)
282+
.await
283+
.map(Some),
271284
ChatCommand::TeamStats { name, repo } => {
272285
team_status_cmd(&ctx, name, repo.as_deref()).await
273286
}
274287
};
275288

276289
let output = output?;
277290

278-
// Let the impersonated person know about the impersonation if the command was sensitive
279-
if impersonated && is_sensitive_command(&cmd) {
291+
// Let the impersonated person know about the impersonation if we should notify
292+
if impersonated && matches!(impersonation_mode, ImpersonationMode::Notify) {
280293
let impersonated_zulip_id =
281294
ctx.team.github_to_zulip_id(gh_id).await?.ok_or_else(|| {
282295
anyhow::anyhow!("Zulip user for GitHub ID {gh_id} was not found")
@@ -356,6 +369,12 @@ async fn handle_command<'a>(
356369
StreamCommand::Backport(args) => {
357370
accept_decline_backport(message_data, &ctx.octocrab, &ctx.zulip, &args).await
358371
}
372+
StreamCommand::Comments {
373+
username,
374+
organization,
375+
} => recent_comments_cmd(&ctx, gh_id, &username, &organization)
376+
.await
377+
.map(Some),
359378
};
360379
}
361380

@@ -465,6 +484,55 @@ async fn ping_goals_cmd(
465484
}
466485
}
467486

487+
/// Output recent GitHub comments made by a given user in a given organization.
488+
/// This command can only be used by team members.
489+
async fn recent_comments_cmd(
490+
ctx: &Context,
491+
gh_id: u64,
492+
username: &str,
493+
organization: &str,
494+
) -> anyhow::Result<String> {
495+
const RECENT_COMMENTS_LIMIT: usize = 10;
496+
497+
let user = User {
498+
login: ctx
499+
.team
500+
.username_from_gh_id(gh_id)
501+
.await?
502+
.ok_or_else(|| anyhow::anyhow!("Username for GitHub user {gh_id} not found"))?,
503+
id: gh_id,
504+
};
505+
if !user.is_team_member(&ctx.team).await? {
506+
return Err(anyhow::anyhow!(
507+
"This command is only available to team members."
508+
));
509+
}
510+
511+
if ctx.team.repos().await?.repos.get(organization).is_none() {
512+
return Err(anyhow::anyhow!(
513+
"Organization `{organization}` is not managed by the team database."
514+
));
515+
}
516+
517+
let comments = ctx
518+
.github
519+
.user_comments_in_org(username, organization, RECENT_COMMENTS_LIMIT)
520+
.await
521+
.context("Cannot load recent comments")?;
522+
523+
if comments.is_empty() {
524+
return Ok(format!(
525+
"No recent comments found for **{username}** in the `{organization}` organization."
526+
));
527+
}
528+
529+
let mut message = format!("**Recent comments by {username} in `{organization}`:**\n");
530+
for comment in &comments {
531+
message.push_str(&format_user_comment(comment));
532+
}
533+
Ok(message)
534+
}
535+
468536
async fn team_status_cmd(
469537
ctx: &Context,
470538
team_name: &str,
@@ -626,23 +694,37 @@ async fn team_status_cmd(
626694
Ok(Some(msg))
627695
}
628696

629-
/// Returns true if we should notify user who was impersonated by someone who executed this command.
630-
/// More or less, the following holds: `sensitive` == `not read-only`.
631-
fn is_sensitive_command(cmd: &ChatCommand) -> bool {
697+
/// How does impersonation work for a given command.
698+
enum ImpersonationMode {
699+
/// Impersonation is enabled, but the impersonated user will not be notified.
700+
/// Should only be used for commands that are "read-only".
701+
Silent,
702+
/// Impersonation is enabled and the impersonated user will be notified.
703+
Notify,
704+
/// Impersonation is disabled.
705+
/// Should be used for commands where impersonation doesn't make sense or if there are some
706+
/// specific permissions required to run the command.
707+
Disabled,
708+
}
709+
710+
/// Returns the impersonation mode of the command.
711+
fn get_cmd_impersonation_mode(cmd: &ChatCommand) -> ImpersonationMode {
632712
match cmd {
633713
ChatCommand::Acknowledge { .. }
634714
| ChatCommand::Add { .. }
635715
| ChatCommand::Move { .. }
636-
| ChatCommand::Meta { .. } => true,
637-
ChatCommand::Whoami
716+
| ChatCommand::Meta { .. }
638717
| ChatCommand::DocsUpdate
639718
| ChatCommand::PingGoals(_)
719+
| ChatCommand::Comments { .. }
640720
| ChatCommand::TeamStats { .. }
641-
| ChatCommand::Lookup(_) => false,
721+
| ChatCommand::Lookup(_) => ImpersonationMode::Disabled,
722+
ChatCommand::Whoami => ImpersonationMode::Silent,
642723
ChatCommand::Work(cmd) => match cmd {
643-
WorkqueueCmd::Show => false,
644-
WorkqueueCmd::SetPrLimit { .. } => true,
645-
WorkqueueCmd::SetRotationMode { .. } => true,
724+
WorkqueueCmd::Show => ImpersonationMode::Silent,
725+
WorkqueueCmd::SetPrLimit { .. } | WorkqueueCmd::SetRotationMode { .. } => {
726+
ImpersonationMode::Notify
727+
}
646728
},
647729
}
648730
}
@@ -1196,3 +1278,31 @@ fn trigger_docs_update(message: &Message, zulip: &ZulipClient) -> anyhow::Result
11961278
"Docs update in progress, I'll let you know when I'm finished.".to_string(),
11971279
))
11981280
}
1281+
1282+
/// Formats user's GitHub comment for display in the Zulip message.
1283+
pub fn format_user_comment(comment: &UserComment) -> String {
1284+
// Limit the size of the comment to avoid running into Zulip max message size limits
1285+
let snippet = truncate_text(&comment.body, 300);
1286+
let date = comment
1287+
.created_at
1288+
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
1289+
.unwrap_or_else(|| "unknown date".to_string());
1290+
1291+
format!(
1292+
"- [{title}]({comment_url}) ({date}):\n > {snippet}\n",
1293+
title = truncate_text(&comment.issue_title, 60),
1294+
comment_url = comment.comment_url,
1295+
)
1296+
}
1297+
1298+
/// Truncates the given text to the specified length, adding ellipsis if needed.
1299+
fn truncate_text(text: &str, max_len: usize) -> String {
1300+
let normalized: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
1301+
1302+
if normalized.len() <= max_len {
1303+
normalized
1304+
} else {
1305+
let truncated: String = normalized.chars().take(max_len - 3).collect();
1306+
format!("{truncated}...")
1307+
}
1308+
}

src/zulip/commands.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ pub enum ChatCommand {
4141
PingGoals(PingGoalsArgs),
4242
/// Update docs
4343
DocsUpdate,
44+
/// Show recent GitHub comments of a user in the rust-lang organization.
45+
Comments {
46+
/// GitHub username to look up.
47+
username: String,
48+
/// Organization where to find the comments.
49+
#[arg(long = "org", default_value = "rust-lang")]
50+
organization: String,
51+
},
4452
/// Shows review queue statistics of members of the given Rust team.
4553
TeamStats {
4654
/// Name of the team to query.
@@ -171,6 +179,14 @@ pub enum StreamCommand {
171179
DocsUpdate,
172180
/// Accept or decline a backport.
173181
Backport(BackportArgs),
182+
/// Show recent GitHub comments of a user in the rust-lang organization.
183+
Comments {
184+
/// GitHub username to look up.
185+
username: String,
186+
/// Organization where to find the comments.
187+
#[arg(long = "org", default_value = "rust-lang")]
188+
organization: String,
189+
},
174190
}
175191

176192
#[derive(clap::Parser, Debug, PartialEq, Clone)]
@@ -369,6 +385,24 @@ mod tests {
369385
);
370386
}
371387

388+
#[test]
389+
fn recent_comments_command() {
390+
assert_eq!(
391+
parse_chat(&["comments", "octocat"]),
392+
ChatCommand::Comments {
393+
username: "octocat".to_string(),
394+
organization: "rust-lang".to_string()
395+
}
396+
);
397+
assert_eq!(
398+
parse_chat(&["comments", "foobar", "--org", "rust-lang-nursery"]),
399+
ChatCommand::Comments {
400+
username: "foobar".to_string(),
401+
organization: "rust-lang-nursery".to_string()
402+
}
403+
);
404+
}
405+
372406
fn parse_chat(input: &[&str]) -> ChatCommand {
373407
parse_cli::<ChatCommand, _>(input.into_iter().copied()).unwrap()
374408
}

0 commit comments

Comments
 (0)