725 lines · 20.2 KB · Rust
RawDownload
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.