Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
26 changes: 25 additions & 1 deletion .github/codeql/queries/SafeURLPathConstruction.ql
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,41 @@ import go
*
* Covered entry points:
* - (github.com/cli/cli/v2/api.Client).REST and .RESTWithNext, where the path is argument 2.
* - (github.com/cli/cli/v2/api.Client).Request, where the path is argument 2.
* - (github.com/cli/cli/v2/api.Client).RequestWithContext, where the path is argument 3.
* - net/http.NewRequest, where the URL is argument 1.
* - net/http.NewRequestWithContext, where the URL is argument 2.
* - (net/http.Client).Get, .Head, .Post and .PostForm, where the URL is argument 0.
*/
/**
* Holds when `call` is one api.Client request method delegating to another from inside the client
* itself, such as Request forwarding its path to RequestWithContext. The forwarded path is the
* caller's own argument, already checked at the real call site, so treating this internal plumbing
* as a sink would only report the client's implementation rather than a hand built URL.
*/
predicate isApiClientForwarding(DataFlow::CallNode call) {
exists(Method enclosing |
enclosing.hasQualifiedName("github.com/cli/cli/v2/api", "Client",
["REST", "RESTWithNext", "Request", "RequestWithContext"]) and
call.asExpr().getEnclosingFunction() = enclosing.getFuncDecl()
)
}

predicate isHttpUrlArgument(DataFlow::Node node) {
exists(Method m, DataFlow::CallNode call |
m.hasQualifiedName("github.com/cli/cli/v2/api", "Client", ["REST", "RESTWithNext"]) and
m.hasQualifiedName("github.com/cli/cli/v2/api", "Client", ["REST", "RESTWithNext", "Request"]) and
call = m.getACall() and
not isApiClientForwarding(call) and
node = call.getArgument(2)
)
or
exists(Method m, DataFlow::CallNode call |
m.hasQualifiedName("github.com/cli/cli/v2/api", "Client", "RequestWithContext") and
call = m.getACall() and
not isApiClientForwarding(call) and
node = call.getArgument(3)
)
or
exists(Function f, DataFlow::CallNode call |
f.hasQualifiedName("net/http", "NewRequest") and
call = f.getACall() and
Expand Down
115 changes: 85 additions & 30 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestAPI(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "api"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "api"))
}

func TestAuth(t *testing.T) {
Expand All @@ -82,7 +82,16 @@ func TestAuth(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "auth"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "auth"))
}

func TestGists(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(t, tsEnv, "gist"))
}

func TestGPGKeys(t *testing.T) {
Expand All @@ -91,7 +100,7 @@ func TestGPGKeys(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "gpg-key"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "gpg-key"))
}

func TestExtensions(t *testing.T) {
Expand All @@ -100,7 +109,7 @@ func TestExtensions(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "extension"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "extension"))
}

func TestIssues(t *testing.T) {
Expand All @@ -109,7 +118,7 @@ func TestIssues(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "issue"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "issue"))
}

func TestDiscussions(t *testing.T) {
Expand All @@ -118,7 +127,7 @@ func TestDiscussions(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "discussion"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "discussion"))
}

func TestIssues2_0(t *testing.T) {
Expand All @@ -127,7 +136,7 @@ func TestIssues2_0(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "issues-2.0"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "issues-2.0"))
}

func TestLabels(t *testing.T) {
Expand All @@ -136,7 +145,7 @@ func TestLabels(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "label"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "label"))
}

func TestOrg(t *testing.T) {
Expand All @@ -145,7 +154,7 @@ func TestOrg(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "org"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "org"))
}

func TestProject(t *testing.T) {
Expand All @@ -154,7 +163,7 @@ func TestProject(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "project"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "project"))
}

func TestPullRequests(t *testing.T) {
Expand All @@ -163,7 +172,7 @@ func TestPullRequests(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "pr"))
}

func TestReleases(t *testing.T) {
Expand All @@ -172,7 +181,7 @@ func TestReleases(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "release"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "release"))
}

func TestRepo(t *testing.T) {
Expand All @@ -181,7 +190,7 @@ func TestRepo(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "repo"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "repo"))
}

func TestRulesets(t *testing.T) {
Expand All @@ -190,7 +199,7 @@ func TestRulesets(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "ruleset"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "ruleset"))
}

func TestSearches(t *testing.T) {
Expand All @@ -199,7 +208,7 @@ func TestSearches(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "search"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "search"))
}

func TestSecrets(t *testing.T) {
Expand All @@ -208,7 +217,7 @@ func TestSecrets(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "secret"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "secret"))
}

func TestSSHKeys(t *testing.T) {
Expand All @@ -217,7 +226,7 @@ func TestSSHKeys(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "ssh-key"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "ssh-key"))
}

func TestVariables(t *testing.T) {
Expand All @@ -226,7 +235,7 @@ func TestVariables(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "variable"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "variable"))
}

func TestWorkflows(t *testing.T) {
Expand All @@ -235,7 +244,7 @@ func TestWorkflows(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "workflow"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "workflow"))
}

func TestTelemetry(t *testing.T) {
Expand All @@ -244,18 +253,21 @@ func TestTelemetry(t *testing.T) {
t.Fatal(err)
}

testscript.Run(t, testScriptParamsFor(tsEnv, "telemetry"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "telemetry"))
}

func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params {
var files []string
if tsEnv.script != "" {
files = []string{path.Join("testdata", command, tsEnv.script)}
}
func testScriptParamsFor(t *testing.T, tsEnv testScriptEnv, command string) testscript.Params {
t.Helper()
files, filtered := selectScripts(command, tsEnv.scripts)

var dir string
if len(files) == 0 {
if !filtered {
// No filter was set - run everything in the directory.
dir = path.Join("testdata", command)
} else if len(files) == 0 {
// A filter was set but none of the selected scripts belong to this
// command directory, so skip rather than running the whole directory.
t.Skipf("testdata/%s: no selected script belongs to this command directory", command)
}

return testscript.Params{
Expand Down Expand Up @@ -287,12 +299,42 @@ func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error {

ts.Setenv("GH_HOST", tsEnv.host)
ts.Setenv("ORG", tsEnv.org)
ts.Setenv("GH_TOKEN", tsEnv.token)

if tsEnv.apiHost == "" {
ts.Setenv("GH_TOKEN", tsEnv.token)
} else {
// api_host is only readable from hosts.yml, and a GH_TOKEN in the
// environment resolves auth without ever consulting that file, so
// the token has to move into the same place as the override.
hostsFile := filepath.Join(ts.Cd, "hosts.yml")
hostsContent := fmt.Sprintf(""+
"%[1]s:\n"+
" user: %[2]s\n"+
" oauth_token: %[3]s\n"+
" git_protocol: https\n"+
" api_host: %[4]s\n"+
" users:\n"+
" %[2]s:\n"+
" oauth_token: %[3]s\n",
tsEnv.host, tsEnv.user, tsEnv.token, tsEnv.apiHost)
if err := os.WriteFile(hostsFile, []byte(hostsContent), 0o600); err != nil {
return fmt.Errorf("writing sandbox hosts.yml: %w", err)
}
}

ts.Setenv("RANDOM_STRING", randomString(10))

ts.Setenv("GH_TELEMETRY", "false")

// testscript constructs a fresh environment from a fixed allowlist and
// does not propagate SSL_CERT_FILE. When the operator has set it - for
// instance because all API traffic routes through a gateway whose CA is
// not in the system bundle - honour that intent explicitly, or every
// request inside the sandbox will fail certificate verification.
if certFile := os.Getenv("SSL_CERT_FILE"); certFile != "" {
ts.Setenv("SSL_CERT_FILE", certFile)
}

// The sandbox overrides HOME, so git cannot find the user's global
// config. Write a minimal identity so commits inside the sandbox
// don't fail with "Author identity unknown".
Expand Down Expand Up @@ -560,8 +602,16 @@ type testScriptEnv struct {
host string
org string
token string
user string

// scripts optionally narrows a run to named scripts within the command
// directory being run. Empty means run every script in the directory.
scripts []string

script string
// apiHost, when set, routes API traffic through that hostname by writing a
// hosts.yml instead of authenticating from GH_TOKEN. Used by the gateway
// harness in script/api-host-gateway.
apiHost string

skipDefer bool
preserveWorkDir bool
Expand Down Expand Up @@ -599,9 +649,14 @@ func (e *testScriptEnv) fromEnv() error {
e.org = envMap["GH_ACCEPTANCE_ORG"]
e.token = envMap["GH_ACCEPTANCE_TOKEN"]

e.script = os.Getenv("GH_ACCEPTANCE_SCRIPT")
e.scripts = parseScriptFilter(os.Getenv("GH_ACCEPTANCE_SCRIPT"))
e.preserveWorkDir = os.Getenv("GH_ACCEPTANCE_PRESERVE_WORK_DIR") == "true"
e.skipDefer = os.Getenv("GH_ACCEPTANCE_SKIP_DEFER") == "true"
e.apiHost = os.Getenv("GH_ACCEPTANCE_API_HOST")
e.user = os.Getenv("GH_ACCEPTANCE_USER")
if e.apiHost != "" && e.user == "" {
return fmt.Errorf("GH_ACCEPTANCE_USER is required when GH_ACCEPTANCE_API_HOST is set")
}

return nil
}
Expand All @@ -611,5 +666,5 @@ func TestSkills(t *testing.T) {
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "skills"))
testscript.Run(t, testScriptParamsFor(t, tsEnv, "skills"))
}
36 changes: 36 additions & 0 deletions acceptance/scriptfilter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package acceptance_test

import (
"os"
"path"
"strings"
)

// parseScriptFilter splits a comma-separated GH_ACCEPTANCE_SCRIPT value into
// individual script names, trimming whitespace and ignoring empty entries.
func parseScriptFilter(raw string) []string {
var scripts []string
for s := range strings.SplitSeq(raw, ",") {
if s = strings.TrimSpace(s); s != "" {
scripts = append(scripts, s)
}
}
return scripts
}

// selectScripts returns the script files under testdata/command that match the
// requested names, and reports whether a filter was applied (i.e. scripts is
// non-empty). A named script not found in the directory is silently ignored
// because it belongs to another command directory in the same run.
func selectScripts(command string, scripts []string) (files []string, filtered bool) {
if len(scripts) == 0 {
return nil, false
}
for _, script := range scripts {
p := path.Join("testdata", command, script)
if _, err := os.Stat(p); err == nil {
files = append(files, p)
}
}
return files, true
}
Loading