Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Fixed-20261027-205433.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Fixed
body: 'github: Fix GHE.com API hosts skipped during stack updates and merges.'
time: 2026-10-27T20:54:33.723586-07:00
2 changes: 1 addition & 1 deletion internal/forge/github/change_match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func TestRepository_MatchChangesToBranches_filtersPushRepository(t *testing.T) {
[]string{"feature"},
&forge.MatchChangesToBranchesOptions{
PushRepository: &RepositoryID{
url: DefaultURL,
url: "https://github.com",
owner: "other",
name: "fork",
},
Expand Down
40 changes: 11 additions & 29 deletions internal/forge/github/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,13 @@ import (
"context"
"fmt"
"net/http"
"net/url"

"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/gateway/github"
"go.abhg.dev/gs/internal/git/giturl"
"go.abhg.dev/gs/internal/silog"
)

// Default URLs for GitHub and its API.
const (
DefaultURL = "https://github.com"
DefaultAPIURL = "https://api.github.com"
)

// Options defines command line options for the GitHub Forge.
// These are all hidden in the CLI,
// and are expected to be set only via environment variables.
Expand All @@ -44,6 +37,10 @@ type Options struct {
Stacks bool `name:"github-stacks" hidden:"" config:"forge.github.stacks" default:"true" help:"Whether to use GitHub native stack operations. One of 'true' and 'false'."`
}

func (o Options) deployment() github.Deployment {
return github.Deployment{URL: o.URL, APIURL: o.APIURL}
}

// Definition configures GitHub forge instances.
type Definition struct {
changeMetadataCodec
Expand All @@ -65,7 +62,7 @@ func (*Definition) ID() string { return "github" }

// BaseURL reports the GitHub web URL used for host matching.
func (d *Definition) BaseURL() string {
return cmp.Or(d.Options.URL, DefaultURL)
return d.Options.deployment().WebURL()
}

// CLIPlugin returns the CLI plugin for the GitHub Forge.
Expand Down Expand Up @@ -116,33 +113,14 @@ func (f *Forge) logger() *silog.Logger {
// URL returns the base URL configured for the GitHub Forge
// or the default URL if none is set.
func (f *Forge) URL() string {
return cmp.Or(f.Options.URL, DefaultURL)
return f.Options.deployment().WebURL()
}

// BaseURL reports the GitHub web URL used for host matching and links.
func (f *Forge) BaseURL() string {
return f.baseURL
}

// APIURL returns the base API URL configured for the GitHub Forge
// or the default URL if none is set.
func (f *Forge) APIURL() string {
if f.Options.APIURL != "" {
return f.Options.APIURL
}

// If the API URL is not set, and base URL is NOT github.com,
// assume API URL is $baseURL/api.
if f.Options.URL != "" && f.Options.URL != DefaultURL {
apiURL, err := url.JoinPath(f.Options.URL, "/api")
if err == nil {
return apiURL
}
}

return DefaultAPIURL
}

// ID reports a unique key for this forge.
func (*Forge) ID() string { return "github" }

Expand Down Expand Up @@ -171,7 +149,11 @@ func (f *Forge) OpenRepository(ctx context.Context, tok forge.AuthenticationToke
}

gatewayTokens := newGatewayTokenSource(tokenSource)
gatewayClient, err := github.NewGateway(f.APIURL(), f.HTTPClient, gatewayTokens)
gatewayClient, err := github.NewGateway(github.Config{
Deployment: f.Options.deployment(),
Tokens: gatewayTokens,
HTTPClient: f.HTTPClient,
})
if err != nil {
return nil, fmt.Errorf("create GitHub gateway: %w", err)
}
Expand Down
182 changes: 159 additions & 23 deletions internal/forge/github/forge_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package github

import (
"io"
"net/http"
"strings"
"testing"

"github.com/alecthomas/kong"
Expand Down Expand Up @@ -51,53 +54,182 @@ func TestURLs(t *testing.T) {
name string
opts Options

wantURL string
wantAPIURL string
wantURL string
}{
{
name: "Default",
wantURL: DefaultURL,
wantAPIURL: DefaultAPIURL,
name: "Default",
wantURL: "https://github.com",
},
{
name: "ExplicitURL",
opts: Options{URL: DefaultURL},
wantURL: DefaultURL,
wantAPIURL: DefaultAPIURL,
name: "ExplicitURL",
opts: Options{URL: "https://github.com"},
wantURL: "https://github.com",
},
{
name: "CustomURL",
opts: Options{URL: "https://example.com"},
wantURL: "https://example.com",
wantAPIURL: "https://example.com/api",
name: "CustomURL",
opts: Options{URL: "https://example.com"},
wantURL: "https://example.com",
},
{
name: "CustomBoth",
opts: Options{
URL: "https://example.com",
APIURL: "https://api.example.com",
},
wantURL: "https://example.com",
wantAPIURL: "https://api.example.com",
wantURL: "https://example.com",
},
{
name: "InvalidURL",
opts: Options{URL: ":/"},
wantURL: ":/",
wantAPIURL: DefaultAPIURL,
name: "InvalidURL",
opts: Options{URL: ":/"},
wantURL: ":/",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := Forge{Options: tt.opts}
d := Definition{Options: tt.opts}

assert.Equal(t, tt.wantURL, f.URL())
assert.Equal(t, tt.wantAPIURL, f.APIURL())
assert.Equal(t, tt.wantURL, d.BaseURL())
})
}
}

func TestForge_OpenRepository_endpoints(t *testing.T) {
tests := []struct {
name string
opts Options
wantGQL string
wantREST string
}{
{
name: "GitHub",
wantGQL: "https://api.github.com/graphql",
wantREST: "https://api.github.com",
},
{
name: "ExplicitGitHub",
opts: Options{URL: "https://github.com"},
wantGQL: "https://api.github.com/graphql",
wantREST: "https://api.github.com",
},
{
name: "MalformedWebURL",
opts: Options{URL: ":/"},
wantGQL: "https://api.github.com/graphql",
wantREST: "https://api.github.com",
},
{
name: "EnterpriseCloud",
opts: Options{APIURL: "https://api.octocorp.ghe.com"},
wantGQL: "https://api.octocorp.ghe.com/graphql",
wantREST: "https://api.octocorp.ghe.com",
},
{
name: "EnterpriseCloudMixedCasePort",
opts: Options{APIURL: "https://API.OctoCorp.GHE.COM:443"},
wantGQL: "https://API.OctoCorp.GHE.COM:443/graphql",
wantREST: "https://API.OctoCorp.GHE.COM:443",
},
{
name: "Enterprise",
opts: Options{URL: "https://github.example.com"},
wantGQL: "https://github.example.com/api/graphql",
wantREST: "https://github.example.com/api/v3",
},
{
name: "WebPrefix",
opts: Options{URL: "https://github.example.com/service"},
wantGQL: "https://github.example.com/service/api/graphql",
wantREST: "https://github.example.com/service/api/v3",
},
{
name: "CustomAPI",
opts: Options{
URL: "https://github.example.com",
APIURL: "https://proxy.example.com/github",
},
wantGQL: "https://proxy.example.com/github/graphql",
wantREST: "https://proxy.example.com/github/v3",
},
{
name: "EnterpriseCloudWebHost",
opts: Options{URL: "https://octocorp.ghe.com"},
wantGQL: "https://octocorp.ghe.com/api/graphql",
wantREST: "https://octocorp.ghe.com/api/v3",
},
{
name: "EnterpriseSimilarCloudHost",
opts: Options{APIURL: "https://api.octocorp.ghe.com.example.net/api"},
wantGQL: "https://api.octocorp.ghe.com.example.net/api/graphql",
wantREST: "https://api.octocorp.ghe.com.example.net/api/v3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stackURL := tt.wantREST + "/repos/octo/hello/stacks"
var requests []string
f := Forge{
Options: tt.opts,
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
requests = append(requests, r.URL.String())
assert.Equal(t, "Bearer token", r.Header.Get("Authorization"))
status, body := http.StatusOK, "[]"
switch r.URL.String() {
case tt.wantGQL:
assert.Equal(t, http.MethodPost, r.Method)
body = `{"data":{"repository":{"id":"R_123"}}}`
case stackURL:
assert.Equal(t, http.MethodGet, r.Method)
default:
status, body = http.StatusNotFound, `{"message":"Not Found"}`
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
}, nil
})},
}
f.Options.Stacks = true
rid, err := f.ParseRepositoryPath("octo/hello")
require.NoError(t, err)
repo, err := f.OpenRepository(t.Context(), &AuthenticationToken{AccessToken: "token"}, rid)
require.NoError(t, err)

_, err = repo.(forge.StackRepository).PlanMergeRanges(t.Context(), nil)
assert.NoError(t, err)
assert.Equal(t, []string{tt.wantGQL, stackURL}, requests)
})
}
}

func TestDefinition_New_invalidAPIURL(t *testing.T) {
d := Definition{Options: Options{
URL: "https://github.example.com",
APIURL: ":/",
}}
assert.Equal(t, "https://github.example.com", d.BaseURL())
remoteURL, err := giturl.Parse("https://github.example.com/octo/hello.git")
require.NoError(t, err)
f, err := d.New(remoteURL, &forge.NewOptions{
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request")
return nil, nil
})},
})
require.NoError(t, err)

ep, err := f.(*Forge).oauth2Endpoint()
require.NoError(t, err)
assert.Equal(t, "https://github.example.com/login/oauth/access_token", ep.TokenURL)

rid, err := f.ParseRepositoryPath("octo/hello")
require.NoError(t, err)
_, err = f.OpenRepository(t.Context(), &AuthenticationToken{AccessToken: "token"}, rid)
assert.ErrorContains(t, err, "parse API URL")
}

func TestExtractRepoInfo(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -229,7 +361,7 @@ func TestForge_ParseRepositoryPath_knownForge(t *testing.T) {

func TestChangeURL(t *testing.T) {
repoID := RepositoryID{
url: DefaultURL,
url: "https://github.com",
owner: "example",
name: "repo",
}
Expand All @@ -242,7 +374,7 @@ func TestRepository_ComparisonURL(t *testing.T) {
r := &Repository{
owner: "example",
repo: "repo",
forge: &Forge{Options: Options{URL: DefaultURL}},
forge: &Forge{Options: Options{URL: "https://github.com"}},
}

t.Run("SameRepository", func(t *testing.T) {
Expand All @@ -258,7 +390,7 @@ func TestRepository_ComparisonURL(t *testing.T) {
Base: "main",
Head: "feat#review",
HeadRepository: &RepositoryID{
url: DefaultURL,
url: "https://github.com",
owner: "fork",
name: "repo",
},
Expand All @@ -268,3 +400,7 @@ func TestRepository_ComparisonURL(t *testing.T) {
got)
})
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
9 changes: 4 additions & 5 deletions internal/forge/github/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,10 @@ func newRecorder(

func newGateway(t *testing.T, httpClient *http.Client) *githubgateway.Gateway {
t.Helper()
client, err := githubgateway.NewGateway(
github.DefaultAPIURL,
&http.Client{Transport: httpClient.Transport},
testTokenSource("token"),
)
client, err := githubgateway.NewGateway(githubgateway.Config{
HTTPClient: &http.Client{Transport: httpClient.Transport},
Tokens: testTokenSource("token"),
})
require.NoError(t, err)
return client
}
Expand Down
Loading
Loading