mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-12 08:41:16 +00:00
feat(github): derive GitHub URLs from the configured host
This commit is contained in:
parent
41df57b8a0
commit
21be241a76
19 changed files with 424 additions and 208 deletions
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(()) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue