use anyhow::{Context, Result}; use jsonwebtoken::EncodingKey; use octocrab::{models::AppId, Octocrab}; use secrecy::ExposeSecret; #[derive(Clone)] pub struct GitHub { octocrab: Octocrab, } impl GitHub { pub async fn new() -> Result { let app_id_str = std::env::var("GITHUB_APP_ID").context("GITHUB_APP_ID must be set")?; let private_key = std::env::var("GITHUB_APP_PRIVATE_KEY") .context("GITHUB_APP_PRIVATE_KEY must be set")?; let app_id: u64 = app_id_str.parse().context("GITHUB_APP_ID must be a number")?; let formatted_key = private_key.replace("\\n", "\n"); let key = EncodingKey::from_rsa_pem(formatted_key.as_bytes()) .context("Failed to parse GITHUB_APP_PRIVATE_KEY as RSA PEM")?; let octocrab = Octocrab::builder() .app(AppId(app_id), key) .build() .context("Failed to build octocrab client")?; Ok(GitHub { octocrab }) } // Returns an installation client scoped to the installation that covers `owner/repo`. async fn installation_client_for_repo(&self, owner: &str, repo: &str) -> Result { let installation = self .octocrab .apps() .get_repository_installation(owner, repo) .await .with_context(|| { format!( "GitHub App is not installed on {}/{} (or has no access to it)", owner, repo ) })?; self.octocrab .installation(installation.id) .map_err(|e| anyhow::anyhow!("Failed to create installation client: {}", e)) } // Returns a short-lived installation access token for the installation that covers // `owner/repo`. Use this for git2 credentials when cloning or pushing. pub async fn installation_token_for_repo(&self, owner: &str, repo: &str) -> Result { let client = self.installation_client_for_repo(owner, repo).await?; let secret = client .installation_token() .await .context("Failed to acquire installation access token")?; Ok(secret.expose_secret().to_string()) } // Creates a PR for `branch` or updates the existing one if the branch already has an open PR. // Returns the PR HTML URL on success. pub async fn upsert_pull_request( &self, owner: &str, repo: &str, branch: &str, title: &str, body: Option<&str>, ) -> Option { let client = match self.installation_client_for_repo(owner, repo).await { Ok(c) => c, Err(e) => { tracing::error!("Failed to get installation client: {}", e); return None; } }; let handler = client.pulls(owner, repo); let builder = handler.create(title, branch, "main"); let builder = if let Some(b) = body { builder.body(b) } else { builder }; match builder.send().await { Ok(pr) => { let url = pr.html_url.to_string(); tracing::info!("Pull request opened: {}", url); return Some(url); } Err(ref e) if format!("{e}").contains("already exists") => { // Fall through to find-and-update } Err(e) => { tracing::error!("GitHub PR creation failed: {}", e); return None; } } tracing::info!("PR already exists for branch {}, updating instead", branch); let prs = match client .pulls(owner, repo) .list() .head(format!("{}:{}", owner, branch)) .state(octocrab::params::State::Open) .send() .await { Ok(p) => p, Err(e) => { tracing::error!("Failed to list PRs: {}", e); return None; } }; let pr_number = match prs.items.first() { Some(p) => p.number, None => { tracing::error!("No open PR found for branch {}", branch); return None; } }; let handler = client.pulls(owner, repo); let update = handler.update(pr_number).title(title); let update = if let Some(b) = body { update.body(b) } else { update }; match update.send().await { Ok(pr) => { let url = pr.html_url.to_string(); tracing::info!("Pull request updated: {}", url); Some(url) } Err(e) => { tracing::error!("GitHub PR update failed: {}", e); None } } } }