diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index 1c61c032c..f5edbcdc0 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -518,7 +518,7 @@ impl InstallInputSource for InteractiveInstallInputSource { match strategy { 0 => { let token = if gh_available { - fabro_github::gh_auth_token() + fabro_github::gh_auth_token(&fabro_github::GitHubHost::default()) .await .context("Run `gh auth login` and rerun `fabro install`.")? } else { @@ -696,7 +696,7 @@ impl InstallInputSource for NonInteractiveInstallInputSource { ) -> Result { match self.args.github_strategy { Some(InstallGitHubStrategyArg::Token) => { - let token = fabro_github::gh_auth_token() + let token = fabro_github::gh_auth_token(&fabro_github::GitHubHost::default()) .await .context("Run `gh auth login` and rerun `fabro install`.")?; fabro_github::validate_static_github_token(&token)?; @@ -782,7 +782,7 @@ async fn choose_install_github_selection( match github_args.strategy { Some(InstallGitHubStrategyArg::Token) => { - let token = fabro_github::gh_auth_token() + let token = fabro_github::gh_auth_token(&fabro_github::GitHubHost::default()) .await .context("Run `gh auth login` and rerun `fabro install github`.")?; fabro_github::validate_static_github_token(&token)?; diff --git a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs index 267c18070..39652fc87 100644 --- a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs +++ b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs @@ -221,9 +221,15 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { None } }; - let read_tokens = publish::read_token_source(&worker.run_state.spec, github.as_ref()); - runtime.stage_credentials = - StageCredentials::for_run(&worker.run_state.spec, github.as_ref(), read_tokens.clone())?; + let github_host = publish::github_host()?; + let read_tokens = + publish::read_token_source(&worker.run_state.spec, github.as_ref(), &github_host); + runtime.stage_credentials = StageCredentials::for_run( + &worker.run_state.spec, + github.as_ref(), + &github_host, + read_tokens.clone(), + )?; let mut source = RunSource::for_run( worker.run_state.spec.target.as_ref(), &worker.run_state.spec.settings.run, @@ -236,6 +242,7 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { run_id, &worker.run_state.spec, github, + github_host, Arc::new(VaultCredentialSource::new(Arc::clone(&vault))), Arc::new(catalog), Arc::clone(&records), diff --git a/lib/apps/fabro-cli/src/commands/run/publish.rs b/lib/apps/fabro-cli/src/commands/run/publish.rs index b4850eec4..68976306c 100644 --- a/lib/apps/fabro-cli/src/commands/run/publish.rs +++ b/lib/apps/fabro-cli/src/commands/run/publish.rs @@ -95,12 +95,14 @@ fn repository(spec: &RunSpec) -> Option { fn token_source( spec: &RunSpec, credentials: Option<&GitHubCredentials>, + host: &GitHubHost, permissions: serde_json::Value, ) -> Option> { let repository = repository(spec)?; let credentials = credentials?; match InstallationTokenSource::for_repository( credentials, + host, repository.owner().to_string(), repository.repo().to_string(), permissions, @@ -120,8 +122,14 @@ fn token_source( pub(super) fn read_token_source( spec: &RunSpec, credentials: Option<&GitHubCredentials>, + host: &GitHubHost, ) -> Option> { - token_source(spec, credentials, serde_json::json!({ "contents": "read" })) + token_source( + spec, + credentials, + host, + serde_json::json!({ "contents": "read" }), + ) } /// Fetch credentials over the run's read-only token source. @@ -166,6 +174,7 @@ pub(super) struct GitHubPublisher { /// The run's model, when its settings name one. model: Option, credentials: Option, + host: GitHubHost, /// The run's `contents: write` token source; `None` without credentials. push_tokens: Option>, pull_request: Option, @@ -182,6 +191,7 @@ impl GitHubPublisher { run_id: RunId, spec: &RunSpec, credentials: Option, + host: GitHubHost, llm_source: Arc, catalog: Arc, records: Arc, @@ -202,6 +212,7 @@ impl GitHubPublisher { let push_tokens = token_source( spec, credentials.as_ref(), + &host, serde_json::json!({ "contents": "write" }), ); Some(Self { @@ -211,6 +222,7 @@ impl GitHubPublisher { goal: spec.graph.goal.clone(), model: settings.model.name.clone(), credentials, + host, push_tokens, pull_request: settings .pull_request @@ -319,8 +331,7 @@ impl RunPublisher for GitHubPublisher { .credentials .as_ref() .ok_or_else(|| "GitHub credentials are unavailable".to_string())?; - let host = github_host().map_err(|err| format!("{err:#}"))?; - let context = GitHubContext::new(credentials, host.api_url()); + let context = GitHubContext::new(credentials, &self.host); Box::pin(self.open_pull_request(context, settings, publication)).await } } @@ -405,6 +416,7 @@ mod tests { RunId::new(), spec, None, + GitHubHost::default(), Arc::new(NoCredentials), Arc::new(test_support::test_catalog()), Arc::new(MemoryPlatformRecords::new()), diff --git a/lib/apps/fabro-server/src/automation_materializer.rs b/lib/apps/fabro-server/src/automation_materializer.rs index c3feace37..ac32323f1 100644 --- a/lib/apps/fabro-server/src/automation_materializer.rs +++ b/lib/apps/fabro-server/src/automation_materializer.rs @@ -143,9 +143,9 @@ trait AutomationGitRemoteResolver: Send + Sync { } struct ServerGitHubRemoteResolver { - credentials: Option, - api_base_url: String, - http_client: Option, + credentials: Option, + host: fabro_types::GitHubHost, + http_client: Option, } #[async_trait] @@ -154,7 +154,7 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver { let auth = git_checkout::resolve_git_read_auth_config( self.credentials.as_ref(), repo, - &self.api_base_url, + &self.host, self.http_client.clone(), ) .await?; @@ -168,7 +168,7 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver { impl ProductionAutomationRunMaterializer { pub(crate) fn new( github_credentials: Option, - github_api_base_url: String, + github_host: fabro_types::GitHubHost, http_client: Option, repo_cache: Arc, version_store: WorkflowVersionStore, @@ -176,7 +176,7 @@ impl ProductionAutomationRunMaterializer { Self { remote_resolver: Arc::new(ServerGitHubRemoteResolver { credentials: github_credentials, - api_base_url: github_api_base_url, + host: github_host, http_client, }), repo_cache, @@ -772,7 +772,7 @@ mod tests { ) -> ProductionAutomationRunMaterializer { ProductionAutomationRunMaterializer::new( None, - "https://api.github.com".to_string(), + fabro_types::GitHubHost::default(), None, Arc::new(GitRepoCache::new(root.join("cache"))), store, diff --git a/lib/apps/fabro-server/src/git_checkout.rs b/lib/apps/fabro-server/src/git_checkout.rs index d2856be42..7ad32dc8a 100644 --- a/lib/apps/fabro-server/src/git_checkout.rs +++ b/lib/apps/fabro-server/src/git_checkout.rs @@ -313,17 +313,15 @@ impl GitAuthConfig { pub(crate) async fn resolve_git_read_auth_config( credentials: Option<&fabro_github::GitHubCredentials>, repo: &GitHubRepositorySlug, - github_api_base_url: &str, + host: &fabro_types::GitHubHost, http_client: Option, ) -> anyhow::Result> { let Some(credentials) = credentials else { return Ok(None); }; let context = match http_client { - Some(client) => { - fabro_github::GitHubContext::with_http_client(credentials, github_api_base_url, client) - } - None => fabro_github::GitHubContext::new(credentials, github_api_base_url), + Some(client) => fabro_github::GitHubContext::with_http_client(credentials, host, client), + None => fabro_github::GitHubContext::new(credentials, host), }; let credentials = fabro_github::resolve_read_only_clone_credentials(&context, repo.owner(), repo.repo()) diff --git a/lib/apps/fabro-server/src/github_webhooks.rs b/lib/apps/fabro-server/src/github_webhooks.rs index dfc74491c..9da8337bf 100644 --- a/lib/apps/fabro-server/src/github_webhooks.rs +++ b/lib/apps/fabro-server/src/github_webhooks.rs @@ -1,4 +1,5 @@ use fabro_static::EnvVars; +use fabro_types::GitHubHost; use hmac::{Hmac, Mac}; use sha2::Sha256; use tokio::process::Command; @@ -56,6 +57,7 @@ pub struct TailscaleFunnelManager { impl TailscaleFunnelManager { pub async fn start( main_server_port: u16, + host: &GitHubHost, app_id: &str, private_key_pem: &str, ) -> anyhow::Result { @@ -63,7 +65,9 @@ impl TailscaleFunnelManager { info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled"); let webhook_url = format!("{funnel_url}{WEBHOOK_ROUTE}"); - match fabro_github::update_app_webhook_config(app_id, private_key_pem, &webhook_url).await { + match fabro_github::update_app_webhook_config(host, app_id, private_key_pem, &webhook_url) + .await + { Ok(()) => { info!(url = %webhook_url, "GitHub App webhook URL updated"); } diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 59b5c7097..3652fab09 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -513,10 +513,17 @@ async fn build_preflight_report( prepared, &resolved_run, github_app.clone(), + &state.github_host, + ) + .await; + let github_token_ok = run_github_token_check( + &mut checks, + prepared, + &resolved_run, + github_app, + &state.github_host, ) .await; - let github_token_ok = - run_github_token_check(&mut checks, prepared, &resolved_run, github_app).await; let checks_ok = sandbox_ok && repository_access_ok && github_token_ok; @@ -699,6 +706,7 @@ async fn run_repository_access_check( prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, + host: &fabro_types::GitHubHost, ) -> bool { run_repository_access_check_with( checks, @@ -706,7 +714,7 @@ async fn run_repository_access_check( prepared, resolved_run, github_app, - check_git_remote_ref, + |request, github_app| check_git_remote_ref(request, github_app, host), ) .await } @@ -780,11 +788,12 @@ where async fn check_git_remote_ref( request: GitRemoteRefCheck, github_app: Option, + host: &fabro_types::GitHubHost, ) -> Result<(), String> { let auth_url = match github_app.as_ref() { Some(creds) => Some( fabro_github::resolve_authenticated_url( - &fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()), + &fabro_github::GitHubContext::new(creds, host), &request.origin_url, ) .await @@ -940,13 +949,15 @@ async fn run_github_token_check( prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, + host: &fabro_types::GitHubHost, ) -> bool { run_github_token_check_with( checks, prepared, resolved_run, github_app, - mint_scoped_github_token, + host, + |access, creds| mint_scoped_github_token(access, creds, host), probe_github_repository, ) .await @@ -957,6 +968,7 @@ async fn run_github_token_check_with( prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, + host: &fabro_types::GitHubHost, mint_scoped_token: M, probe_repository: P, ) -> bool @@ -998,6 +1010,7 @@ where checks, prepared, github_app, + host, &integration.permissions, perm_details, ) @@ -1163,11 +1176,12 @@ async fn check_primary_only_github_token( checks: &mut Vec, prepared: &PreparedManifest, github_app: Option, + host: &fabro_types::GitHubHost, permissions: &HashMap, perm_details: Vec, ) -> bool { if let (Some(creds), Some(git)) = (&github_app, prepared.git.as_ref()) { - match mint_github_token(creds, &git.origin_url, permissions).await { + match mint_github_token(creds, host, &git.origin_url, permissions).await { Ok(_) => { checks.push(CheckResult { name: "GitHub Token".into(), @@ -1207,9 +1221,10 @@ async fn check_primary_only_github_token( async fn mint_scoped_github_token( access: fabro_github::GitHubRepositoryAccess, creds: fabro_github::GitHubCredentials, + host: &fabro_types::GitHubHost, ) -> std::result::Result { - let source = - InstallationTokenSource::for_access(&creds, &access).map_err(|err| format!("{err:#}"))?; + let source = InstallationTokenSource::for_access(&creds, host, &access) + .map_err(|err| format!("{err:#}"))?; source.resolve().await.map_err(|err| format!("{err:#}")) } @@ -1356,6 +1371,7 @@ async fn run_probe_ls_remote(url: &str, token: &ResolvedToken) -> std::result::R async fn mint_github_token( creds: &fabro_github::GitHubCredentials, + host: &fabro_types::GitHubHost, origin_url: &str, permissions: &HashMap, ) -> Result { @@ -1364,13 +1380,7 @@ async fn mint_github_token( let client = fabro_http::http_client()?; let perms_json = serde_json::to_value(permissions)?; creds - .resolve_bearer_token( - &client, - &owner, - &repo, - &fabro_github::github_api_base_url(), - perms_json, - ) + .resolve_bearer_token(&client, &owner, &repo, host, perms_json) .await } @@ -3155,6 +3165,7 @@ dockerfile = { path = "Dockerfile" } &prepared, &resolved, Some(pat_creds()), + &fabro_types::GitHubHost::default(), move |access, _creds| { minted_for_seam.lock().unwrap().push(access); async { Ok(static_token("scoped-token")) } @@ -3212,6 +3223,7 @@ dockerfile = { path = "Dockerfile" } &prepared, &resolved, Some(pat_creds()), + &fabro_types::GitHubHost::default(), |_access, _creds| async { Err( "the GitHub App installation cannot see repository acme/keystone; add \ @@ -3250,6 +3262,7 @@ dockerfile = { path = "Dockerfile" } &prepared, &resolved, Some(pat_creds()), + &fabro_types::GitHubHost::default(), |_access, _creds| async { Ok(static_token("scoped-token")) }, |slug, _token| async move { if slug.repo() == "keystone" { @@ -3296,6 +3309,7 @@ dockerfile = { path = "Dockerfile" } &prepared, &resolved, Some(pat_creds()), + &fabro_types::GitHubHost::default(), |_access, _creds| async { Ok(static_token("scoped-token")) }, |_slug, _token| async { Ok(()) }, ) @@ -3324,6 +3338,7 @@ dockerfile = { path = "Dockerfile" } &prepared, &resolved, Some(pat_creds()), + &fabro_types::GitHubHost::default(), |_access, _creds| async { Ok(static_token("scoped-token")) }, |_slug, _token| async { Ok(()) }, ) @@ -3354,6 +3369,7 @@ dockerfile = { path = "Dockerfile" } &prepared, &resolved, None, + &fabro_types::GitHubHost::default(), |_access, _creds| async { Ok(static_token("scoped-token")) }, |_slug, _token| async { Ok(()) }, ) diff --git a/lib/apps/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs index 06e8cc56d..ab4a0ed09 100644 --- a/lib/apps/fabro-server/src/serve.rs +++ b/lib/apps/fabro-server/src/serve.rs @@ -334,7 +334,9 @@ async fn start_webhook_strategy( ); return Ok(None); }; - match TailscaleFunnelManager::start(port, &app_id, &private_key_pem).await { + match TailscaleFunnelManager::start(port, &state.github_host, &app_id, &private_key_pem) + .await + { Ok(manager) => Ok(Some(manager)), Err(err) => { error!( @@ -356,8 +358,13 @@ async fn start_webhook_strategy( ) })?; let webhook_url = format!("{}{WEBHOOK_ROUTE}", server_api_url.trim_end_matches('/')); - match fabro_github::update_app_webhook_config(&app_id, &private_key_pem, &webhook_url) - .await + match fabro_github::update_app_webhook_config( + &state.github_host, + &app_id, + &private_key_pem, + &webhook_url, + ) + .await { Ok(()) => info!(url = %webhook_url, "GitHub App webhook URL updated"), Err(err) => warn!( diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 91f7bfa30..58db78bed 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -1277,7 +1277,7 @@ impl AppState { .map_err(|source| RunMaterializeError::LoadCredentials { source })?; ProductionAutomationRunMaterializer::new( credentials, - self.github_host.api_url().to_string(), + self.github_host.clone(), self.http_client.clone(), Arc::clone(&self.automation_repo_cache), fabro_workflow_version::WorkflowVersionStore::new(self.store_ref().blobs()), diff --git a/lib/apps/fabro-server/src/server/handler/pull_requests.rs b/lib/apps/fabro-server/src/server/handler/pull_requests.rs index 10e826b30..e665964c5 100644 --- a/lib/apps/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/apps/fabro-server/src/server/handler/pull_requests.rs @@ -123,7 +123,7 @@ pub(in crate::server) fn server_github_context<'a>( })?; Ok(fabro_github::GitHubContext::with_http_client( creds, - state.github_host.api_url(), + &state.github_host, http_client, )) } diff --git a/lib/apps/fabro-server/src/server/handler/system.rs b/lib/apps/fabro-server/src/server/handler/system.rs index 917fff590..c950df09d 100644 --- a/lib/apps/fabro-server/src/server/handler/system.rs +++ b/lib/apps/fabro-server/src/server/handler/system.rs @@ -493,9 +493,11 @@ async fn get_github_repo( .into_response(); } }; - let install_url = creds.installation_url(&owner).unwrap_or_else(|| { - format!("https://github.com/organizations/{owner}/settings/installations") - }); + let install_url = creds + .installation_url(&state.github_host, &owner) + .unwrap_or_else(|| { + format!("https://github.com/organizations/{owner}/settings/installations") + }); let client = match state.http_client() { Ok(http) => http, @@ -537,7 +539,7 @@ async fn get_github_repo( &jwt, &owner, &name, - &base_url, + &state.github_host, serde_json::json!({ "contents": "write", "pull_requests": "write" }), Some(&install_url), ) diff --git a/lib/components/fabro-github/src/identity.rs b/lib/components/fabro-github/src/identity.rs index ac53116b6..b1c4e36d4 100644 --- a/lib/components/fabro-github/src/identity.rs +++ b/lib/components/fabro-github/src/identity.rs @@ -2,11 +2,12 @@ //! //! A run's commits carry the identity of the credential the run uses: //! the App's bot account (`[bot]`) or the authenticated user of a -//! personal access token. Both resolve to a stable GitHub.com noreply -//! address, `{id}+{login}@users.noreply.github.com`, so GitHub attributes +//! personal access token. Both resolve to a stable noreply +//! address, `{id}+{login}@users.noreply.`, so GitHub attributes //! the commits to that account without a private email. use anyhow::{Context as _, bail}; +use fabro_types::GitHubHost; use serde::Deserialize; use crate::{GitHubAppCredentials, HttpClient, HttpMethod, github_headers, sign_app_jwt}; @@ -21,10 +22,10 @@ pub struct GitHubAccountIdentity { } impl GitHubAccountIdentity { - /// The GitHub.com noreply address GitHub attributes to this account. + /// The noreply address `host` attributes to this account. #[must_use] - pub fn noreply_email(&self) -> String { - format!("{}+{}@users.noreply.github.com", self.id, self.login) + pub fn noreply_email(&self, host: &GitHubHost) -> String { + format!("{}+{}@{}", self.id, self.login, host.noreply_email_domain()) } } @@ -153,6 +154,24 @@ mod tests { } } + #[test] + fn noreply_email_uses_the_hosts_domain() { + let identity = GitHubAccountIdentity { + login: "octo".to_string(), + id: 7, + }; + let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap(); + assert_eq!( + identity.noreply_email(&ghes), + "7+octo@users.noreply.ghe.example.com" + ); + let ghec = GitHubHost::new("https://octocorp.ghe.com", None).unwrap(); + assert_eq!( + identity.noreply_email(&ghec), + "7+octo@users.noreply.octocorp.ghe.com" + ); + } + #[test] fn noreply_email_uses_id_plus_login() { let identity = GitHubAccountIdentity { @@ -160,7 +179,7 @@ mod tests { id: 281_434_857, }; assert_eq!( - identity.noreply_email(), + identity.noreply_email(&GitHubHost::default()), "281434857+fabro-sh[bot]@users.noreply.github.com" ); } @@ -251,7 +270,7 @@ mod tests { assert_eq!(identity.login, "my-app[bot]"); assert_eq!(identity.id, 4242); assert_eq!( - identity.noreply_email(), + identity.noreply_email(&GitHubHost::default()), "4242+my-app[bot]@users.noreply.github.com" ); // The configured slug skips the `/app` lookup entirely. diff --git a/lib/components/fabro-github/src/lib.rs b/lib/components/fabro-github/src/lib.rs index c4a9f3fdb..83f116945 100644 --- a/lib/components/fabro-github/src/lib.rs +++ b/lib/components/fabro-github/src/lib.rs @@ -4,9 +4,10 @@ use base64::engine::general_purpose::STANDARD; use chrono::{DateTime, Utc}; use fabro_redact::DisplaySafeUrl; use fabro_static::EnvVars; +pub use fabro_types::GitHubHost; +use fabro_types::PullRequestGithubDetail; use fabro_types::settings::run::MergeStrategy; use fabro_types::settings::server::GithubIntegrationSettings; -use fabro_types::{GitHubHost, PullRequestGithubDetail}; use serde::Deserialize; use tokio::process::Command; @@ -51,17 +52,6 @@ pub fn apply_probe_git_env(command: &mut Command, token: &str) { .env("GIT_CONFIG_VALUE_0", GITHUB_CREDENTIAL_HELPER); } -/// REST API base from `GITHUB_BASE_URL` or github.com, for call sites that -/// have no settings to resolve a [`GitHubHost`] from. -#[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(|_| GitHubHost::default().api_url().to_string()) -} - /// Resolves the GitHub deployment from settings, letting `GITHUB_WEB_URL` /// override the web origin and `GITHUB_BASE_URL` override the REST API base. #[expect( @@ -91,33 +81,33 @@ fn host_with_overrides( host.context("invalid GITHUB_WEB_URL or GITHUB_BASE_URL override") } -/// Bundle of GitHub credentials and the API base URL, threaded through every +/// Bundle of GitHub credentials and the GitHub host, 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, + host: &'a GitHubHost, http_client: Option, } impl<'a> GitHubContext<'a> { - pub fn new(creds: &'a GitHubCredentials, base_url: &'a str) -> Self { + pub fn new(creds: &'a GitHubCredentials, host: &'a GitHubHost) -> Self { Self { creds, - base_url, + host, http_client: None, } } pub fn with_http_client( creds: &'a GitHubCredentials, - base_url: &'a str, + host: &'a GitHubHost, http_client: fabro_http::HttpClient, ) -> Self { Self { creds, - base_url, + host, http_client: Some(http_client), } } @@ -202,9 +192,12 @@ impl GitHubAppCredentials { })) } - pub fn installation_url(&self, owner: &str) -> Option { + pub fn installation_url(&self, host: &GitHubHost, owner: &str) -> Option { self.slug.as_ref().map(|slug| { - format!("https://github.com/organizations/{owner}/settings/apps/{slug}/installations") + format!( + "{}/organizations/{owner}/settings/apps/{slug}/installations", + host.web_url() + ) }) } @@ -213,7 +206,7 @@ impl GitHubAppCredentials { client: &impl HttpClient, owner: &str, repo: &str, - base_url: &str, + host: &GitHubHost, permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { @@ -221,7 +214,7 @@ impl GitHubAppCredentials { client, owner, &[repo.to_string()], - base_url, + host, permissions, install_url, ) @@ -236,19 +229,19 @@ impl GitHubAppCredentials { client: &impl HttpClient, owner: &str, repository_names: &[String], - base_url: &str, + host: &GitHubHost, 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 default_install_url = self.installation_url(host, owner); let install_url = install_url.or(default_install_url.as_deref()); mint_installation_token_with_jwt( client, &jwt, owner, repository_names, - base_url, + host, permissions, install_url, ) @@ -309,18 +302,18 @@ impl GitHubCredentials { client: &impl HttpClient, owner: &str, repo: &str, - base_url: &str, + host: &GitHubHost, permissions: serde_json::Value, ) -> anyhow::Result { match self { Self::App(creds) => { - let install_url = creds.installation_url(owner); + let install_url = creds.installation_url(host, owner); creds .mint_installation_token( client, owner, repo, - base_url, + host, permissions, install_url.as_deref(), ) @@ -343,9 +336,9 @@ pub fn validate_static_github_token(token: &str) -> anyhow::Result<()> { Ok(()) } -pub async fn gh_auth_token() -> anyhow::Result { +pub async fn gh_auth_token(host: &GitHubHost) -> anyhow::Result { let output = Command::new("gh") - .args(["auth", "token"]) + .args(["auth", "token", "--hostname", host.host()]) .output() .await .context("Failed to run `gh auth token`")?; @@ -539,7 +532,7 @@ pub async fn create_installation_access_token_with_permissions( jwt: &str, owner: &str, repo: &str, - base_url: &str, + host: &GitHubHost, permissions: serde_json::Value, ) -> anyhow::Result { create_installation_access_token_with_permissions_and_install_url( @@ -547,7 +540,7 @@ pub async fn create_installation_access_token_with_permissions( jwt, owner, repo, - base_url, + host, permissions, None, ) @@ -559,7 +552,7 @@ pub async fn create_installation_access_token_with_permissions_and_install_url( jwt: &str, owner: &str, repo: &str, - base_url: &str, + host: &GitHubHost, permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { @@ -568,7 +561,7 @@ pub async fn create_installation_access_token_with_permissions_and_install_url( jwt, owner, &[repo.to_string()], - base_url, + host, permissions, install_url, ) @@ -627,10 +620,11 @@ async fn mint_installation_token_with_jwt( jwt: &str, owner: &str, repos: &[String], - base_url: &str, + host: &GitHubHost, permissions: serde_json::Value, install_url: Option<&str>, ) -> anyhow::Result { + let base_url = host.api_url(); let Some(primary_repo) = repos.first() else { bail!("installation token mint requires at least one repository"); }; @@ -646,7 +640,12 @@ async fn mint_installation_token_with_jwt( InstallationLookup::Found(id) => id, InstallationLookup::NotFound => { let install_url = install_url.map_or_else( - || format!("https://github.com/organizations/{owner}/settings/installations"), + || { + format!( + "{}/organizations/{owner}/settings/installations", + host.web_url() + ) + }, str::to_string, ); bail!( @@ -754,14 +753,14 @@ pub async fn create_installation_access_token( jwt: &str, owner: &str, repo: &str, - base_url: &str, + host: &GitHubHost, ) -> anyhow::Result { create_installation_access_token_with_permissions( client, jwt, owner, repo, - base_url, + host, serde_json::json!({ "contents": "write" }), ) .await @@ -774,14 +773,14 @@ pub async fn create_installation_access_token_for_pr( jwt: &str, owner: &str, repo: &str, - base_url: &str, + host: &GitHubHost, ) -> anyhow::Result { create_installation_access_token_with_permissions( client, jwt, owner, repo, - base_url, + host, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await @@ -846,12 +845,15 @@ pub async fn find_open_pull_request_with_client( client, owner, repo, - ctx.base_url, + ctx.host, 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")?; + let mut url = DisplaySafeUrl::parse(&format!( + "{}/repos/{owner}/{repo}/pulls", + ctx.host.api_url() + )) + .context("Failed to build pull request reconciliation URL")?; url.query_pairs_mut() .append_pair("state", "open") .append_pair("base", base) @@ -938,7 +940,7 @@ pub async fn create_pull_request_with_client( client, owner, repo, - ctx.base_url, + ctx.host, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; @@ -953,7 +955,7 @@ pub async fn create_pull_request_with_client( "draft": draft, }); - let url = format!("{}/repos/{owner}/{repo}/pulls", ctx.base_url); + let url = format!("{}/repos/{owner}/{repo}/pulls", ctx.host.api_url()); let auth = format!("Bearer {token}"); let resp = HttpClient::request( client, @@ -1034,7 +1036,7 @@ pub async fn enable_auto_merge_with_client( client, owner, repo, - ctx.base_url, + ctx.host, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; @@ -1059,7 +1061,7 @@ pub async fn enable_auto_merge_with_client( "Enabling auto-merge" ); - let graphql_url = format!("{}/graphql", ctx.base_url); + let graphql_url = ctx.host.graphql_url(); let auth = format!("Bearer {token}"); let graphql_body = serde_json::json!({ "query": query }); let resp = HttpClient::request( @@ -1177,12 +1179,15 @@ async fn branch_head_sha_with_client( client, owner, repo, - ctx.base_url, + ctx.host, serde_json::json!({ "contents": "read" }), ) .await?; - let url = format!("{}/repos/{owner}/{repo}/branches/{branch}", ctx.base_url); + let url = format!( + "{}/repos/{owner}/{repo}/branches/{branch}", + ctx.host.api_url() + ); let auth = format!("Bearer {token}"); let resp = client .request(HttpMethod::Get, &url, &github_headers(&auth), None) @@ -1269,13 +1274,14 @@ pub async fn get_authenticated_app( /// /// Signs an App JWT and sets the webhook endpoint and content type. pub async fn update_app_webhook_config( + host: &GitHubHost, 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 url = format!("{}/app/hook/config", host.api_url()); let auth = format!("Bearer {jwt}"); let body = serde_json::json!({ "url": webhook_url, @@ -1411,7 +1417,7 @@ async fn mint_git_token( permissions: serde_json::Value, ) -> anyhow::Result { ctx.creds - .resolve_bearer_token(client, owner, repo, ctx.base_url, permissions) + .resolve_bearer_token(client, owner, repo, ctx.host, permissions) .await } @@ -1470,12 +1476,12 @@ pub async fn get_pull_request_with_client( client, owner, repo, - ctx.base_url, + ctx.host, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; - let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.base_url); + let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.host.api_url()); let auth = format!("Bearer {token}"); let resp = client .request(HttpMethod::Get, &url, &github_headers(&auth), None) @@ -1540,12 +1546,15 @@ pub async fn merge_pull_request_with_client( client, owner, repo, - ctx.base_url, + ctx.host, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; - let url = format!("{}/repos/{owner}/{repo}/pulls/{number}/merge", ctx.base_url); + let url = format!( + "{}/repos/{owner}/{repo}/pulls/{number}/merge", + ctx.host.api_url() + ); let body = serde_json::json!({ "merge_method": method }); let auth = format!("Bearer {token}"); @@ -1604,12 +1613,12 @@ pub async fn close_pull_request_with_client( client, owner, repo, - ctx.base_url, + ctx.host, serde_json::json!({ "contents": "write", "pull_requests": "write" }), ) .await?; - let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.base_url); + let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.host.api_url()); let body = serde_json::json!({ "state": "closed" }); let auth = format!("Bearer {token}"); @@ -1645,14 +1654,14 @@ pub async fn create_installation_access_token_for_projects( jwt: &str, owner: &str, repo: &str, - base_url: &str, + host: &GitHubHost, ) -> anyhow::Result { create_installation_access_token_with_permissions( client, jwt, owner, repo, - base_url, + host, serde_json::json!({ "issues": "write", "organization_projects": "write" }), ) .await @@ -1671,6 +1680,75 @@ mod tests { use super::*; + fn ghes() -> GitHubHost { + GitHubHost::new("https://ghe.example.com", None).unwrap() + } + + fn app_with_slug(slug: Option<&str>) -> GitHubAppCredentials { + GitHubAppCredentials { + app_id: "test".to_string(), + private_key_pem: test_rsa_key().to_string(), + slug: slug.map(str::to_string), + } + } + + #[test] + fn installation_url_uses_the_web_origin() { + let creds = app_with_slug(Some("fabro")); + assert_eq!( + creds + .installation_url(&GitHubHost::default(), "acme") + .unwrap(), + "https://github.com/organizations/acme/settings/apps/fabro/installations" + ); + assert_eq!( + creds.installation_url(&ghes(), "acme").unwrap(), + "https://ghe.example.com/organizations/acme/settings/apps/fabro/installations" + ); + } + + #[tokio::test] + async fn missing_installation_guidance_names_the_configured_host() { + let mock = + MockHttpClient::new().on(HttpMethod::Get, "/repos/acme/widgets/installation", 404, ""); + let error = app_with_slug(None) + .mint_installation_token( + &mock, + "acme", + "widgets", + &ghes(), + serde_json::json!({}), + None, + ) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("https://ghe.example.com/organizations/acme/settings/installations"), + "{error}" + ); + } + + #[tokio::test] + async fn auto_merge_posts_to_the_hosts_graphql_endpoint() { + let mock = + MockHttpClient::new().on(HttpMethod::Post, "/api/graphql", 200, r#"{"data":{}}"#); + let creds = GitHubCredentials::Pat("ghp_test".to_string()); + let host = ghes(); + enable_auto_merge_with_client( + &mock, + &GitHubContext::new(&creds, &host), + "acme", + "widgets", + "PR_node", + MergeStrategy::Squash, + ) + .await + .unwrap(); + assert_eq!(mock.request_count(), 1); + } + #[test] fn host_overrides_apply_over_settings() { let configured = GitHubHost::new("https://ghe.example.com", None).unwrap(); @@ -2041,7 +2119,7 @@ mod tests { &mock, "owner", "repo", - "", + &GitHubHost::default(), serde_json::json!({ "contents": "write" }), None, ) @@ -2103,7 +2181,7 @@ mod tests { &mock, access.owner(), &access.repository_names(), - "", + &GitHubHost::default(), access.permissions_json().unwrap(), None, ) @@ -2132,9 +2210,15 @@ mod tests { .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(); + let token = create_installation_access_token( + &mock, + "test-jwt", + "owner", + "repo", + &GitHubHost::default(), + ) + .await + .unwrap(); assert_eq!(token, "ghs_xxx"); } @@ -2147,10 +2231,11 @@ mod tests { 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(); + let err = + create_installation_access_token(&mock, "jwt", "owner", "repo", &GitHubHost::default()) + .await + .unwrap_err() + .to_string(); assert!(err.contains("not installed"), "got: {err}"); assert!(err.contains("owner"), "got: {err}"); } @@ -2167,7 +2252,7 @@ mod tests { "jwt", "owner", "repo", - "", + &GitHubHost::default(), serde_json::json!({ "contents": "write" }), Some(install_url), ) @@ -2188,10 +2273,11 @@ mod tests { 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(); + let err = + create_installation_access_token(&mock, "jwt", "owner", "repo", &GitHubHost::default()) + .await + .unwrap_err() + .to_string(); assert!(err.contains("suspended"), "got: {err}"); } @@ -2211,10 +2297,11 @@ mod tests { "", ); - let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "") - .await - .unwrap_err() - .to_string(); + let err = + create_installation_access_token(&mock, "jwt", "owner", "repo", &GitHubHost::default()) + .await + .unwrap_err() + .to_string(); assert!(err.contains("does not have access"), "got: {err}"); assert!(err.contains("repo"), "got: {err}"); } @@ -2245,7 +2332,8 @@ mod tests { ) .with_req_header("Authorization", "Bearer ghu_test"); let creds = GitHubCredentials::Pat("ghu_test".to_string()); - let ctx = GitHubContext::new(&creds, "https://api.test"); + let host = GitHubHost::default(); + let ctx = GitHubContext::new(&creds, &host); let found = find_open_pull_request_with_client( &mock, @@ -2270,10 +2358,11 @@ mod tests { 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(); + let err = + create_installation_access_token(&mock, "jwt", "owner", "repo", &GitHubHost::default()) + .await + .unwrap_err() + .to_string(); assert!(err.contains("authentication failed"), "got: {err}"); } @@ -2302,9 +2391,15 @@ mod tests { r#"{"permissions":{"contents":"write","pull_requests":"write"},"repositories":["repo"]}"#, ); - let token = create_installation_access_token_for_pr(&mock, "test-jwt", "owner", "repo", "") - .await - .unwrap(); + let token = create_installation_access_token_for_pr( + &mock, + "test-jwt", + "owner", + "repo", + &GitHubHost::default(), + ) + .await + .unwrap(); assert_eq!(token, "ghs_pr_token"); } @@ -2350,7 +2445,7 @@ mod tests { let creds = app_creds(); let result = branch_head_sha_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", "my-branch", @@ -2372,7 +2467,7 @@ mod tests { let creds = app_creds(); let result = branch_head_sha_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", "no-such-branch", @@ -2394,7 +2489,7 @@ mod tests { let creds = app_creds(); let result = branch_head_sha_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", "broken", @@ -2418,7 +2513,7 @@ mod tests { let creds = GitHubCredentials::Pat("ghu_test".to_string()); let result = branch_head_sha_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", "my-branch", @@ -2563,7 +2658,7 @@ mod tests { }); let detail = get_pull_request_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", 42, @@ -2609,7 +2704,7 @@ mod tests { }); let err = get_pull_request_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", 999, @@ -2643,7 +2738,7 @@ mod tests { let creds = GitHubCredentials::Pat("ghu_test".to_string()); let detail = get_pull_request_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", 42, @@ -2658,10 +2753,13 @@ mod tests { 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(); + let credentials = resolve_clone_credentials( + &GitHubContext::new(&creds, &GitHubHost::default()), + "owner", + "repo", + ) + .await + .unwrap(); assert_eq!(credentials.username(), "x-access-token"); assert_eq!(credentials.password(), "ghu_test"); @@ -2672,10 +2770,13 @@ mod tests { 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(); + let credentials = resolve_read_only_clone_credentials( + &GitHubContext::new(&creds, &GitHubHost::default()), + "owner", + "repo", + ) + .await + .unwrap(); assert_eq!(credentials.username(), "x-access-token"); assert_eq!(credentials.password(), "ghu_test"); @@ -2702,7 +2803,8 @@ mod tests { private_key_pem: test_rsa_key().to_string(), slug: None, }); - let context = GitHubContext::new(&credentials, ""); + let host = GitHubHost::default(); + let context = GitHubContext::new(&credentials, &host); let token = mint_git_token( &mock, &context, @@ -2737,7 +2839,8 @@ mod tests { private_key_pem: test_rsa_key().to_string(), slug: None, }); - let context = GitHubContext::new(&credentials, ""); + let host = GitHubHost::default(); + let context = GitHubContext::new(&credentials, &host); let token = mint_git_token( &mock, &context, @@ -2831,7 +2934,7 @@ mod tests { }); merge_pull_request_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", 42, @@ -2866,7 +2969,7 @@ mod tests { }); let err = merge_pull_request_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", 42, @@ -2902,7 +3005,7 @@ mod tests { }); let err = merge_pull_request_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", 42, @@ -2945,9 +3048,15 @@ mod tests { private_key_pem: pem.to_string(), slug: None, }); - close_pull_request_with_client(&mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42) - .await - .unwrap(); + close_pull_request_with_client( + &mock, + &GitHubContext::new(&creds, &GitHubHost::default()), + "owner", + "repo", + 42, + ) + .await + .unwrap(); } #[tokio::test] @@ -2975,7 +3084,7 @@ mod tests { }); let err = close_pull_request_with_client( &mock, - &GitHubContext::new(&creds, ""), + &GitHubContext::new(&creds, &GitHubHost::default()), "owner", "repo", 999, diff --git a/lib/components/fabro-github/src/token_source.rs b/lib/components/fabro-github/src/token_source.rs index 6e67f3423..a4765568b 100644 --- a/lib/components/fabro-github/src/token_source.rs +++ b/lib/components/fabro-github/src/token_source.rs @@ -17,6 +17,7 @@ use std::time::Duration; use anyhow::{Context as _, bail}; use chrono::{DateTime, Utc}; +use fabro_types::GitHubHost; use tokio::sync::Mutex; use crate::{GitHubAppCredentials, GitHubCredentials, InstallationToken}; @@ -162,10 +163,10 @@ enum AppTokenScope { /// Real minter backed by GitHub App credentials. struct AppTokenMinter { - creds: GitHubAppCredentials, - http: fabro_http::HttpClient, - base_url: String, - scope: AppTokenScope, + creds: GitHubAppCredentials, + http: fabro_http::HttpClient, + host: GitHubHost, + scope: AppTokenScope, } #[async_trait::async_trait] @@ -182,7 +183,7 @@ impl InstallationTokenMinter for AppTokenMinter { &self.http, owner, repo, - &self.base_url, + &self.host, permissions.clone(), None, ) @@ -190,7 +191,7 @@ impl InstallationTokenMinter for AppTokenMinter { } AppTokenScope::Access(access) => { access - .mint_installation_token(&self.creds, &self.http, &self.base_url) + .mint_installation_token(&self.creds, &self.http, self.host.api_url()) .await } } @@ -263,24 +264,26 @@ impl InstallationTokenSource { /// pass through and ignore it. pub fn for_origin( creds: &GitHubCredentials, + host: &GitHubHost, origin_url: &str, permissions: serde_json::Value, ) -> anyhow::Result> { let normalized = crate::normalize_repo_origin_url(origin_url); let (owner, repo) = crate::parse_github_owner_repo(&normalized) .context("parsing GitHub origin for token source")?; - Self::for_repository(creds, owner, repo, permissions) + Self::for_repository(creds, host, owner, repo, permissions) } /// Build a source for an already parsed GitHub repository. pub fn for_repository( creds: &GitHubCredentials, + host: &GitHubHost, owner: String, repo: String, permissions: serde_json::Value, ) -> anyhow::Result> { let repo_display = format!("{owner}/{repo}"); - Self::with_app_scope(creds, repo_display, AppTokenScope::Repository { + Self::with_app_scope(creds, host, repo_display, AppTokenScope::Repository { owner, repo, permissions, @@ -294,15 +297,22 @@ impl InstallationTokenSource { /// single-flight behavior are identical to the single-repository source. pub fn for_access( creds: &GitHubCredentials, + host: &GitHubHost, access: &crate::GitHubRepositoryAccess, ) -> anyhow::Result> { let repository_names = access.repository_names(); let repo_display = repository_set_display(access.owner(), &repository_names)?; - Self::with_app_scope(creds, repo_display, AppTokenScope::Access(access.clone())) + Self::with_app_scope( + creds, + host, + repo_display, + AppTokenScope::Access(access.clone()), + ) } fn with_app_scope( creds: &GitHubCredentials, + host: &GitHubHost, repo_display: String, scope: AppTokenScope, ) -> anyhow::Result> { @@ -317,7 +327,7 @@ impl InstallationTokenSource { minter: Box::new(AppTokenMinter { creds: app.clone(), http, - base_url: crate::github_api_base_url(), + host: host.clone(), scope, }), cache: Mutex::new(None), @@ -550,6 +560,7 @@ mod tests { async fn pat_resolves_as_static_generation_zero() { let source = InstallationTokenSource::for_origin( &GitHubCredentials::Pat("ghp_pat".to_string()), + &GitHubHost::default(), "https://github.com/owner/repo.git", serde_json::json!({ "contents": "write" }), ) @@ -578,6 +589,7 @@ mod tests { let source = InstallationTokenSource::for_access( &GitHubCredentials::Pat("ghp_pat".to_string()), + &GitHubHost::default(), &access, ) .unwrap(); @@ -594,6 +606,7 @@ mod tests { token: "ghs_static".to_string(), expires_at: Utc::now() + chrono::Duration::minutes(30), }), + &GitHubHost::default(), "https://github.com/owner/repo.git", serde_json::json!({}), ) @@ -607,6 +620,7 @@ mod tests { token: "ghs_expired".to_string(), expires_at: Utc::now() - chrono::Duration::seconds(1), }), + &GitHubHost::default(), "https://github.com/owner/repo.git", serde_json::json!({}), ) diff --git a/lib/components/fabro-github/tests/integration.rs b/lib/components/fabro-github/tests/integration.rs index 5dc0ec20a..6801a44b0 100644 --- a/lib/components/fabro-github/tests/integration.rs +++ b/lib/components/fabro-github/tests/integration.rs @@ -4,10 +4,15 @@ use fabro_github::{ get_pull_request, merge_pull_request, resolve_authenticated_url, sign_app_jwt, }; use fabro_test::{GitHubAppOptions, GitHubAppState, TwinGitHub}; +use fabro_types::GitHubHost; use fabro_types::settings::run::MergeStrategy; const TEST_RSA_KEY: &str = include_str!("../src/testdata/rsa_private.pem"); +fn twin_host(twin: &TwinGitHub) -> GitHubHost { + GitHubHost::new(&twin.base_url, Some(&twin.base_url)).expect("twin URL should be a valid host") +} + fn github_credentials() -> GitHubCredentials { GitHubCredentials::App(GitHubAppCredentials { app_id: "42".to_string(), @@ -40,7 +45,8 @@ fn standard_app_state() -> GitHubAppState { async fn create_and_get_pull_request() { let twin = TwinGitHub::start(standard_app_state()).await; let creds = github_credentials(); - let ctx = &GitHubContext::new(&creds, &twin.base_url); + let host = twin_host(&twin); + let ctx = &GitHubContext::new(&creds, &host); let created = create_pull_request( ctx, @@ -71,7 +77,8 @@ async fn create_and_get_pull_request() { async fn create_merge_and_verify_state() { let twin = TwinGitHub::start(standard_app_state()).await; let creds = github_credentials(); - let ctx = &GitHubContext::new(&creds, &twin.base_url); + let host = twin_host(&twin); + let ctx = &GitHubContext::new(&creds, &host); let created = create_pull_request( ctx, "acme", "widgets", "main", "feature", "Merge me", "PR body", false, @@ -104,7 +111,8 @@ async fn create_close_and_verify_state() { let twin = TwinGitHub::start(standard_app_state()).await; let creds = github_credentials(); - let ctx = &GitHubContext::new(&creds, &twin.base_url); + let host = twin_host(&twin); + let ctx = &GitHubContext::new(&creds, &host); let created = create_pull_request( ctx, "acme", "widgets", "main", "feature", "Close me", "PR body", false, @@ -130,7 +138,8 @@ async fn enable_auto_merge_persists() { let twin = TwinGitHub::start(standard_app_state()).await; let creds = github_credentials(); - let ctx = &GitHubContext::new(&creds, &twin.base_url); + let host = twin_host(&twin); + let ctx = &GitHubContext::new(&creds, &host); let created = create_pull_request( ctx, @@ -160,10 +169,9 @@ async fn enable_auto_merge_persists() { }; let jwt = sign_app_jwt(&app_creds.app_id, &app_creds.private_key_pem).unwrap(); let client = fabro_test::test_http_client(); - let token = - create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &twin.base_url) - .await - .unwrap(); + let token = create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &host) + .await + .unwrap(); let detail: serde_json::Value = fabro_test::test_http_client() .get(format!( @@ -192,9 +200,10 @@ async fn enable_auto_merge_persists() { async fn resolve_authenticated_url_embeds_token() { let twin = TwinGitHub::start(standard_app_state()).await; let creds = github_credentials(); + let host = twin_host(&twin); let url = resolve_authenticated_url( - &GitHubContext::new(&creds, &twin.base_url), + &GitHubContext::new(&creds, &host), "https://github.com/acme/widgets.git", ) .await @@ -214,9 +223,10 @@ async fn resolve_authenticated_url_embeds_token() { async fn resolve_authenticated_url_errors_on_non_github_url() { let twin = TwinGitHub::start(standard_app_state()).await; let creds = github_credentials(); + let host = twin_host(&twin); let error = resolve_authenticated_url( - &GitHubContext::new(&creds, &twin.base_url), + &GitHubContext::new(&creds, &host), "https://gitlab.com/foo/bar", ) .await diff --git a/lib/components/fabro-github/tests/live_access.rs b/lib/components/fabro-github/tests/live_access.rs index 0b13a582c..62c539135 100644 --- a/lib/components/fabro-github/tests/live_access.rs +++ b/lib/components/fabro-github/tests/live_access.rs @@ -21,7 +21,7 @@ use std::time::Duration; use fabro_github::token_source::InstallationTokenSource; use fabro_github::{GitHubAppCredentials, GitHubCredentials, GitHubRepositoryAccess}; -use fabro_types::GitHubRepositorySlug; +use fabro_types::{GitHubHost, GitHubRepositorySlug}; use tokio::process::Command; use tokio::time::sleep; @@ -75,8 +75,8 @@ async fn scoped_token_reaches_the_declared_additional_repository() { // The production choreography: every target resolves to one shared // installation, then one mint scoped to the whole effective set. let creds = GitHubCredentials::App(app.clone()); - let source = - InstallationTokenSource::for_access(&creds, &access).expect("token source should build"); + let source = InstallationTokenSource::for_access(&creds, &GitHubHost::default(), &access) + .expect("token source should build"); let resolved = source.resolve().await.expect("scoped mint should succeed"); let token = resolved.token.expose(); @@ -108,9 +108,12 @@ async fn scoped_token_reaches_the_declared_additional_repository() { ) .expect("primary-only access should validate") .expect("origin should produce an access value"); - let narrow_source = - InstallationTokenSource::for_access(&GitHubCredentials::App(app), &primary_only) - .expect("primary-only token source should build"); + let narrow_source = InstallationTokenSource::for_access( + &GitHubCredentials::App(app), + &GitHubHost::default(), + &primary_only, + ) + .expect("primary-only token source should build"); let narrow = narrow_source .resolve() .await diff --git a/lib/components/fabro-petri/src/stage_credentials.rs b/lib/components/fabro-petri/src/stage_credentials.rs index 547d529ae..ab0bacfdb 100644 --- a/lib/components/fabro-petri/src/stage_credentials.rs +++ b/lib/components/fabro-petri/src/stage_credentials.rs @@ -15,7 +15,7 @@ use fabro_github::token_source::{InstallationTokenSource, ResolvedToken}; use fabro_github::{GITHUB_CREDENTIAL_HELPER_KEY, GitHubCredentials, GitHubRepositoryAccess}; use fabro_static::EnvVars; use fabro_types::settings::run::{RunIntegrationsGithubSettings, RunMode}; -use fabro_types::{GitHubRepositorySlug, RunSpec, RunTarget}; +use fabro_types::{GitHubHost, GitHubRepositorySlug, RunSpec, RunTarget}; use fabro_util::shell; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use petri_runtime::executor::{ @@ -48,6 +48,7 @@ impl StageCredentials { pub fn for_run( spec: &RunSpec, credentials: Option<&GitHubCredentials>, + host: &GitHubHost, read_tokens: Option>, ) -> anyhow::Result> { if spec.settings.run.execution.mode == RunMode::DryRun { @@ -76,7 +77,7 @@ impl StageCredentials { }; let api_tokens = integration .is_token_requested() - .then(|| InstallationTokenSource::for_access(credentials, &access)) + .then(|| InstallationTokenSource::for_access(credentials, host, &access)) .transpose()?; let contents_declared = integration .permissions @@ -487,9 +488,14 @@ mod tests { ); let pat = GitHubCredentials::Pat("scripted-personal-access-token".to_string()); let read = InstallationTokenSource::pat("scripted-personal-access-token".to_string()); - let default = StageCredentials::for_run(&spec, Some(&pat), Some(Arc::clone(&read))) - .expect("policy") - .expect("credentials"); + let default = StageCredentials::for_run( + &spec, + Some(&pat), + &GitHubHost::default(), + Some(Arc::clone(&read)), + ) + .expect("policy") + .expect("credentials"); assert!(default.api_tokens.is_none()); assert_eq!(default.repositories.len(), 1); spec.settings @@ -504,9 +510,10 @@ mod tests { .github .additional_repositories .insert(GitHubRepositorySlug::try_new("acme/another").expect("slug")); - let declared = StageCredentials::for_run(&spec, Some(&pat), Some(read)) - .expect("policy") - .expect("credentials"); + let declared = + StageCredentials::for_run(&spec, Some(&pat), &GitHubHost::default(), Some(read)) + .expect("policy") + .expect("credentials"); assert!(declared.api_tokens.is_some()); assert_eq!(declared.repositories.len(), 2); } diff --git a/lib/components/fabro-tracker/src/github.rs b/lib/components/fabro-tracker/src/github.rs index c0ced8015..8bbffe178 100644 --- a/lib/components/fabro-tracker/src/github.rs +++ b/lib/components/fabro-tracker/src/github.rs @@ -1,7 +1,7 @@ use anyhow::{Context as _, anyhow}; use async_trait::async_trait; use fabro_github::{ - GitHubCredentials, create_installation_access_token_for_projects, sign_app_jwt, + GitHubCredentials, GitHubHost, create_installation_access_token_for_projects, sign_app_jwt, }; use tokio::sync::OnceCell; @@ -35,7 +35,7 @@ pub struct GitHubTracker { owner: String, repo: String, project_number: u64, - base_url: String, + host: GitHubHost, project_node_id: OnceCell, } @@ -46,7 +46,7 @@ impl GitHubTracker { owner: String, repo: String, project_number: u64, - base_url: String, + host: GitHubHost, ) -> Self { Self { creds, @@ -54,13 +54,13 @@ impl GitHubTracker { owner, repo, project_number, - base_url, + host, project_node_id: OnceCell::new(), } } fn graphql_url(&self) -> String { - format!("{}/graphql", self.base_url) + self.host.graphql_url() } async fn fresh_token(&self) -> anyhow::Result { @@ -73,7 +73,7 @@ impl GitHubTracker { &jwt, &self.owner, &self.repo, - &self.base_url, + &self.host, ) .await .context("creating GitHub App installation token") @@ -645,7 +645,7 @@ mod tests { "owner".to_string(), "repo".to_string(), 1, - server_url.to_string(), + GitHubHost::new(server_url, Some(server_url)).unwrap(), ) } @@ -1225,7 +1225,7 @@ mod tests { #[tokio::test] async fn github_tracker_fetch_issues_by_ids_empty() { let pem = test_rsa_key(); - let tracker = mock_github_tracker("http://unused", pem); + let tracker = mock_github_tracker("http://127.0.0.1:1", pem); // Empty input → no HTTP calls at all let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap(); diff --git a/lib/components/fabro-workflow/src/pull_request.rs b/lib/components/fabro-workflow/src/pull_request.rs index 130abe3e4..8cd6f88d1 100644 --- a/lib/components/fabro-workflow/src/pull_request.rs +++ b/lib/components/fabro-workflow/src/pull_request.rs @@ -1272,9 +1272,11 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr let payload = pr_content_json("Fix bug", "Narrative."); let harness = setup_fallback_test_harness_with_branch_sha(&payload, "stale-sha").await; let github_base_url = harness.github_server.url(""); + let github_host = + fabro_github::GitHubHost::new(&github_base_url, Some(&github_base_url)).unwrap(); let error = open_pull_request(OpenPullRequestRequest { run_id: RunId::new(), - github: fabro_github::GitHubContext::new(&harness.creds, &github_base_url), + github: fabro_github::GitHubContext::new(&harness.creds, &github_host), origin_url: "https://github.com/owner/repo.git", base_branch: "main", head_branch: "fabro/run/123", @@ -1553,7 +1555,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr .await; let github_base_url = harness.github_server.url(""); - let github = github_app::GitHubContext::new(&harness.creds, &github_base_url); + let github_host = + fabro_github::GitHubHost::new(&github_base_url, Some(&github_base_url)).unwrap(); + let github = github_app::GitHubContext::new(&harness.creds, &github_host); let result = open_pull_request(OpenPullRequestRequest { run_id: RunId::new(), @@ -1601,7 +1605,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr let harness = setup_fallback_test_harness(&payload).await; let github_base_url = harness.github_server.url(""); - let github = github_app::GitHubContext::new(&harness.creds, &github_base_url); + let github_host = + fabro_github::GitHubHost::new(&github_base_url, Some(&github_base_url)).unwrap(); + let github = github_app::GitHubContext::new(&harness.creds, &github_host); let result = open_pull_request(OpenPullRequestRequest { run_id: RunId::new(), @@ -1635,7 +1641,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr let harness = setup_fallback_test_harness(&payload).await; let github_base_url = harness.github_server.url(""); - let github = github_app::GitHubContext::new(&harness.creds, &github_base_url); + let github_host = + fabro_github::GitHubHost::new(&github_base_url, Some(&github_base_url)).unwrap(); + let github = github_app::GitHubContext::new(&harness.creds, &github_host); // Single ~200-char line, no `Plan:` / heading prefix, no newlines. let goal = "x".repeat(200);