use anyhow::{Context as _, anyhow, bail}; use base64::Engine; use base64::engine::general_purpose::STANDARD; use chrono::{DateTime, Utc}; use fabro_redact::DisplaySafeUrl; use fabro_static::EnvVars; use fabro_types::PullRequestGithubDetail; use fabro_types::settings::run::MergeStrategy; use serde::Deserialize; use tokio::process::Command; use crate::token_source::SecretString; pub mod access; pub mod token_source; #[cfg(any(test, feature = "test-support"))] pub mod test_support; #[cfg(test)] pub(crate) mod tests_mock; pub use access::GitHubRepositoryAccess; pub const GITHUB_API_BASE_URL: &str = "https://api.github.com"; /// Git config key that routes github.com HTTPS credentials through /// [`GITHUB_CREDENTIAL_HELPER`]. pub const GITHUB_CREDENTIAL_HELPER_KEY: &str = "credential.https://github.com.helper"; /// Secret-free git credential helper: reads `$GITHUB_TOKEN` from the /// invoking git process's environment at invocation time, so the token never /// lands in git configuration, argv, or rendered errors. Non-`get` /// operations (`store`, `erase`) are ignored. The one definition shared by /// the runtime git bridge, server preflight probes, and the live contract /// test, so the probes always exercise exactly what the bridge configures. pub const GITHUB_CREDENTIAL_HELPER: &str = r#"!f() { if [ "$1" = get ]; then echo username=x-access-token; echo "password=$GITHUB_TOKEN"; fi; }; f"#; /// Configure a `git` invocation to authenticate to github.com through /// [`GITHUB_CREDENTIAL_HELPER`] with `token`, isolated from user/system git /// configuration and terminal prompts. The token is passed only through the /// child process environment. pub fn apply_probe_git_env(command: &mut Command, token: &str) { command .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env(EnvVars::GITHUB_TOKEN, token) .env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", GITHUB_CREDENTIAL_HELPER_KEY) .env("GIT_CONFIG_VALUE_0", GITHUB_CREDENTIAL_HELPER); } /// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env /// var. #[expect( clippy::disallowed_methods, reason = "GitHub API client exposes a documented process-env base URL override." )] pub fn github_api_base_url() -> String { std::env::var(EnvVars::GITHUB_BASE_URL).unwrap_or_else(|_| GITHUB_API_BASE_URL.to_string()) } /// Bundle of GitHub credentials and the API base URL, threaded through every /// authenticated GitHub call. Lets call sites pass one parameter instead of /// two, and keeps the auth/endpoint pair from drifting apart. #[derive(Debug, Clone)] pub struct GitHubContext<'a> { creds: &'a GitHubCredentials, base_url: &'a str, http_client: Option, } impl<'a> GitHubContext<'a> { pub fn new(creds: &'a GitHubCredentials, base_url: &'a str) -> Self { Self { creds, base_url, http_client: None, } } pub fn with_http_client( creds: &'a GitHubCredentials, base_url: &'a str, http_client: fabro_http::HttpClient, ) -> Self { Self { creds, base_url, http_client: Some(http_client), } } fn http_client(&self) -> anyhow::Result { self.http_client.clone().map_or_else(http_client, Ok) } } /// Errors returned by pull-request endpoints. Callers branch on `NotFound` to /// distinguish a missing PR from any other failure. #[derive(Debug, thiserror::Error)] pub enum PullRequestApiError { #[error("Pull request #{number} not found in {owner}/{repo}")] NotFound { owner: String, repo: String, number: u64, }, #[error(transparent)] Other(#[from] anyhow::Error), } fn http_client() -> anyhow::Result { fabro_http::http_client().map_err(Into::into) } /// Owner information for a GitHub App. #[derive(Debug, Clone, Deserialize)] pub struct AppOwner { pub login: String, } /// Information about a GitHub App from the authenticated `/app` endpoint. #[derive(Debug, Clone, Deserialize)] pub struct AppInfo { pub slug: String, pub owner: AppOwner, } /// Credentials for authenticating as a GitHub App. #[derive(Clone, Debug)] pub struct GitHubAppCredentials { pub app_id: String, pub private_key_pem: String, pub slug: Option, } impl GitHubAppCredentials { #[expect( clippy::disallowed_methods, reason = "GitHub App credentials support a documented private-key env source." )] pub fn private_key_from_env() -> Result, String> { let Ok(raw) = std::env::var(EnvVars::GITHUB_APP_PRIVATE_KEY) else { return Ok(None); }; decode_pem_env(EnvVars::GITHUB_APP_PRIVATE_KEY, &raw).map(Some) } pub fn from_env(app_id: Option<&str>) -> Result, String> { Self::from_env_with_slug(app_id, None) } pub fn from_env_with_slug( app_id: Option<&str>, slug: Option<&str>, ) -> Result, String> { let Some(app_id) = app_id else { return Ok(None); }; let Some(private_key_pem) = Self::private_key_from_env()? else { return Ok(None); }; Ok(Some(Self { app_id: app_id.to_string(), private_key_pem, slug: slug .map(str::trim) .filter(|slug| !slug.is_empty()) .map(str::to_string), })) } pub fn installation_url(&self, owner: &str) -> Option { self.slug.as_ref().map(|slug| { format!("https://github.com/organizations/{owner}/settings/apps/{slug}/installations") }) } pub async fn mint_installation_token( &self, client: &impl HttpClient, owner: &str, repo: &str, base_url: &str, permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { self.mint_installation_token_for_repositories( client, owner, &[repo.to_string()], base_url, permissions, install_url, ) .await } /// Mint one installation token scoped to every repository in /// `repository_names` (names within `owner`'s installation, primary /// first) with the shared `permissions`. pub async fn mint_installation_token_for_repositories( &self, client: &impl HttpClient, owner: &str, repository_names: &[String], base_url: &str, permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { let jwt = sign_app_jwt(&self.app_id, &self.private_key_pem)?; let default_install_url = self.installation_url(owner); let install_url = install_url.or(default_install_url.as_deref()); mint_installation_token_with_jwt( client, &jwt, owner, repository_names, base_url, permissions, install_url, ) .await } } #[derive(Clone, Debug)] pub struct InstallationToken { pub token: String, pub expires_at: DateTime, } impl InstallationToken { pub fn near_expiry(&self, threshold: std::time::Duration) -> bool { let threshold = chrono::Duration::from_std(threshold).unwrap_or(chrono::Duration::MAX); self.expires_at <= Utc::now() + threshold } pub fn valid_token(&self) -> anyhow::Result<&str> { if self.expires_at <= Utc::now() { bail!( "GitHub installation access token expired at {}", self.expires_at ); } Ok(&self.token) } } #[derive(Clone, Debug)] pub enum GitHubCredentials { App(GitHubAppCredentials), Pat(String), Installation(InstallationToken), } impl GitHubCredentials { /// Whether resolving these credentials mints a new installation token. #[must_use] pub fn mints_installation_token(&self) -> bool { matches!(self, Self::App(_)) } pub fn from_env(app_id: Option<&str>) -> Result, String> { Ok(GitHubAppCredentials::from_env(app_id)?.map(Self::App)) } pub fn from_env_with_slug( app_id: Option<&str>, slug: Option<&str>, ) -> Result, String> { Ok(GitHubAppCredentials::from_env_with_slug(app_id, slug)?.map(Self::App)) } pub async fn resolve_bearer_token( &self, client: &impl HttpClient, owner: &str, repo: &str, base_url: &str, permissions: serde_json::Value, ) -> anyhow::Result { match self { Self::App(creds) => { let install_url = creds.installation_url(owner); creds .mint_installation_token( client, owner, repo, base_url, permissions, install_url.as_deref(), ) .await .map(|token| token.token) } Self::Pat(token) => Ok(token.clone()), Self::Installation(token) => token.valid_token().map(str::to_owned), } } } pub fn validate_static_github_token(token: &str) -> anyhow::Result<()> { if token.starts_with("ghs_") { bail!( "GitHub installation access token (ghs_*) cannot be configured as a static token \ because it expires quickly; use a PAT or GitHub App credentials instead" ); } Ok(()) } pub async fn gh_auth_token() -> anyhow::Result { let output = Command::new("gh") .args(["auth", "token"]) .output() .await .context("Failed to run `gh auth token`")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let message = if stderr.is_empty() { format!("`gh auth token` exited with status {}", output.status) } else { stderr }; bail!("Failed to get GitHub CLI token: {message}"); } let token = String::from_utf8(output.stdout).context("`gh auth token` returned invalid UTF-8")?; let token = token.trim().to_string(); if token.is_empty() { bail!("`gh auth token` returned an empty token"); } Ok(token) } fn decode_pem_env(name: &str, raw: &str) -> Result { if raw.starts_with("-----") { return Ok(raw.to_string()); } let pem_bytes = STANDARD .decode(raw) .map_err(|err| format!("{name} is not valid PEM or base64: {err}"))?; String::from_utf8(pem_bytes) .map_err(|err| format!("{name} base64 decoded to invalid UTF-8: {err}")) } /// HTTP method used in GitHub API calls. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HttpMethod { Get, Post, Put, Patch, } /// A minimal HTTP response for testability. pub struct HttpResponse { pub status: u16, body: String, } impl HttpResponse { pub fn new(status: u16, body: String) -> Self { Self { status, body } } pub fn json Deserialize<'de>>(&self) -> anyhow::Result { serde_json::from_str(&self.body).context("Failed to parse response") } pub fn text(&self) -> &str { &self.body } } /// Abstract HTTP client for GitHub API calls. /// /// Implemented for `fabro_http::HttpClient` in production; tests use a mock /// to avoid TCP/process overhead. pub trait HttpClient: Send + Sync { fn request( &self, method: HttpMethod, url: &str, headers: &[(&str, &str)], body: Option<&serde_json::Value>, ) -> impl std::future::Future> + Send; } impl HttpClient for fabro_http::HttpClient { async fn request( &self, method: HttpMethod, url: &str, headers: &[(&str, &str)], body: Option<&serde_json::Value>, ) -> anyhow::Result { let mut builder = match method { HttpMethod::Get => self.get(url), HttpMethod::Post => self.post(url), HttpMethod::Put => self.put(url), HttpMethod::Patch => self.patch(url), }; for &(key, value) in headers { builder = builder.header(key, value); } if let Some(json_body) = body { builder = builder.json(json_body); } let resp = builder.send().await.map_err(anyhow::Error::new)?; let status = resp.status().as_u16(); let text = resp.text().await.map_err(anyhow::Error::new)?; Ok(HttpResponse::new(status, text)) } } /// Parse `owner` and `repo` from a GitHub HTTPS URL. /// /// Accepts URLs like: /// - `https://github.com/owner/repo.git` /// - `https://github.com/owner/repo` /// - `https://github.com/owner/repo/` /// - `https://x-access-token:TOKEN@github.com/owner/repo.git` pub fn parse_github_owner_repo(url: &str) -> anyhow::Result<(String, String)> { // Strip credentials from URLs like https://x-access-token:TOKEN@github.com/... let stripped = url.strip_prefix("https://").and_then(|rest| { rest.split_once('@') .map(|(_, after)| format!("https://{after}")) }); let url = stripped.as_deref().unwrap_or(url); let display_url = redacted_url_for_error(url); let path = url .strip_prefix("https://github.com/") .ok_or_else(|| anyhow!("Not a GitHub HTTPS URL: {display_url}"))?; let path = path.trim_end_matches('/'); let path = path.strip_suffix(".git").unwrap_or(path); let mut parts = path.split('/'); let owner = parts .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow!("Missing owner in GitHub URL: {display_url}"))?; let repo = parts .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow!("Missing repo in GitHub URL: {display_url}"))?; if parts.next().is_some() { bail!("GitHub URL must identify one repository: {display_url}"); } Ok((owner.to_string(), repo.to_string())) } fn redacted_url_for_error(url: &str) -> String { DisplaySafeUrl::parse(url) .map_or_else(|_| "".to_string(), |url| url.redacted_string()) } /// Create a signed JWT for GitHub App authentication (RS256). /// /// The JWT is valid for 10 minutes with a 60-second clock skew allowance. pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> anyhow::Result { use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use serde::Serialize; #[derive(Serialize)] struct Claims { iss: String, iat: i64, exp: i64, } let now = chrono::Utc::now().timestamp(); let claims = Claims { iss: app_id.to_string(), iat: now - 60, exp: now + 600, }; let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).context("Invalid RSA private key")?; let jwt = encode(&Header::new(Algorithm::RS256), &claims, &key).context("Failed to sign JWT")?; Ok(jwt) } /// Standard GitHub API headers for authenticated requests. fn github_headers(auth: &str) -> [(&str, &str); 3] { [ ("Authorization", auth), ("Accept", "application/vnd.github+json"), ("User-Agent", "fabro"), ] } /// Request a scoped Installation Access Token for a specific repository. /// /// Uses the App JWT to find the installation for `owner/repo`, then requests /// a token scoped to the given `permissions` on that single repository. pub async fn create_installation_access_token_with_permissions( client: &impl HttpClient, jwt: &str, owner: &str, repo: &str, base_url: &str, permissions: serde_json::Value, ) -> anyhow::Result { create_installation_access_token_with_permissions_and_install_url( client, jwt, owner, repo, base_url, permissions, None, ) .await } pub async fn create_installation_access_token_with_permissions_and_install_url( client: &impl HttpClient, jwt: &str, owner: &str, repo: &str, base_url: &str, permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { mint_installation_token_with_jwt( client, jwt, owner, &[repo.to_string()], base_url, permissions, install_url, ) .await .map(|token| token.token) } /// Outcome of one GitHub App installation lookup /// (`GET /repos/{owner}/{repo}/installation`). /// /// Status interpretation stays with callers because their user guidance /// differs: the mint path speaks about the owner's App installation, the /// multi-repository resolution names the specific repository. pub(crate) enum InstallationLookup { Found(u64), /// 404: the App cannot see the repository (or is not installed at all). NotFound, /// Any other non-200 status. Failed(u16), } /// Look up the App installation covering `owner/repo` and parse its id. /// Transport and parse failures carry no caller-specific context; callers /// attach their own. pub(crate) async fn lookup_installation( client: &impl HttpClient, jwt: &str, base_url: &str, owner: &str, repo: &str, ) -> anyhow::Result { #[derive(Deserialize)] struct Installation { id: u64, } let endpoint = format!("{base_url}/repos/{owner}/{repo}/installation"); let auth = format!("Bearer {jwt}"); let resp = client .request(HttpMethod::Get, &endpoint, &github_headers(&auth), None) .await?; match resp.status { 200 => { let installation: Installation = resp .json() .context("Failed to parse installation response")?; Ok(InstallationLookup::Found(installation.id)) } 404 => Ok(InstallationLookup::NotFound), status => Ok(InstallationLookup::Failed(status)), } } async fn mint_installation_token_with_jwt( client: &impl HttpClient, jwt: &str, owner: &str, repos: &[String], base_url: &str, permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { let Some(primary_repo) = repos.first() else { bail!("installation token mint requires at least one repository"); }; // Step 1: Find the installation via the primary repository. Multi- // repository callers resolve every repository's installation up front // (`GitHubRepositoryAccess::resolve_shared_installation`), so the // primary stands for the whole set here. let installation_id = match lookup_installation(client, jwt, base_url, owner, primary_repo) .await .context("Failed to look up GitHub App installation")? { InstallationLookup::Found(id) => id, InstallationLookup::NotFound => { let install_url = install_url.map_or_else( || format!("https://github.com/organizations/{owner}/settings/installations"), str::to_string, ); bail!( "GitHub App is not installed for {owner}, or its installation does not \ include {owner}/{primary_repo}. Check repository access at {install_url}" ); } InstallationLookup::Failed(403) => { bail!( "GitHub App installation is suspended. \ Re-enable it in your organization's GitHub App settings." ); } InstallationLookup::Failed(401) => { bail!( "GitHub App authentication failed. \ Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." ); } InstallationLookup::Failed(status) => { bail!("Unexpected status {status} looking up GitHub App installation"); } }; mint_installation_token_for_id_with_jwt( client, jwt, installation_id, repos, base_url, permissions, ) .await } /// Create a repository-scoped token for an installation already resolved by /// the caller. pub(crate) async fn mint_installation_token_for_id_with_jwt( client: &impl HttpClient, jwt: &str, installation_id: u64, repos: &[String], base_url: &str, permissions: serde_json::Value, ) -> anyhow::Result { #[derive(Deserialize)] struct AccessToken { token: String, expires_at: DateTime, } let auth = format!("Bearer {jwt}"); let token_url = format!("{base_url}/app/installations/{installation_id}/access_tokens"); let body = serde_json::json!({ "repositories": repos, "permissions": permissions, }); let token_resp = client .request( HttpMethod::Post, &token_url, &github_headers(&auth), Some(&body), ) .await .context("Failed to create installation access token")?; match token_resp.status { 201 => {} 422 => { bail!( "GitHub App does not have access to every requested repository ({}). \ Update the installation's repository permissions to include them.", repos.join(", ") ); } 401 => { bail!( "GitHub App authentication failed. \ Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." ); } _ => { bail!( "Unexpected status {} creating installation access token", token_resp.status ); } } let access_token: AccessToken = token_resp .json() .context("Failed to parse access token response")?; Ok(InstallationToken { token: access_token.token, expires_at: access_token.expires_at, }) } /// Request a scoped Installation Access Token with `contents: write`. pub async fn create_installation_access_token( client: &impl HttpClient, jwt: &str, owner: &str, repo: &str, base_url: &str, ) -> anyhow::Result { create_installation_access_token_with_permissions( client, jwt, owner, repo, base_url, serde_json::json!({ "contents": "write" }), ) .await } /// Request a scoped Installation Access Token with `contents: write` /// and `pull_requests: write`. Used for creating pull requests. pub async fn create_installation_access_token_for_pr( client: &impl HttpClient, jwt: &str, owner: &str, repo: &str, base_url: &str, ) -> anyhow::Result { create_installation_access_token_with_permissions( client, jwt, owner, repo, base_url, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await } /// Pull request created on, or reconciled from, GitHub. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreatedPullRequest { pub html_url: String, pub number: u64, pub node_id: String, pub title: String, } /// Find an open pull request that already carries the expected head commit. /// /// This supports recovery when GitHub created a pull request but the caller /// stopped before it could persist the result locally. pub async fn find_open_pull_request( ctx: &GitHubContext<'_>, owner: &str, repo: &str, base: &str, head: &str, expected_head_sha: &str, ) -> anyhow::Result> { let client = ctx.http_client()?; find_open_pull_request_with_client(&client, ctx, owner, repo, base, head, expected_head_sha) .await } #[allow( clippy::too_many_arguments, reason = "Pull request reconciliation needs explicit repo, branch, and commit coordinates." )] pub async fn find_open_pull_request_with_client( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, base: &str, head: &str, expected_head_sha: &str, ) -> anyhow::Result> { #[derive(Deserialize)] struct PullRequestHead { sha: String, } #[derive(Deserialize)] struct PullRequestListItem { html_url: String, number: u64, node_id: String, title: String, head: PullRequestHead, } let token = ctx .creds .resolve_bearer_token( client, owner, repo, ctx.base_url, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; let mut url = DisplaySafeUrl::parse(&format!("{}/repos/{owner}/{repo}/pulls", ctx.base_url)) .context("Failed to build pull request reconciliation URL")?; url.query_pairs_mut() .append_pair("state", "open") .append_pair("base", base) .append_pair("head", &format!("{owner}:{head}")); let auth = format!("Bearer {token}"); let resp = client .request( HttpMethod::Get, &url.raw_string(), &github_headers(&auth), None, ) .await .context("Failed to find an existing pull request")?; if resp.status != 200 { bail!( "Unexpected status {} finding an existing pull request: {}", resp.status, resp.text() ); } let pull_requests = resp .json::>() .context("Failed to parse existing pull request response")?; Ok(pull_requests .into_iter() .find(|pull_request| pull_request.head.sha == expected_head_sha) .map(|pull_request| CreatedPullRequest { html_url: pull_request.html_url, number: pull_request.number, node_id: pull_request.node_id, title: pull_request.title, })) } /// Create a pull request on GitHub. /// /// Signs a JWT, obtains a PR-scoped installation token, and POSTs to the /// GitHub pulls API. #[allow( clippy::too_many_arguments, reason = "Creating a pull request needs explicit repo, branch, and body fields." )] pub async fn create_pull_request( ctx: &GitHubContext<'_>, owner: &str, repo: &str, base: &str, head: &str, title: &str, body: &str, draft: bool, ) -> anyhow::Result { let client = ctx.http_client()?; create_pull_request_with_client(&client, ctx, owner, repo, base, head, title, body, draft).await } #[allow( clippy::too_many_arguments, reason = "Creating a pull request needs explicit repo, branch, and body fields." )] pub async fn create_pull_request_with_client( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, base: &str, head: &str, title: &str, body: &str, draft: bool, ) -> anyhow::Result { #[derive(Deserialize)] struct PullRequestResponse { html_url: String, number: u64, node_id: String, } let token = ctx .creds .resolve_bearer_token( client, owner, repo, ctx.base_url, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; tracing::info!(title = %title, head = %head, base = %base, draft, "Creating pull request"); let pr_body = serde_json::json!({ "title": title, "head": head, "base": base, "body": body, "draft": draft, }); let url = format!("{}/repos/{owner}/{repo}/pulls", ctx.base_url); let auth = format!("Bearer {token}"); let resp = HttpClient::request( client, HttpMethod::Post, &url, &github_headers(&auth), Some(&pr_body), ) .await .context("Failed to create pull request")?; match resp.status { 201 => {} 422 => { bail!("Pull request could not be created (422): {}", resp.text()); } 401 | 403 => { bail!( "Authentication failed creating pull request ({})", resp.status ); } _ => { bail!( "Unexpected status {} creating pull request: {}", resp.status, resp.text() ); } } let pr: PullRequestResponse = resp .json() .context("Failed to parse pull request response")?; Ok(CreatedPullRequest { html_url: pr.html_url, number: pr.number, node_id: pr.node_id, title: title.to_string(), }) } fn merge_method_as_graphql_value(method: MergeStrategy) -> &'static str { match method { MergeStrategy::Merge => "MERGE", MergeStrategy::Squash => "SQUASH", MergeStrategy::Rebase => "REBASE", } } /// Enable auto-merge on a pull request via GitHub's GraphQL API. /// /// Requires the PR's `node_id` (from the REST API response) and a merge method. /// The repository must have auto-merge enabled in its settings. pub async fn enable_auto_merge( ctx: &GitHubContext<'_>, owner: &str, repo: &str, pr_node_id: &str, merge_method: MergeStrategy, ) -> anyhow::Result<()> { let client = ctx.http_client()?; enable_auto_merge_with_client(&client, ctx, owner, repo, pr_node_id, merge_method).await } pub async fn enable_auto_merge_with_client( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, pr_node_id: &str, merge_method: MergeStrategy, ) -> anyhow::Result<()> { let token = ctx .creds .resolve_bearer_token( client, owner, repo, ctx.base_url, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; let graphql_value = merge_method_as_graphql_value(merge_method); let query = format!( r#"mutation {{ enablePullRequestAutoMerge(input: {{pullRequestId: "{pr_node_id}", mergeMethod: {graphql_value}}}) {{ pullRequest {{ autoMergeRequest {{ enabledAt mergeMethod }} }} }} }}"#, ); tracing::debug!( pr_node_id, merge_method = graphql_value, "Enabling auto-merge" ); let graphql_url = format!("{}/graphql", ctx.base_url); let auth = format!("Bearer {token}"); let graphql_body = serde_json::json!({ "query": query }); let resp = HttpClient::request( client, HttpMethod::Post, &graphql_url, &[("Authorization", auth.as_str()), ("User-Agent", "fabro")], Some(&graphql_body), ) .await .context("Failed to enable auto-merge")?; let status = resp.status; let body: serde_json::Value = resp.json().context("Failed to parse auto-merge response")?; if !(200..300).contains(&status) { bail!("Auto-merge request failed ({status}): {body}"); } if let Some(errors) = body.get("errors") { bail!("Auto-merge GraphQL error: {errors}"); } tracing::info!(pr_node_id, "Auto-merge enabled"); Ok(()) } /// Convert a Git SSH URL to HTTPS format for token-based authentication. /// /// SSH URLs like `git@github.com:owner/repo.git` become /// `https://github.com/owner/repo.git`. URLs that are already HTTPS /// (or any other non-SSH format) are returned unchanged. pub fn ssh_url_to_https(url: &str) -> String { // Match `git@:` (standard SSH URL format) if let Some(rest) = url.strip_prefix("git@") { if let Some((host, path)) = rest.split_once(':') { return format!("https://{host}/{path}"); } } // Match `ssh://git@/` if let Some(rest) = url.strip_prefix("ssh://git@") { return format!("https://{rest}"); } url.to_string() } pub fn normalize_repo_origin_url(url: &str) -> String { let https = ssh_url_to_https(url.trim()); let without_credentials = strip_https_credentials(&https); let normalized = normalize_https_host_path(&without_credentials); let normalized = normalized.trim_end_matches('/'); normalized .strip_suffix(".git") .unwrap_or(normalized) .to_string() } fn strip_https_credentials(url: &str) -> String { let Some(rest) = url.strip_prefix("https://") else { return url.to_string(); }; match rest.split_once('@') { Some((before, after)) if !before.contains('/') => format!("https://{after}"), _ => url.to_string(), } } fn normalize_https_host_path(url: &str) -> String { let Some(rest) = url.strip_prefix("https://") else { return url.to_string(); }; match rest.split_once(':') { Some((host, path)) if !host.contains('/') && !path.starts_with('/') => { format!("https://{host}/{path}") } _ => url.to_string(), } } /// Return the commit SHA at the head of a GitHub branch. /// /// Returns `None` when the branch does not exist. pub async fn branch_head_sha( ctx: &GitHubContext<'_>, owner: &str, repo: &str, branch: &str, ) -> anyhow::Result> { let client = ctx.http_client()?; branch_head_sha_with_client(&client, ctx, owner, repo, branch).await } async fn branch_head_sha_with_client( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, branch: &str, ) -> anyhow::Result> { #[derive(Deserialize)] struct BranchResponse { commit: BranchCommit, } #[derive(Deserialize)] struct BranchCommit { sha: String, } let token = ctx .creds .resolve_bearer_token( client, owner, repo, ctx.base_url, serde_json::json!({ "contents": "read" }), ) .await?; let url = format!("{}/repos/{owner}/{repo}/branches/{branch}", ctx.base_url); let auth = format!("Bearer {token}"); let resp = client .request(HttpMethod::Get, &url, &github_headers(&auth), None) .await .context("Failed to read remote branch head")?; match resp.status { 200 => { let branch: BranchResponse = resp .json() .context("Failed to parse remote branch response")?; Ok(Some(branch.commit.sha)) } 404 => Ok(None), status => bail!("Unexpected status {status} reading branch '{branch}'"), } } /// Check whether a GitHub App is installed for a specific repository. /// /// Uses the App JWT to query `GET /repos/{owner}/{repo}/installation`. /// Returns `Ok(true)` on 200, `Ok(false)` on 404. pub async fn check_app_installed( client: &impl HttpClient, jwt: &str, owner: &str, repo: &str, base_url: &str, ) -> anyhow::Result { let lookup = lookup_installation(client, jwt, base_url, owner, repo) .await .context("Failed to check GitHub App installation")?; match lookup { InstallationLookup::Found(_) => Ok(true), InstallationLookup::NotFound => Ok(false), InstallationLookup::Failed(401) => bail!( "GitHub App authentication failed. \ Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." ), InstallationLookup::Failed(403) => bail!( "GitHub App installation is suspended. \ Re-enable it in your organization's GitHub App settings." ), InstallationLookup::Failed(status) => { bail!("Unexpected status {status} checking GitHub App installation") } } } /// Fetch information about the authenticated GitHub App. /// /// Uses the App JWT to call `GET /app` and returns the app's slug and owner. pub async fn get_authenticated_app( client: &impl HttpClient, jwt: &str, base_url: &str, ) -> anyhow::Result { let url = format!("{base_url}/app"); let auth = format!("Bearer {jwt}"); let resp = client .request(HttpMethod::Get, &url, &github_headers(&auth), None) .await .context("Failed to fetch GitHub App info")?; match resp.status { 200 => {} 401 => { bail!( "GitHub App authentication failed. \ Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." ); } status => { bail!("Unexpected status {status} fetching GitHub App info"); } } resp.json::() .context("Failed to parse GitHub App info") } /// Update a GitHub App's webhook URL via `PATCH /app/hook/config`. /// /// Signs an App JWT and sets the webhook endpoint and content type. pub async fn update_app_webhook_config( app_id: &str, private_key_pem: &str, webhook_url: &str, ) -> anyhow::Result<()> { let jwt = sign_app_jwt(app_id, private_key_pem)?; let client = http_client()?; let url = format!("{}/app/hook/config", github_api_base_url()); let auth = format!("Bearer {jwt}"); let body = serde_json::json!({ "url": webhook_url, "content_type": "json", }); let resp = HttpClient::request( &client, HttpMethod::Patch, &url, &github_headers(&auth), Some(&body), ) .await .context("Failed to update GitHub App webhook")?; if !(200..300).contains(&resp.status) { bail!("GitHub API returned {}: {}", resp.status, resp.text()); } Ok(()) } /// Required credentials for an authenticated GitHub HTTPS clone. /// /// The password is redacted from `Debug` output and should only be exposed at /// the point where it is passed to Git. #[derive(Clone, Debug)] pub struct GitCloneCredentials { username: String, password: SecretString, } impl GitCloneCredentials { fn from_token(token: String) -> anyhow::Result { if token.is_empty() { bail!("GitHub clone credential token is empty"); } Ok(Self { username: "x-access-token".to_string(), password: SecretString::new(token), }) } /// The username passed to Git's HTTPS basic authentication. #[must_use] pub fn username(&self) -> &str { &self.username } /// The secret passed to Git's HTTPS basic authentication. /// /// Callers must not log or persist the returned value. #[must_use] pub fn password(&self) -> &str { self.password.expose() } } /// Resolve Git clone credentials for a GitHub repository. /// /// Always generates credentials regardless of repository visibility because /// the token is needed for pushing from the sandbox. /// /// # Errors /// /// Returns an error when an installation token is expired, a GitHub App token /// cannot be minted, the HTTP client cannot be created, or the resolved token /// is empty. pub async fn resolve_clone_credentials( ctx: &GitHubContext<'_>, owner: &str, repo: &str, ) -> anyhow::Result { let token = match ctx.creds { GitHubCredentials::Pat(token) => token.clone(), GitHubCredentials::Installation(token) => token.valid_token()?.to_string(), GitHubCredentials::App(_) => { let client = ctx.http_client()?; mint_git_token( &client, ctx, owner, repo, serde_json::json!({ "contents": "write" }), ) .await? } }; GitCloneCredentials::from_token(token) } /// Resolve credentials for fetching repository contents without granting a /// GitHub App token permission to push. /// /// Static PATs and pre-minted installation tokens retain their configured /// permissions. App credentials mint a repository-scoped token with /// `contents: read`. /// /// # Errors /// /// Returns an error when an installation token is expired, a GitHub App token /// cannot be minted, the HTTP client cannot be created, or the resolved token /// is empty. pub async fn resolve_read_only_clone_credentials( ctx: &GitHubContext<'_>, owner: &str, repo: &str, ) -> anyhow::Result { let token = match ctx.creds { GitHubCredentials::Pat(token) => token.clone(), GitHubCredentials::Installation(token) => token.valid_token()?.to_string(), GitHubCredentials::App(_) => { let client = ctx.http_client()?; mint_git_token( &client, ctx, owner, repo, serde_json::json!({ "contents": "read" }), ) .await? } }; GitCloneCredentials::from_token(token) } async fn mint_git_token( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, permissions: serde_json::Value, ) -> anyhow::Result { ctx.creds .resolve_bearer_token(client, owner, repo, ctx.base_url, permissions) .await } /// Embed a token into an HTTPS URL for authenticated git operations. /// /// Converts `https://github.com/owner/repo` to /// `https://x-access-token:@github.com/owner/repo`. pub fn embed_token_in_url(url: &str, token: &str) -> anyhow::Result { let mut url = DisplaySafeUrl::parse(url).context("Failed to parse GitHub HTTPS URL")?; if url.scheme() != "https" { bail!("GitHub clone URL must use HTTPS: {}", url.redacted_string()); } url.set_username("x-access-token") .map_err(|()| anyhow!("Failed to set GitHub token username"))?; url.set_password(Some(token)) .map_err(|()| anyhow!("Failed to set GitHub token password"))?; Ok(url) } /// Resolve an authenticated HTTPS URL for a GitHub repository. /// /// Parses owner/repo from the URL, obtains a fresh installation access token, /// and returns the URL with embedded credentials. pub async fn resolve_authenticated_url( ctx: &GitHubContext<'_>, url: &str, ) -> anyhow::Result { let (owner, repo) = parse_github_owner_repo(url)?; let credentials = resolve_clone_credentials(ctx, &owner, &repo).await?; embed_token_in_url(url, credentials.password()) } /// Fetch detailed information about a pull request. pub async fn get_pull_request( ctx: &GitHubContext<'_>, owner: &str, repo: &str, number: u64, ) -> Result { let client = ctx.http_client()?; get_pull_request_with_client(&client, ctx, owner, repo, number).await } pub async fn get_pull_request_with_client( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, number: u64, ) -> Result { tracing::debug!(owner, repo, number, "Fetching pull request"); let token = ctx .creds .resolve_bearer_token( client, owner, repo, ctx.base_url, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.base_url); let auth = format!("Bearer {token}"); let resp = client .request(HttpMethod::Get, &url, &github_headers(&auth), None) .await .context("Failed to fetch pull request")?; match resp.status { 200 => {} 404 => { return Err(PullRequestApiError::NotFound { owner: owner.to_string(), repo: repo.to_string(), number, }); } 401 | 403 => { return Err(anyhow!( "Authentication failed fetching pull request ({})", resp.status ) .into()); } status => { return Err(anyhow!( "Unexpected status {status} fetching pull request: {}", resp.text() ) .into()); } } Ok(resp .json::() .context("Failed to parse pull request response")?) } /// Merge a pull request. pub async fn merge_pull_request( ctx: &GitHubContext<'_>, owner: &str, repo: &str, number: u64, method: MergeStrategy, ) -> Result<(), PullRequestApiError> { let client = ctx.http_client()?; merge_pull_request_with_client(&client, ctx, owner, repo, number, method).await } pub async fn merge_pull_request_with_client( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, number: u64, method: MergeStrategy, ) -> Result<(), PullRequestApiError> { tracing::debug!(owner, repo, number, method = %method, "Merging pull request"); let token = ctx .creds .resolve_bearer_token( client, owner, repo, ctx.base_url, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; let url = format!("{}/repos/{owner}/{repo}/pulls/{number}/merge", ctx.base_url); let body = serde_json::json!({ "merge_method": method }); let auth = format!("Bearer {token}"); let resp = client .request(HttpMethod::Put, &url, &github_headers(&auth), Some(&body)) .await .context("Failed to merge pull request")?; match resp.status { 200 => Ok(()), 405 => Err( anyhow!("Pull request #{number} is not mergeable (method may not be allowed)").into(), ), 409 => Err(anyhow!("Pull request #{number} has a merge conflict").into()), 404 => Err(PullRequestApiError::NotFound { owner: owner.to_string(), repo: repo.to_string(), number, }), 401 | 403 => Err(anyhow!( "Authentication failed merging pull request ({})", resp.status ) .into()), status => Err(anyhow!( "Unexpected status {status} merging pull request: {}", resp.text() ) .into()), } } /// Close a pull request. pub async fn close_pull_request( ctx: &GitHubContext<'_>, owner: &str, repo: &str, number: u64, ) -> Result<(), PullRequestApiError> { let client = ctx.http_client()?; close_pull_request_with_client(&client, ctx, owner, repo, number).await } pub async fn close_pull_request_with_client( client: &impl HttpClient, ctx: &GitHubContext<'_>, owner: &str, repo: &str, number: u64, ) -> Result<(), PullRequestApiError> { tracing::debug!(owner, repo, number, "Closing pull request"); let token = ctx .creds .resolve_bearer_token( client, owner, repo, ctx.base_url, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.base_url); let body = serde_json::json!({ "state": "closed" }); let auth = format!("Bearer {token}"); let resp = client .request(HttpMethod::Patch, &url, &github_headers(&auth), Some(&body)) .await .context("Failed to close pull request")?; match resp.status { 200 => Ok(()), 404 => Err(PullRequestApiError::NotFound { owner: owner.to_string(), repo: repo.to_string(), number, }), 401 | 403 => Err(anyhow!( "Authentication failed closing pull request ({})", resp.status ) .into()), status => Err(anyhow!( "Unexpected status {status} closing pull request: {}", resp.text() ) .into()), } } /// Request a scoped Installation Access Token with `issues: write` /// and `organization_projects: write`. Used for GitHub Projects V2. pub async fn create_installation_access_token_for_projects( client: &impl HttpClient, jwt: &str, owner: &str, repo: &str, base_url: &str, ) -> anyhow::Result { create_installation_access_token_with_permissions( client, jwt, owner, repo, base_url, serde_json::json!({ "issues": "write", "organization_projects": "write" }), ) .await } #[cfg(test)] mod tests { use std::io; use std::sync::{Arc, Mutex}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use tracing::{debug, subscriber}; use tracing_subscriber::fmt::{self as tracing_fmt, MakeWriter}; use tracing_subscriber::prelude::*; use tracing_subscriber::registry; use super::*; #[test] fn decode_pem_env_accepts_raw_pem() { let pem = "-----BEGIN TEST KEY-----\nabc\n-----END TEST KEY-----"; assert_eq!(decode_pem_env("GITHUB_APP_PRIVATE_KEY", pem).unwrap(), pem); } #[test] fn pull_request_api_error_preserves_other_source_chain() { let original = anyhow::Error::new(std::io::Error::other("leaf failure")) .context("middle context") .context("outer context"); let original_chain = original .chain() .map(ToString::to_string) .collect::>(); let err = PullRequestApiError::from(original); let wrapped = anyhow::Error::new(err); let wrapped_chain = wrapped.chain().map(ToString::to_string).collect::>(); assert_eq!(wrapped_chain.len(), original_chain.len()); for original_cause in original_chain { assert!( wrapped_chain.iter().any(|cause| cause == &original_cause), "missing original cause {original_cause:?} in {wrapped_chain:#?}" ); } } #[test] fn decode_pem_env_accepts_base64_pem() { let pem = "-----BEGIN TEST KEY-----\nabc\n-----END TEST KEY-----"; let encoded = STANDARD.encode(pem); assert_eq!( decode_pem_env("GITHUB_APP_PRIVATE_KEY", &encoded).unwrap(), pem ); } #[test] fn decode_pem_env_rejects_invalid_base64() { let err = decode_pem_env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%").unwrap_err(); assert!(err.contains("GITHUB_APP_PRIVATE_KEY is not valid PEM or base64")); } // ----------------------------------------------------------------------- // parse_github_owner_repo // ----------------------------------------------------------------------- #[test] fn parse_https_with_git_suffix() { let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo.git").unwrap(); assert_eq!(owner, "owner"); assert_eq!(repo, "repo"); } #[test] fn parse_https_without_git_suffix() { let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo").unwrap(); assert_eq!(owner, "owner"); assert_eq!(repo, "repo"); } #[test] fn parse_https_with_trailing_slash() { let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo/").unwrap(); assert_eq!(owner, "owner"); assert_eq!(repo, "repo"); } #[test] fn parse_rejects_extra_path_components() { let error = parse_github_owner_repo("https://github.com/owner/repo/issues").unwrap_err(); assert!( error.to_string().contains("must identify one repository"), "got: {error}" ); } // ----------------------------------------------------------------------- // ssh_url_to_https // ----------------------------------------------------------------------- #[test] fn ssh_url_to_https_converts_git_at_syntax() { assert_eq!( ssh_url_to_https("git@github.com:brynary/arc.git"), "https://github.com/brynary/arc.git" ); } #[test] fn ssh_url_to_https_converts_ssh_protocol() { assert_eq!( ssh_url_to_https("ssh://git@github.com/brynary/arc.git"), "https://github.com/brynary/arc.git" ); } #[test] fn ssh_url_to_https_passes_through_https() { assert_eq!( ssh_url_to_https("https://github.com/brynary/arc.git"), "https://github.com/brynary/arc.git" ); } #[test] fn normalize_repo_origin_url_converts_ssh_and_trims_git_suffix() { assert_eq!( normalize_repo_origin_url("git@github.com:brynary/arc.git"), "https://github.com/brynary/arc" ); } #[test] fn normalize_repo_origin_url_strips_credentials_and_trailing_slash() { assert_eq!( normalize_repo_origin_url("https://token@github.com/acme/widgets.git/"), "https://github.com/acme/widgets" ); } #[test] fn normalize_repo_origin_url_handles_sanitized_git_at_shape() { assert_eq!( normalize_repo_origin_url("https://***@github.com:acme/widgets.git"), "https://github.com/acme/widgets" ); } #[test] fn parse_github_url_with_credentials() { let (owner, repo) = parse_github_owner_repo( "https://x-access-token:ghs_abc123@github.com/acme/widgets.git", ) .unwrap(); assert_eq!(owner, "acme"); assert_eq!(repo, "widgets"); } #[test] fn parse_github_url_with_credentials_no_password() { let (owner, repo) = parse_github_owner_repo("https://token@github.com/acme/widgets.git").unwrap(); assert_eq!(owner, "acme"); assert_eq!(repo, "widgets"); } #[test] fn parse_credentials_non_github_still_errors() { let result = parse_github_owner_repo("https://user:pass@gitlab.com/owner/repo"); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("Not a GitHub HTTPS URL") ); } #[test] fn embed_token_in_url_redacts_display_and_keeps_raw_access() { let url = embed_token_in_url("https://github.com/acme/widgets.git", "ghs_abc123").unwrap(); assert_eq!( url.redacted_string(), "https://x-access-token:****@github.com/acme/widgets.git" ); assert_eq!( url.raw_string(), "https://x-access-token:ghs_abc123@github.com/acme/widgets.git" ); } #[test] fn logging_embedded_token_url_does_not_emit_token() { let output = CapturedTrace::default(); let subscriber = registry().with( tracing_fmt::layer() .with_writer(output.clone()) .without_time() .with_target(false), ); subscriber::with_default(subscriber, || { let url = embed_token_in_url("https://github.com/acme/widgets.git", "ghs_abc123").unwrap(); debug!(?url, %url, "resolved authenticated GitHub URL"); }); let formatted = output.captured_output(); assert!(formatted.contains("x-access-token:****@")); assert!(!formatted.contains("ghs_abc123")); } #[derive(Clone, Default)] struct CapturedTrace { buffer: Arc>>, } impl CapturedTrace { fn captured_output(&self) -> String { let buffer = self.buffer.lock().unwrap(); String::from_utf8(buffer.clone()).unwrap() } } impl<'writer> MakeWriter<'writer> for CapturedTrace { type Writer = CapturedTraceWriter; fn make_writer(&'writer self) -> Self::Writer { CapturedTraceWriter { buffer: Arc::clone(&self.buffer), } } } struct CapturedTraceWriter { buffer: Arc>>, } impl io::Write for CapturedTraceWriter { fn write(&mut self, bytes: &[u8]) -> io::Result { self.buffer.lock().unwrap().extend_from_slice(bytes); Ok(bytes.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } #[test] fn parse_non_github_url_errors() { let result = parse_github_owner_repo("https://gitlab.com/owner/repo"); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("Not a GitHub HTTPS URL") ); } #[test] fn parse_missing_repo_errors() { let result = parse_github_owner_repo("https://github.com/owner"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Missing repo")); } #[test] fn parse_empty_string_errors() { let result = parse_github_owner_repo(""); assert!(result.is_err()); } // ----------------------------------------------------------------------- // sign_app_jwt // ----------------------------------------------------------------------- fn test_rsa_key() -> &'static str { tests_mock::test_rsa_key() } #[test] fn jwt_is_three_part_string() { let pem = test_rsa_key(); let jwt = sign_app_jwt("12345", pem).unwrap(); assert_eq!(jwt.split('.').count(), 3); } #[test] fn jwt_has_rs256_header() { let pem = test_rsa_key(); let jwt = sign_app_jwt("12345", pem).unwrap(); let header_b64 = jwt.split('.').next().unwrap(); let header_json = URL_SAFE_NO_PAD.decode(header_b64).unwrap(); let header: serde_json::Value = serde_json::from_slice(&header_json).unwrap(); assert_eq!(header["alg"], "RS256"); } #[test] fn jwt_has_correct_claims() { let pem = test_rsa_key(); let jwt = sign_app_jwt("99999", pem).unwrap(); let payload_b64 = jwt.split('.').nth(1).unwrap(); let payload_json = URL_SAFE_NO_PAD.decode(payload_b64).unwrap(); let claims: serde_json::Value = serde_json::from_slice(&payload_json).unwrap(); assert_eq!(claims["iss"], "99999"); let now = chrono::Utc::now().timestamp(); let iat = claims["iat"].as_i64().unwrap(); let exp = claims["exp"].as_i64().unwrap(); // iat should be ~60s before now assert!((now - 60 - iat).abs() < 5); // exp should be ~10min after now assert!((now + 600 - exp).abs() < 5); } #[test] fn jwt_invalid_pem_errors() { let result = sign_app_jwt("12345", "not-a-pem"); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("Invalid RSA private key") ); } // ----------------------------------------------------------------------- // MockHttpClient // ----------------------------------------------------------------------- use crate::tests_mock::{self, MockHttpClient}; // ----------------------------------------------------------------------- // create_installation_access_token — success // ----------------------------------------------------------------------- #[tokio::test] async fn app_credentials_mint_installation_token_preserves_expiry() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 123}"#, ) .on( HttpMethod::Post, "/app/installations/123/access_tokens", 201, r#"{"token": "ghs_xxx", "expires_at": "2026-01-01T12:00:00Z"}"#, ) .with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#); let creds = GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: test_rsa_key().to_string(), slug: None, }; let token = creds .mint_installation_token( &mock, "owner", "repo", "", serde_json::json!({ "contents": "write" }), None, ) .await .unwrap(); assert_eq!(token.token, "ghs_xxx"); assert_eq!( token.expires_at, "2026-01-01T12:00:00Z" .parse::>() .unwrap() ); } /// The multi-repository mint sends one request listing every projected /// repository name exactly once, primary first, with the shared /// permissions; the installation lookup uses the primary repository. #[tokio::test] async fn multi_repository_mint_lists_every_repository_name_once() { let access = GitHubRepositoryAccess::new( Some("git@github.com:owner/repo.git"), &[ "owner/keystone".parse().unwrap(), "owner/arc".parse().unwrap(), ] .into_iter() .collect(), std::collections::HashMap::from([("contents".to_string(), "read".to_string())]), ) .unwrap() .expect("origin should produce an access value"); let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 123}"#, ) .on( HttpMethod::Post, "/app/installations/123/access_tokens", 201, r#"{"token": "ghs_multi", "expires_at": "2026-01-01T12:00:00Z"}"#, ) .with_req_body( r#"{"permissions":{"contents":"read"},"repositories":["repo","arc","keystone"]}"#, ); let creds = GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: test_rsa_key().to_string(), slug: None, }; let token = creds .mint_installation_token_for_repositories( &mock, access.owner(), &access.repository_names(), "", access.permissions_json().unwrap(), None, ) .await .unwrap(); assert_eq!(token.token, "ghs_multi"); } #[tokio::test] async fn create_iat_requests_only_contents_write() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 123}"#, ) .with_req_header("Authorization", "Bearer test-jwt") .on( HttpMethod::Post, "/app/installations/123/access_tokens", 201, r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .with_req_header("Authorization", "Bearer test-jwt") .with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#); let token = create_installation_access_token(&mock, "test-jwt", "owner", "repo", "") .await .unwrap(); assert_eq!(token, "ghs_xxx"); } // ----------------------------------------------------------------------- // create_installation_access_token — failure modes // ----------------------------------------------------------------------- #[tokio::test] async fn create_iat_not_installed() { let mock = MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 404, ""); let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "") .await .unwrap_err() .to_string(); assert!(err.contains("not installed"), "got: {err}"); assert!(err.contains("owner"), "got: {err}"); } #[tokio::test] async fn create_iat_not_installed_uses_app_specific_install_url() { let mock = MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 404, ""); let install_url = "https://github.com/organizations/owner/settings/apps/fabro-test/installations"; let err = create_installation_access_token_with_permissions_and_install_url( &mock, "jwt", "owner", "repo", "", serde_json::json!({ "contents": "write" }), Some(install_url), ) .await .unwrap_err() .to_string(); assert!(err.contains("not installed"), "got: {err}"); assert!(err.contains(install_url), "got: {err}"); assert!( !err.contains("https://github.com/organizations/owner/settings/installations"), "got: {err}" ); } #[tokio::test] async fn create_iat_suspended() { let mock = MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 403, ""); let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "") .await .unwrap_err() .to_string(); assert!(err.contains("suspended"), "got: {err}"); } #[tokio::test] async fn create_iat_no_repo_access() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 123}"#, ) .on( HttpMethod::Post, "/app/installations/123/access_tokens", 422, "", ); let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "") .await .unwrap_err() .to_string(); assert!(err.contains("does not have access"), "got: {err}"); assert!(err.contains("repo"), "got: {err}"); } #[tokio::test] async fn find_open_pull_request_matches_the_expected_head_commit() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/pulls?state=open&base=main&head=owner%3Afabro%2Frun%2F1", 200, r#"[ { "html_url": "https://github.com/owner/repo/pull/40", "number": 40, "node_id": "PR_wrong", "title": "Old head", "head": {"sha": "old-sha"} }, { "html_url": "https://github.com/owner/repo/pull/42", "number": 42, "node_id": "PR_expected", "title": "Expected head", "head": {"sha": "final-sha"} } ]"#, ) .with_req_header("Authorization", "Bearer ghu_test"); let creds = GitHubCredentials::Pat("ghu_test".to_string()); let ctx = GitHubContext::new(&creds, "https://api.test"); let found = find_open_pull_request_with_client( &mock, &ctx, "owner", "repo", "main", "fabro/run/1", "final-sha", ) .await .unwrap() .expect("matching pull request should be found"); assert_eq!(found.number, 42); assert_eq!(found.node_id, "PR_expected"); assert_eq!(found.title, "Expected head"); } #[tokio::test] async fn create_iat_auth_failed() { let mock = MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 401, ""); let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "") .await .unwrap_err() .to_string(); assert!(err.contains("authentication failed"), "got: {err}"); } // ----------------------------------------------------------------------- // create_installation_access_token_for_pr // ----------------------------------------------------------------------- #[tokio::test] async fn create_iat_for_pr_requests_pr_permissions() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 456}"#, ) .with_req_header("Authorization", "Bearer test-jwt") .on( HttpMethod::Post, "/app/installations/456/access_tokens", 201, r#"{"token": "ghs_pr_token", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .with_req_header("Authorization", "Bearer test-jwt") .with_req_body( r#"{"permissions":{"contents":"write","pull_requests":"write"},"repositories":["repo"]}"#, ); let token = create_installation_access_token_for_pr(&mock, "test-jwt", "owner", "repo", "") .await .unwrap(); assert_eq!(token, "ghs_pr_token"); } // ----------------------------------------------------------------------- // branch_head_sha // ----------------------------------------------------------------------- fn app_creds() -> GitHubCredentials { GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: test_rsa_key().to_string(), slug: None, }) } /// Mock the installation-token exchange every App-credentialed call makes /// before it reaches the endpoint under test. fn mock_with_installation_token() -> MockHttpClient { MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) } #[tokio::test] async fn branch_head_sha_returns_commit_on_200() { let mock = mock_with_installation_token().on( HttpMethod::Get, "/repos/owner/repo/branches/my-branch", 200, r#"{"name": "my-branch", "commit": {"sha": "abc123"}}"#, ); let creds = app_creds(); let result = branch_head_sha_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", "my-branch", ) .await; assert_eq!(result.unwrap(), Some("abc123".to_string())); } #[tokio::test] async fn branch_head_sha_returns_none_on_404() { let mock = mock_with_installation_token().on( HttpMethod::Get, "/repos/owner/repo/branches/no-such-branch", 404, "", ); let creds = app_creds(); let result = branch_head_sha_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", "no-such-branch", ) .await; assert_eq!(result.unwrap(), None); } #[tokio::test] async fn branch_head_sha_returns_error_on_500() { let mock = mock_with_installation_token().on( HttpMethod::Get, "/repos/owner/repo/branches/broken", 500, "", ); let creds = app_creds(); let result = branch_head_sha_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", "broken", ) .await; assert!(result.is_err()); } #[tokio::test] async fn branch_head_sha_with_token_uses_direct_bearer_token() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/branches/my-branch", 200, r#"{"name": "my-branch", "commit": {"sha": "abc123"}}"#, ) .with_req_header("Authorization", "Bearer ghu_test"); let creds = GitHubCredentials::Pat("ghu_test".to_string()); let result = branch_head_sha_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", "my-branch", ) .await; assert_eq!(result.unwrap(), Some("abc123".to_string())); } // ----------------------------------------------------------------------- // check_app_installed // ----------------------------------------------------------------------- #[tokio::test] async fn check_app_installed_returns_true_on_200() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .with_req_header("Authorization", "Bearer test-jwt"); let result = check_app_installed(&mock, "test-jwt", "owner", "repo", "").await; assert!(result.unwrap()); } #[tokio::test] async fn check_app_installed_returns_false_on_404() { let mock = MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 404, ""); let result = check_app_installed(&mock, "test-jwt", "owner", "repo", "").await; assert!(!result.unwrap()); } #[tokio::test] async fn check_app_installed_returns_error_on_401() { let mock = MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 401, ""); let result = check_app_installed(&mock, "test-jwt", "owner", "repo", "").await; assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("authentication failed"), "expected auth error" ); } // ----------------------------------------------------------------------- // get_authenticated_app // ----------------------------------------------------------------------- #[tokio::test] async fn get_authenticated_app_success() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/app", 200, r#"{"slug": "my-fabro-app", "owner": {"login": "my-org"}}"#, ) .with_req_header("Authorization", "Bearer test-jwt"); let info = get_authenticated_app(&mock, "test-jwt", "").await.unwrap(); assert_eq!(info.slug, "my-fabro-app"); assert_eq!(info.owner.login, "my-org"); } #[tokio::test] async fn get_authenticated_app_auth_failure() { let mock = MockHttpClient::new().on(HttpMethod::Get, "/app", 401, ""); let result = get_authenticated_app(&mock, "bad-jwt", "").await; assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("authentication failed"), "expected auth error" ); } // ----------------------------------------------------------------------- // get_pull_request // ----------------------------------------------------------------------- fn mock_pr_json() -> &'static str { r#"{ "number": 42, "title": "Fix the bug", "body": "Detailed description", "state": "open", "draft": false, "merged": false, "merged_at": null, "mergeable": true, "additions": 10, "deletions": 3, "changed_files": 2, "html_url": "https://github.com/owner/repo/pull/42", "user": {"login": "testuser"}, "head": {"ref": "feature-branch"}, "base": {"ref": "main"}, "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-02T12:00:00Z" }"# } #[tokio::test] async fn get_pr_success() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .on( HttpMethod::Get, "/repos/owner/repo/pulls/42", 200, mock_pr_json(), ); let pem = test_rsa_key(); let creds = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: pem.to_string(), slug: None, }); let detail = get_pull_request_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42, ) .await .unwrap(); assert_eq!(detail.number, 42); assert_eq!(detail.title, "Fix the bug"); assert_eq!(detail.state, "open"); assert!(!detail.merged); assert_eq!(detail.merged_at, None); assert_eq!(detail.additions, 10); assert_eq!(detail.deletions, 3); assert_eq!(detail.changed_files, 2); assert_eq!(detail.user.login, "testuser"); assert_eq!(detail.head.ref_name, "feature-branch"); assert_eq!(detail.base.ref_name, "main"); } #[tokio::test] async fn get_pr_not_found() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .on(HttpMethod::Get, "/repos/owner/repo/pulls/999", 404, ""); let pem = test_rsa_key(); let creds = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: pem.to_string(), slug: None, }); let err = get_pull_request_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", 999, ) .await .unwrap_err(); assert!( matches!( err, PullRequestApiError::NotFound { number: 999, ref owner, ref repo, } if owner == "owner" && repo == "repo" ), "got: {err}" ); } #[tokio::test] async fn get_pr_with_token_uses_direct_bearer_token() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/pulls/42", 200, mock_pr_json(), ) .with_req_header("Authorization", "Bearer ghu_test"); let creds = GitHubCredentials::Pat("ghu_test".to_string()); let detail = get_pull_request_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42, ) .await .unwrap(); assert_eq!(detail.number, 42); } #[tokio::test] async fn resolve_clone_credentials_returns_token_for_token_credentials() { let creds = GitHubCredentials::Pat("ghu_test".to_string()); let credentials = resolve_clone_credentials(&GitHubContext::new(&creds, ""), "owner", "repo") .await .unwrap(); assert_eq!(credentials.username(), "x-access-token"); assert_eq!(credentials.password(), "ghu_test"); assert!(!format!("{credentials:?}").contains("ghu_test")); } #[tokio::test] async fn resolve_read_only_clone_credentials_returns_static_token_unchanged() { let creds = GitHubCredentials::Pat("ghu_test".to_string()); let credentials = resolve_read_only_clone_credentials(&GitHubContext::new(&creds, ""), "owner", "repo") .await .unwrap(); assert_eq!(credentials.username(), "x-access-token"); assert_eq!(credentials.password(), "ghu_test"); } #[tokio::test] async fn clone_token_requests_only_contents_write() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 123}"#, ) .on( HttpMethod::Post, "/app/installations/123/access_tokens", 201, r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#); let credentials = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: test_rsa_key().to_string(), slug: None, }); let context = GitHubContext::new(&credentials, ""); let token = mint_git_token( &mock, &context, "owner", "repo", serde_json::json!({ "contents": "write" }), ) .await .unwrap(); assert_eq!(token, "ghs_xxx"); } #[tokio::test] async fn read_only_clone_token_requests_only_contents_read() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 123}"#, ) .on( HttpMethod::Post, "/app/installations/123/access_tokens", 201, r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .with_req_body(r#"{"permissions":{"contents":"read"},"repositories":["repo"]}"#); let credentials = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: test_rsa_key().to_string(), slug: None, }); let context = GitHubContext::new(&credentials, ""); let token = mint_git_token( &mock, &context, "owner", "repo", serde_json::json!({ "contents": "read" }), ) .await .unwrap(); assert_eq!(token, "ghs_xxx"); } #[test] fn installation_token_valid_token_rejects_expired_tokens() { let expired = InstallationToken { token: "ghs_expired".to_string(), expires_at: chrono::Utc::now() - chrono::Duration::seconds(1), }; assert!(expired.valid_token().is_err()); let fresh = InstallationToken { token: "ghs_fresh".to_string(), expires_at: chrono::Utc::now() + chrono::Duration::minutes(30), }; assert_eq!(fresh.valid_token().unwrap(), "ghs_fresh"); assert!(!fresh.near_expiry(std::time::Duration::from_mins(15))); } #[test] fn only_app_credentials_mint_installation_tokens() { let app = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: test_rsa_key().to_string(), slug: None, }); let pat = GitHubCredentials::Pat("ghp_personal".to_string()); let installation = GitHubCredentials::Installation(InstallationToken { token: "ghs_installation".to_string(), expires_at: chrono::Utc::now() + chrono::Duration::minutes(30), }); assert!(app.mints_installation_token()); assert!(!pat.mints_installation_token()); assert!(!installation.mints_installation_token()); } #[test] fn validate_static_github_token_rejects_installation_tokens() { validate_static_github_token("ghp_personal").unwrap(); validate_static_github_token("gho_oauth").unwrap(); validate_static_github_token("ghu_user").unwrap(); let err = validate_static_github_token("ghs_installation") .unwrap_err() .to_string(); assert!(err.contains("installation access token"), "got: {err}"); } // ----------------------------------------------------------------------- // merge_pull_request // ----------------------------------------------------------------------- #[tokio::test] async fn merge_pr_success() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .on( HttpMethod::Put, "/repos/owner/repo/pulls/42/merge", 200, r#"{"merged": true}"#, ); let pem = test_rsa_key(); let creds = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: pem.to_string(), slug: None, }); merge_pull_request_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42, MergeStrategy::Squash, ) .await .unwrap(); } #[tokio::test] async fn merge_pr_not_mergeable() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .on(HttpMethod::Put, "/repos/owner/repo/pulls/42/merge", 405, ""); let pem = test_rsa_key(); let creds = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: pem.to_string(), slug: None, }); let err = merge_pull_request_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42, MergeStrategy::Squash, ) .await .unwrap_err(); assert!(err.to_string().contains("not mergeable"), "got: {err}"); } #[tokio::test] async fn merge_pr_conflict() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .on(HttpMethod::Put, "/repos/owner/repo/pulls/42/merge", 409, ""); let pem = test_rsa_key(); let creds = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: pem.to_string(), slug: None, }); let err = merge_pull_request_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42, MergeStrategy::Squash, ) .await .unwrap_err(); assert!(err.to_string().contains("merge conflict"), "got: {err}"); } // ----------------------------------------------------------------------- // close_pull_request // ----------------------------------------------------------------------- #[tokio::test] async fn close_pr_success() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .on( HttpMethod::Patch, "/repos/owner/repo/pulls/42", 200, mock_pr_json(), ); let pem = test_rsa_key(); let creds = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: pem.to_string(), slug: None, }); close_pull_request_with_client(&mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42) .await .unwrap(); } #[tokio::test] async fn close_pr_not_found() { let mock = MockHttpClient::new() .on( HttpMethod::Get, "/repos/owner/repo/installation", 200, r#"{"id": 1}"#, ) .on( HttpMethod::Post, "/app/installations/1/access_tokens", 201, r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#, ) .on(HttpMethod::Patch, "/repos/owner/repo/pulls/999", 404, ""); let pem = test_rsa_key(); let creds = GitHubCredentials::App(GitHubAppCredentials { app_id: "test".to_string(), private_key_pem: pem.to_string(), slug: None, }); let err = close_pull_request_with_client( &mock, &GitHubContext::new(&creds, ""), "owner", "repo", 999, ) .await .unwrap_err(); assert!( matches!( err, PullRequestApiError::NotFound { number: 999, ref owner, ref repo, } if owner == "owner" && repo == "repo" ), "got: {err}" ); } }