Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
48 changes: 42 additions & 6 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())
.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| dt.format("%Y-%m-%d %H:%M UTC").to_string())
Comment thread
Kobzol marked this conversation as resolved.
Outdated
.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