use std::{collections::HashMap, fmt, ops::Not, rc::Rc}; use anyhow::Result; use derive_more::Deref; use serde::Deserialize; use crate::git::CommitSha; pub const PR_REVIEW_LABEL: &str = "PR state:needs review"; #[derive(Debug, Clone)] pub struct GitHubUser { pub login: String, } #[derive(Debug, Clone)] pub struct PullRequestData { pub number: u64, pub user: Option, pub merged_by: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReviewState { Approved, Other, } #[derive(Debug, Clone)] pub struct PullRequestReview { pub user: Option, pub state: Option, } #[derive(Debug, Clone)] pub struct PullRequestComment { pub user: GitHubUser, pub body: Option, } #[derive(Debug, Deserialize, Clone, Deref, PartialEq, Eq)] pub struct GithubLogin { login: String, } impl GithubLogin { pub(crate) fn new(login: String) -> Self { Self { login } } } impl fmt::Display for GithubLogin { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "@{}", self.login) } } #[derive(Debug, Deserialize, Clone)] pub struct CommitAuthor { name: String, email: String, user: Option, } impl CommitAuthor { pub(crate) fn user(&self) -> Option<&GithubLogin> { self.user.as_ref() } } impl PartialEq for CommitAuthor { fn eq(&self, other: &Self) -> bool { self.user.as_ref().zip(other.user.as_ref()).map_or_else( || self.email == other.email || self.name == other.name, |(l, r)| l == r, ) } } impl fmt::Display for CommitAuthor { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self.user.as_ref() { Some(user) => write!(formatter, "{} ({user})", self.name), None => write!(formatter, "{} ({})", self.name, self.email), } } } #[derive(Debug, Deserialize)] pub struct CommitAuthors { #[serde(rename = "author")] primary_author: CommitAuthor, #[serde(rename = "authors")] co_authors: Vec, } impl CommitAuthors { pub fn co_authors(&self) -> Option> { self.co_authors.is_empty().not().then(|| { self.co_authors .iter() .filter(|co_author| *co_author != &self.primary_author) }) } } #[derive(Debug, Deserialize, Deref)] pub struct AuthorsForCommits(HashMap); #[async_trait::async_trait(?Send)] pub trait GitHubApiClient { async fn get_pull_request(&self, pr_number: u64) -> Result; async fn get_pull_request_reviews(&self, pr_number: u64) -> Result>; async fn get_pull_request_comments(&self, pr_number: u64) -> Result>; async fn get_commit_authors(&self, commit_shas: &[&CommitSha]) -> Result; async fn check_org_membership(&self, login: &GithubLogin) -> Result; async fn ensure_pull_request_has_label(&self, label: &str, pr_number: u64) -> Result<()>; } pub struct GitHubClient { api: Rc, } impl GitHubClient { pub fn new(api: Rc) -> Self { Self { api } } #[cfg(feature = "octo-client")] pub async fn for_app(app_id: u64, app_private_key: &str) -> Result { let client = OctocrabClient::new(app_id, app_private_key).await?; Ok(Self::new(Rc::new(client))) } pub async fn get_pull_request(&self, pr_number: u64) -> Result { self.api.get_pull_request(pr_number).await } pub async fn get_pull_request_reviews(&self, pr_number: u64) -> Result> { self.api.get_pull_request_reviews(pr_number).await } pub async fn get_pull_request_comments( &self, pr_number: u64, ) -> Result> { self.api.get_pull_request_comments(pr_number).await } pub async fn get_commit_authors<'a>( &self, commit_shas: impl IntoIterator, ) -> Result { let shas: Vec<&CommitSha> = commit_shas.into_iter().collect(); self.api.get_commit_authors(&shas).await } pub async fn check_org_membership(&self, login: &GithubLogin) -> Result { self.api.check_org_membership(login).await } pub async fn add_label_to_pull_request(&self, label: &str, pr_number: u64) -> Result<()> { self.api .ensure_pull_request_has_label(label, pr_number) .await } } #[cfg(feature = "octo-client")] mod octo_client { use anyhow::{Context, Result}; use futures::TryStreamExt as _; use itertools::Itertools; use jsonwebtoken::EncodingKey; use octocrab::{ Octocrab, Page, models::pulls::ReviewState as OctocrabReviewState, service::middleware::cache::mem::InMemoryCache, }; use serde::de::DeserializeOwned; use tokio::pin; use crate::git::CommitSha; use super::{ AuthorsForCommits, GitHubApiClient, GitHubUser, GithubLogin, PullRequestComment, PullRequestData, PullRequestReview, ReviewState, }; const PAGE_SIZE: u8 = 100; const ORG: &str = "zed-industries"; const REPO: &str = "zed"; pub struct OctocrabClient { client: Octocrab, } impl OctocrabClient { pub async fn new(app_id: u64, app_private_key: &str) -> Result { let octocrab = Octocrab::builder() .cache(InMemoryCache::new()) .app( app_id.into(), EncodingKey::from_rsa_pem(app_private_key.as_bytes())?, ) .build()?; let installations = octocrab .apps() .installations() .send() .await .context("Failed to fetch installations")? .take_items(); let installation_id = installations .into_iter() .find(|installation| installation.account.login == ORG) .context("Could not find Zed repository in installations")? .id; let client = octocrab.installation(installation_id)?; Ok(Self { client }) } fn build_co_authors_query<'a>(shas: impl IntoIterator) -> String { const FRAGMENT: &str = r#" ... on Commit { author { name email user { login } } authors(first: 10) { nodes { name email user { login } } } } "#; let objects: String = shas .into_iter() .map(|commit_sha| { format!( "commit{sha}: object(oid: \"{sha}\") {{ {FRAGMENT} }}", sha = **commit_sha ) }) .join("\n"); format!("{{ repository(owner: \"{ORG}\", name: \"{REPO}\") {{ {objects} }} }}") .replace("\n", "") } async fn graphql( &self, query: &serde_json::Value, ) -> octocrab::Result { self.client.graphql(query).await } async fn get_all( &self, page: Page, ) -> octocrab::Result> { self.get_filtered(page, |_| true).await } async fn get_filtered( &self, page: Page, predicate: impl Fn(&T) -> bool, ) -> octocrab::Result> { let stream = page.into_stream(&self.client); pin!(stream); let mut results = Vec::new(); while let Some(item) = stream.try_next().await? && predicate(&item) { results.push(item); } Ok(results) } } #[async_trait::async_trait(?Send)] impl GitHubApiClient for OctocrabClient { async fn get_pull_request(&self, pr_number: u64) -> Result { let pr = self.client.pulls(ORG, REPO).get(pr_number).await?; Ok(PullRequestData { number: pr.number, user: pr.user.map(|user| GitHubUser { login: user.login }), merged_by: pr.merged_by.map(|user| GitHubUser { login: user.login }), }) } async fn get_pull_request_reviews(&self, pr_number: u64) -> Result> { let page = self .client .pulls(ORG, REPO) .list_reviews(pr_number) .per_page(PAGE_SIZE) .send() .await?; let reviews = self.get_all(page).await?; Ok(reviews .into_iter() .map(|review| PullRequestReview { user: review.user.map(|user| GitHubUser { login: user.login }), state: review.state.map(|state| match state { OctocrabReviewState::Approved => ReviewState::Approved, _ => ReviewState::Other, }), }) .collect()) } async fn get_pull_request_comments( &self, pr_number: u64, ) -> Result> { let page = self .client .issues(ORG, REPO) .list_comments(pr_number) .per_page(PAGE_SIZE) .send() .await?; let comments = self.get_all(page).await?; Ok(comments .into_iter() .map(|comment| PullRequestComment { user: GitHubUser { login: comment.user.login, }, body: comment.body, }) .collect()) } async fn get_commit_authors( &self, commit_shas: &[&CommitSha], ) -> Result { let query = Self::build_co_authors_query(commit_shas.iter().copied()); let query = serde_json::json!({ "query": query }); let mut response = self.graphql::(&query).await?; response .get_mut("data") .and_then(|data| data.get_mut("repository")) .and_then(|repo| repo.as_object_mut()) .ok_or_else(|| anyhow::anyhow!("Unexpected response format!")) .and_then(|commit_data| { let mut response_map = serde_json::Map::with_capacity(commit_data.len()); for (key, value) in commit_data.iter_mut() { let key_without_prefix = key.strip_prefix("commit").unwrap_or(key); if let Some(authors) = value.get_mut("authors") { if let Some(nodes) = authors.get("nodes") { *authors = nodes.clone(); } } response_map.insert(key_without_prefix.to_owned(), value.clone()); } serde_json::from_value(serde_json::Value::Object(response_map)) .context("Failed to deserialize commit authors") }) } async fn check_org_membership(&self, login: &GithubLogin) -> Result { let page = self .client .orgs(ORG) .list_members() .per_page(PAGE_SIZE) .send() .await?; let members = self.get_all(page).await?; Ok(members .into_iter() .any(|member| member.login == login.as_str())) } async fn ensure_pull_request_has_label(&self, label: &str, pr_number: u64) -> Result<()> { if self .get_filtered( self.client .issues(ORG, REPO) .list_labels_for_issue(pr_number) .per_page(PAGE_SIZE) .send() .await?, |pr_label| pr_label.name == label, ) .await .is_ok_and(|l| l.is_empty()) { self.client .issues(ORG, REPO) .add_labels(pr_number, &[label.to_owned()]) .await?; } Ok(()) } } } #[cfg(feature = "octo-client")] pub use octo_client::OctocrabClient;