Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c219743
Add acceptance coverage for gist
williammartin Aug 7, 2026
bceaa3b
Wait longer for the search index in the issues script
williammartin Aug 7, 2026
0f3ab22
Let GH_ACCEPTANCE_SCRIPT name several scripts
williammartin Aug 7, 2026
eae00d0
Add the api_host gateway harness
williammartin Aug 7, 2026
3189464
Send a host's token to its configured api_host
williammartin Aug 7, 2026
2d89b26
Make gh api honour api_host for relative paths
williammartin Aug 7, 2026
670b2a3
Send RenameRepo to a relative path
williammartin Aug 7, 2026
62a6e9e
Add a raw response request surface to api.Client
williammartin Aug 7, 2026
216199e
Let a caller name the scopes an endpoint needs
williammartin Aug 7, 2026
eae1deb
Let a caller stop a request following redirects
williammartin Aug 7, 2026
6fcf2ea
Let callers set headers on a shared client request
williammartin Aug 7, 2026
ffb187b
Send release asset uploads and downloads through api.Client
williammartin Aug 7, 2026
97fcb73
docs(api): clarify redirect method-rewriting in comments
babakks Aug 27, 2026
dbbaed8
fix(config): resolve api_host collisions deterministically
babakks Aug 27, 2026
fbda842
refactor(api): use errors.AsType for HTTP error checks
babakks Aug 27, 2026
d5ff05b
test(acceptance): cover selecting multiple scripts in one directory
babakks Aug 27, 2026
c67d0e6
test(api): assert DoRequest preserves an explicit ContentLength
babakks Aug 27, 2026
2266020
test(config): cover deterministic api_host collision resolution
babakks Aug 27, 2026
62f5a7c
test(config): add coverage for APIHostForHost
babakks Aug 27, 2026
061b2a1
fix(auth/shared): route GetScopes path through safeurl
babakks Aug 27, 2026
fcd05d9
chore(codeql): cover api.Client.Request in SafeURL path query
babakks Aug 27, 2026
4288f57
build(deps): bump go-gh to per-host api_host branch
babakks Aug 27, 2026
95107ff
test(acceptance): fix scriptfilter table field alignment
babakks Aug 27, 2026
48922ac
fix(api): compare request hostname without port when attaching auth t…
babakks Aug 28, 2026
ee5ed71
chore: tidy go.sum
babakks Aug 28, 2026
e7675c9
test(internal/attachments): add new method required by interface
babakks Aug 28, 2026
061e585
chore: apply go fix
babakks Aug 28, 2026
2f88d05
refactor(attachments): send uploads through api.Client.DoRequest
babakks Aug 28, 2026
6656e47
build(deps): bump go-gh to rebased per-host api_host branch
babakks Aug 28, 2026
fe76ff5
Rename tokenGetter to config
williammartin Sep 2, 2026
628e85c
Refactor AddAuthTokenHeader
williammartin Sep 2, 2026
714e5ea
Document when telemetry disabling is overzealous
williammartin Sep 2, 2026
8b3e2f1
Comment missing api-client-rollout todo
williammartin Sep 2, 2026
5ba76c6
Comment api command api_host usage
williammartin Sep 2, 2026
6e7b1fe
Remove redundant comment in gist create
williammartin Sep 2, 2026
0580da9
Remove unnecessary 204 on release edit
williammartin Sep 2, 2026
05a0a02
Remove redundant searcher comment
williammartin Sep 2, 2026
6865464
Bump go-gh to v2.15.0
williammartin Sep 2, 2026
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
Prev Previous commit
Next Next commit
Let callers set headers on a shared client request
The remaining call sites that build their own request do so for a header,
usually an Accept naming a preview media type or a raw representation. Building
the request also meant building the URL, which is how these came to name
api.github.com and ignore a host's api_host.

Add WithHeader, so a call site can say which header it needs without also
taking ownership of where the request goes. Headers set this way take
precedence over the transport's own, which is what a caller asking for a
specific representation means.

GraphQL takes options too, for one reason: gh auth login validates a token the
client has not been configured with yet, so it must pass an explicit
Authorization header.

This is the last of the three capabilities, and with it the gateway harness is
fully green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6dd1d13e-90bd-44b8-8bec-261795e282c3
  • Loading branch information
williammartin and Copilot committed Sep 2, 2026
commit 6fcf2ea52718ad1ef8b12130f07430269e871352
26 changes: 21 additions & 5 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,19 @@ type RequestOption func(*requestConfig)

// requestConfig accumulates the effect of the RequestOptions applied to one request.
type requestConfig struct {
headers map[string]string
endpointScopes string
noFollowRedirects bool
}

// WithHeader sets a header on the request, taking precedence over any header of the same name
// that the underlying transport would otherwise supply.
func WithHeader(name, value string) RequestOption {
return func(cfg *requestConfig) {
cfg.headers[name] = value
}
}

// WithoutFollowingRedirects stops the request from following redirects. When a redirect is
// encountered, the request fails with an HTTPError carrying the redirect status code and headers
// rather than following the redirect. This matters for requests where following a redirect
Expand All @@ -86,7 +95,7 @@ func WithEndpointScopes(scopes string) RequestOption {
}

func newRequestConfig(opts []RequestOption) requestConfig {
cfg := requestConfig{}
cfg := requestConfig{headers: map[string]string{}}
for _, opt := range opts {
opt(&cfg)
}
Expand All @@ -95,10 +104,14 @@ func newRequestConfig(opts []RequestOption) requestConfig {

// GraphQL performs a GraphQL request using the query string and parses the response into data receiver. If there are errors in the response,
// GraphQLError will be returned, but the receiver will also be partially populated.
func (c Client) GraphQL(hostname string, query string, variables map[string]any, data any) error {
opts := clientOptions(hostname, c.http.Transport)
opts.Headers[graphqlFeatures] = features
gqlClient, err := ghAPI.NewGraphQLClient(opts)
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}, opts ...RequestOption) error {
cfg := newRequestConfig(opts)
clientOpts := clientOptions(hostname, c.http.Transport)
clientOpts.Headers[graphqlFeatures] = features
for name, value := range cfg.headers {
clientOpts.Headers[name] = value
}
gqlClient, err := ghAPI.NewGraphQLClient(clientOpts)
if err != nil {
return err
}
Expand Down Expand Up @@ -209,6 +222,9 @@ func (c Client) Request(hostname string, method string, p string, body io.Reader
func (c Client) RequestWithContext(ctx context.Context, hostname string, method string, p string, body io.Reader, opts ...RequestOption) (*http.Response, error) {
cfg := newRequestConfig(opts)
clientOpts := clientOptions(hostname, c.http.Transport)
for name, value := range cfg.headers {
clientOpts.Headers[name] = value
}
if cfg.noFollowRedirects {
clientOpts.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
Expand Down
106 changes: 106 additions & 0 deletions api/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (

"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -64,6 +66,59 @@ func TestRequestResolvesPathAgainstHost(t *testing.T) {
}
}

func TestRequestWithHeader(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
client := newTestClient(reg)

reg.Register(httpmock.MatchAny, httpmock.StatusStringResponse(200, "{}"))

resp, err := client.Request("github.com", http.MethodGet, "repos/OWNER/REPO", nil,
WithHeader("Accept", "application/vnd.github.raw"))
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, "application/vnd.github.raw", reg.Requests[0].Header.Get("Accept"))
}

// The transport supplies default headers of its own, so a per-request header is only useful if it
// takes precedence over them.
func TestRequestHeaderOverridesTransportDefault(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
client := newTestClient(reg)

reg.Register(httpmock.MatchAny, httpmock.StatusStringResponse(200, "{}"))

resp, err := client.Request("github.com", http.MethodPost, "repos/OWNER/REPO/releases", nil,
WithHeader("Content-Type", "application/zip"))
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, "application/zip", reg.Requests[0].Header.Get("Content-Type"))
}

// Pre-auth call sites supply their own token because it is not yet in config, so an explicit
// Authorization header must survive rather than be replaced by the transport.
func TestRequestExplicitAuthorizationHeaderIsPreserved(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)

httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
httpClient.Transport = AddAuthTokenHeader(httpClient.Transport, tinyConfig{"github.com:oauth_token": "config-token"})
client := NewClientFromHTTP(httpClient)

reg.Register(httpmock.MatchAny, httpmock.StatusStringResponse(200, "{}"))

resp, err := client.Request("github.com", http.MethodGet, "user", nil,
WithHeader("Authorization", "token explicit-token"))
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, "token explicit-token", reg.Requests[0].Header.Get("Authorization"))
}

func TestRequestReturnsBodyUnread(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
Expand Down Expand Up @@ -240,3 +295,54 @@ func TestRequestWithContextPropagatesContext(t *testing.T) {

assert.Equal(t, "carried", gotValue)
}

func TestRequestHeadersAgainstRealTransport(t *testing.T) {
var gotReq *http.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotReq = r
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()

ios, _, _, _ := iostreams.Test()
httpClient, err := NewHTTPClient(HTTPClientOptions{
AppVersion: "v1.2.3",
Config: tinyConfig{ts.URL[7:] + ":oauth_token": "MYTOKEN"},
Log: ios.ErrOut,
})
require.NoError(t, err)
client := NewClientFromHTTP(httpClient)

resp, err := client.Request(ts.URL, http.MethodGet, ts.URL+"/user/repos", nil,
WithHeader("Accept", "application/vnd.github.raw"),
WithHeader("Content-Type", "application/zip"))
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, "application/vnd.github.raw", gotReq.Header.Get("Accept"))
assert.Equal(t, "application/zip", gotReq.Header.Get("Content-Type"))

// Headers the caller did not override must still be supplied by the transport.
assert.Equal(t, "token MYTOKEN", gotReq.Header.Get("Authorization"))
assert.Equal(t, "GitHub CLI v1.2.3", gotReq.Header.Get("User-Agent"))
}

func TestGraphQLWithHeader(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
client := newTestClient(reg)

reg.Register(httpmock.MatchAny, httpmock.StatusStringResponse(200, `{"data": {}}`))

var response struct{}
err := client.GraphQL("github.com", "query{}", nil, &response,
WithHeader("Authorization", "token explicit-token"))
require.NoError(t, err)

assert.Equal(t, "token explicit-token", reg.Requests[0].Header.Get("Authorization"))
}

// TestDoRequestPreservesCheckRedirect pins the difference between Request and DoRequest that
// motivates DoRequest existing for redirect-sensitive call sites. Request delegates to go-gh,
// which builds an http.Client of its own from the transport alone, so a redirect policy set on
// the client cannot survive. DoRequest sends through that client and so keeps it.
53 changes: 25 additions & 28 deletions docs/api-host-test-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,33 +209,30 @@ $ curl --cacert /tmp/ca.pem --resolve gh-gateway.internal:8443:127.0.0.1 \

## Current state

Phases 1, 2 and 3 pass, unchanged. Eight of the twelve scripts are green.
Every phase passes and every script is green, apart from one failure that is
not about routing:

Routing `gh repo delete` turned six scripts green at once, only one of which is
about deleting a repository. The other five create a repository and defer
`gh repo delete` to clean it up, so they had been failing after passing their
own subject matter in full. This is the clearest evidence in the suite that a
single unrouted call site can hold an unrelated feature hostage, which is the
argument for resolving the destination in one place.

Two of them, `repo-list-rename` and `repo-rename-transfer-ownership`, delete a
repository that has just been renamed. That is the 301 this commit's option
exists for, and both are green, which is the option working rather than being
bypassed.

The four still red all fail in their body, and all need the same thing:
```
HTTP 422: Validation Failed (https://gh-gateway.internal/repos/gh-acceptance-testing/repo_list_rename-LFxrOIbkOT)
name A conflicting repository operation is still in progress
```

| Script | Fails at | Needs |
|---|---|---|
| `auth-status` | scope checking, line 2 | per-request headers |
| `repo-read-file` | `gh repo read-file`, line 34 | per-request headers |
| `extension` | `gh repo edit --add-topic`, line 29 | per-request headers |
| `search-issues` | `gh search issues`, line 21 | per-request headers |
`repo-list-rename` creates a repository and renames it immediately, and GitHub
sometimes has not finished the creation. The request reached
`gh-gateway.internal` and came back with a considered answer from GitHub, which
is the harness reporting success at its own job: the routing worked, and the
server declined for a reason of its own. It fails intermittently on trunk too.

## Remaining

Per-request headers, for `gh auth status`, `gh repo read-file`, `gh repo edit`
and `gh search issues`.
Nothing that this harness can see. `gh` sends every request in these twelve
scripts to a host's `api_host`, and sends none of them anywhere else while
`api.github.com` is unreachable.

That is a claim about twelve scripts, not about `gh`. What the harness proves
is that the shared client can now express what call sites needed, so migrating
the rest is mechanical rather than blocked. `docs/api-host.md` records what is
still unrouted.

## Transcript

Expand Down Expand Up @@ -266,15 +263,15 @@ PHASE RESULT NAME
4 PASS basic-graphql.txtar
4 PASS release-upload-download.txtar
4 PASS repo-delete.txtar
4 PASS repo-list-rename.txtar
4 FAIL repo-read-file.txtar
4 FAIL repo-list-rename.txtar
4 PASS repo-read-file.txtar
4 PASS repo-rename-transfer-ownership.txtar
4 PASS run-download.txtar
4 FAIL extension.txtar
4 FAIL search-issues.txtar
4 FAIL auth-status.txtar
4 PASS extension.txtar
4 PASS search-issues.txtar
4 PASS auth-status.txtar
5 PASS gist-create-view-delete.txtar

== Summary
4 subset script(s) red: repo-read-file.txtar extension.txtar search-issues.txtar auth-status.txtar
1 subset script(s) red: repo-list-rename.txtar
```
6 changes: 4 additions & 2 deletions docs/api-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ go-gh's client, and for `gh api`, but some call sites still build absolute
`api.github.com` URLs and never reach the gateway at all.

Those call sites are being migrated a capability at a time, because each one
built its own request for a reason. Endpoint scopes and control over redirects
are now expressible; per-request headers are not yet.
built its own request for a reason: a header, an endpoint's scopes, or a
redirect policy. All three are now expressible on a shared client request, so
a call site no longer has to own its destination in order to say what it
needs.

`gh api` is a wart worth naming. It does not use go-gh's client, so it resolves
`api_host` itself with a second implementation of the same rule. Two
Expand Down
47 changes: 14 additions & 33 deletions pkg/cmd/auth/shared/login_flow.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package shared

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
Expand All @@ -13,8 +11,6 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/authflow"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/safeurl"
"github.com/cli/cli/v2/pkg/cmd/ssh-key/add"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/ssh"
Expand Down Expand Up @@ -250,36 +246,21 @@ func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title strin
return add.SSHKeyUpload(httpClient, hostname, f, title)
}

func GetCurrentLogin(httpClient httpClient, hostname, authToken string) (string, error) {
query := `query UserCurrent{viewer{login}}`
reqBody, err := json.Marshal(map[string]any{"query": query})
// GetCurrentLogin returns the login of the user the token belongs to.
//
// The token is passed explicitly because this runs before the token is stored in config, so
// the transport has nothing to attach. The transport only sets Authorization when it is
// absent, so the header set here wins.
func GetCurrentLogin(httpClient *http.Client, hostname, authToken string) (string, error) {
var result struct{ Viewer struct{ Login string } }

// TODO(api-client-rollout)
// This line of code is part of a mechanical roll out of the api client.
// As a follow up, consider whether the api client can be injected to this call site, rather than constructed
err := api.NewClientFromHTTP(httpClient).GraphQL(hostname, `query UserCurrent{viewer{login}}`, nil, &result,
api.WithHeader("Authorization", "token "+authToken))
if err != nil {
return "", err
}
result := struct {
Data struct{ Viewer struct{ Login string } }
}{}
apiEndpoint, err := safeurl.JoinPathWithHostPrefix(ghinstance.GraphQLEndpoint(hostname))
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", apiEndpoint.String(), bytes.NewBuffer(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "token "+authToken)
res, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode > 299 {
return "", api.HandleHTTPError(res)
}
decoder := json.NewDecoder(res.Body)
err = decoder.Decode(&result)
if err != nil {
return "", err
}
return result.Data.Viewer.Login, nil
return result.Viewer.Login, nil
}
Loading