feat(config): add GitHub host settings

This commit is contained in:
Dávid Balatoni 2026-10-24 23:09:04 +02:00
parent 1906d7c9ea
commit 41df57b8a0
No known key found for this signature in database
20 changed files with 605 additions and 55 deletions

View file

@ -15672,7 +15672,17 @@ components:
- client_id
- slug
- webhooks
- web_url
- api_url
properties:
web_url:
type: string
description: Web origin of the GitHub deployment, without a trailing slash.
example: https://github.com
api_url:
type: string
description: REST API base URL of the GitHub deployment, without a trailing slash.
example: https://api.github.com
enabled:
type: boolean
strategy:

View file

@ -39,7 +39,7 @@ use fabro_static::EnvVars;
use fabro_store::platform_records::{PlatformRecord, PullRequestCreatedRecord};
use fabro_types::settings::run::{PullRequestSettings, RunMode};
use fabro_types::settings::server::GithubIntegrationStrategy;
use fabro_types::{GitHubRepositorySlug, RunId, RunSpec, RunTarget};
use fabro_types::{GitHubHost, GitHubRepositorySlug, RunId, RunSpec, RunTarget};
use fabro_vault::Vault;
use fabro_workflow::pull_request::{self, AutoMergeOptions, OpenPullRequestRequest};
use tokio::time;
@ -52,6 +52,12 @@ const PUSH_TIMEOUT: Duration = Duration::from_mins(5);
const PUSH_ATTEMPTS: u32 = 3;
const PUSH_RETRY_DELAY: Duration = Duration::from_secs(2);
/// The GitHub deployment the server is configured for.
pub(super) fn github_host() -> Result<GitHubHost> {
let settings = ServerSettingsBuilder::load_default().context("loading the server settings")?;
fabro_github::github_host(&settings.server.integrations.github)
}
/// The server's GitHub credentials as the worker reaches them, or `None`
/// when none are configured.
pub(super) fn github_credentials(vault: &Vault) -> Result<Option<GitHubCredentials>> {
@ -313,8 +319,8 @@ impl RunPublisher for GitHubPublisher {
.credentials
.as_ref()
.ok_or_else(|| "GitHub credentials are unavailable".to_string())?;
let base_url = fabro_github::github_api_base_url();
let context = GitHubContext::new(credentials, &base_url);
let host = github_host().map_err(|err| format!("{err:#}"))?;
let context = GitHubContext::new(credentials, host.api_url());
Box::pin(self.open_pull_request(context, settings, publication)).await
}
}

View file

@ -399,7 +399,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
};
let probe = timeout(
EXTERNAL_SERVICE_PROBE_TIMEOUT,
http.get(format!("{}/user", fabro_github::github_api_base_url()))
http.get(format!("{}/user", state.github_host.api_url()))
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "fabro-server")
@ -549,7 +549,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
};
let auth_result = timeout(
EXTERNAL_SERVICE_PROBE_TIMEOUT,
fabro_github::get_authenticated_app(&http, &jwt, &fabro_github::github_api_base_url()),
fabro_github::get_authenticated_app(&http, &jwt, state.github_host.api_url()),
)
.await;
match auth_result {

View file

@ -737,7 +737,7 @@ where
preloaded_vault: startup_vault.into_vault(),
server_secrets,
env_lookup,
github_api_base_url: None,
github_host: None,
active_config_path,
http_client: None,
sandbox_inventory: None,

View file

@ -1152,28 +1152,28 @@ pub struct AppState {
control_request_locks: KeyedMutex<RunId>,
parent_link_lock: AsyncMutex<()>,
pub(super) server_secrets: ServerSecrets,
pub(crate) llm_source: Arc<dyn CredentialProvider>,
pub(super) server_secrets: ServerSecrets,
pub(crate) llm_source: Arc<dyn CredentialProvider>,
/// The database pool the stores share, for the Petri run store a
/// server-process run writes its records through.
pub(crate) db_pool: DbPool,
manifest_run_defaults: RwLock<Arc<RunLayer>>,
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
pub(crate) db_pool: DbPool,
manifest_run_defaults: RwLock<Arc<RunLayer>>,
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
pub(crate) server_settings: RwLock<Arc<ServerSettings>>,
effective_web_url: RwLock<String>,
catalog: RwLock<Arc<Catalog>>,
pub(crate) env_lookup: EnvLookup,
pub(crate) github_api_base_url: String,
active_config_path: PathBuf,
http_client: Option<fabro_http::HttpClient>,
sandbox_inventory: SandboxInventory,
shutdown: CancellationToken,
shutting_down: AtomicBool,
effective_web_url: RwLock<String>,
catalog: RwLock<Arc<Catalog>>,
pub(crate) env_lookup: EnvLookup,
pub(crate) github_host: fabro_types::GitHubHost,
active_config_path: PathBuf,
http_client: Option<fabro_http::HttpClient>,
sandbox_inventory: SandboxInventory,
shutdown: CancellationToken,
shutting_down: AtomicBool,
/// Test switch: execute runs in this process instead of a worker.
execute_in_process: bool,
slack_service: Option<Arc<SlackService>>,
slack_started: AtomicBool,
github_webhook_secret: Option<String>,
execute_in_process: bool,
slack_service: Option<Arc<SlackService>>,
slack_started: AtomicBool,
github_webhook_secret: Option<String>,
}
pub(crate) struct AppStores {
@ -1277,7 +1277,7 @@ impl AppState {
.map_err(|source| RunMaterializeError::LoadCredentials { source })?;
ProductionAutomationRunMaterializer::new(
credentials,
self.github_api_base_url.clone(),
self.github_host.api_url().to_string(),
self.http_client.clone(),
Arc::clone(&self.automation_repo_cache),
fabro_workflow_version::WorkflowVersionStore::new(self.store_ref().blobs()),
@ -1352,7 +1352,7 @@ pub(crate) struct AppStateConfig {
pub(crate) preloaded_vault: Vault,
pub(crate) server_secrets: ServerSecrets,
pub(crate) env_lookup: EnvLookup,
pub(crate) github_api_base_url: Option<String>,
pub(crate) github_host: Option<fabro_types::GitHubHost>,
pub(crate) active_config_path: PathBuf,
pub(crate) http_client: Option<fabro_http::HttpClient>,
pub(crate) sandbox_inventory: Option<SandboxInventory>,
@ -2519,7 +2519,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
preloaded_vault,
server_secrets,
env_lookup,
github_api_base_url,
github_host,
active_config_path,
http_client,
sandbox_inventory,
@ -2643,7 +2643,10 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
}
};
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
let github_host = match github_host {
Some(host) => host,
None => fabro_github::github_host(&current_server_settings.server.integrations.github)?,
};
let storage_root = PathBuf::from(&current_server_settings.server.storage.root);
let automation_repo_cache = Arc::new(GitRepoCache::new(
Storage::new(&storage_root)
@ -2721,7 +2724,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
effective_web_url: RwLock::new(current_effective_web_url),
catalog: RwLock::new(current_catalog),
env_lookup: Arc::clone(&env_lookup),
github_api_base_url,
github_host,
active_config_path,
http_client,
sandbox_inventory,

View file

@ -123,7 +123,7 @@ pub(in crate::server) fn server_github_context<'a>(
})?;
Ok(fabro_github::GitHubContext::with_http_client(
creds,
state.github_api_base_url.as_str(),
state.github_host.api_url(),
http_client,
))
}

View file

@ -455,7 +455,7 @@ async fn get_github_repo(
}
let settings = state.server_settings();
let github_settings = &settings.server.integrations.github;
let base_url = fabro_github::github_api_base_url();
let base_url = state.github_host.api_url();
let (token, client) = match github_settings.strategy {
GithubIntegrationStrategy::App => {
if github_settings.app_id.is_none() {

View file

@ -2114,7 +2114,7 @@ fn slack_app_state_with_settings_and_secret_sources(
preloaded_vault: vault,
server_secrets: load_test_server_secrets(server_env_path, server_secret_env),
env_lookup: default_env_lookup(),
github_api_base_url: None,
github_host: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,
@ -5102,7 +5102,9 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
preloaded_vault,
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),
env_lookup: Arc::new(env_lookup),
github_api_base_url,
github_host: github_api_base_url.map(|url| {
fabro_types::GitHubHost::new(&url, Some(&url)).expect("mock GitHub URL should be valid")
}),
active_config_path,
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,
@ -10808,7 +10810,7 @@ methods = ["dev-token"]
preloaded_vault,
server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(),
env_lookup: default_env_lookup(),
github_api_base_url: None,
github_host: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,
@ -10866,7 +10868,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() {
HashMap::new(),
),
env_lookup: default_env_lookup(),
github_api_base_url: None,
github_host: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,

View file

@ -68,6 +68,11 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
// the vault and supplies it explicitly to the in-process provider.
EnvVars::DAYTONA_API_URL,
EnvVars::DAYTONA_ORGANIZATION_ID,
// The GitHub deployment's non-secret selection. The worker's GitHub
// client reads the web origin and API base overrides; the settings
// reach it through FABRO_HOME.
EnvVars::GITHUB_WEB_URL,
EnvVars::GITHUB_BASE_URL,
// A test's checkpoint gates: the worker's hooks hold at a named point
// until the test releases them, so a crash can be placed there.
EnvVars::FABRO_TEST_CHECKPOINT_GATES,

View file

@ -302,7 +302,7 @@ impl TestAppStateBuilder {
preloaded_vault,
server_secrets: load_test_server_secrets(server_env_path, self.server_secret_env),
env_lookup: self.env_lookup,
github_api_base_url: None,
github_host: None,
active_config_path,
http_client: Some(
fabro_http::test_http_client().expect("test HTTP client should build"),

View file

@ -4,8 +4,9 @@ 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 fabro_types::settings::server::GithubIntegrationSettings;
use fabro_types::{GitHubHost, PullRequestGithubDetail};
use serde::Deserialize;
use tokio::process::Command;
@ -23,8 +24,6 @@ pub(crate) mod tests_mock;
pub use access::GitHubRepositoryAccess;
pub use identity::GitHubAccountIdentity;
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";
@ -52,14 +51,44 @@ pub fn apply_probe_git_env(command: &mut Command, token: &str) {
.env("GIT_CONFIG_VALUE_0", GITHUB_CREDENTIAL_HELPER);
}
/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env
/// var.
/// 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(|_| GITHUB_API_BASE_URL.to_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(
clippy::disallowed_methods,
reason = "GitHub API client exposes documented process-env web origin and API base overrides."
)]
pub fn github_host(settings: &GithubIntegrationSettings) -> anyhow::Result<GitHubHost> {
host_with_overrides(
&settings.host,
std::env::var(EnvVars::GITHUB_WEB_URL).ok().as_deref(),
std::env::var(EnvVars::GITHUB_BASE_URL).ok().as_deref(),
)
}
/// A `web_url` override re-derives the API URL, since the settings' API URL
/// belonged to the old origin; an `api_url` override always wins.
fn host_with_overrides(
configured: &GitHubHost,
web_url: Option<&str>,
api_url: Option<&str>,
) -> anyhow::Result<GitHubHost> {
let host = match (web_url, api_url) {
(None, None) => return Ok(configured.clone()),
(None, Some(api)) => GitHubHost::new(configured.web_url(), Some(api)),
(Some(web), api) => GitHubHost::new(web, api),
};
host.context("invalid GITHUB_WEB_URL or GITHUB_BASE_URL override")
}
/// Bundle of GitHub credentials and the API base URL, threaded through every
@ -1642,6 +1671,24 @@ mod tests {
use super::*;
#[test]
fn host_overrides_apply_over_settings() {
let configured = GitHubHost::new("https://ghe.example.com", None).unwrap();
assert_eq!(
host_with_overrides(&configured, None, None).unwrap(),
configured
);
let api_only = host_with_overrides(&configured, None, Some("http://127.0.0.1:9")).unwrap();
assert_eq!(api_only.web_url(), "https://ghe.example.com");
assert_eq!(api_only.api_url(), "http://127.0.0.1:9");
let web = host_with_overrides(&configured, Some("https://octocorp.ghe.com"), None).unwrap();
assert_eq!(web.api_url(), "https://api.octocorp.ghe.com");
assert!(host_with_overrides(&configured, Some("http://ghe.example.com"), None).is_err());
}
#[test]
fn decode_pem_env_accepts_raw_pem() {
let pem = "-----BEGIN TEST KEY-----\nabc\n-----END TEST KEY-----";

View file

@ -233,6 +233,10 @@ pub struct GithubIntegrationLayer {
pub slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhooks: Option<IntegrationWebhooksLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_url: Option<String>,
}
/// `[server.integrations.slack]` — Slack workspace credentials and defaults.

View file

@ -1,7 +1,6 @@
use std::collections::BTreeMap;
use std::path::Path;
use fabro_types::SandboxProviderKind;
use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
ObjectStoreProvider, ObjectStoreSettings, SandboxPluginSettings, ServerApiSettings,
@ -11,6 +10,7 @@ use fabro_types::settings::server::{
ServerSchedulerSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
WebhookStrategy,
};
use fabro_types::{GitHubHost, SandboxProviderKind};
use fabro_util::Home;
use super::{
@ -19,9 +19,10 @@ use super::{
};
use crate::user::default_storage_dir;
use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer, ServerWebLayer,
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
ServerListenLayer, ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer,
ServerWebLayer,
};
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
@ -29,7 +30,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
let listen = resolve_listen(layer.listen.as_ref(), errors);
let web = resolve_web(layer.web.as_ref());
let auth = resolve_auth(layer.auth.as_ref(), errors);
let integrations = resolve_integrations(layer.integrations.as_ref());
let integrations = resolve_integrations(layer.integrations.as_ref(), errors);
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
let api_url = layer.api.as_ref().and_then(|api| api.url.clone());
@ -330,7 +331,10 @@ fn resolve_object_store(
}
}
fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
fn resolve_integrations(
layer: Option<&ServerIntegrationsLayer>,
errors: &mut Vec<ResolveError>,
) -> ServerIntegrationsSettings {
ServerIntegrationsSettings {
github: layer
.and_then(|integrations| integrations.github.as_ref())
@ -344,13 +348,15 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
github.client_id.as_deref(),
);
warn_if_demoted_template("server.integrations.github.slug", github.slug.as_deref());
let host = resolve_github_host(github, errors);
GithubIntegrationSettings {
enabled: github.enabled.unwrap_or(true),
strategy: github.strategy.unwrap_or_default(),
app_id: github.app_id.clone(),
host,
enabled: github.enabled.unwrap_or(true),
strategy: github.strategy.unwrap_or_default(),
app_id: github.app_id.clone(),
client_id: github.client_id.clone(),
slug: github.slug.clone(),
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
slug: github.slug.clone(),
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
}
})
.unwrap_or_default(),
@ -375,6 +381,21 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
}
}
fn resolve_github_host(
layer: &GithubIntegrationLayer,
errors: &mut Vec<ResolveError>,
) -> GitHubHost {
let default = GitHubHost::default();
let web_url = layer.web_url.as_deref().unwrap_or(default.web_url());
GitHubHost::new(web_url, layer.api_url.as_deref()).unwrap_or_else(|err| {
errors.push(ResolveError::Invalid {
path: format!("server.integrations.github.{}", err.field()),
reason: err.to_string(),
});
default
})
}
fn resolve_github_webhooks(layer: &IntegrationWebhooksLayer) -> IntegrationWebhooksSettings {
IntegrationWebhooksSettings {
strategy: layer.strategy,

View file

@ -3,11 +3,11 @@
reason = "sync test fixture setup and raw template source assertions; not on a Tokio path"
)]
use fabro_types::SandboxProviderKind;
use fabro_types::settings::server::{
GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod,
ServerListenSettings, ServerNamespace,
};
use fabro_types::{GitHubHost, SandboxProviderKind};
use fabro_util::Home;
use temp_env::with_var;
@ -108,6 +108,8 @@ fn resolved_server_integrations_disable_slack_when_config_is_absent() {
"client_id": null,
"slug": null,
"webhooks": null,
"web_url": "https://github.com",
"api_url": "https://api.github.com",
},
"slack": {
"enabled": false,
@ -527,6 +529,72 @@ enabled = true
);
}
#[test]
fn github_host_defaults_to_github_com() {
let settings = resolve_server(&parse("_version = 1\n"));
assert_eq!(settings.integrations.github.host, GitHubHost::default());
let settings = resolve_server(&parse(
"_version = 1\n\n[server.integrations.github]\nenabled = true\n",
));
assert_eq!(settings.integrations.github.host, GitHubHost::default());
}
#[test]
fn github_host_derives_api_url_from_web_url() {
let settings = resolve_server(&parse(
r#"
_version = 1
[server.integrations.github]
web_url = "https://ghe.example.com"
"#,
));
let host = &settings.integrations.github.host;
assert_eq!(host.web_url(), "https://ghe.example.com");
assert_eq!(host.api_url(), "https://ghe.example.com/api/v3");
}
#[test]
fn github_host_honours_explicit_api_url() {
let settings = resolve_server(&parse(
r#"
_version = 1
[server.integrations.github]
web_url = "https://ghe.example.com"
api_url = "https://api.ghe.example.com"
"#,
));
assert_eq!(
settings.integrations.github.host.api_url(),
"https://api.ghe.example.com"
);
}
#[test]
fn github_host_rejects_invalid_web_url() {
let file = parse(
r#"
_version = 1
[server.integrations.github]
web_url = "http://ghe.example.com"
"#,
);
let error =
ServerSettingsBuilder::from_layer(&file).expect_err("plain http should be rejected");
let errors = resolve_errors(error);
assert!(
errors.iter().any(|err| {
err.to_string()
.contains("server.integrations.github.web_url")
&& err.to_string().contains("must use https")
}),
"unexpected errors: {errors:?}"
);
}
#[test]
fn parsing_rejects_removed_server_ip_allowlist() {
let err = r#"

View file

@ -124,6 +124,7 @@ impl EnvVars {
pub const GITHUB_APP_WEBHOOK_SECRET: &'static str = "GITHUB_APP_WEBHOOK_SECRET";
pub const GITHUB_BASE_URL: &'static str = "GITHUB_BASE_URL";
pub const GITHUB_TOKEN: &'static str = "GITHUB_TOKEN";
pub const GITHUB_WEB_URL: &'static str = "GITHUB_WEB_URL";
pub const OAUTH_CALLBACK_PATH: &'static str = "OAUTH_CALLBACK_PATH";
pub const OAUTH_CLIENT_ID: &'static str = "OAUTH_CLIENT_ID";
pub const OAUTH_ISSUER: &'static str = "OAUTH_ISSUER";
@ -285,6 +286,7 @@ mod tests {
EnvVars::GITHUB_APP_WEBHOOK_SECRET,
EnvVars::GITHUB_BASE_URL,
EnvVars::GITHUB_TOKEN,
EnvVars::GITHUB_WEB_URL,
EnvVars::OAUTH_CALLBACK_PATH,
EnvVars::OAUTH_CLIENT_ID,
EnvVars::OAUTH_ISSUER,

View file

@ -123,6 +123,7 @@ mod tests {
for name in [
EnvVars::GH_TOKEN,
EnvVars::GITHUB_BASE_URL,
EnvVars::GITHUB_WEB_URL,
EnvVars::SLACK_BASE_URL,
EnvVars::DAYTONA_API_URL,
EnvVars::DAYTONA_ORGANIZATION_ID,

View file

@ -0,0 +1,368 @@
use serde::{Deserialize, Serialize};
const DEFAULT_WEB_URL: &str = "https://github.com";
const DEFAULT_API_URL: &str = "https://api.github.com";
/// Rejected GitHub host setting, naming the offending field.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{field} {problem}")]
pub struct GitHubHostError {
field: &'static str,
problem: String,
}
impl GitHubHostError {
/// Name of the rejected setting: `web_url` or `api_url`.
#[must_use]
pub fn field(&self) -> &'static str {
self.field
}
fn new(field: &'static str, problem: impl Into<String>) -> Self {
Self {
field,
problem: problem.into(),
}
}
}
/// The GitHub deployment Fabro talks to: github.com (the default), a GitHub
/// Enterprise Cloud tenant with data residency (`*.ghe.com`) or a GitHub
/// Enterprise Server instance. The web origin is the one source of truth;
/// every other URL is derived from it unless the API URL is given explicitly.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "GitHubHostWire")]
pub struct GitHubHost {
web_url: String,
api_url: String,
}
#[derive(Deserialize)]
struct GitHubHostWire {
#[serde(default)]
web_url: Option<String>,
#[serde(default)]
api_url: Option<String>,
}
impl TryFrom<GitHubHostWire> for GitHubHost {
type Error = GitHubHostError;
fn try_from(wire: GitHubHostWire) -> Result<Self, Self::Error> {
Self::new(
wire.web_url.as_deref().unwrap_or(DEFAULT_WEB_URL),
wire.api_url.as_deref(),
)
}
}
impl Default for GitHubHost {
fn default() -> Self {
Self {
web_url: DEFAULT_WEB_URL.to_string(),
api_url: DEFAULT_API_URL.to_string(),
}
}
}
#[expect(
clippy::disallowed_types,
reason = "GitHub host settings are public service origins; validation errors never echo credentials."
)]
fn parse_origin(field: &'static str, raw: &str) -> Result<url::Url, GitHubHostError> {
let raw = raw.trim().trim_end_matches('/');
let parsed = url::Url::parse(raw).map_err(|_| {
GitHubHostError::new(
field,
format!("must be an https URL such as https://github.com (got `{raw}`)"),
)
})?;
let host = parsed
.host_str()
.ok_or_else(|| GitHubHostError::new(field, "must include a host"))?;
let loopback = matches!(host, "localhost" | "127.0.0.1" | "[::1]");
match parsed.scheme() {
"https" => {}
"http" if loopback => {}
_ => {
return Err(GitHubHostError::new(
field,
format!("must use https (got `{raw}`); http is only allowed for localhost"),
));
}
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(GitHubHostError::new(field, "must not contain credentials"));
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(GitHubHostError::new(
field,
"must not contain a query or fragment",
));
}
Ok(parsed)
}
impl GitHubHost {
/// Builds a host from the web origin and an optional explicit API URL.
/// Without `api_url`, the API URL is derived from the web origin.
pub fn new(web_url: &str, api_url: Option<&str>) -> Result<Self, GitHubHostError> {
let web = parse_origin("web_url", web_url)?;
if !matches!(web.path(), "" | "/") {
return Err(GitHubHostError::new(
"web_url",
"must not contain a path; GitHub is never served under a path prefix",
));
}
let web_origin = web.origin().ascii_serialization();
let api_url = match api_url {
Some(api) => {
parse_origin("api_url", api)?;
api.trim().trim_end_matches('/').to_string()
}
None => derive_api_url(&web_origin, web.host_str().unwrap_or_default()),
};
Ok(Self {
web_url: web_origin,
api_url,
})
}
/// Web origin without a trailing slash, e.g. `https://ghe.example.com`.
#[must_use]
pub fn web_url(&self) -> &str {
&self.web_url
}
/// REST API base without a trailing slash, e.g.
/// `https://ghe.example.com/api/v3`.
#[must_use]
pub fn api_url(&self) -> &str {
&self.api_url
}
/// Authority of the web origin, e.g. `ghe.example.com` or
/// `ghe.example.com:8443`.
#[must_use]
pub fn host(&self) -> &str {
self.web_url
.split_once("://")
.map_or(self.web_url.as_str(), |(_, authority)| authority)
}
#[must_use]
pub fn is_github_com(&self) -> bool {
self.host() == "github.com"
}
/// GraphQL endpoint derived from the REST API URL.
#[must_use]
pub fn graphql_url(&self) -> String {
match self.api_url.strip_suffix("/api/v3") {
Some(base) => format!("{base}/api/graphql"),
None => format!("{}/graphql", self.api_url),
}
}
#[must_use]
pub fn repository_url(&self, owner: &str, repo: &str) -> String {
format!("{}/{owner}/{repo}", self.web_url)
}
#[must_use]
pub fn pull_request_url(&self, owner: &str, repo: &str, number: u64) -> String {
format!("{}/pull/{number}", self.repository_url(owner, repo))
}
/// Domain of GitHub's private noreply commit emails.
#[must_use]
pub fn noreply_email_domain(&self) -> String {
format!("users.noreply.{}", self.host())
}
/// Git config key that routes this host's HTTPS credentials to a helper.
#[must_use]
pub fn credential_helper_key(&self) -> String {
format!("credential.{}.helper", self.web_url)
}
/// Whether `origin` is a git remote URL on this host (HTTPS, HTTP, scp-like
/// SSH or `ssh://`).
#[must_use]
pub fn is_origin(&self, origin: &str) -> bool {
let host = self.host();
origin.starts_with(&format!("{}/", self.web_url))
|| origin.starts_with(&format!("http://{host}/"))
|| origin.starts_with(&format!("git@{host}:"))
|| origin.starts_with(&format!("ssh://git@{host}/"))
}
}
fn derive_api_url(web_origin: &str, hostname: &str) -> String {
let under = |domain: &str| hostname == domain || hostname.ends_with(&format!(".{domain}"));
if under("github.com") {
DEFAULT_API_URL.to_string()
} else if under("ghe.com") {
web_origin.replacen("://", "://api.", 1)
} else {
format!("{web_origin}/api/v3")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn host(web: &str) -> GitHubHost {
GitHubHost::new(web, None).unwrap()
}
#[test]
fn default_matches_github_com_constants() {
let default = GitHubHost::default();
assert_eq!(default.web_url(), "https://github.com");
assert_eq!(default.api_url(), "https://api.github.com");
assert_eq!(default, host("https://github.com"));
assert!(default.is_github_com());
assert_eq!(default.graphql_url(), "https://api.github.com/graphql");
assert_eq!(default.repository_url("o", "r"), "https://github.com/o/r");
assert_eq!(
default.pull_request_url("o", "r", 7),
"https://github.com/o/r/pull/7"
);
assert_eq!(default.noreply_email_domain(), "users.noreply.github.com");
assert_eq!(
default.credential_helper_key(),
"credential.https://github.com.helper"
);
assert!(default.is_origin("git@github.com:o/r.git"));
assert!(default.is_origin("https://github.com/o/r"));
assert!(default.is_origin("http://github.com/o/r"));
assert!(default.is_origin("ssh://git@github.com/o/r"));
assert!(!default.is_origin("https://gitlab.com/o/r"));
}
#[test]
fn data_residency_host_derives_urls() {
let ghec = host("https://octocorp.ghe.com");
assert_eq!(ghec.api_url(), "https://api.octocorp.ghe.com");
assert_eq!(ghec.graphql_url(), "https://api.octocorp.ghe.com/graphql");
assert_eq!(ghec.host(), "octocorp.ghe.com");
assert!(!ghec.is_github_com());
assert_eq!(
ghec.repository_url("o", "r"),
"https://octocorp.ghe.com/o/r"
);
assert_eq!(
ghec.pull_request_url("o", "r", 7),
"https://octocorp.ghe.com/o/r/pull/7"
);
assert_eq!(
ghec.noreply_email_domain(),
"users.noreply.octocorp.ghe.com"
);
assert_eq!(
ghec.credential_helper_key(),
"credential.https://octocorp.ghe.com.helper"
);
assert!(ghec.is_origin("git@octocorp.ghe.com:o/r.git"));
assert!(ghec.is_origin("ssh://git@octocorp.ghe.com/o/r"));
assert!(!ghec.is_origin("https://github.com/o/r"));
}
#[test]
fn server_host_derives_urls() {
let ghes = host("https://ghe.example.com/");
assert_eq!(ghes.web_url(), "https://ghe.example.com");
assert_eq!(ghes.api_url(), "https://ghe.example.com/api/v3");
assert_eq!(ghes.graphql_url(), "https://ghe.example.com/api/graphql");
assert_eq!(ghes.host(), "ghe.example.com");
assert_eq!(ghes.repository_url("o", "r"), "https://ghe.example.com/o/r");
assert_eq!(
ghes.pull_request_url("o", "r", 7),
"https://ghe.example.com/o/r/pull/7"
);
assert_eq!(ghes.noreply_email_domain(), "users.noreply.ghe.example.com");
assert_eq!(
ghes.credential_helper_key(),
"credential.https://ghe.example.com.helper"
);
assert!(ghes.is_origin("git@ghe.example.com:o/r.git"));
assert!(ghes.is_origin("https://ghe.example.com/o/r"));
}
#[test]
fn host_keeps_port_and_lowercases() {
let ghes = host("https://GHE.Example.com:8443");
assert_eq!(ghes.web_url(), "https://ghe.example.com:8443");
assert_eq!(ghes.host(), "ghe.example.com:8443");
}
#[test]
fn explicit_api_url_wins() {
let ghes = GitHubHost::new(
"https://ghe.example.com",
Some("https://api.ghe.example.com/"),
)
.unwrap();
assert_eq!(ghes.api_url(), "https://api.ghe.example.com");
assert_eq!(ghes.graphql_url(), "https://api.ghe.example.com/graphql");
}
#[test]
fn loopback_http_is_accepted() {
let local = host("http://127.0.0.1:1234");
assert_eq!(local.web_url(), "http://127.0.0.1:1234");
assert_eq!(local.api_url(), "http://127.0.0.1:1234/api/v3");
let mock = GitHubHost::new("http://127.0.0.1:1234", Some("http://127.0.0.1:1234")).unwrap();
assert_eq!(mock.graphql_url(), "http://127.0.0.1:1234/graphql");
}
#[test]
fn invalid_values_name_the_problem() {
let plain_http = GitHubHost::new("http://ghe.example.com", None).unwrap_err();
assert!(
plain_http.to_string().starts_with("web_url must use https"),
"{plain_http}"
);
let bare = GitHubHost::new("ghe.example.com", None).unwrap_err();
assert!(
bare.to_string().starts_with("web_url must be an https URL"),
"{bare}"
);
let path = GitHubHost::new("https://ghe.example.com/github", None).unwrap_err();
assert!(
path.to_string().contains("must not contain a path"),
"{path}"
);
let api = GitHubHost::new(
"https://ghe.example.com",
Some("http://ghe.example.com/api/v3"),
)
.unwrap_err();
assert!(
api.to_string().starts_with("api_url must use https"),
"{api}"
);
}
#[test]
fn serde_round_trips_and_validates() {
let ghes = host("https://ghe.example.com");
let json = serde_json::to_value(&ghes).unwrap();
assert_eq!(
json,
serde_json::json!({
"web_url": "https://ghe.example.com",
"api_url": "https://ghe.example.com/api/v3",
})
);
assert_eq!(serde_json::from_value::<GitHubHost>(json).unwrap(), ghes);
assert!(
serde_json::from_value::<GitHubHost>(
serde_json::json!({"web_url": "http://x.example.com", "api_url": "https://x.example.com"})
)
.is_err()
);
}
}

View file

@ -15,6 +15,7 @@ pub mod diff;
pub mod engine;
pub mod failure_signature;
pub mod git_identity;
pub mod github_host;
mod id;
mod input_scalar;
pub mod interview;
@ -86,6 +87,7 @@ pub use diff::{DiffStats, DiffSummary, RunDiff};
pub use engine::{PetriAdmission, PetriGraphRef};
pub use failure_signature::FailureSignature;
pub use git_identity::{GitIdentity, GitIdentitySource};
pub use github_host::{GitHubHost, GitHubHostError};
pub use input_scalar::{
JsonScalarToTomlError, TomlScalarToJsonError, json_scalar_to_toml_value,
toml_scalar_to_json_value,

View file

@ -11,7 +11,7 @@ use std::net::SocketAddr;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::SandboxProviderKind;
use crate::{GitHubHost, SandboxProviderKind};
/// A structurally resolved `[server]` view for consumers.
///
@ -297,6 +297,9 @@ pub struct GithubIntegrationSettings {
pub client_id: Option<String>,
pub slug: Option<String>,
pub webhooks: Option<IntegrationWebhooksSettings>,
/// The GitHub deployment (`web_url`/`api_url` on the wire).
#[serde(flatten)]
pub host: GitHubHost,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -27,4 +27,12 @@ export interface GithubIntegrationSettings {
'client_id': string | null;
'slug': string | null;
'webhooks': IntegrationWebhooksSettings | null;
/**
* Web origin of the GitHub deployment, without a trailing slash.
*/
'web_url': string;
/**
* REST API base URL of the GitHub deployment, without a trailing slash.
*/
'api_url': string;
}