@@ -7,7 +7,7 @@ use crate::db::notifications::{self, Identifier, delete_ping, move_indices, reco
77use 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 } ;
1111use crate :: handlers:: Context ;
1212use crate :: handlers:: docs_update:: docs_update;
1313use 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+
468536async 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+ }
0 commit comments