Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/gh_comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 => "👎",
Expand Down
1 change: 1 addition & 0 deletions src/github.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/github/queries/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub(crate) mod issue_with_comments;
pub(crate) mod user_comments_in_org;
pub(crate) mod user_prs_in_org;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand All @@ -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 }
Expand All @@ -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("");

Expand All @@ -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(),
Expand Down
119 changes: 119 additions & 0 deletions src/github/queries/user_prs_in_org.rs
Original file line number Diff line number Diff line change
@@ -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<DateTime<Utc>>,
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<Vec<UserPullRequest>> {
// 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<UserPullRequest> = 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)
}
}
3 changes: 0 additions & 3 deletions src/github/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
26 changes: 26 additions & 0 deletions src/handlers/report_user_bans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 43 additions & 7 deletions src/zulip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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!("<time:{}>", 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!("<time:{}>", 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::<Vec<_>>().join(" ");
/// Also removes backticks from it.
fn truncate_and_normalize(text: &str, max_len: usize) -> String {
let normalized: String = text
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace("`", "");

if normalized.len() <= max_len {
normalized
Expand Down