use std::{borrow::Cow, collections::HashMap, fmt}; 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<GithubUser>, pub merged_by: Option<GithubUser>, pub labels: Option<Vec<String>>,} #[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum ReviewState { Approved, Other,} #[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum AuthorAssociation { Owner, Member, Collaborator, Contributor, FirstTimeContributor, FirstTimer, Mannequin, None,} impl AuthorAssociation { pub fn has_write_access(&self) -> bool { matches!(self, Self::Owner | Self::Member | Self::Collaborator) }} pub trait Approvable { fn author_login(&self) -> Option<&str>; fn review_state(&self) -> Option<ReviewState>; fn body(&self) -> Option<&str>; fn author_association(&self) -> Option<AuthorAssociation>;} #[derive(Debug, Clone)]pub struct PullRequestReview { pub user: Option<GithubUser>, pub state: Option<ReviewState>, pub body: Option<String>, pub author_association: Option<AuthorAssociation>,} impl PullRequestReview { pub fn with_body(self, body: impl ToString) -> Self { Self { body: Some(body.to_string()), ..self } }} impl Approvable for PullRequestReview { fn author_login(&self) -> Option<&str> { self.user.as_ref().map(|user| user.login.as_str()) } fn review_state(&self) -> Option<ReviewState> { self.state } fn body(&self) -> Option<&str> { self.body.as_deref() } fn author_association(&self) -> Option<AuthorAssociation> { self.author_association }} #[derive(Debug, Clone)]pub struct PullRequestComment { pub user: GithubUser, pub body: Option<String>, pub author_association: Option<AuthorAssociation>,} impl Approvable for PullRequestComment { fn author_login(&self) -> Option<&str> { Some(&self.user.login) } fn review_state(&self) -> Option<ReviewState> { None } fn body(&self) -> Option<&str> { self.body.as_deref() } fn author_association(&self) -> Option<AuthorAssociation> { self.author_association }} #[derive(Debug, Deserialize, Clone, Deref, PartialEq, Eq)]pub struct GithubLogin { login: String,} impl GithubLogin { pub 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<GithubLogin>,} 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, Clone)]pub struct CommitSignature { #[serde(rename = "isValid")] is_valid: bool, signer: Option<GithubLogin>,} impl CommitSignature { pub fn is_valid(&self) -> bool { self.is_valid } pub fn signer(&self) -> Option<&GithubLogin> { self.signer.as_ref() }} #[derive(Debug, Clone, Deserialize)]pub struct CommitFileChange { pub filename: String,} #[derive(Debug, Deserialize)]pub struct CommitMetadata { #[serde(rename = "author")] primary_author: CommitAuthor, #[serde(rename = "authors", deserialize_with = "graph_ql::deserialize_nodes")] co_authors: Vec<CommitAuthor>, #[serde(default)] signature: Option<CommitSignature>, #[serde(default)] additions: u64, #[serde(default)] deletions: u64,} impl CommitMetadata { pub fn co_authors(&self) -> Option<impl Iterator<Item = &CommitAuthor>> { let mut co_authors = self .co_authors .iter() .filter(|co_author| *co_author != &self.primary_author) .peekable(); co_authors.peek().is_some().then_some(co_authors) } pub fn primary_author(&self) -> &CommitAuthor { &self.primary_author } pub fn signature(&self) -> Option<&CommitSignature> { self.signature.as_ref() } pub fn additions(&self) -> u64 { self.additions } pub fn deletions(&self) -> u64 { self.deletions }} #[derive(Debug, Deref)]pub struct CommitMetadataBySha(HashMap<CommitSha, CommitMetadata>); impl CommitMetadataBySha { const SHA_PREFIX: &'static str = "commit";} impl<'de> serde::Deserialize<'de> for CommitMetadataBySha { fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error> where D: serde::Deserializer<'de>, { let raw = HashMap::<String, CommitMetadata>::deserialize(deserializer)?; let map = raw .into_iter() .map(|(key, value)| { let sha = key .strip_prefix(CommitMetadataBySha::SHA_PREFIX) .unwrap_or(&key); (CommitSha::new(sha.to_owned()), value) }) .collect(); Ok(Self(map)) }} #[derive(Clone)]pub struct Repository<'a> { owner: Cow<'a, str>, name: Cow<'a, str>,} impl<'a> Repository<'a> { pub const ZED: Repository<'static> = Repository::new_static("zed-industries", "zed"); pub fn new(owner: &'a str, name: &'a str) -> Self { Self { owner: Cow::Borrowed(owner), name: Cow::Borrowed(name), } } pub fn owner(&self) -> &str { &self.owner } pub fn name(&self) -> &str { &self.name }} impl Repository<'static> { pub const fn new_static(owner: &'static str, name: &'static str) -> Self { Self { owner: Cow::Borrowed(owner), name: Cow::Borrowed(name), } }} #[async_trait::async_trait(?Send)]pub trait GithubApiClient { async fn get_pull_request( &self, repo: &Repository<'_>, pr_number: u64, ) -> Result<PullRequestData>; async fn get_pull_request_reviews( &self, repo: &Repository<'_>, pr_number: u64, ) -> Result<Vec<PullRequestReview>>; async fn get_pull_request_comments( &self, repo: &Repository<'_>, pr_number: u64, ) -> Result<Vec<PullRequestComment>>; async fn get_commit_metadata( &self, repo: &Repository<'_>, commit_shas: &[&CommitSha], ) -> Result<CommitMetadataBySha>; async fn get_commit_files( &self, repo: &Repository<'_>, sha: &CommitSha, ) -> Result<Vec<CommitFileChange>>; async fn check_repo_write_permission( &self, repo: &Repository<'_>, login: &GithubLogin, ) -> Result<bool>; async fn add_label_to_issue( &self, repo: &Repository<'_>, label: &str, issue_number: u64, ) -> Result<()>;} pub mod graph_ql { use anyhow::{Context as _, Result}; use itertools::Itertools as _; use serde::Deserialize; use crate::git::CommitSha; use super::CommitMetadataBySha; #[derive(Debug, Deserialize)] pub struct GraphQLResponse<T> { pub data: Option<T>, pub errors: Option<Vec<GraphQLError>>, } impl<T> GraphQLResponse<T> { pub fn into_data(self) -> Result<T> { if let Some(errors) = &self.errors { if !errors.is_empty() { let messages: String = errors.iter().map(|e| e.message.as_str()).join("; "); anyhow::bail!("GraphQL error: {messages}"); } } self.data.context("GraphQL response contained no data") } } #[derive(Debug, Deserialize)] pub struct GraphQLError { pub message: String, } #[derive(Debug, Deserialize)] pub struct CommitMetadataResponse { pub repository: CommitMetadataBySha, } pub fn deserialize_nodes<'de, T, D>(deserializer: D) -> std::result::Result<Vec<T>, D::Error> where T: Deserialize<'de>, D: serde::Deserializer<'de>, { #[derive(Deserialize)] struct Nodes<T> { nodes: Vec<T>, } Nodes::<T>::deserialize(deserializer).map(|wrapper| wrapper.nodes) } pub fn build_commit_metadata_query<'a>( org: &str, repo: &str, shas: impl IntoIterator<Item = &'a CommitSha>, ) -> String { const FRAGMENT: &str = r#" ... on Commit { author { name email user { login } } authors(first: 10) { nodes { name email user { login } } } signature { isValid signer { login } } additions deletions } "#; let objects = shas .into_iter() .map(|commit_sha| { format!( "{sha_prefix}{sha}: object(oid: \"{sha}\") {{ {FRAGMENT} }}", sha_prefix = CommitMetadataBySha::SHA_PREFIX, sha = **commit_sha, ) }) .join("\n"); format!("{{ repository(owner: \"{org}\", name: \"{repo}\") {{ {objects} }} }}") .replace("\n", "") }} #[cfg(feature = "octo-client")]mod octo_client { use anyhow::{Context, Result}; use futures::TryStreamExt as _; use jsonwebtoken::EncodingKey; use octocrab::{ Octocrab, Page, models::{ AuthorAssociation as OctocrabAuthorAssociation, pulls::ReviewState as OctocrabReviewState, }, service::middleware::cache::mem::InMemoryCache, }; use serde::de::DeserializeOwned; use tokio::pin; use crate::{ git::CommitSha, github::{Repository, graph_ql}, }; use super::{ AuthorAssociation, CommitFileChange, CommitMetadataBySha, GithubApiClient, GithubLogin, GithubUser, PullRequestComment, PullRequestData, PullRequestReview, ReviewState, }; fn convert_author_association(association: OctocrabAuthorAssociation) -> AuthorAssociation { match association { OctocrabAuthorAssociation::Owner => AuthorAssociation::Owner, OctocrabAuthorAssociation::Member => AuthorAssociation::Member, OctocrabAuthorAssociation::Collaborator => AuthorAssociation::Collaborator, OctocrabAuthorAssociation::Contributor => AuthorAssociation::Contributor, OctocrabAuthorAssociation::FirstTimeContributor => { AuthorAssociation::FirstTimeContributor } OctocrabAuthorAssociation::FirstTimer => AuthorAssociation::FirstTimer, OctocrabAuthorAssociation::Mannequin => AuthorAssociation::Mannequin, OctocrabAuthorAssociation::None => AuthorAssociation::None, _ => AuthorAssociation::None, } } const PAGE_SIZE: u8 = 100; pub struct OctocrabClient { client: Octocrab, } impl OctocrabClient { pub async fn new(app_id: u64, app_private_key: &str, org: &str) -> Result<Self> { 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 }) } async fn graphql<R: DeserializeOwned>(&self, query: &serde_json::Value) -> Result<R> { let response: serde_json::Value = self.client.graphql(query).await?; let parsed: graph_ql::GraphQLResponse<R> = serde_json::from_value(response) .context("Failed to parse GraphQL response envelope")?;Lines 1–500 of 725. Load more
use std::{borrow::Cow, collections::HashMap, fmt}; 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<GithubUser>, pub merged_by: Option<GithubUser>, pub labels: Option<Vec<String>>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReviewState { Approved, Other, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthorAssociation { Owner, Member, Collaborator, Contributor, FirstTimeContributor, FirstTimer, Mannequin, None, } impl AuthorAssociation { pub fn has_write_access(&self) -> bool { matches!(self, Self::Owner | Self::Member | Self::Collaborator) } } pub trait Approvable { fn author_login(&self) -> Option<&str>; fn review_state(&self) -> Option<ReviewState>; fn body(&self) -> Option<&str>; fn author_association(&self) -> Option<AuthorAssociation>; } #[derive(Debug, Clone)] pub struct PullRequestReview { pub user: Option<GithubUser>, pub state: Option<ReviewState>, pub body: Option<String>, pub author_association: Option<AuthorAssociation>, } impl PullRequestReview { pub fn with_body(self, body: impl ToString) -> Self { Self { body: Some(body.to_string()), ..self } } } impl Approvable for PullRequestReview { fn author_login(&self) -> Option<&str> { self.user.as_ref().map(|user| user.login.as_str()) } fn review_state(&self) -> Option<ReviewState> { self.state } fn body(&self) -> Option<&str> { self.body.as_deref() } fn author_association(&self) -> Option<AuthorAssociation> { self.author_association } } #[derive(Debug, Clone)] pub struct PullRequestComment { pub user: GithubUser, pub body: Option<String>, pub author_association: Option<AuthorAssociation>, } impl Approvable for PullRequestComment { fn author_login(&self) -> Option<&str> { Some(&self.user.login) } fn review_state(&self) -> Option<ReviewState> { None } fn body(&self) -> Option<&str> { self.body.as_deref() } fn author_association(&self) -> Option<AuthorAssociation> { self.author_association } } #[derive(Debug, Deserialize, Clone, Deref, PartialEq, Eq)] pub struct GithubLogin { login: String, } impl GithubLogin { pub 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<GithubLogin>, } 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, Clone)] pub struct CommitSignature { #[serde(rename = "isValid")] is_valid: bool, signer: Option<GithubLogin>, } impl CommitSignature { pub fn is_valid(&self) -> bool { self.is_valid } pub fn signer(&self) -> Option<&GithubLogin> { self.signer.as_ref() } } #[derive(Debug, Clone, Deserialize)] pub struct CommitFileChange { pub filename: String, } #[derive(Debug, Deserialize)] pub struct CommitMetadata { #[serde(rename = "author")] primary_author: CommitAuthor, #[serde(rename = "authors", deserialize_with = "graph_ql::deserialize_nodes")] co_authors: Vec<CommitAuthor>, #[serde(default)] signature: Option<CommitSignature>, #[serde(default)] additions: u64, #[serde(default)] deletions: u64, } impl CommitMetadata { pub fn co_authors(&self) -> Option<impl Iterator<Item = &CommitAuthor>> { let mut co_authors = self .co_authors .iter() .filter(|co_author| *co_author != &self.primary_author) .peekable(); co_authors.peek().is_some().then_some(co_authors) } pub fn primary_author(&self) -> &CommitAuthor { &self.primary_author } pub fn signature(&self) -> Option<&CommitSignature> { self.signature.as_ref() } pub fn additions(&self) -> u64 { self.additions } pub fn deletions(&self) -> u64 { self.deletions } } #[derive(Debug, Deref)] pub struct CommitMetadataBySha(HashMap<CommitSha, CommitMetadata>); impl CommitMetadataBySha { const SHA_PREFIX: &'static str = "commit"; } impl<'de> serde::Deserialize<'de> for CommitMetadataBySha { fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error> where D: serde::Deserializer<'de>, { let raw = HashMap::<String, CommitMetadata>::deserialize(deserializer)?; let map = raw .into_iter() .map(|(key, value)| { let sha = key .strip_prefix(CommitMetadataBySha::SHA_PREFIX) .unwrap_or(&key); (CommitSha::new(sha.to_owned()), value) }) .collect(); Ok(Self(map)) } } #[derive(Clone)] pub struct Repository<'a> { owner: Cow<'a, str>, name: Cow<'a, str>, } impl<'a> Repository<'a> { pub const ZED: Repository<'static> = Repository::new_static("zed-industries", "zed"); pub fn new(owner: &'a str, name: &'a str) -> Self { Self { owner: Cow::Borrowed(owner), name: Cow::Borrowed(name), } } pub fn owner(&self) -> &str { &self.owner } pub fn name(&self) -> &str { &self.name } } impl Repository<'static> { pub const fn new_static(owner: &'static str, name: &'static str) -> Self { Self { owner: Cow::Borrowed(owner), name: Cow::Borrowed(name), } } } #[async_trait::async_trait(?Send)] pub trait GithubApiClient { async fn get_pull_request( &self, repo: &Repository<'_>, pr_number: u64, ) -> Result<PullRequestData>; async fn get_pull_request_reviews( &self, repo: &Repository<'_>, pr_number: u64, ) -> Result<Vec<PullRequestReview>>; async fn get_pull_request_comments( &self, repo: &Repository<'_>, pr_number: u64, ) -> Result<Vec<PullRequestComment>>; async fn get_commit_metadata( &self, repo: &Repository<'_>, commit_shas: &[&CommitSha], ) -> Result<CommitMetadataBySha>; async fn get_commit_files( &self, repo: &Repository<'_>, sha: &CommitSha, ) -> Result<Vec<CommitFileChange>>; async fn check_repo_write_permission( &self, repo: &Repository<'_>, login: &GithubLogin, ) -> Result<bool>; async fn add_label_to_issue( &self, repo: &Repository<'_>, label: &str, issue_number: u64, ) -> Result<()>; } pub mod graph_ql { use anyhow::{Context as _, Result}; use itertools::Itertools as _; use serde::Deserialize; use crate::git::CommitSha; use super::CommitMetadataBySha; #[derive(Debug, Deserialize)] pub struct GraphQLResponse<T> { pub data: Option<T>, pub errors: Option<Vec<GraphQLError>>, } impl<T> GraphQLResponse<T> { pub fn into_data(self) -> Result<T> { if let Some(errors) = &self.errors { if !errors.is_empty() { let messages: String = errors.iter().map(|e| e.message.as_str()).join("; "); anyhow::bail!("GraphQL error: {messages}"); } } self.data.context("GraphQL response contained no data") } } #[derive(Debug, Deserialize)] pub struct GraphQLError { pub message: String, } #[derive(Debug, Deserialize)] pub struct CommitMetadataResponse { pub repository: CommitMetadataBySha, } pub fn deserialize_nodes<'de, T, D>(deserializer: D) -> std::result::Result<Vec<T>, D::Error> where T: Deserialize<'de>, D: serde::Deserializer<'de>, { #[derive(Deserialize)] struct Nodes<T> { nodes: Vec<T>, } Nodes::<T>::deserialize(deserializer).map(|wrapper| wrapper.nodes) } pub fn build_commit_metadata_query<'a>( org: &str, repo: &str, shas: impl IntoIterator<Item = &'a CommitSha>, ) -> String { const FRAGMENT: &str = r#" ... on Commit { author { name email user { login } } authors(first: 10) { nodes { name email user { login } } } signature { isValid signer { login } } additions deletions } "#; let objects = shas .into_iter() .map(|commit_sha| { format!( "{sha_prefix}{sha}: object(oid: \"{sha}\") {{ {FRAGMENT} }}", sha_prefix = CommitMetadataBySha::SHA_PREFIX, sha = **commit_sha, ) }) .join("\n"); format!("{{ repository(owner: \"{org}\", name: \"{repo}\") {{ {objects} }} }}") .replace("\n", "") } } #[cfg(feature = "octo-client")] mod octo_client { use anyhow::{Context, Result}; use futures::TryStreamExt as _; use jsonwebtoken::EncodingKey; use octocrab::{ Octocrab, Page, models::{ AuthorAssociation as OctocrabAuthorAssociation, pulls::ReviewState as OctocrabReviewState, }, service::middleware::cache::mem::InMemoryCache, }; use serde::de::DeserializeOwned; use tokio::pin; use crate::{ git::CommitSha, github::{Repository, graph_ql}, }; use super::{ AuthorAssociation, CommitFileChange, CommitMetadataBySha, GithubApiClient, GithubLogin, GithubUser, PullRequestComment, PullRequestData, PullRequestReview, ReviewState, }; fn convert_author_association(association: OctocrabAuthorAssociation) -> AuthorAssociation { match association { OctocrabAuthorAssociation::Owner => AuthorAssociation::Owner, OctocrabAuthorAssociation::Member => AuthorAssociation::Member, OctocrabAuthorAssociation::Collaborator => AuthorAssociation::Collaborator, OctocrabAuthorAssociation::Contributor => AuthorAssociation::Contributor, OctocrabAuthorAssociation::FirstTimeContributor => { AuthorAssociation::FirstTimeContributor } OctocrabAuthorAssociation::FirstTimer => AuthorAssociation::FirstTimer, OctocrabAuthorAssociation::Mannequin => AuthorAssociation::Mannequin, OctocrabAuthorAssociation::None => AuthorAssociation::None, _ => AuthorAssociation::None, } } const PAGE_SIZE: u8 = 100; pub struct OctocrabClient { client: Octocrab, } impl OctocrabClient { pub async fn new(app_id: u64, app_private_key: &str, org: &str) -> Result<Self> { 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 }) } async fn graphql<R: DeserializeOwned>(&self, query: &serde_json::Value) -> Result<R> { let response: serde_json::Value = self.client.graphql(query).await?; let parsed: graph_ql::GraphQLResponse<R> = serde_json::from_value(response) .context("Failed to parse GraphQL response envelope")?;