diff --git a/src/gh_comments.rs b/src/gh_comments.rs index 9df5faa82..21e69c387 100644 --- a/src/gh_comments.rs +++ b/src/gh_comments.rs @@ -16,7 +16,7 @@ use hyper::{ use crate::{ cache, - github::issue_with_comments::{ + github::queries::issue_with_comments::{ GitHubGraphQlComment, GitHubGraphQlReactionGroup, GitHubGraphQlReviewThreadComment, GitHubIssueState, GitHubIssueStateReason, GitHubIssueWithComments, GitHubReviewState, GitHubSimplifiedAuthor, @@ -726,7 +726,7 @@ fn write_reaction_groups_as_html( continue; } - use crate::github::issue_with_comments::GitHubGraphQlReactionContent::*; + use crate::github::queries::issue_with_comments::GitHubGraphQlReactionContent::*; let emoji = match reaction_group.content { ThumbsUp => "👍", ThumbsDown => "👎", diff --git a/src/github.rs b/src/github.rs index 70d7062a5..24e311525 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,6 +1,7 @@ pub(crate) mod client; pub(crate) mod issue; pub(crate) mod issue_query; +pub(crate) mod queries; pub(crate) mod repos; pub(crate) mod utils; mod webhook; diff --git a/src/github/repos/issue_with_comments.rs b/src/github/queries/issue_with_comments.rs similarity index 100% rename from src/github/repos/issue_with_comments.rs rename to src/github/queries/issue_with_comments.rs diff --git a/src/github/queries/mod.rs b/src/github/queries/mod.rs new file mode 100644 index 000000000..2e1fa8a9e --- /dev/null +++ b/src/github/queries/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod issue_with_comments; +pub(crate) mod user_comments_in_org; +pub(crate) mod user_prs_in_org; diff --git a/src/github/repos/user_comments_in_org.rs b/src/github/queries/user_comments_in_org.rs similarity index 85% rename from src/github/repos/user_comments_in_org.rs rename to src/github/queries/user_comments_in_org.rs index bafe1c827..66220411d 100644 --- a/src/github/repos/user_comments_in_org.rs +++ b/src/github/queries/user_comments_in_org.rs @@ -6,6 +6,9 @@ use crate::github::GithubClient; /// A comment made by a user on an issue or PR. #[derive(Debug, Clone)] pub struct UserComment { + pub repo_owner: String, + pub repo_name: String, + pub issue_number: u64, pub issue_title: String, pub issue_url: String, pub comment_url: String, @@ -46,8 +49,13 @@ query($query: String!, $issueLimit: Int!, $commentLimit: Int!) { search(query: $query, type: ISSUE, first: $issueLimit) { nodes { ... on Issue { + number url title + repository { + name + owner { login } + } comments(first: $commentLimit, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { author { login } @@ -58,8 +66,13 @@ query($query: String!, $issueLimit: Int!, $commentLimit: Int!) { } } ... on PullRequest { + number url title + repository { + name + owner { login } + } comments(first: $commentLimit, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { author { login } @@ -86,6 +99,15 @@ query($query: String!, $issueLimit: Int!, $commentLimit: Int!) { if let Some(nodes) = data["data"]["search"]["nodes"].as_array() { for node in nodes { + 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 issue_number = node["number"].as_u64().unwrap_or(0); let issue_title = node["title"].as_str().unwrap_or("Unknown"); let issue_url = node["url"].as_str().unwrap_or(""); @@ -105,6 +127,9 @@ query($query: String!, $issueLimit: Int!, $commentLimit: Int!) { .map(|dt| dt.with_timezone(&chrono::Utc)); all_comments.push(UserComment { + repo_owner: repo_owner.clone(), + repo_name: repo_name.clone(), + issue_number, issue_title: issue_title.to_string(), issue_url: issue_url.to_string(), comment_url: url.to_string(), diff --git a/src/github/queries/user_prs_in_org.rs b/src/github/queries/user_prs_in_org.rs new file mode 100644 index 000000000..23ee18585 --- /dev/null +++ b/src/github/queries/user_prs_in_org.rs @@ -0,0 +1,119 @@ +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/repos.rs b/src/github/repos.rs index cf2060452..adc38eaf2 100644 --- a/src/github/repos.rs +++ b/src/github/repos.rs @@ -13,9 +13,6 @@ use reqwest::StatusCode; use std::collections::HashSet; use tracing as log; -pub(crate) mod issue_with_comments; -pub(crate) mod user_comments_in_org; - // User #[derive(Debug, PartialEq, Eq, Hash, serde::Deserialize, Clone)] diff --git a/src/handlers/report_user_bans.rs b/src/handlers/report_user_bans.rs index cad5c8946..42ce3ad5b 100644 --- a/src/handlers/report_user_bans.rs +++ b/src/handlers/report_user_bans.rs @@ -13,6 +13,9 @@ const ZULIP_STREAM_ID: u64 = 464799; /// Maximum number of recent comments to include in ban reports. const MAX_RECENT_COMMENTS: usize = 10; +/// Maximum number of recent PRs to include in ban reports. +const MAX_RECENT_PRS: usize = 10; + pub async fn handle(ctx: &Context, event: &OrgBlockEvent) -> anyhow::Result<()> { let topic = format!("github user {}", event.blocked_user.login); @@ -56,6 +59,29 @@ pub async fn handle(ctx: &Context, event: &OrgBlockEvent) -> anyhow::Result<()> message.push_str("\n\n*Could not fetch recent comments.*"); } } + + match ctx + .github + .user_prs_in_org(username, org, MAX_RECENT_PRS) + .await + { + Ok(prs) if !prs.is_empty() => { + message.push_str("\n\n**Recent PRs:**\n"); + for pr in prs { + message.push_str(&zulip::format_user_pr(&pr)); + } + } + Ok(_) => { + message.push_str("\n\n*No recent PRs found in this organization.*"); + } + Err(err) => { + log::warn!( + "Failed to fetch recent prs for {}: {err:?}", + event.blocked_user.login + ); + message.push_str("\n\n*Could not fetch recent PRs.*"); + } + } } let recipient = Recipient::Stream { diff --git a/src/zulip.rs b/src/zulip.rs index e27fc3764..52fb19a7b 100644 --- a/src/zulip.rs +++ b/src/zulip.rs @@ -8,7 +8,8 @@ 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, }; -use crate::github::user_comments_in_org::UserComment; +use crate::github::queries::user_comments_in_org::UserComment; +use crate::github::queries::user_prs_in_org::{PullRequestState, UserPullRequest}; use crate::github::{self, User}; use crate::handlers::Context; use crate::handlers::docs_update::docs_update; @@ -1459,22 +1460,57 @@ fn trigger_docs_update(message: &Message, zulip: &ZulipClient) -> anyhow::Result /// Formats user's GitHub comment for display in the Zulip message. 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_text(&comment.body, 300); + let snippet = truncate_and_normalize(&comment.body, 300); let date = comment .created_at - .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) + .map(|dt| format!("", dt.to_rfc3339())) .unwrap_or_else(|| "unknown date".to_string()); format!( - "- [{title}]({comment_url}) ({date}):\n > {snippet}\n", - title = truncate_text(&comment.issue_title, 60), + "- [{title}]({comment_url}) (`{repo}#{number}`, {date}):\n > {snippet}\n", + title = truncate_and_normalize(&comment.issue_title, 60), comment_url = comment.comment_url, + repo = comment.repo_name, + number = comment.issue_number + ) +} + +/// 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 pre_snippet = if snippet.is_empty() { + // Using empty > without text would break following lines + "" + } else { + "\n >" + }; + + format!( + "- [{title}]({pr_url}) (`{repo}#{number}`, {date}) {state}{pre_snippet}{snippet}\n", + title = truncate_and_normalize(&pr.title, 60), + repo = pr.repo_name, + number = pr.number, + pr_url = pr.url, + state = match pr.state { + PullRequestState::Open => ":green_circle:", + PullRequestState::Closed => ":red_circle:", + PullRequestState::Merged => ":purple_circle:", + } ) } /// Truncates the given text to the specified length, adding ellipsis if needed. -fn truncate_text(text: &str, max_len: usize) -> String { - let normalized: String = text.split_whitespace().collect::>().join(" "); +/// Also removes backticks from it. +fn truncate_and_normalize(text: &str, max_len: usize) -> String { + let normalized: String = text + .split_whitespace() + .collect::>() + .join(" ") + .replace("`", ""); if normalized.len() <= max_len { normalized