Skip to content

Commit

Permalink
Allow explicitly specifying the hostname for gh operations
Browse files Browse the repository at this point in the history
Accept the "HOST/OWNER/REPO" syntax or passing a full URL for both the
`--repo` flag and the GH_REPO environment variable and allow setting
GH_HOST environment variable to override just the hostname for
operations that assume "github.com" by default.

Examples:

    $ gh repo clone example.org/owner/repo
    $ GH_HOST=example.org gh repo clone repo

    $ GH_HOST=example.org gh api user
    $ GH_HOST=example.org gh gist create myfile.txt

    $ gh issue list -R example.org/owner/repo
    $ gh issue list -R https://example.org/owner/repo.git
    $ GH_REPO=example.org/owner/repo gh issue list
  • Loading branch information
mislav committed Aug 12, 2020
1 parent 7908c21 commit c095a4b
Show file tree
Hide file tree
Showing 16 changed files with 257 additions and 63 deletions.
5 changes: 5 additions & 0 deletions cmd/gh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/cli/cli/command"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/alias/expand"
"github.com/cli/cli/pkg/cmd/factory"
Expand All @@ -35,6 +36,10 @@ func main() {

hasDebug := os.Getenv("DEBUG") != ""

if hostFromEnv := os.Getenv("GH_HOST"); hostFromEnv != "" {
ghinstance.OverrideDefault(hostFromEnv)
}

cmdFactory := factory.New(command.Version)
stderr := cmdFactory.IOStreams.ErrOut
rootCmd := root.NewCmdRoot(cmdFactory, command.Version, command.BuildDate)
Expand Down
2 changes: 1 addition & 1 deletion context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re
continue
}
repos = append(repos, r)
if ghrepo.IsSame(r, baseOverride) {
if baseOverride != nil && ghrepo.IsSame(r, baseOverride) {
foundBaseOverride = true
}
if len(repos) == maxRemotesForLookup {
Expand Down
4 changes: 4 additions & 0 deletions git/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ var (
protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
)

func IsURL(u string) bool {
return strings.HasPrefix(u, "git@") || protocolRe.MatchString(u)
}

// ParseURL normalizes git remote urls
func ParseURL(rawURL string) (u *url.URL, err error) {
if !protocolRe.MatchString(rawURL) &&
Expand Down
16 changes: 16 additions & 0 deletions internal/ghinstance/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@ import (

const defaultHostname = "github.com"

var hostnameOverride string

// Default returns the host name of the default GitHub instance
func Default() string {
return defaultHostname
}

// OverridableDefault is like Default, except it is overridable by the GH_HOST environment variable
func OverridableDefault() string {
if hostnameOverride != "" {
return hostnameOverride
}
return defaultHostname
}

// OverrideDefault overrides the value returned from OverridableDefault. This should only ever be
// called from the main runtime path, not tests.
func OverrideDefault(newhost string) {
hostnameOverride = newhost
}

// IsEnterprise reports whether a non-normalized host name looks like a GHE instance
func IsEnterprise(h string) bool {
return NormalizeHostname(h) != defaultHostname
Expand Down
23 changes: 23 additions & 0 deletions internal/ghinstance/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ import (
"testing"
)

func TestOverridableDefault(t *testing.T) {
oldOverride := hostnameOverride
t.Cleanup(func() {
hostnameOverride = oldOverride
})

host := OverridableDefault()
if host != "github.com" {
t.Errorf("expected github.com, got %q", host)
}

OverrideDefault("example.org")

host = OverridableDefault()
if host != "example.org" {
t.Errorf("expected example.org, got %q", host)
}
host = Default()
if host != "github.com" {
t.Errorf("expected github.com, got %q", host)
}
}

func TestIsEnterprise(t *testing.T) {
tests := []struct {
host string
Expand Down
53 changes: 30 additions & 23 deletions internal/ghrepo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import (
"fmt"
"net/url"
"strings"
)

const defaultHostname = "github.com"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghinstance"
)

// Interface describes an object that represents a GitHub repository
type Interface interface {
Expand All @@ -17,18 +18,15 @@ type Interface interface {

// New instantiates a GitHub repository from owner and name arguments
func New(owner, repo string) Interface {
return &ghRepo{
owner: owner,
name: repo,
}
return NewWithHost(owner, repo, ghinstance.OverridableDefault())
}

// NewWithHost is like New with an explicit host name
func NewWithHost(owner, repo, hostname string) Interface {
return &ghRepo{
owner: owner,
name: repo,
hostname: hostname,
hostname: normalizeHostname(hostname),
}
}

Expand All @@ -37,15 +35,31 @@ func FullName(r Interface) string {
return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName())
}

// FromFullName extracts the GitHub repository information from an "OWNER/REPO" string
// FromFullName extracts the GitHub repository information from the following
// formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL.
func FromFullName(nwo string) (Interface, error) {
var r ghRepo
parts := strings.SplitN(nwo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return &r, fmt.Errorf("expected OWNER/REPO format, got %q", nwo)
if git.IsURL(nwo) {
u, err := git.ParseURL(nwo)
if err != nil {
return nil, err
}
return FromURL(u)
}

parts := strings.SplitN(nwo, "/", 4)
for _, p := range parts {
if len(p) == 0 {
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
}
}
switch len(parts) {
case 3:
return NewWithHost(parts[1], parts[2], normalizeHostname(parts[0])), nil
case 2:
return New(parts[0], parts[1]), nil
default:
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
}
r.owner, r.name = parts[0], parts[1]
return &r, nil
}

// FromURL extracts the GitHub repository information from a git remote URL
Expand All @@ -59,11 +73,7 @@ func FromURL(u *url.URL) (Interface, error) {
return nil, fmt.Errorf("invalid path: %s", u.Path)
}

return &ghRepo{
owner: parts[0],
name: strings.TrimSuffix(parts[1], ".git"),
hostname: normalizeHostname(u.Hostname()),
}, nil
return NewWithHost(parts[0], strings.TrimSuffix(parts[1], ".git"), u.Hostname()), nil
}

func normalizeHostname(h string) string {
Expand Down Expand Up @@ -109,8 +119,5 @@ func (r ghRepo) RepoName() string {
}

func (r ghRepo) RepoHost() string {
if r.hostname != "" {
return r.hostname
}
return defaultHostname
return r.hostname
}
84 changes: 84 additions & 0 deletions internal/ghrepo/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,87 @@ func Test_repoFromURL(t *testing.T) {
})
}
}

func TestFromFullName(t *testing.T) {
tests := []struct {
name string
input string
wantOwner string
wantName string
wantHost string
wantErr error
}{
{
name: "OWNER/REPO combo",
input: "OWNER/REPO",
wantHost: "github.com",
wantOwner: "OWNER",
wantName: "REPO",
wantErr: nil,
},
{
name: "too few elements",
input: "OWNER",
wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "OWNER"`),
},
{
name: "too many elements",
input: "a/b/c/d",
wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "a/b/c/d"`),
},
{
name: "blank value",
input: "a/",
wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "a/"`),
},
{
name: "with hostname",
input: "example.org/OWNER/REPO",
wantHost: "example.org",
wantOwner: "OWNER",
wantName: "REPO",
wantErr: nil,
},
{
name: "full URL",
input: "https://example.org/OWNER/REPO.git",
wantHost: "example.org",
wantOwner: "OWNER",
wantName: "REPO",
wantErr: nil,
},
{
name: "SSH URL",
input: "[email protected]:OWNER/REPO.git",
wantHost: "example.org",
wantOwner: "OWNER",
wantName: "REPO",
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := FromFullName(tt.input)
if tt.wantErr != nil {
if err == nil {
t.Fatalf("no error in result, expected %v", tt.wantErr)
} else if err.Error() != tt.wantErr.Error() {
t.Fatalf("expected error %q, got %q", tt.wantErr.Error(), err.Error())
}
return
}
if err != nil {
t.Fatalf("got error %v", err)
}
if r.RepoHost() != tt.wantHost {
t.Errorf("expected host %q, got %q", tt.wantHost, r.RepoHost())
}
if r.RepoOwner() != tt.wantOwner {
t.Errorf("expected owner %q, got %q", tt.wantOwner, r.RepoOwner())
}
if r.RepoName() != tt.wantName {
t.Errorf("expected name %q, got %q", tt.wantName, r.RepoName())
}
})
}
}
5 changes: 4 additions & 1 deletion pkg/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"strings"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
Expand Down Expand Up @@ -195,9 +196,11 @@ func apiRun(opts *ApiOptions) error {
opts.IO.Out = ioutil.Discard
}

host := ghinstance.OverridableDefault()

hasNextPage := true
for hasNextPage {
resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
if err != nil {
return err
}
Expand Down
11 changes: 7 additions & 4 deletions pkg/cmd/api/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,23 @@ import (
"net/url"
"strconv"
"strings"

"github.com/cli/cli/internal/ghinstance"
)

func httpRequest(client *http.Client, method string, p string, params interface{}, headers []string) (*http.Response, error) {
func httpRequest(client *http.Client, hostname string, method string, p string, params interface{}, headers []string) (*http.Response, error) {
isGraphQL := p == "graphql"
var requestURL string
if strings.Contains(p, "://") {
requestURL = p
} else if isGraphQL {
requestURL = ghinstance.GraphQLEndpoint(hostname)
} else {
// TODO: GHE support
requestURL = "https://api.github.com/" + p
requestURL = ghinstance.RESTPrefix(hostname) + strings.TrimPrefix(p, "/")
}

var body io.Reader
var bodyIsJSON bool
isGraphQL := p == "graphql"

switch pp := params.(type) {
case map[string]interface{}:
Expand Down
Loading

0 comments on commit c095a4b

Please sign in to comment.