diff --git a/src/github/queries/mod.rs b/src/github/queries/mod.rs index 2e1fa8a9e..28ceed1b5 100644 --- a/src/github/queries/mod.rs +++ b/src/github/queries/mod.rs @@ -1,3 +1,5 @@ pub(crate) mod issue_with_comments; pub(crate) mod user_comments_in_org; -pub(crate) mod user_prs_in_org; +pub(crate) mod user_info; +pub(crate) mod user_prs; +pub(crate) mod user_repos; diff --git a/src/github/queries/user_comments_in_org.rs b/src/github/queries/user_comments_in_org.rs index 66220411d..c04491318 100644 --- a/src/github/queries/user_comments_in_org.rs +++ b/src/github/queries/user_comments_in_org.rs @@ -21,7 +21,7 @@ impl GithubClient { /// /// Returns up to `limit` comments, sorted by creation date (most recent first). /// Each comment includes the URL, body snippet, and the issue/PR title it was made on. - pub async fn user_comments_in_org( + pub async fn recent_user_comments_in_org( &self, username: &str, org: &str, diff --git a/src/github/queries/user_info.rs b/src/github/queries/user_info.rs new file mode 100644 index 000000000..33e986e33 --- /dev/null +++ b/src/github/queries/user_info.rs @@ -0,0 +1,24 @@ +use anyhow::Context; +use chrono::{DateTime, Utc}; + +use crate::github::GithubClient; + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct UserInfo { + /// When was the user account created? + pub created_at: DateTime, + pub public_repos: u32, +} + +impl GithubClient { + /// Fetches basic public information about a GitHub user. + pub async fn user_info(&self, username: &str) -> anyhow::Result { + let url = format!("{}/users/{username}", self.api_url); + let info: UserInfo = self + .json(self.get(&url)) + .await + .with_context(|| format!("failed to fetch user info for {username}"))?; + + Ok(info) + } +} diff --git a/src/github/queries/user_prs.rs b/src/github/queries/user_prs.rs new file mode 100644 index 000000000..d470251f8 --- /dev/null +++ b/src/github/queries/user_prs.rs @@ -0,0 +1,193 @@ +use anyhow::Context; +use chrono::{DateTime, Utc}; + +use crate::github::GithubClient; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PullRequestState { + Open, + Closed, + Merged, +} + +#[derive(Debug, Clone)] +pub struct UserPullRequest { + pub title: String, + pub url: String, + pub number: u64, + pub repo_owner: String, + pub repo_name: String, + pub body: String, + pub created_at: Option>, + pub state: PullRequestState, +} + +fn parse_pr_node(node: &serde_json::Value) -> Option { + let title = node["title"].as_str()?; + let url = node["url"].as_str().unwrap_or(""); + let number = node["number"].as_u64().unwrap_or(0); + let repo_owner = node["repository"]["owner"]["login"] + .as_str() + .unwrap_or("") + .to_string(); + let repo_name = node["repository"]["name"] + .as_str() + .unwrap_or("") + .to_string(); + let body = node["body"].as_str().unwrap_or("").to_string(); + let created_at = node["createdAt"] + .as_str() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + let state = match node["state"].as_str() { + Some("MERGED") => PullRequestState::Merged, + Some("CLOSED") => PullRequestState::Closed, + _ => PullRequestState::Open, + }; + + Some(UserPullRequest { + title: title.to_string(), + url: url.to_string(), + number, + repo_owner, + repo_name, + body, + created_at, + state, + }) +} + +impl GithubClient { + /// Fetches recent pull requests created by a user across all repositories. + /// + /// Returns up to `limit` PRs, sorted by creation date (most recent first). + /// Uses cursor-based pagination to retrieve multiple pages if needed. + pub async fn recent_user_prs( + &self, + username: &str, + limit: usize, + ) -> anyhow::Result> { + // GitHub allows at most 100 items per page. + const MAX_PAGE_SIZE: usize = 100; + + // Here we don't need to scope anything to a given organization, so we don't use the + // search endpoint to conserve rate limit. + let mut prs: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let page_size = (limit - prs.len()).min(MAX_PAGE_SIZE); + let data = self + .graphql_query( + r#" +query($username: String!, $pageSize: Int!, $cursor: String) { + user(login: $username) { + pullRequests(first: $pageSize, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { + hasNextPage + endCursor + } + nodes { + title + url + number + body + createdAt + state + repository { + name + owner { + login + } + } + } + } + } +} + "#, + serde_json::json!({ + "username": username, + "pageSize": page_size, + "cursor": cursor, + }), + ) + .await + .context("failed to fetch user PRs")?; + + let connection = &data["data"]["user"]["pullRequests"]; + + if let Some(nodes) = connection["nodes"].as_array() { + prs.extend(nodes.iter().filter_map(parse_pr_node)); + } + + let has_next_page = connection["pageInfo"]["hasNextPage"] + .as_bool() + .unwrap_or(false); + + if !has_next_page || prs.len() >= limit { + break; + } + + cursor = connection["pageInfo"]["endCursor"] + .as_str() + .map(|s| s.to_string()); + } + + prs.truncate(limit); + Ok(prs) + } + + /// Fetches recent pull requests created by a user in a GitHub organization. + /// + /// Returns up to `limit` PRs, sorted by creation date (most recent first). + pub async fn recent_user_prs_in_org( + &self, + username: &str, + org: &str, + limit: usize, + ) -> anyhow::Result> { + // We could avoid the search API by searching for user's PRs directly. However, + // if the user makes a lot of PRs in various organizations, we might have to load a bunch + // of pages before we get to PRs from the given org. So instead we use the search API. + let search_query = format!("author:{username} org:{org} type:pr sort:created-desc"); + + let data = self + .graphql_query( + r#" +query($query: String!, $limit: Int!) { + search(query: $query, type: ISSUE, first: $limit) { + nodes { + ... on PullRequest { + title + url + number + body + createdAt + state + repository { + name + owner { + login + } + } + } + } + } +} + "#, + serde_json::json!({ + "query": search_query, + "limit": limit, + }), + ) + .await + .context("failed to search for user PRs")?; + + let prs = data["data"]["search"]["nodes"] + .as_array() + .map(|nodes| nodes.iter().filter_map(parse_pr_node).collect()) + .unwrap_or_default(); + + Ok(prs) + } +} diff --git a/src/github/queries/user_prs_in_org.rs b/src/github/queries/user_prs_in_org.rs deleted file mode 100644 index 23ee18585..000000000 --- a/src/github/queries/user_prs_in_org.rs +++ /dev/null @@ -1,119 +0,0 @@ -use anyhow::Context; -use chrono::{DateTime, Utc}; - -use crate::github::GithubClient; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PullRequestState { - Open, - Closed, - Merged, -} - -#[derive(Debug, Clone)] -pub struct UserPullRequest { - pub title: String, - pub url: String, - pub number: u64, - pub repo_owner: String, - pub repo_name: String, - pub body: String, - pub created_at: Option>, - pub state: PullRequestState, -} - -impl GithubClient { - /// Fetches recent pull requests created by a user in a GitHub organization. - /// - /// Returns up to `limit` PRs, sorted by creation date (most recent first). - pub async fn user_prs_in_org( - &self, - username: &str, - org: &str, - limit: usize, - ) -> anyhow::Result> { - // We could avoid the search API by searching for user's PRs directly. However, - // if the user makes a lot of PRs in various organizations, we might have to load a bunch - // of pages before we get to PRs from the given org. So instead we use the search API. - let search_query = format!("author:{username} org:{org} type:pr sort:created-desc"); - - let data = self - .graphql_query( - r#" -query($query: String!, $limit: Int!) { - search(query: $query, type: ISSUE, first: $limit) { - nodes { - ... on PullRequest { - title - url - number - body - createdAt - state - merged - repository { - name - owner { - login - } - } - } - } - } -} - "#, - serde_json::json!({ - "query": search_query, - "limit": limit, - }), - ) - .await - .context("failed to search for user PRs")?; - - let mut prs: Vec = Vec::new(); - - if let Some(nodes) = data["data"]["search"]["nodes"].as_array() { - for node in nodes { - let Some(title) = node["title"].as_str() else { - continue; - }; - let url = node["url"].as_str().unwrap_or(""); - let number = node["number"].as_u64().unwrap_or(0); - let repository_owner = node["repository"]["owner"]["login"] - .as_str() - .unwrap_or("") - .to_string(); - let repository_name = node["repository"]["name"] - .as_str() - .unwrap_or("") - .to_string(); - let body = node["body"].as_str().unwrap_or("").to_string(); - let created_at = node["createdAt"] - .as_str() - .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&Utc)); - - let state = if node["merged"].as_bool().unwrap_or(false) { - PullRequestState::Merged - } else if node["state"].as_str() == Some("CLOSED") { - PullRequestState::Closed - } else { - PullRequestState::Open - }; - - prs.push(UserPullRequest { - title: title.to_string(), - url: url.to_string(), - number, - repo_owner: repository_owner, - repo_name: repository_name, - body, - created_at, - state, - }); - } - } - - Ok(prs) - } -} diff --git a/src/github/queries/user_repos.rs b/src/github/queries/user_repos.rs new file mode 100644 index 000000000..f0dba6769 --- /dev/null +++ b/src/github/queries/user_repos.rs @@ -0,0 +1,77 @@ +use anyhow::Context; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use crate::github::GithubClient; + +#[derive(Debug, Clone)] +pub struct UserRepository { + pub name: String, + pub owner: String, + pub created_at: DateTime, + pub fork: bool, +} + +#[derive(Deserialize)] +struct RepoResponse { + name: String, + owner: OwnerResponse, + created_at: DateTime, + fork: bool, +} + +#[derive(Deserialize)] +struct OwnerResponse { + login: String, +} + +impl GithubClient { + /// Fetches recently created repositories for a GitHub user. + /// + /// Returns up to `limit` repositories, sorted by creation date (most recent first). + pub async fn recent_user_repositories( + &self, + username: &str, + limit: usize, + ) -> anyhow::Result> { + // GitHub allows at most 100 items per page. + const MAX_PAGE_SIZE: usize = 100; + + let mut repos: Vec = Vec::new(); + let mut page: u32 = 1; + + loop { + let per_page = limit.saturating_sub(repos.len()).min(MAX_PAGE_SIZE); + let per_page_str = per_page.to_string(); + let page_str = page.to_string(); + let url = format!("{}/users/{username}/repos", self.api_url); + let page_repos: Vec = self + .json(self.get(&url).query(&[ + ("sort", "created"), + ("direction", "desc"), + ("per_page", per_page_str.as_str()), + ("page", page_str.as_str()), + ])) + .await + .with_context(|| format!("failed to fetch repositories for {username}"))?; + + let is_last_page = page_repos.len() < per_page; + + repos.extend(page_repos.into_iter().map(|r| UserRepository { + name: r.name, + owner: r.owner.login, + created_at: r.created_at, + fork: r.fork, + })); + + if is_last_page || repos.len() >= limit { + break; + } + + page += 1; + } + + repos.truncate(limit); + Ok(repos) + } +} diff --git a/src/handlers/report_user_bans.rs b/src/handlers/report_user_bans.rs index 42ce3ad5b..280a0d024 100644 --- a/src/handlers/report_user_bans.rs +++ b/src/handlers/report_user_bans.rs @@ -39,7 +39,7 @@ pub async fn handle(ctx: &Context, event: &OrgBlockEvent) -> anyhow::Result<()> let org = &event.organization.login; match ctx .github - .user_comments_in_org(username, org, MAX_RECENT_COMMENTS) + .recent_user_comments_in_org(username, org, MAX_RECENT_COMMENTS) .await { Ok(comments) if !comments.is_empty() => { @@ -62,7 +62,7 @@ pub async fn handle(ctx: &Context, event: &OrgBlockEvent) -> anyhow::Result<()> match ctx .github - .user_prs_in_org(username, org, MAX_RECENT_PRS) + .recent_user_prs_in_org(username, org, MAX_RECENT_PRS) .await { Ok(prs) if !prs.is_empty() => { diff --git a/src/zulip.rs b/src/zulip.rs index 7076fb3a5..e1aec791d 100644 --- a/src/zulip.rs +++ b/src/zulip.rs @@ -2,8 +2,9 @@ pub mod api; pub mod client; mod commands; -use crate::db::notifications::add_metadata; -use crate::db::notifications::{self, Identifier, delete_ping, move_indices, record_ping}; +use crate::db::notifications::{ + self, Identifier, add_metadata, delete_ping, move_indices, record_ping, +}; use crate::db::review_prefs::{ ReviewPreferences, RotationMode, get_review_prefs, get_review_prefs_batch, upsert_repo_review_prefs, upsert_team_review_prefs, upsert_user_review_prefs, @@ -11,7 +12,8 @@ use crate::db::review_prefs::{ use crate::db::users::DbUser; use crate::github; use crate::github::queries::user_comments_in_org::UserComment; -use crate::github::queries::user_prs_in_org::{PullRequestState, UserPullRequest}; +use crate::github::queries::user_prs::PullRequestState; +use crate::github::queries::user_prs::UserPullRequest; use crate::handlers::Context; use crate::handlers::docs_update::docs_update; use crate::handlers::pr_tracking::{ReviewerWorkqueue, get_assigned_prs}; @@ -29,12 +31,13 @@ use axum::Json; use axum::extract::State; use axum::extract::rejection::JsonRejection; use axum::response::IntoResponse; +use chrono::{DateTime, Duration, Utc}; use commands::BackportArgs; use itertools::Itertools; use rust_team_data::v1::{TeamKind, TeamMember}; use secrecy::{ExposeSecret, SecretString}; use std::cmp::{Ordering, Reverse}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use std::sync::Arc; use subtle::ConstantTimeEq; @@ -273,10 +276,10 @@ async fn handle_command<'a>( ping_goals_cmd(ctx.clone(), gh_id, message_data, args).await } ChatCommand::DocsUpdate => trigger_docs_update(message_data, &ctx.zulip), - ChatCommand::Comments { + ChatCommand::UserInfo { username, organization, - } => recent_comments_cmd(&ctx, gh_id, username, &organization) + } => user_info_cmd(&ctx, gh_id, username, &organization) .await .map(Some), ChatCommand::TeamStats { name, repo } => { @@ -368,10 +371,10 @@ async fn handle_command<'a>( StreamCommand::Backport(args) => { accept_decline_backport(ctx, message_data, &args).await } - StreamCommand::Comments { + StreamCommand::UserInfo { username, organization, - } => recent_comments_cmd(&ctx, gh_id, &username, &organization) + } => user_info_cmd(&ctx, gh_id, &username, &organization) .await .map(Some), }; @@ -535,9 +538,9 @@ async fn ping_goals_cmd( } } -/// Output recent GitHub comments made by a given user in a given organization. +/// Output recent GitHub activity made by a given user (both globally and in a given organization). /// This command can only be used by team members. -async fn recent_comments_cmd( +async fn user_info_cmd( ctx: &Context, gh_id: u64, username: &str, @@ -555,32 +558,198 @@ async fn recent_comments_cmd( "This command is only available to team members." )); } - if ctx.team.repos().await?.repos.get(organization).is_none() { return Err(anyhow::anyhow!( "Organization `{organization}` is not managed by the team database." )); } + let pr_limit = 100; + let recent_days = 7; + + // Load data concurrently to make the command faster + let (user, user_prs, org_user_prs, user_repos) = futures::future::join4( + ctx.github.user_info(username), + ctx.github.recent_user_prs(username, pr_limit), + ctx.github + .recent_user_prs_in_org(username, organization, pr_limit), + ctx.github.recent_user_repositories(username, 100), + ) + .await; + let (user, user_prs, org_user_prs, user_repos) = (user?, user_prs?, org_user_prs?, user_repos?); + + let recent_date_cutoff = Utc::now() - Duration::days(recent_days as i64); + + let all_prs_stats = analyze_pr_stats(&user_prs, pr_limit, recent_days); + let org_prs_stats = analyze_pr_stats(&org_user_prs, pr_limit, recent_days); + + let pr_bandwidth_msg = |stats: &PullRequestStats, org: Option<&str>| { + stats + .oldest_pr_created_at + .map(|date| { + format!( + "Their most recent {} {}PRs were created during the past {} day(s).", + pr_limit.min(stats.pr_count as usize), + match org { + Some(org) => format!("`{org}` "), + None => String::new(), + }, + (Utc::now() - date).num_days() + ) + }) + .unwrap_or_else(|| "N/A".to_string()) + }; + + let pr_orgs = all_prs_stats + .recent_prs + .iter() + .map(|pr| pr.repo_owner.clone()) + .collect::>(); + let pr_repo_count = all_prs_stats + .recent_prs + .iter() + .map(|pr| format!("{}/{}", pr.repo_owner, pr.repo_name)) + .collect::>() + .len(); + let org_pr_count = pr_orgs.len(); + let opened_orgs = if org_pr_count > 0 { + let mut orgs = pr_orgs.into_iter().collect::>(); + orgs.sort(); + let orgs = orgs + .into_iter() + .take(10) + .map(|org| format!("`{org}`")) + .join(", "); + format!(" ({orgs})") + } else { + String::new() + }; + let mut open_prs = 0; + let mut closed_prs = 0; + let mut merged_prs = 0; + for pr in &all_prs_stats.recent_prs { + match pr.state { + PullRequestState::Open => open_prs += 1, + PullRequestState::Closed => closed_prs += 1, + PullRequestState::Merged => merged_prs += 1, + } + } + let mut org_open_prs = 0; + let mut org_closed_prs = 0; + let mut org_merged_prs = 0; + for pr in &org_prs_stats.recent_prs { + match pr.state { + PullRequestState::Open => org_open_prs += 1, + PullRequestState::Closed => org_closed_prs += 1, + PullRequestState::Merged => org_merged_prs += 1, + } + } + let recently_opened_repos = user_repos + .iter() + .filter(|repo| repo.created_at >= recent_date_cutoff) + .collect::>(); + let recent_forks = recently_opened_repos + .iter() + .filter(|repo| repo.fork) + .count(); + + let mut message = format!( + r#"# User `{username}` activity + +- Account created at: {date} ({ago}) +- Public repository count: {repos} +- Repositories created in past {recent_days} days: `{recent_repo_count}` ({recent_forks} of those are forks) + +## Overall GitHub activity +In past {recent_days} days, the user opened `{recent_pr_count}{more_prs}` PRs ({open_prs} open, {merged_prs} merged, {closed_prs} closed). + +The PRs were opened in `{pr_repo_count}` repo(s) and `{org_pr_count}` organization(s){opened_orgs}. + +{pr_oldest_msg} + +## `{organization}` activity +In past {recent_days} days, the user opened `{org_recent_pr_count}{org_more_prs}` PRs ({org_open_prs} open, {org_merged_prs} merged, {org_closed_prs} closed). + +{org_pr_oldest_msg} +"#, + date = format_date(Some(user.created_at)), + ago = format!("`{}` days ago", (Utc::now() - user.created_at).num_days()), + repos = user.public_repos, + recent_pr_count = all_prs_stats.recent_pr_count, + org_recent_pr_count = org_prs_stats.recent_pr_count, + more_prs = if all_prs_stats.maybe_has_more { + "+" + } else { + "" + }, + org_more_prs = if org_prs_stats.maybe_has_more { + "+" + } else { + "" + }, + pr_oldest_msg = pr_bandwidth_msg(&all_prs_stats, None), + org_pr_oldest_msg = pr_bandwidth_msg(&org_prs_stats, Some(organization)), + recent_repo_count = recently_opened_repos.len() + ); + let comments = ctx .github - .user_comments_in_org(username, organization, RECENT_COMMENTS_LIMIT) + .recent_user_comments_in_org(username, organization, RECENT_COMMENTS_LIMIT) .await .context("Cannot load recent comments")?; - if comments.is_empty() { - return Ok(format!( - "No recent comments found for **{username}** in the `{organization}` organization." - )); + if !comments.is_empty() { + message.push_str(&format!("\n\n## Recent comments in `{organization}`\n")); + for comment in &comments { + message.push_str(&format_user_comment(comment)); + } } - let mut message = format!("**Recent comments by {username} in `{organization}`:**\n"); - for comment in &comments { - message.push_str(&format_user_comment(comment)); - } Ok(message) } +struct PullRequestStats<'a> { + pr_count: u64, + recent_prs: Vec<&'a UserPullRequest>, + recent_pr_count: u64, + // Does the user possibly have more recent PRs than `recent_count`? + // This can happen when we do not fetch enough PRs from GitHub. + maybe_has_more: bool, + // At what time was the oldest PR that we know of created? + // None if there are no PRs. + oldest_pr_created_at: Option>, +} + +fn analyze_pr_stats( + prs: &[UserPullRequest], + pr_limit: usize, + recent_days: u32, +) -> PullRequestStats<'_> { + let recent_pr_cutoff = Utc::now() - Duration::days(recent_days as i64); + let recent_prs: Vec<&UserPullRequest> = prs + .iter() + .filter(|pr| { + let Some(date) = pr.created_at else { + return false; + }; + date >= recent_pr_cutoff + }) + .collect(); + let oldest_pr_created_at = prs.last().and_then(|pr| pr.created_at); + let maybe_has_more = oldest_pr_created_at + .map(|date| date > recent_pr_cutoff) + .unwrap_or(false) + && prs.len() == pr_limit; + + PullRequestStats { + pr_count: prs.len() as u64, + recent_pr_count: recent_prs.len() as u64, + recent_prs, + maybe_has_more, + oldest_pr_created_at, + } +} + async fn team_status_cmd( ctx: &Context, team_name: &str, @@ -823,7 +992,7 @@ fn get_cmd_impersonation_mode(cmd: &ChatCommand) -> ImpersonationMode { | ChatCommand::Meta { .. } | ChatCommand::DocsUpdate | ChatCommand::PingGoals(_) - | ChatCommand::Comments { .. } + | ChatCommand::UserInfo { .. } | ChatCommand::TeamStats { .. } | ChatCommand::Lookup(_) => ImpersonationMode::Disabled, ChatCommand::Whoami => ImpersonationMode::Silent, @@ -1458,10 +1627,7 @@ fn trigger_docs_update(message: &Message, zulip: &ZulipClient) -> anyhow::Result pub fn format_user_comment(comment: &UserComment) -> String { // Limit the size of the comment to avoid running into Zulip max message size limits let snippet = truncate_and_normalize(&comment.body, 300); - let date = comment - .created_at - .map(|dt| format!("", dt.to_rfc3339())) - .unwrap_or_else(|| "unknown date".to_string()); + let date = format_date(comment.created_at); format!( "- {title} ([{repo}#{number}]({comment_url}), {date}):\n > {snippet}\n", @@ -1475,10 +1641,7 @@ pub fn format_user_comment(comment: &UserComment) -> String { /// Formats user's GitHub PR for display in the Zulip message. pub fn format_user_pr(pr: &UserPullRequest) -> String { let snippet = truncate_and_normalize(&pr.body, 300); - let date = pr - .created_at - .map(|dt| format!("", dt.to_rfc3339())) - .unwrap_or_else(|| "unknown date".to_string()); + let date = format_date(pr.created_at); let pre_snippet = if snippet.is_empty() { // Using empty > without text would break following lines "" @@ -1500,14 +1663,22 @@ pub fn format_user_pr(pr: &UserPullRequest) -> String { ) } +fn format_date(date: Option>) -> String { + date.map(|dt| format!("", dt.to_rfc3339())) + .unwrap_or_else(|| "unknown date".to_string()) +} + /// Truncates the given text to the specified length, adding ellipsis if needed. -/// Also removes backticks from it. +/// Also removes various special Markdown symbols from it. fn truncate_and_normalize(text: &str, max_len: usize) -> String { let normalized: String = text .split_whitespace() .collect::>() .join(" ") - .replace("`", ""); + // Avoid things that could interact with the rest of the Markdown message. + // And avoid and @ pings (in case the text is outputted in a public Zulip stream). + .replace("`", "") + .replace("@", ""); if normalized.len() <= max_len { normalized diff --git a/src/zulip/commands.rs b/src/zulip/commands.rs index a36e89c7e..0cc60e6b7 100644 --- a/src/zulip/commands.rs +++ b/src/zulip/commands.rs @@ -43,11 +43,13 @@ pub enum ChatCommand { PingGoals(PingGoalsArgs), /// Update docs DocsUpdate, - /// Show recent GitHub comments of a user in the rust-lang organization. - Comments { + /// Show recent GitHub activity of a user. + /// + /// It also shows scoped information in a selected organization. + UserInfo { /// GitHub username to look up. username: String, - /// Organization where to find the comments. + /// Organization where to find the user's activity. #[arg(long = "org", default_value = "rust-lang")] organization: String, }, @@ -198,11 +200,11 @@ pub enum StreamCommand { DocsUpdate, /// Accept or decline a backport. Backport(BackportArgs), - /// Show recent GitHub comments of a user in the rust-lang organization. - Comments { + /// Show recent GitHub activity of a user. + UserInfo { /// GitHub username to look up. username: String, - /// Organization where to find the comments. + /// Organization where to find the activity. #[arg(long = "org", default_value = "rust-lang")] organization: String, }, @@ -446,17 +448,17 @@ mod tests { } #[test] - fn recent_comments_command() { + fn recent_activity_command() { assert_eq!( - parse_chat(&["comments", "octocat"]), - ChatCommand::Comments { + parse_chat(&["user-info", "octocat"]), + ChatCommand::UserInfo { username: "octocat".to_string(), organization: "rust-lang".to_string() } ); assert_eq!( - parse_chat(&["comments", "foobar", "--org", "rust-lang-nursery"]), - ChatCommand::Comments { + parse_chat(&["user-info", "foobar", "--org", "rust-lang-nursery"]), + ChatCommand::UserInfo { username: "foobar".to_string(), organization: "rust-lang-nursery".to_string() }