mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-06 07:47:20 +00:00
feat(config): add GitHub host settings
This commit is contained in:
parent
1906d7c9ea
commit
41df57b8a0
20 changed files with 605 additions and 55 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(¤t_server_settings.server.integrations.github)?,
|
||||
};
|
||||
let storage_root = PathBuf::from(¤t_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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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-----";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
368
lib/foundation/fabro-types/src/github_host.rs
Normal file
368
lib/foundation/fabro-types/src/github_host.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue