Skip to content

Commit 1b98d07

Browse files
authored
Merge pull request #2025 from Kobzol/whois-cmd
Add `lookup [github|zulip] <username>` command for determining the GitHub/Zulip username mapping
2 parents 9b44bb7 + 79ea3ba commit 1b98d07

File tree

1 file changed

+206
-1
lines changed

1 file changed

+206
-1
lines changed

src/zulip.rs

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ use crate::handlers::docs_update::docs_update;
66
use crate::handlers::pr_tracking::get_assigned_prs;
77
use crate::handlers::project_goals::{self, ping_project_goals_owners};
88
use crate::handlers::Context;
9-
use crate::team_data::teams;
9+
use crate::team_data::{people, teams};
1010
use crate::utils::pluralize;
1111
use anyhow::{format_err, Context as _};
1212
use rust_team_data::v1::TeamKind;
13+
use std::collections::HashMap;
1314
use std::env;
1415
use std::fmt::Write as _;
1516
use std::str::FromStr;
@@ -204,6 +205,8 @@ fn handle_command<'a>(
204205
.map_err(|e| format_err!("Failed to parse `meta` command. Synopsis: meta <num> <text>: Add <text> to your notification identified by <num> (>0)\n\nError: {e:?}")),
205206
Some("whoami") => whoami_cmd(&ctx, gh_id, words).await
206207
.map_err(|e| format_err!("Failed to run the `whoami` command. Synopsis: whoami: Show to which Rust teams you are a part of\n\nError: {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\nError: {e:?}")),
207210
Some("work") => workqueue_commands(ctx, gh_id, words).await
208211
.map_err(|e| format_err!("Failed to parse `work` command. Help: {WORKQUEUE_HELP}\n\nError: {e:?}")),
209212
_ => {
@@ -476,6 +479,208 @@ async fn whoami_cmd(
476479
Ok(Some(output))
477480
}
478481

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 custom profile field ID for GitHub profiles on the Rust Zulip
621+
// is 3873. This is likely not portable across different Zulip instance,
622+
// but we assume that triagebot will only be used on this Zulip instance anyway.
623+
pub(crate) fn get_github_username(&self) -> Option<&str> {
624+
self.profile_data.get("3873").map(|v| v.value.as_str())
625+
}
626+
}
627+
628+
/// A collection of Zulip users, as returned from '/users'
629+
#[derive(serde::Deserialize)]
630+
struct ZulipUsers {
631+
members: Vec<ZulipUser>,
632+
}
633+
634+
// From https://github.com/kobzol/team/blob/0f68ffc8b0d438d88ef4573deb54446d57e1eae6/src/api/zulip.rs#L45
635+
async fn get_zulip_users(client: &reqwest::Client) -> anyhow::Result<Vec<ZulipUser>> {
636+
let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
637+
638+
let resp = client
639+
.get(&format!(
640+
"{}/api/v1/users?include_custom_profile_fields=true",
641+
*ZULIP_URL
642+
))
643+
.basic_auth(&*ZULIP_BOT_EMAIL, Some(&bot_api_token))
644+
.send()
645+
.await?;
646+
647+
let status = resp.status();
648+
649+
if !status.is_success() {
650+
let body = resp
651+
.text()
652+
.await
653+
.context("fail receiving Zulip API response (when getting Zulip users)")?;
654+
655+
anyhow::bail!(body)
656+
} else {
657+
Ok(resp.json::<ZulipUsers>().await.map(|users| users.members)?)
658+
}
659+
}
660+
661+
async fn get_zulip_user(client: &reqwest::Client, zulip_id: u64) -> anyhow::Result<ZulipUser> {
662+
let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
663+
664+
let resp = client
665+
.get(&format!("{}/api/v1/users/{zulip_id}", *ZULIP_URL))
666+
.basic_auth(&*ZULIP_BOT_EMAIL, Some(&bot_api_token))
667+
.send()
668+
.await?;
669+
670+
let status = resp.status();
671+
672+
if !status.is_success() {
673+
let body = resp
674+
.text()
675+
.await
676+
.context("fail receiving Zulip API response (when getting Zulip user)")?;
677+
678+
anyhow::bail!(body)
679+
} else {
680+
Ok(resp.json::<ZulipUser>().await?)
681+
}
682+
}
683+
479684
// This does two things:
480685
// * execute the command for the other user
481686
// * tell the user executed for that a command was run as them by the user

0 commit comments

Comments
 (0)