@@ -6,10 +6,11 @@ use crate::handlers::docs_update::docs_update;
6
6
use crate :: handlers:: pr_tracking:: get_assigned_prs;
7
7
use crate :: handlers:: project_goals:: { self , ping_project_goals_owners} ;
8
8
use crate :: handlers:: Context ;
9
- use crate :: team_data:: teams;
9
+ use crate :: team_data:: { people , teams} ;
10
10
use crate :: utils:: pluralize;
11
11
use anyhow:: { format_err, Context as _} ;
12
12
use rust_team_data:: v1:: TeamKind ;
13
+ use std:: collections:: HashMap ;
13
14
use std:: env;
14
15
use std:: fmt:: Write as _;
15
16
use std:: str:: FromStr ;
@@ -204,6 +205,8 @@ fn handle_command<'a>(
204
205
. map_err ( |e| format_err ! ( "Failed to parse `meta` command. Synopsis: meta <num> <text>: Add <text> to your notification identified by <num> (>0)\n \n Error: {e:?}" ) ) ,
205
206
Some ( "whoami" ) => whoami_cmd ( & ctx, gh_id, words) . await
206
207
. map_err ( |e| format_err ! ( "Failed to run the `whoami` command. Synopsis: whoami: Show to which Rust teams you are a part of\n \n Error: {e:?}" ) ) ,
208
+ Some ( "lookup" ) => lookup_cmd ( & ctx, words) . await
209
+ . map_err ( |e| format_err ! ( "Failed to run the `lookup` command. Synopsis: lookup (github <zulip-username>|zulip <github-username>): Show the GitHub username of a Zulip <user> or the Zulip username of a GitHub user\n \n Error: {e:?}" ) ) ,
207
210
Some ( "work" ) => workqueue_commands ( ctx, gh_id, words) . await
208
211
. map_err ( |e| format_err ! ( "Failed to parse `work` command. Help: {WORKQUEUE_HELP}\n \n Error: {e:?}" ) ) ,
209
212
_ => {
@@ -476,6 +479,206 @@ async fn whoami_cmd(
476
479
Ok ( Some ( output) )
477
480
}
478
481
482
+ /// The lookup command has two forms:
483
+ /// - `lookup github <zulip-username>`: displays the GitHub username of a Zulip user.
484
+ /// - `lookup zulip <github-username>`: displays the Zulip username of a GitHub user.
485
+ async fn lookup_cmd (
486
+ ctx : & Context ,
487
+ mut words : impl Iterator < Item = & str > ,
488
+ ) -> anyhow:: Result < Option < String > > {
489
+ let subcommand = match words. next ( ) {
490
+ Some ( subcommand) => subcommand,
491
+ None => return Err ( anyhow:: anyhow!( "no subcommand provided" ) ) ,
492
+ } ;
493
+
494
+ // Usernames could contain spaces, so rejoin everything after `whois` to serve as the username.
495
+ let args = words. collect :: < Vec < _ > > ( ) ;
496
+ if args. is_empty ( ) {
497
+ return Err ( anyhow:: anyhow!( "no username provided" ) ) ;
498
+ }
499
+ let args = args. join ( " " ) ;
500
+
501
+ // The username could be a mention, which looks like this: `@**<username>**`, so strip the
502
+ // extra sigils.
503
+ let username = args. trim_matches ( & [ '@' , '*' ] ) ;
504
+
505
+ match subcommand {
506
+ "github" => Ok ( Some ( lookup_github_username ( ctx, username) . await ?) ) ,
507
+ "zulip" => Ok ( Some ( lookup_zulip_username ( ctx, username) . await ?) ) ,
508
+ _ => Err ( anyhow:: anyhow!( "Unknown subcommand {subcommand}" ) ) ,
509
+ }
510
+ }
511
+
512
+ /// Tries to find a GitHub username from a Zulip username.
513
+ async fn lookup_github_username ( ctx : & Context , zulip_username : & str ) -> anyhow:: Result < String > {
514
+ let username_lowercase = zulip_username. to_lowercase ( ) ;
515
+
516
+ let users = get_zulip_users ( & ctx. github . raw ( ) )
517
+ . await
518
+ . context ( "Cannot get Zulip users" ) ?;
519
+ let Some ( zulip_user) = users
520
+ . iter ( )
521
+ . find ( |user| user. name . to_lowercase ( ) == username_lowercase)
522
+ else {
523
+ return Ok ( format ! (
524
+ "Zulip user {zulip_username} was not found on Zulip"
525
+ ) ) ;
526
+ } ;
527
+
528
+ // Prefer what is configured on Zulip. If there is nothing, try to lookup the GitHub username
529
+ // from the team database.
530
+ let github_username = match zulip_user. get_github_username ( ) {
531
+ Some ( name) => name. to_string ( ) ,
532
+ None => {
533
+ let zulip_id = zulip_user. user_id ;
534
+ let Some ( gh_id) = to_github_id ( & ctx. github , zulip_id) . await ? else {
535
+ return Ok ( format ! ( "Zulip user {zulip_username} was not found in team Zulip mapping. Maybe they do not have zulip-id configured in team." ) ) ;
536
+ } ;
537
+ let Some ( username) = username_from_gh_id ( & ctx. github , gh_id) . await ? else {
538
+ return Ok ( format ! (
539
+ "Zulip user {zulip_username} was not found in the team database."
540
+ ) ) ;
541
+ } ;
542
+ username
543
+ }
544
+ } ;
545
+
546
+ Ok ( format ! ( "{zulip_username}'s GitHub profile is [{github_username}](https://github.com/{github_username})." ) )
547
+ }
548
+
549
+ /// Tries to find a Zulip username from a GitHub username.
550
+ async fn lookup_zulip_username ( ctx : & Context , gh_username : & str ) -> anyhow:: Result < String > {
551
+ async fn lookup_from_zulip ( ctx : & Context , gh_username : & str ) -> anyhow:: Result < Option < String > > {
552
+ let username_lowercase = gh_username. to_lowercase ( ) ;
553
+ let users = get_zulip_users ( ctx. github . raw ( ) ) . await ?;
554
+ Ok ( users
555
+ . into_iter ( )
556
+ . find ( |user| {
557
+ user. get_github_username ( )
558
+ . map ( |u| u. to_lowercase ( ) )
559
+ . as_deref ( )
560
+ == Some ( username_lowercase. as_str ( ) )
561
+ } )
562
+ . map ( |u| u. name ) )
563
+ }
564
+
565
+ async fn lookup_from_team ( ctx : & Context , gh_username : & str ) -> anyhow:: Result < Option < String > > {
566
+ let people = people ( & ctx. github ) . await ?. people ;
567
+
568
+ // Lookup the person in the team DB
569
+ let Some ( person) = people. get ( gh_username) . or_else ( || {
570
+ let username_lowercase = gh_username. to_lowercase ( ) ;
571
+ people
572
+ . keys ( )
573
+ . find ( |key| key. to_lowercase ( ) == username_lowercase)
574
+ . and_then ( |key| people. get ( key) )
575
+ } ) else {
576
+ return Ok ( None ) ;
577
+ } ;
578
+
579
+ let Some ( zulip_id) = to_zulip_id ( & ctx. github , person. github_id ) . await ? else {
580
+ return Ok ( None ) ;
581
+ } ;
582
+ let Ok ( zulip_user) = get_zulip_user ( & ctx. github . raw ( ) , zulip_id) . await else {
583
+ return Ok ( None ) ;
584
+ } ;
585
+ Ok ( Some ( zulip_user. name ) )
586
+ }
587
+
588
+ let zulip_username = match lookup_from_team ( ctx, gh_username) . await ? {
589
+ Some ( username) => username,
590
+ None => match lookup_from_zulip ( ctx, gh_username) . await ? {
591
+ Some ( username) => username,
592
+ None => {
593
+ return Ok ( format ! (
594
+ "No Zulip account found for GitHub username `{gh_username}`."
595
+ ) )
596
+ }
597
+ } ,
598
+ } ;
599
+ Ok ( format ! (
600
+ "The GitHub user `{gh_username}` has the following Zulip account: @**{zulip_username}**"
601
+ ) )
602
+ }
603
+
604
+ #[ derive( Clone , serde:: Deserialize , Debug , PartialEq , Eq ) ]
605
+ pub ( crate ) struct ProfileValue {
606
+ value : String ,
607
+ }
608
+
609
+ /// A single Zulip user
610
+ #[ derive( Clone , serde:: Deserialize , Debug , PartialEq , Eq ) ]
611
+ pub ( crate ) struct ZulipUser {
612
+ pub ( crate ) user_id : u64 ,
613
+ #[ serde( rename = "full_name" ) ]
614
+ pub ( crate ) name : String ,
615
+ #[ serde( default ) ]
616
+ pub ( crate ) profile_data : HashMap < String , ProfileValue > ,
617
+ }
618
+
619
+ impl ZulipUser {
620
+ // The GitHub profile data key is 3873
621
+ pub ( crate ) fn get_github_username ( & self ) -> Option < & str > {
622
+ self . profile_data . get ( "3873" ) . map ( |v| v. value . as_str ( ) )
623
+ }
624
+ }
625
+
626
+ /// A collection of Zulip users, as returned from '/users'
627
+ #[ derive( serde:: Deserialize ) ]
628
+ struct ZulipUsers {
629
+ members : Vec < ZulipUser > ,
630
+ }
631
+
632
+ // From https://github.com/kobzol/team/blob/0f68ffc8b0d438d88ef4573deb54446d57e1eae6/src/api/zulip.rs#L45
633
+ async fn get_zulip_users ( client : & reqwest:: Client ) -> anyhow:: Result < Vec < ZulipUser > > {
634
+ let bot_api_token = env:: var ( "ZULIP_API_TOKEN" ) . expect ( "ZULIP_API_TOKEN" ) ;
635
+
636
+ let resp = client
637
+ . get ( & format ! (
638
+ "{}/api/v1/users?include_custom_profile_fields=true" ,
639
+ * ZULIP_URL
640
+ ) )
641
+ . basic_auth ( & * ZULIP_BOT_EMAIL , Some ( & bot_api_token) )
642
+ . send ( )
643
+ . await ?;
644
+
645
+ let status = resp. status ( ) ;
646
+
647
+ if !status. is_success ( ) {
648
+ let body = resp
649
+ . text ( )
650
+ . await
651
+ . context ( "fail receiving Zulip API response (when getting Zulip users)" ) ?;
652
+
653
+ anyhow:: bail!( body)
654
+ } else {
655
+ Ok ( resp. json :: < ZulipUsers > ( ) . await . map ( |users| users. members ) ?)
656
+ }
657
+ }
658
+
659
+ async fn get_zulip_user ( client : & reqwest:: Client , zulip_id : u64 ) -> anyhow:: Result < ZulipUser > {
660
+ let bot_api_token = env:: var ( "ZULIP_API_TOKEN" ) . expect ( "ZULIP_API_TOKEN" ) ;
661
+
662
+ let resp = client
663
+ . get ( & format ! ( "{}/api/v1/users/{zulip_id}" , * ZULIP_URL ) )
664
+ . basic_auth ( & * ZULIP_BOT_EMAIL , Some ( & bot_api_token) )
665
+ . send ( )
666
+ . await ?;
667
+
668
+ let status = resp. status ( ) ;
669
+
670
+ if !status. is_success ( ) {
671
+ let body = resp
672
+ . text ( )
673
+ . await
674
+ . context ( "fail receiving Zulip API response (when getting Zulip user)" ) ?;
675
+
676
+ anyhow:: bail!( body)
677
+ } else {
678
+ Ok ( resp. json :: < ZulipUser > ( ) . await ?)
679
+ }
680
+ }
681
+
479
682
// This does two things:
480
683
// * execute the command for the other user
481
684
// * tell the user executed for that a command was run as them by the user
0 commit comments