From c99f24e2d9a24d02c0342dc206599c6c77c48476 Mon Sep 17 00:00:00 2001 From: Eric d'Ales de Corbet Date: Fri, 28 Feb 2025 00:23:45 +0100 Subject: [PATCH] Add support for GitHub Enterprise Cloud Data residency (#2547) * Add support for GitHub Enterprise Cloud with Data residency * Fix broken build * Add test for GHECDataResidencyMatch --------- Co-authored-by: Keegan Campbell --- github/apps.go | 2 +- github/config.go | 9 +++++-- github/config_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/github/apps.go b/github/apps.go index c8e425b84a..ae29ff7caf 100644 --- a/github/apps.go +++ b/github/apps.go @@ -31,7 +31,7 @@ func GenerateOAuthTokenFromApp(baseURL, appID, appInstallationID, pemData string } func getInstallationAccessToken(baseURL string, jwt string, installationID string) (string, error) { - if baseURL != "https://api.github.com/" { + if baseURL != "https://api.github.com/" && !GHECDataResidencyMatch.MatchString(baseURL) { baseURL += "api/v3/" } diff --git a/github/config.go b/github/config.go index b082fe6864..37bc75321e 100644 --- a/github/config.go +++ b/github/config.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "path" + "regexp" "strings" "time" @@ -36,6 +37,10 @@ type Owner struct { IsOrganization bool } +// GHECDataResidencyMatch is a regex to match a GitHub Enterprise Cloud data residency URL: +// https://[hostname].ghe.com instances expect paths that behave similar to GitHub.com, not GitHub Enterprise Server. +var GHECDataResidencyMatch = regexp.MustCompile(`^https:\/\/[a-zA-Z0-9.\-]*\.ghe\.com$`) + func RateLimitedHTTPClient(client *http.Client, writeDelay time.Duration, readDelay time.Duration, retryDelay time.Duration, parallelRequests bool, retryableErrors map[int]bool, maxRetries int) *http.Client { client.Transport = NewEtagTransport(client.Transport) @@ -80,7 +85,7 @@ func (c *Config) NewGraphQLClient(client *http.Client) (*githubv4.Client, error) return nil, err } - if uv4.String() != "https://api.github.com/" { + if uv4.String() != "https://api.github.com/" && !GHECDataResidencyMatch.MatchString(uv4.String()) { uv4.Path = path.Join(uv4.Path, "api/graphql/") } else { uv4.Path = path.Join(uv4.Path, "graphql") @@ -96,7 +101,7 @@ func (c *Config) NewRESTClient(client *http.Client) (*github.Client, error) { return nil, err } - if uv3.String() != "https://api.github.com/" { + if uv3.String() != "https://api.github.com/" && !GHECDataResidencyMatch.MatchString(uv3.String()) { uv3.Path = uv3.Path + "api/v3/" } diff --git a/github/config_test.go b/github/config_test.go index 45cafe69d9..002542e335 100644 --- a/github/config_test.go +++ b/github/config_test.go @@ -7,6 +7,64 @@ import ( "github.com/shurcooL/githubv4" ) +func TestGHECDataResidencyMatch(t *testing.T) { + testCases := []struct { + url string + matches bool + description string + }{ + { + url: "https://customer.ghe.com", + matches: true, + description: "GHEC data residency URL with customer name", + }, + { + url: "https://customer-name.ghe.com", + matches: true, + description: "GHEC data residency URL with hyphenated name", + }, + { + url: "https://api.github.com", + matches: false, + description: "GitHub.com API URL", + }, + { + url: "https://github.com", + matches: false, + description: "GitHub.com URL", + }, + { + url: "https://example.com", + matches: false, + description: "Generic URL", + }, + { + url: "http://customer.ghe.com", + matches: false, + description: "Non-HTTPS GHEC URL", + }, + { + url: "https://customer.ghe.com/api/v3", + matches: false, + description: "GHEC URL with path", + }, + { + url: "https://ghe.com", + matches: false, + description: "GHEC domain without subdomain", + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + matches := GHECDataResidencyMatch.MatchString(tc.url) + if matches != tc.matches { + t.Errorf("URL %q: expected match=%v, got %v", tc.url, tc.matches, matches) + } + }) + } +} + func TestAccConfigMeta(t *testing.T) { // FIXME: Skip test runs during travis lint checking