feat(github): derive GitHub URLs from the configured host

This commit is contained in:
Dávid Balatoni 2026-10-24 23:44:32 +02:00
parent 41df57b8a0
commit 21be241a76
No known key found for this signature in database
19 changed files with 424 additions and 208 deletions

View file

@ -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<GitHubInstallSelection> {
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)?;

View file

@ -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),

View file

@ -95,12 +95,14 @@ fn repository(spec: &RunSpec) -> Option<GitHubRepositorySlug> {
fn token_source(
spec: &RunSpec,
credentials: Option<&GitHubCredentials>,
host: &GitHubHost,
permissions: serde_json::Value,
) -> Option<Arc<InstallationTokenSource>> {
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<Arc<InstallationTokenSource>> {
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<String>,
credentials: Option<GitHubCredentials>,
host: GitHubHost,
/// The run's `contents: write` token source; `None` without credentials.
push_tokens: Option<Arc<InstallationTokenSource>>,
pull_request: Option<PullRequestSettings>,
@ -182,6 +191,7 @@ impl GitHubPublisher {
run_id: RunId,
spec: &RunSpec,
credentials: Option<GitHubCredentials>,
host: GitHubHost,
llm_source: Arc<dyn CredentialProvider>,
catalog: Arc<Catalog>,
records: Arc<dyn PlatformRecords>,
@ -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()),

View file

@ -143,9 +143,9 @@ trait AutomationGitRemoteResolver: Send + Sync {
}
struct ServerGitHubRemoteResolver {
credentials: Option<fabro_github::GitHubCredentials>,
api_base_url: String,
http_client: Option<fabro_http::HttpClient>,
credentials: Option<fabro_github::GitHubCredentials>,
host: fabro_types::GitHubHost,
http_client: Option<fabro_http::HttpClient>,
}
#[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<fabro_github::GitHubCredentials>,
github_api_base_url: String,
github_host: fabro_types::GitHubHost,
http_client: Option<fabro_http::HttpClient>,
repo_cache: Arc<GitRepoCache>,
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,

View file

@ -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<fabro_http::HttpClient>,
) -> anyhow::Result<Option<GitAuthConfig>> {
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())

View file

@ -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<Self> {
@ -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");
}

View file

@ -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<fabro_github::GitHubCredentials>,
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<fabro_github::GitHubCredentials>,
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<fabro_github::GitHubCredentials>,
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<M, MFut, P, PFut>(
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
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<CheckResult>,
prepared: &PreparedManifest,
github_app: Option<fabro_github::GitHubCredentials>,
host: &fabro_types::GitHubHost,
permissions: &HashMap<String, String>,
perm_details: Vec<CheckDetail>,
) -> 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<ResolvedToken, String> {
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<String, String>,
) -> Result<String> {
@ -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(()) },
)

View file

@ -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!(

View file

@ -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()),

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_host.api_url(),
&state.github_host,
http_client,
))
}

View file

@ -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),
)

View file

@ -2,11 +2,12 @@
//!
//! A run's commits carry the identity of the credential the run uses:
//! the App's bot account (`<slug>[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.<host>`, 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.

View file

@ -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<fabro_http::HttpClient>,
}
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<String> {
pub fn installation_url(&self, host: &GitHubHost, owner: &str) -> Option<String> {
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<InstallationToken> {
@ -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<InstallationToken> {
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<String> {
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<String> {
pub async fn gh_auth_token(host: &GitHubHost) -> anyhow::Result<String> {
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<String> {
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<String> {
@ -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<InstallationToken> {
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<String> {
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<String> {
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<String> {
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<String> {
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,

View file

@ -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<Arc<Self>> {
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<Arc<Self>> {
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<Arc<Self>> {
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<Arc<Self>> {
@ -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!({}),
)

View file

@ -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

View file

@ -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

View file

@ -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<Arc<InstallationTokenSource>>,
) -> anyhow::Result<Option<Self>> {
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);
}

View file

@ -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<String>,
}
@ -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<String> {
@ -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();

View file

@ -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);