diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8a1dc4849..09f60f318 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -27,7 +27,7 @@ Please avoid: Prerequisites: - Go 1.13+ for building the binary -- Go 1.14+ for running the test suite +- Go 1.15+ for running the test suite Build with: `make` or `go build -o bin/gh ./cmd/gh` diff --git a/.github/ISSUE_TEMPLATE/feedback.md b/.github/ISSUE_TEMPLATE/feedback.md new file mode 100644 index 000000000..837c36632 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feedback.md @@ -0,0 +1,28 @@ +--- +name: "\U0001F4E3 Feedback" +about: Give us general feedback about the GitHub CLI +title: '' +labels: feedback +assignees: '' + +--- + +# CLI Feedback + +You can use this template to give us structured feedback or just wipe it and leave us a note. Thank you! + +## What have you loved? + +_eg "the nice colors"_ + +## What was confusing or gave you pause? + +_eg "it did something unexpected"_ + +## Are there features you'd like to see added? + +_eg "gh cli needs mini-games"_ + +## Anything else? + +_eg "have a nice day"_ diff --git a/api/queries_repo.go b/api/queries_repo.go index e9d8534a5..fdbd13e92 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -91,6 +91,8 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { query RepositoryInfo($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id + name + owner { login } hasIssuesEnabled description viewerPermission @@ -317,8 +319,8 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { }, nil } -// RepoFindFork finds a fork of repo affiliated with the viewer -func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) { +// RepoFindForks finds forks of the repo that are affiliated with the viewer +func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Repository, error) { result := struct { Repository struct { Forks struct { @@ -330,12 +332,13 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) { variables := map[string]interface{}{ "owner": repo.RepoOwner(), "repo": repo.RepoName(), + "limit": limit, } if err := client.GraphQL(repo.RepoHost(), ` - query RepositoryFindFork($owner: String!, $repo: String!) { + query RepositoryFindFork($owner: String!, $repo: String!, $limit: Int!) { repository(owner: $owner, name: $repo) { - forks(first: 1, affiliations: [OWNER, COLLABORATOR]) { + forks(first: $limit, affiliations: [OWNER, COLLABORATOR]) { nodes { id name @@ -350,14 +353,18 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) { return nil, err } - forks := result.Repository.Forks.Nodes - // we check ViewerCanPush, even though we expect it to always be true per - // `affiliations` condition, to guard against versions of GitHub with a - // faulty `affiliations` implementation - if len(forks) > 0 && forks[0].ViewerCanPush() { - return InitRepoHostname(&forks[0], repo.RepoHost()), nil + var results []*Repository + for _, r := range result.Repository.Forks.Nodes { + // we check ViewerCanPush, even though we expect it to always be true per + // `affiliations` condition, to guard against versions of GitHub with a + // faulty `affiliations` implementation + if !r.ViewerCanPush() { + continue + } + results = append(results, InitRepoHostname(&r, repo.RepoHost())) } - return nil, &NotFoundError{errors.New("no fork found")} + + return results, nil } type RepoMetadataResult struct { diff --git a/cmd/gh/main.go b/cmd/gh/main.go index fdde7acd3..db329c3cd 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -10,6 +10,7 @@ import ( "path" "strings" + surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/cli/cli/api" "github.com/cli/cli/command" "github.com/cli/cli/internal/config" @@ -43,6 +44,23 @@ func main() { cmdFactory := factory.New(command.Version) stderr := cmdFactory.IOStreams.ErrOut + if !cmdFactory.IOStreams.ColorEnabled() { + surveyCore.DisableColor = true + } else { + // override survey's poor choice of color + surveyCore.TemplateFuncsWithColor["color"] = func(style string) string { + switch style { + case "white": + if cmdFactory.IOStreams.ColorSupport256() { + return fmt.Sprintf("\x1b[%d;5;%dm", 38, 242) + } + return ansi.ColorCode("default") + default: + return ansi.ColorCode(style) + } + } + } + rootCmd := root.NewCmdRoot(cmdFactory, command.Version, command.BuildDate) cfg, err := cmdFactory.Config() @@ -51,12 +69,14 @@ func main() { os.Exit(2) } - prompt, _ := cfg.Get("", "prompt") - - if prompt == config.PromptsDisabled { + if prompt, _ := cfg.Get("", "prompt"); prompt == config.PromptsDisabled { cmdFactory.IOStreams.SetNeverPrompt(true) } + if pager, _ := cfg.Get("", "pager"); pager != "" { + cmdFactory.IOStreams.SetPager(pager) + } + expandedArgs := []string{} if len(os.Args) > 0 { expandedArgs = os.Args[1:] diff --git a/context/blank_context.go b/context/blank_context.go deleted file mode 100644 index 96f599981..000000000 --- a/context/blank_context.go +++ /dev/null @@ -1,24 +0,0 @@ -package context - -import ( - "fmt" - - "github.com/cli/cli/internal/config" -) - -// NewBlank initializes a blank Context suitable for testing -func NewBlank() *blankContext { - return &blankContext{} -} - -// A Context implementation that queries the filesystem -type blankContext struct { -} - -func (c *blankContext) Config() (config.Config, error) { - cfg, err := config.ParseConfig("config.yml") - if err != nil { - panic(fmt.Sprintf("failed to parse config during tests. did you remember to stub? error: %s", err)) - } - return cfg, nil -} diff --git a/context/context.go b/context/context.go index 9b2fc47c1..babd51f55 100644 --- a/context/context.go +++ b/context/context.go @@ -1,33 +1,27 @@ +// TODO: rename this package to avoid clash with stdlib package context import ( "errors" - "fmt" - "os" "sort" - "strings" + "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/api" - "github.com/cli/cli/internal/config" + "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" ) -// Context represents the interface for querying information about the current environment -type Context interface { - Config() (config.Config, error) -} - // cap the number of git remotes looked up, since the user might have an // unusually large number of git remotes const maxRemotesForLookup = 5 -// ResolveRemotesToRepos takes in a list of git remotes and fetches more information about the repositories they map to. -// Only the git remotes belonging to the same hostname are ever looked up; all others are ignored. -func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (ResolvedRemotes, error) { +func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (*ResolvedRemotes, error) { sort.Stable(remotes) - result := ResolvedRemotes{ - Remotes: remotes, + result := &ResolvedRemotes{ + remotes: remotes, apiClient: client, } @@ -38,138 +32,136 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re if err != nil { return result, err } - result.BaseOverride = baseOverride + result.baseOverride = baseOverride } - foundBaseOverride := false - var hostname string + return result, nil +} + +func resolveNetwork(result *ResolvedRemotes) error { var repos []ghrepo.Interface - for i, r := range remotes { - if i == 0 { - hostname = r.RepoHost() - } else if !strings.EqualFold(r.RepoHost(), hostname) { - // ignore all remotes for a hostname different to that of the 1st remote - continue - } + for _, r := range result.remotes { repos = append(repos, r) - if baseOverride != nil && ghrepo.IsSame(r, baseOverride) { - foundBaseOverride = true - } if len(repos) == maxRemotesForLookup { break } } - if baseOverride != nil && !foundBaseOverride { - // additionally, look up the explicitly specified base repo if it's not - // already covered by git remotes - repos = append(repos, baseOverride) - } - networkResult, err := api.RepoNetwork(client, repos) - if err != nil { - return result, err - } - result.Network = networkResult - return result, nil + networkResult, err := api.RepoNetwork(result.apiClient, repos) + result.network = &networkResult + return err } type ResolvedRemotes struct { - BaseOverride ghrepo.Interface - Remotes Remotes - Network api.RepoNetworkResult + baseOverride ghrepo.Interface + remotes Remotes + network *api.RepoNetworkResult apiClient *api.Client } -// BaseRepo is the first found repository in the "upstream", "github", "origin" -// git remote order, resolved to the parent repo if the git remote points to a fork -func (r ResolvedRemotes) BaseRepo() (*api.Repository, error) { - if r.BaseOverride != nil { - for _, repo := range r.Network.Repositories { - if repo != nil && ghrepo.IsSame(repo, r.BaseOverride) { - return repo, nil - } - } - return nil, fmt.Errorf("failed looking up information about the '%s' repository", - ghrepo.FullName(r.BaseOverride)) +func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, error) { + if r.baseOverride != nil { + return r.baseOverride, nil } - for _, repo := range r.Network.Repositories { + // if any of the remotes already has a resolution, respect that + for _, r := range r.remotes { + if r.Resolved == "base" { + return r, nil + } else if r.Resolved != "" { + repo, err := ghrepo.FromFullName(r.Resolved) + if err != nil { + return nil, err + } + return ghrepo.NewWithHost(repo.RepoOwner(), repo.RepoName(), r.RepoHost()), nil + } + } + + if !io.CanPrompt() { + // we cannot prompt, so just resort to the 1st remote + return r.remotes[0], nil + } + + // from here on, consult the API + if r.network == nil { + err := resolveNetwork(r) + if err != nil { + return nil, err + } + } + + var repoNames []string + repoMap := map[string]*api.Repository{} + add := func(r *api.Repository) { + fn := ghrepo.FullName(r) + if _, ok := repoMap[fn]; !ok { + repoMap[fn] = r + repoNames = append(repoNames, fn) + } + } + + for _, repo := range r.network.Repositories { if repo == nil { continue } if repo.IsFork() { - return repo.Parent, nil + add(repo.Parent) } - return repo, nil + add(repo) } - return nil, errors.New("not found") + if len(repoNames) == 0 { + return r.remotes[0], nil + } + + baseName := repoNames[0] + if len(repoNames) > 1 { + err := prompt.SurveyAskOne(&survey.Select{ + Message: "Which should be the base repository (used for e.g. querying issues) for this directory?", + Options: repoNames, + }, &baseName) + if err != nil { + return nil, err + } + } + + // determine corresponding git remote + selectedRepo := repoMap[baseName] + resolution := "base" + remote, _ := r.RemoteForRepo(selectedRepo) + if remote == nil { + remote = r.remotes[0] + resolution = ghrepo.FullName(selectedRepo) + } + + // cache the result to git config + err := git.SetRemoteResolution(remote.Name, resolution) + return selectedRepo, err } -// HeadRepo is a fork of base repo (if any), or the first found repository that -// has push access -func (r ResolvedRemotes) HeadRepo() (*api.Repository, error) { - baseRepo, err := r.BaseRepo() - if err != nil { - return nil, err - } - - // try to find a pushable fork among existing remotes - for _, repo := range r.Network.Repositories { - if repo != nil && repo.Parent != nil && repo.ViewerCanPush() && ghrepo.IsSame(repo.Parent, baseRepo) { - return repo, nil +func (r *ResolvedRemotes) HeadRepos() ([]*api.Repository, error) { + if r.network == nil { + err := resolveNetwork(r) + if err != nil { + return nil, err } } - // a fork might still exist on GitHub, so let's query for it - var notFound *api.NotFoundError - if repo, err := api.RepoFindFork(r.apiClient, baseRepo); err == nil { - return repo, nil - } else if !errors.As(err, ¬Found) { - return nil, err - } - - // fall back to any listed repository that has push access - for _, repo := range r.Network.Repositories { + var results []*api.Repository + for _, repo := range r.network.Repositories { if repo != nil && repo.ViewerCanPush() { - return repo, nil + results = append(results, repo) } } - return nil, errors.New("none of the repositories have push access") + return results, nil } // RemoteForRepo finds the git remote that points to a repository -func (r ResolvedRemotes) RemoteForRepo(repo ghrepo.Interface) (*Remote, error) { - for i, remote := range r.Remotes { - if ghrepo.IsSame(remote, repo) || - // additionally, look up the resolved repository name in case this - // git remote points to this repository via a redirect - (r.Network.Repositories[i] != nil && ghrepo.IsSame(r.Network.Repositories[i], repo)) { +func (r *ResolvedRemotes) RemoteForRepo(repo ghrepo.Interface) (*Remote, error) { + for _, remote := range r.remotes { + if ghrepo.IsSame(remote, repo) { return remote, nil } } return nil, errors.New("not found") } - -// New initializes a Context that reads from the filesystem -func New() Context { - return &fsContext{} -} - -// A Context implementation that queries the filesystem -type fsContext struct { - config config.Config -} - -func (c *fsContext) Config() (config.Config, error) { - if c.config == nil { - cfg, err := config.ParseDefaultConfig() - if errors.Is(err, os.ErrNotExist) { - cfg = config.NewBlankConfig() - } else if err != nil { - return nil, err - } - c.config = cfg - } - return c.config, nil -} diff --git a/context/remote_test.go b/context/remote_test.go index 98326b3aa..ab3f7e2e2 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -1,16 +1,13 @@ package context import ( - "bytes" "errors" "net/url" "reflect" "testing" - "github.com/cli/cli/api" "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/httpmock" ) func eq(t *testing.T, got interface{}, expected interface{}) { @@ -69,163 +66,3 @@ func Test_translateRemotes(t *testing.T) { t.Errorf("got %q", result[0].RepoName()) } } - -func Test_resolvedRemotes_triangularSetup(t *testing.T) { - http := &httpmock.Registry{} - apiClient := api.NewClient(api.ReplaceTripper(http)) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - - resolved := ResolvedRemotes{ - BaseOverride: nil, - Remotes: Remotes{ - &Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - &Remote{ - Remote: &git.Remote{Name: "fork"}, - Repo: ghrepo.New("MYSELF", "REPO"), - }, - }, - Network: api.RepoNetworkResult{ - Repositories: []*api.Repository{ - { - Name: "NEWNAME", - Owner: api.RepositoryOwner{Login: "NEWOWNER"}, - ViewerPermission: "READ", - }, - { - Name: "REPO", - Owner: api.RepositoryOwner{Login: "MYSELF"}, - ViewerPermission: "ADMIN", - }, - }, - }, - apiClient: apiClient, - } - - baseRepo, err := resolved.BaseRepo() - if err != nil { - t.Fatalf("got %v", err) - } - eq(t, ghrepo.FullName(baseRepo), "NEWOWNER/NEWNAME") - baseRemote, err := resolved.RemoteForRepo(baseRepo) - if err != nil { - t.Fatalf("got %v", err) - } - if baseRemote.Name != "origin" { - t.Errorf("got remote %q", baseRemote.Name) - } - - headRepo, err := resolved.HeadRepo() - if err != nil { - t.Fatalf("got %v", err) - } - eq(t, ghrepo.FullName(headRepo), "MYSELF/REPO") - headRemote, err := resolved.RemoteForRepo(headRepo) - if err != nil { - t.Fatalf("got %v", err) - } - if headRemote.Name != "fork" { - t.Errorf("got remote %q", headRemote.Name) - } -} - -func Test_resolvedRemotes_forkLookup(t *testing.T) { - http := &httpmock.Registry{} - apiClient := api.NewClient(api.ReplaceTripper(http)) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - { "id": "FORKID", - "url": "https://github.com/FORKOWNER/REPO", - "name": "REPO", - "owner": { "login": "FORKOWNER" }, - "viewerPermission": "WRITE" - } - ] } } } } - `)) - - resolved := ResolvedRemotes{ - BaseOverride: nil, - Remotes: Remotes{ - &Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - }, - Network: api.RepoNetworkResult{ - Repositories: []*api.Repository{ - { - Name: "NEWNAME", - Owner: api.RepositoryOwner{Login: "NEWOWNER"}, - ViewerPermission: "READ", - }, - }, - }, - apiClient: apiClient, - } - - headRepo, err := resolved.HeadRepo() - if err != nil { - t.Fatalf("got %v", err) - } - eq(t, ghrepo.FullName(headRepo), "FORKOWNER/REPO") - _, err = resolved.RemoteForRepo(headRepo) - if err == nil { - t.Fatal("expected to not find a matching remote") - } -} - -func Test_resolvedRemotes_clonedFork(t *testing.T) { - resolved := ResolvedRemotes{ - BaseOverride: nil, - Remotes: Remotes{ - &Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - }, - Network: api.RepoNetworkResult{ - Repositories: []*api.Repository{ - { - Name: "REPO", - Owner: api.RepositoryOwner{Login: "OWNER"}, - ViewerPermission: "ADMIN", - Parent: &api.Repository{ - Name: "REPO", - Owner: api.RepositoryOwner{Login: "PARENTOWNER"}, - ViewerPermission: "READ", - }, - }, - }, - }, - } - - baseRepo, err := resolved.BaseRepo() - if err != nil { - t.Fatalf("got %v", err) - } - eq(t, ghrepo.FullName(baseRepo), "PARENTOWNER/REPO") - baseRemote, err := resolved.RemoteForRepo(baseRepo) - if baseRemote != nil || err == nil { - t.Error("did not expect any remote for base") - } - - headRepo, err := resolved.HeadRepo() - if err != nil { - t.Fatalf("got %v", err) - } - eq(t, ghrepo.FullName(headRepo), "OWNER/REPO") - headRemote, err := resolved.RemoteForRepo(headRepo) - if err != nil { - t.Fatalf("got %v", err) - } - if headRemote.Name != "origin" { - t.Errorf("got remote %q", headRemote.Name) - } -} diff --git a/git/remote.go b/git/remote.go index e808c1492..77e550c37 100644 --- a/git/remote.go +++ b/git/remote.go @@ -1,6 +1,7 @@ package git import ( + "fmt" "net/url" "os/exec" "regexp" @@ -26,6 +27,7 @@ func NewRemote(name string, u string) *Remote { // Remote is a parsed git remote type Remote struct { Name string + Resolved string FetchURL *url.URL PushURL *url.URL } @@ -40,7 +42,30 @@ func Remotes() (RemoteSet, error) { if err != nil { return nil, err } - return parseRemotes(list), nil + remotes := parseRemotes(list) + + // this is affected by SetRemoteResolution + remoteCmd := exec.Command("git", "config", "--get-regexp", `^remote\..*\.gh-resolved$`) + output, _ := run.PrepareCmd(remoteCmd).Output() + for _, l := range outputLines(output) { + parts := strings.SplitN(l, " ", 2) + if len(parts) < 2 { + continue + } + rp := strings.SplitN(parts[0], ".", 3) + if len(rp) < 2 { + continue + } + name := rp[1] + for _, r := range remotes { + if r.Name == name { + r.Resolved = parts[1] + break + } + } + } + + return remotes, nil } func parseRemotes(gitRemotes []string) (remotes RemoteSet) { @@ -109,3 +134,8 @@ func AddRemote(name, u string) (*Remote, error) { PushURL: urlParsed, }, nil } + +func SetRemoteResolution(name, resolution string) error { + addCmd := exec.Command("git", "config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution) + return run.PrepareCmd(addCmd).Run() +} diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 1eb97ab10..f40cb9097 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -110,14 +110,14 @@ github.com: _, err := ParseConfig("config.yml") assert.Nil(t, err) - expectedMain := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled\nprompt: enabled\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n" expectedHosts := `github.com: user: keiyuri oauth_token: "123456" ` - assert.Equal(t, expectedMain, mainBuf.String()) assert.Equal(t, expectedHosts, hostsBuf.String()) + assert.NotContains(t, mainBuf.String(), "github.com") + assert.NotContains(t, mainBuf.String(), "oauth_token") } func Test_parseConfigFile(t *testing.T) { diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 740955539..2ff23e58c 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -180,6 +180,15 @@ func NewBlankRoot() *yaml.Node { Kind: yaml.ScalarNode, Value: PromptsEnabled, }, + { + HeadComment: "A pager program to send command output to. Example value: less", + Kind: yaml.ScalarNode, + Value: "pager", + }, + { + Kind: yaml.ScalarNode, + Value: "", + }, { HeadComment: "Aliases allow you to create nicknames for gh commands", Kind: yaml.ScalarNode, diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go index 6e561f634..50e2b9f5a 100644 --- a/internal/config/config_type_test.go +++ b/internal/config/config_type_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" ) @@ -19,8 +20,8 @@ func Test_fileConfig_Set(t *testing.T) { assert.NoError(t, c.Set("github.com", "user", "hubot")) assert.NoError(t, c.Write()) - expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor: nano\n# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled\nprompt: enabled\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n" - assert.Equal(t, expected, mainBuf.String()) + assert.Contains(t, mainBuf.String(), "editor: nano") + assert.Contains(t, mainBuf.String(), "git_protocol: https") assert.Equal(t, `github.com: git_protocol: ssh user: hubot @@ -37,7 +38,19 @@ func Test_defaultConfig(t *testing.T) { cfg := NewBlankConfig() assert.NoError(t, cfg.Write()) - expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled\nprompt: enabled\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n" + expected := heredoc.Doc(` + # What protocol to use when performing git operations. Supported values: ssh, https + git_protocol: https + # What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment. + editor: + # When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled + prompt: enabled + # A pager program to send command output to. Example value: less + pager: + # Aliases allow you to create nicknames for gh commands + aliases: + co: pr checkout + `) assert.Equal(t, expected, mainBuf.String()) assert.Equal(t, "", hostsBuf.String()) diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index 560cc9c5c..642dd0846 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -55,3 +55,10 @@ func RESTPrefix(hostname string) string { } return "https://api.github.com/" } + +func GistPrefix(hostname string) string { + if IsEnterprise(hostname) { + return fmt.Sprintf("https://%s/gist/", hostname) + } + return fmt.Sprintf("https://gist.%s/", hostname) +} diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 67ebca92f..c710a3b38 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -30,7 +30,7 @@ func ForOS(goos, url string) *exec.Cmd { r := strings.NewReplacer("&", "^&") args = append(args, "/c", "start", r.Replace(url)) default: - exe = "xdg-open" + exe = linuxExe() args = append(args, url) } @@ -51,3 +51,19 @@ func FromLauncher(launcher, url string) (*exec.Cmd, error) { cmd.Stderr = os.Stderr return cmd, nil } + +func linuxExe() string { + exe := "xdg-open" + + _, err := lookPath(exe) + if err != nil { + _, err := lookPath("wslview") + if err == nil { + exe = "wslview" + } + } + + return exe +} + +var lookPath = exec.LookPath diff --git a/pkg/browser/browser_test.go b/pkg/browser/browser_test.go index 749d0693e..48b91f7c1 100644 --- a/pkg/browser/browser_test.go +++ b/pkg/browser/browser_test.go @@ -1,6 +1,7 @@ package browser import ( + "errors" "reflect" "testing" ) @@ -13,6 +14,7 @@ func TestForOS(t *testing.T) { tests := []struct { name string args args + exe string want []string }{ { @@ -29,8 +31,18 @@ func TestForOS(t *testing.T) { goos: "linux", url: "https://example.com/path?a=1&b=2", }, + exe: "xdg-open", want: []string{"xdg-open", "https://example.com/path?a=1&b=2"}, }, + { + name: "WSL", + args: args{ + goos: "linux", + url: "https://example.com/path?a=1&b=2", + }, + exe: "wslview", + want: []string{"wslview", "https://example.com/path?a=1&b=2"}, + }, { name: "Windows", args: args{ @@ -41,6 +53,14 @@ func TestForOS(t *testing.T) { }, } for _, tt := range tests { + lookPath = func(file string) (string, error) { + if file == tt.exe { + return file, nil + } else { + return "", errors.New("not found") + } + } + t.Run(tt.name, func(t *testing.T) { if cmd := ForOS(tt.args.goos, tt.args.url); !reflect.DeepEqual(cmd.Args, tt.want) { t.Errorf("ForOS() = %v, want %v", cmd.Args, tt.want) diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go index b9d67c2ae..a8576db5c 100644 --- a/pkg/cmd/alias/list/list.go +++ b/pkg/cmd/alias/list/list.go @@ -69,12 +69,7 @@ func listRun(opts *ListOptions) error { sort.Strings(keys) for _, alias := range keys { - if tp.IsTTY() { - // ensure that screen readers pause - tp.AddField(alias+":", nil, nil) - } else { - tp.AddField(alias, nil, nil) - } + tp.AddField(alias+":", nil, nil) tp.AddField(aliasMap[alias], nil, nil) tp.EndRow() } diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index f051883a8..9ec4de435 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "syscall" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/ghinstance" @@ -196,6 +197,12 @@ func apiRun(opts *ApiOptions) error { headersOutputStream := opts.IO.Out if opts.Silent { opts.IO.Out = ioutil.Discard + } else { + err := opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() } host := ghinstance.OverridableDefault() @@ -265,12 +272,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream if isJSON && opts.IO.ColorEnabled() { err = jsoncolor.Write(opts.IO.Out, responseBody, " ") - if err != nil { - return - } } else { _, err = io.Copy(opts.IO.Out, responseBody) - if err != nil { + } + if err != nil { + if errors.Is(err, syscall.EPIPE) { + err = nil + } else { return } } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 630677dff..b6d2a2f99 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -24,8 +24,11 @@ type LoginOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) + Interactive bool + Hostname string Token string + Web bool } func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { @@ -58,6 +61,14 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm # => read token from mytoken.txt and authenticate against a GitHub Enterprise Server instance `), RunE: func(cmd *cobra.Command, args []string) error { + if !opts.IO.CanPrompt() && !(tokenStdin || opts.Web) { + return &cmdutil.FlagError{Err: errors.New("--web or --with-token required when not running interactively")} + } + + if tokenStdin && opts.Web { + return &cmdutil.FlagError{Err: errors.New("specify only one of --web or --with-token")} + } + if tokenStdin { defer opts.IO.In.Close() token, err := ioutil.ReadAll(opts.IO.In) @@ -67,15 +78,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm opts.Token = strings.TrimSpace(string(token)) } - if opts.Token != "" { - // Assume non-interactive if a token is specified - if opts.Hostname == "" { - opts.Hostname = ghinstance.Default() - } - } else { - if !opts.IO.CanPrompt() { - return &cmdutil.FlagError{Err: errors.New("--with-token required when not running interactively")} - } + if opts.IO.CanPrompt() && opts.Token == "" && !opts.Web { + opts.Interactive = true } if cmd.Flags().Changed("hostname") { @@ -84,6 +88,12 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm } } + if !opts.Interactive { + if opts.Hostname == "" { + opts.Hostname = ghinstance.Default() + } + } + if runF != nil { return runF(opts) } @@ -94,6 +104,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with") cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate") return cmd } @@ -160,7 +171,7 @@ func loginRun(opts *LoginOptions) error { existingToken, _ := cfg.Get(hostname, "oauth_token") - if existingToken != "" { + if existingToken != "" && opts.Interactive { err := client.ValidateHostCfg(hostname, cfg) if err == nil { apiClient, err := client.ClientFromCfg(hostname, cfg) @@ -195,15 +206,19 @@ func loginRun(opts *LoginOptions) error { } var authMode int - err = prompt.SurveyAskOne(&survey.Select{ - Message: "How would you like to authenticate?", - Options: []string{ - "Login with a web browser", - "Paste an authentication token", - }, - }, &authMode) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) + if opts.Web { + authMode = 0 + } else { + err = prompt.SurveyAskOne(&survey.Select{ + Message: "How would you like to authenticate?", + Options: []string{ + "Login with a web browser", + "Paste an authentication token", + }, + }, &authMode) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } } if authMode == 0 { @@ -239,28 +254,30 @@ func loginRun(opts *LoginOptions) error { } } - var gitProtocol string - err = prompt.SurveyAskOne(&survey.Select{ - Message: "Choose default git protocol", - Options: []string{ - "HTTPS", - "SSH", - }, - }, &gitProtocol) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) + gitProtocol := "https" + if opts.Interactive { + err = prompt.SurveyAskOne(&survey.Select{ + Message: "Choose default git protocol", + Options: []string{ + "HTTPS", + "SSH", + }, + }, &gitProtocol) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + gitProtocol = strings.ToLower(gitProtocol) + + fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h %s git_protocol %s\n", hostname, gitProtocol) + err = cfg.Set(hostname, "git_protocol", gitProtocol) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck()) } - gitProtocol = strings.ToLower(gitProtocol) - - fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h %s git_protocol %s\n", hostname, gitProtocol) - err = cfg.Set(hostname, "git_protocol", gitProtocol) - if err != nil { - return err - } - - fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck()) - apiClient, err := client.ClientFromCfg(hostname, cfg) if err != nil { return err diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 5d0ba1942..7166eac92 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -81,8 +81,9 @@ func Test_NewCmdLogin(t *testing.T) { stdinTTY: true, cli: "--hostname barry.burton", wants: LoginOptions{ - Hostname: "barry.burton", - Token: "", + Hostname: "barry.burton", + Token: "", + Interactive: true, }, }, { @@ -90,10 +91,33 @@ func Test_NewCmdLogin(t *testing.T) { stdinTTY: true, cli: "", wants: LoginOptions{ - Hostname: "", - Token: "", + Hostname: "", + Token: "", + Interactive: true, }, }, + { + name: "tty web", + stdinTTY: true, + cli: "--web", + wants: LoginOptions{ + Hostname: "github.com", + Web: true, + }, + }, + { + name: "nontty web", + cli: "--web", + wants: LoginOptions{ + Hostname: "github.com", + Web: true, + }, + }, + { + name: "web and with-token", + cli: "--web --with-token", + wantsErr: true, + }, } for _, tt := range tests { @@ -134,6 +158,8 @@ func Test_NewCmdLogin(t *testing.T) { assert.Equal(t, tt.wants.Token, gotOpts.Token) assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + assert.Equal(t, tt.wants.Web, gotOpts.Web) + assert.Equal(t, tt.wants.Interactive, gotOpts.Interactive) }) } } @@ -262,6 +288,9 @@ func Test_loginRun_Survey(t *testing.T) { }{ { name: "already authenticated", + opts: &LoginOptions{ + Interactive: true, + }, cfg: func(cfg config.Config) { _ = cfg.Set("github.com", "oauth_token", "ghi789") }, @@ -280,7 +309,8 @@ func Test_loginRun_Survey(t *testing.T) { { name: "hostname set", opts: &LoginOptions{ - Hostname: "rebecca.chambers", + Hostname: "rebecca.chambers", + Interactive: true, }, wantHosts: "rebecca.chambers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", askStubs: func(as *prompt.AskStubber) { @@ -298,6 +328,9 @@ func Test_loginRun_Survey(t *testing.T) { { name: "choose enterprise", wantHosts: "brad.vickers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + opts: &LoginOptions{ + Interactive: true, + }, askStubs: func(as *prompt.AskStubber) { as.StubOne(1) // host type enterprise as.StubOne("brad.vickers") // hostname @@ -315,6 +348,9 @@ func Test_loginRun_Survey(t *testing.T) { { name: "choose github.com", wantHosts: "github.com:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + opts: &LoginOptions{ + Interactive: true, + }, askStubs: func(as *prompt.AskStubber) { as.StubOne(0) // host type github.com as.StubOne(1) // auth mode: token @@ -325,6 +361,9 @@ func Test_loginRun_Survey(t *testing.T) { { name: "sets git_protocol", wantHosts: "github.com:\n oauth_token: def456\n git_protocol: ssh\n user: jillv\n", + opts: &LoginOptions{ + Interactive: true, + }, askStubs: func(as *prompt.AskStubber) { as.StubOne(0) // host type github.com as.StubOne(1) // auth mode: token diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index 0adc87a12..47fbbefe8 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -24,7 +24,7 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)), api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) - if token, err := cfg.Get(hostname, "oauth_token"); err == nil || token != "" { + if token, err := cfg.Get(hostname, "oauth_token"); err == nil && token != "" { return fmt.Sprintf("token %s", token), nil } return "", nil diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index e2d6975b8..8024cfc24 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -23,9 +23,10 @@ import ( type CreateOptions struct { IO *iostreams.IOStreams - Description string - Public bool - Filenames []string + Description string + Public bool + Filenames []string + FilenameOverride string HttpClient func() (*http.Client, error) } @@ -84,6 +85,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "A description for this gist") cmd.Flags().BoolVarP(&opts.Public, "public", "p", false, "List the gist publicly (default: private)") + cmd.Flags().StringVarP(&opts.FilenameOverride, "filename", "f", "", "Provide a filename to be used when reading from STDIN") return cmd } @@ -93,7 +95,7 @@ func createRun(opts *CreateOptions) error { fileArgs = []string{"-"} } - files, err := processFiles(opts.IO.In, fileArgs) + files, err := processFiles(opts.IO.In, opts.FilenameOverride, fileArgs) if err != nil { return fmt.Errorf("failed to collect files for posting: %w", err) } @@ -137,7 +139,7 @@ func createRun(opts *CreateOptions) error { return nil } -func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, error) { +func processFiles(stdin io.ReadCloser, filenameOverride string, filenames []string) (map[string]string, error) { fs := map[string]string{} if len(filenames) == 0 { @@ -149,7 +151,11 @@ func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, e var content []byte var err error if f == "-" { - filename = fmt.Sprintf("gistfile%d.txt", i) + if filenameOverride != "" { + filename = filenameOverride + } else { + filename = fmt.Sprintf("gistfile%d.txt", i) + } content, err = ioutil.ReadAll(stdin) if err != nil { return fs, fmt.Errorf("failed to read from stdin: %w", err) diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index bd57cfa2e..043b6f2d1 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -21,7 +21,7 @@ const ( func Test_processFiles(t *testing.T) { fakeStdin := strings.NewReader("hey cool how is it going") - files, err := processFiles(ioutil.NopCloser(fakeStdin), []string{"-"}) + files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"}) if err != nil { t.Fatalf("unexpected error processing files: %s", err) } diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go new file mode 100644 index 000000000..554e9363d --- /dev/null +++ b/pkg/cmd/gist/edit/edit.go @@ -0,0 +1,210 @@ +package edit + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/pkg/surveyext" + "github.com/spf13/cobra" +) + +type EditOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + + Edit func(string, string, string, *iostreams.IOStreams) (string, error) + + Selector string + Filename string +} + +func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command { + opts := EditOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Edit: func(editorCmd, filename, defaultContent string, io *iostreams.IOStreams) (string, error) { + return surveyext.Edit( + editorCmd, + "*."+filename, + defaultContent, + io.In, io.Out, io.ErrOut, nil) + }, + } + + cmd := &cobra.Command{ + Use: "edit { | }", + Short: "Edit one of your gists", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + opts.Selector = args[0] + + if runF != nil { + return runF(&opts) + } + + return editRun(&opts) + }, + } + cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "a specific file to edit") + + return cmd +} + +func editRun(opts *EditOptions) error { + gistID := opts.Selector + + u, err := url.Parse(opts.Selector) + if err == nil { + if strings.HasPrefix(u.Path, "/") { + gistID = u.Path[1:] + } + } + + client, err := opts.HttpClient() + if err != nil { + return err + } + + gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + + filesToUpdate := map[string]string{} + + for { + filename := opts.Filename + candidates := []string{} + for filename := range gist.Files { + candidates = append(candidates, filename) + } + + sort.Strings(candidates) + + if filename == "" { + if len(candidates) == 1 { + filename = candidates[0] + } else { + if !opts.IO.CanPrompt() { + return errors.New("unsure what file to edit; either specify --filename or run interactively") + } + err = prompt.SurveyAskOne(&survey.Select{ + Message: "Edit which file?", + Options: candidates, + }, &filename) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } + + if _, ok := gist.Files[filename]; !ok { + return fmt.Errorf("gist has no file %q", filename) + } + + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + text, err := opts.Edit(editorCommand, filename, gist.Files[filename].Content, opts.IO) + + if err != nil { + return err + } + + if text != gist.Files[filename].Content { + gistFile := gist.Files[filename] + gistFile.Content = text // so it appears if they re-edit + filesToUpdate[filename] = text + } + + if !opts.IO.CanPrompt() { + break + } + + if len(candidates) == 1 { + break + } + + choice := "" + + err = prompt.SurveyAskOne(&survey.Select{ + Message: "What next?", + Options: []string{ + "Edit another file", + "Submit", + "Cancel", + }, + }, &choice) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + stop := false + + switch choice { + case "Edit another file": + continue + case "Submit": + stop = true + case "Cancel": + return cmdutil.SilentError + } + + if stop { + break + } + } + + err = updateGist(client, ghinstance.OverridableDefault(), gist) + if err != nil { + return err + } + + return nil +} + +func updateGist(client *http.Client, hostname string, gist *shared.Gist) error { + body := shared.Gist{ + Description: gist.Description, + Files: gist.Files, + } + + path := "gists/" + gist.ID + + requestByte, err := json.Marshal(body) + if err != nil { + return err + } + + requestBody := bytes.NewReader(requestByte) + + result := shared.Gist{} + + apiClient := api.NewClientFromHTTP(client) + err = apiClient.REST(hostname, "POST", path, requestBody, &result) + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/gist/edit/edit_test.go b/pkg/cmd/gist/edit/edit_test.go new file mode 100644 index 000000000..d7fffd958 --- /dev/null +++ b/pkg/cmd/gist/edit/edit_test.go @@ -0,0 +1,244 @@ +package edit + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdEdit(t *testing.T) { + tests := []struct { + name string + cli string + wants EditOptions + }{ + { + name: "no flags", + cli: "123", + wants: EditOptions{ + Selector: "123", + }, + }, + { + name: "filename", + cli: "123 --filename cool.md", + wants: EditOptions{ + Selector: "123", + Filename: "cool.md", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *EditOptions + cmd := NewCmdEdit(f, func(opts *EditOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Filename, gotOpts.Filename) + assert.Equal(t, tt.wants.Selector, gotOpts.Selector) + }) + } +} + +func Test_editRun(t *testing.T) { + tests := []struct { + name string + opts *EditOptions + gist *shared.Gist + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + nontty bool + wantErr bool + wantParams map[string]interface{} + }{ + { + name: "no such gist", + wantErr: true, + }, + { + name: "one file", + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), + httpmock.StatusStringResponse(201, "{}")) + }, + wantParams: map[string]interface{}{ + "description": "", + "updated_at": "0001-01-01T00:00:00Z", + "public": false, + "files": map[string]interface{}{ + "cicada.txt": map[string]interface{}{ + "content": "new file content", + "filename": "cicada.txt", + "type": "text/plain", + }, + }, + }, + }, + { + name: "multiple files, submit", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("unix.md") + as.StubOne("Submit") + }, + gist: &shared.Gist{ + ID: "1234", + Description: "catbug", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "unix.md": { + Filename: "unix.md", + Content: "meow", + Type: "application/markdown", + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), + httpmock.StatusStringResponse(201, "{}")) + }, + wantParams: map[string]interface{}{ + "description": "catbug", + "updated_at": "0001-01-01T00:00:00Z", + "public": false, + "files": map[string]interface{}{ + "cicada.txt": map[string]interface{}{ + "content": "bwhiizzzbwhuiiizzzz", + "filename": "cicada.txt", + "type": "text/plain", + }, + "unix.md": map[string]interface{}{ + "content": "new file content", + "filename": "unix.md", + "type": "application/markdown", + }, + }, + }, + }, + { + name: "multiple files, cancel", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("unix.md") + as.StubOne("Cancel") + }, + wantErr: true, + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "unix.md": { + Filename: "unix.md", + Content: "meow", + Type: "application/markdown", + }, + }, + }, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.gist == nil { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.StatusStringResponse(404, "Not Found")) + } else { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.JSONResponse(tt.gist)) + } + + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + if tt.opts == nil { + tt.opts = &EditOptions{} + } + + tt.opts.Edit = func(_, _, _ string, _ *iostreams.IOStreams) (string, error) { + return "new file content", nil + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + io.SetStdinTTY(!tt.nontty) + tt.opts.IO = io + + tt.opts.Selector = "1234" + + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + t.Run(tt.name, func(t *testing.T) { + err := editRun(tt.opts) + reg.Verify(t) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + if tt.wantParams != nil { + bodyBytes, _ := ioutil.ReadAll(reg.Requests[1].Body) + reqBody := make(map[string]interface{}) + err = json.Unmarshal(bodyBytes, &reqBody) + if err != nil { + t.Fatalf("error decoding JSON: %v", err) + } + assert.Equal(t, tt.wantParams, reqBody) + } + }) + } +} diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go index ea10a8c40..029fab588 100644 --- a/pkg/cmd/gist/gist.go +++ b/pkg/cmd/gist/gist.go @@ -1,7 +1,11 @@ package gist import ( + "github.com/MakeNowJust/heredoc" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit" + gistListCmd "github.com/cli/cli/pkg/cmd/gist/list" + gistViewCmd "github.com/cli/cli/pkg/cmd/gist/view" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -11,9 +15,20 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command { Use: "gist", Short: "Create gists", Long: `Work with GitHub gists.`, + Annotations: map[string]string{ + "IsCore": "true", + "help:arguments": heredoc.Doc(` + A gist can be supplied as argument in either of the following formats: + - by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f + - by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f" + `), + }, } cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil)) + cmd.AddCommand(gistListCmd.NewCmdList(f, nil)) + cmd.AddCommand(gistViewCmd.NewCmdView(f, nil)) + cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil)) return cmd } diff --git a/pkg/cmd/gist/list/http.go b/pkg/cmd/gist/list/http.go new file mode 100644 index 000000000..aa75eef25 --- /dev/null +++ b/pkg/cmd/gist/list/http.go @@ -0,0 +1,46 @@ +package list + +import ( + "fmt" + "net/http" + "net/url" + "sort" + + "github.com/cli/cli/api" + "github.com/cli/cli/pkg/cmd/gist/shared" +) + +func listGists(client *http.Client, hostname string, limit int, visibility string) ([]shared.Gist, error) { + result := []shared.Gist{} + + query := url.Values{} + if visibility == "all" { + query.Add("per_page", fmt.Sprintf("%d", limit)) + } else { + query.Add("per_page", "100") + } + + // TODO switch to graphql + apiClient := api.NewClientFromHTTP(client) + err := apiClient.REST(hostname, "GET", "gists?"+query.Encode(), nil, &result) + if err != nil { + return nil, err + } + + gists := []shared.Gist{} + + for _, gist := range result { + if len(gists) == limit { + break + } + if visibility == "all" || (visibility == "secret" && !gist.Public) || (visibility == "public" && gist.Public) { + gists = append(gists, gist) + } + } + + sort.SliceStable(gists, func(i, j int) bool { + return gists[i].UpdatedAt.After(gists[j].UpdatedAt) + }) + + return gists, nil +} diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go new file mode 100644 index 000000000..e1142bdb7 --- /dev/null +++ b/pkg/cmd/gist/list/list.go @@ -0,0 +1,116 @@ +package list + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + Limit int + Visibility string // all, secret, public +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List your gists", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Limit < 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.Limit)} + } + + pub := cmd.Flags().Changed("public") + secret := cmd.Flags().Changed("secret") + + opts.Visibility = "all" + if pub && !secret { + opts.Visibility = "public" + } else if secret && !pub { + opts.Visibility = "secret" + } + + if runF != nil { + return runF(opts) + } + + return listRun(opts) + }, + } + + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 10, "Maximum number of gists to fetch") + cmd.Flags().Bool("public", false, "Show only public gists") + cmd.Flags().Bool("secret", false, "Show only secret gists") + + return cmd +} + +func listRun(opts *ListOptions) error { + client, err := opts.HttpClient() + if err != nil { + return err + } + + gists, err := listGists(client, ghinstance.OverridableDefault(), opts.Limit, opts.Visibility) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + + tp := utils.NewTablePrinter(opts.IO) + + for _, gist := range gists { + fileCount := 0 + for range gist.Files { + fileCount++ + } + + visibility := "public" + visColor := cs.Green + if !gist.Public { + visibility = "secret" + visColor = cs.Red + } + + description := gist.Description + if description == "" { + for filename := range gist.Files { + if !strings.HasPrefix(filename, "gistfile") { + description = filename + break + } + } + } + + tp.AddField(gist.ID, nil, nil) + tp.AddField(description, nil, cs.Bold) + tp.AddField(utils.Pluralize(fileCount, "file"), nil, nil) + tp.AddField(visibility, nil, visColor) + if tp.IsTTY() { + updatedAt := utils.FuzzyAgo(time.Since(gist.UpdatedAt)) + tp.AddField(updatedAt, nil, cs.Gray) + } else { + tp.AddField(gist.UpdatedAt.String(), nil, nil) + } + tp.EndRow() + } + + return tp.Render() +} diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go new file mode 100644 index 000000000..497f68edc --- /dev/null +++ b/pkg/cmd/gist/list/list_test.go @@ -0,0 +1,224 @@ +package list + +import ( + "bytes" + "net/http" + "testing" + "time" + + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants ListOptions + }{ + { + name: "no arguments", + wants: ListOptions{ + Limit: 10, + Visibility: "all", + }, + }, + { + name: "public", + cli: "--public", + wants: ListOptions{ + Limit: 10, + Visibility: "public", + }, + }, + { + name: "secret", + cli: "--secret", + wants: ListOptions{ + Limit: 10, + Visibility: "secret", + }, + }, + { + name: "public and secret", + cli: "--secret --public", + wants: ListOptions{ + Limit: 10, + Visibility: "all", + }, + }, + { + name: "limit", + cli: "--limit 30", + wants: ListOptions{ + Limit: 30, + Visibility: "all", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) + assert.Equal(t, tt.wants.Limit, gotOpts.Limit) + }) + } +} + +func Test_listRun(t *testing.T) { + tests := []struct { + name string + opts *ListOptions + wantOut string + stubs func(*httpmock.Registry) + nontty bool + updatedAt *time.Time + }{ + { + name: "no gists", + opts: &ListOptions{}, + stubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "gists"), + httpmock.JSONResponse([]shared.Gist{})) + + }, + wantOut: "", + }, + { + name: "default behavior", + opts: &ListOptions{}, + wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n4567890123 1 file public about 6 hours ago\n2345678901 tea leaves thwart... 2 files secret about 6 hours ago\n3456789012 short desc 11 files secret about 6 hours ago\n", + }, + { + name: "with public filter", + opts: &ListOptions{Visibility: "public"}, + wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n4567890123 1 file public about 6 hours ago\n", + }, + { + name: "with secret filter", + opts: &ListOptions{Visibility: "secret"}, + wantOut: "2345678901 tea leaves thwart... 2 files secret about 6 hours ago\n3456789012 short desc 11 files secret about 6 hours ago\n", + }, + { + name: "with limit", + opts: &ListOptions{Limit: 1}, + wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n", + }, + { + name: "nontty output", + opts: &ListOptions{}, + updatedAt: &time.Time{}, + wantOut: "1234567890\tcool.txt\t1 file\tpublic\t0001-01-01 00:00:00 +0000 UTC\n4567890123\t\t1 file\tpublic\t0001-01-01 00:00:00 +0000 UTC\n2345678901\ttea leaves thwart those who court catastrophe\t2 files\tsecret\t0001-01-01 00:00:00 +0000 UTC\n3456789012\tshort desc\t11 files\tsecret\t0001-01-01 00:00:00 +0000 UTC\n", + nontty: true, + }, + } + + for _, tt := range tests { + sixHoursAgo, _ := time.ParseDuration("-6h") + updatedAt := time.Now().Add(sixHoursAgo) + if tt.updatedAt != nil { + updatedAt = *tt.updatedAt + } + + reg := &httpmock.Registry{} + if tt.stubs == nil { + reg.Register(httpmock.REST("GET", "gists"), + httpmock.JSONResponse([]shared.Gist{ + { + ID: "1234567890", + UpdatedAt: updatedAt, + Description: "", + Files: map[string]*shared.GistFile{ + "cool.txt": {}, + }, + Public: true, + }, + { + ID: "4567890123", + UpdatedAt: updatedAt, + Description: "", + Files: map[string]*shared.GistFile{ + "gistfile0.txt": {}, + }, + Public: true, + }, + { + ID: "2345678901", + UpdatedAt: updatedAt, + Description: "tea leaves thwart those who court catastrophe", + Files: map[string]*shared.GistFile{ + "gistfile0.txt": {}, + "gistfile1.txt": {}, + }, + Public: false, + }, + { + ID: "3456789012", + UpdatedAt: updatedAt, + Description: "short desc", + Files: map[string]*shared.GistFile{ + "gistfile0.txt": {}, + "gistfile1.txt": {}, + "gistfile2.txt": {}, + "gistfile3.txt": {}, + "gistfile4.txt": {}, + "gistfile5.txt": {}, + "gistfile6.txt": {}, + "gistfile7.txt": {}, + "gistfile8.txt": {}, + "gistfile9.txt": {}, + "gistfile10.txt": {}, + }, + Public: false, + }, + })) + } else { + tt.stubs(reg) + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + tt.opts.IO = io + + if tt.opts.Limit == 0 { + tt.opts.Limit = 10 + } + + if tt.opts.Visibility == "" { + tt.opts.Visibility = "all" + } + t.Run(tt.name, func(t *testing.T) { + err := listRun(tt.opts) + assert.NoError(t, err) + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go new file mode 100644 index 000000000..95d8bae31 --- /dev/null +++ b/pkg/cmd/gist/shared/shared.go @@ -0,0 +1,39 @@ +package shared + +import ( + "fmt" + "net/http" + "time" + + "github.com/cli/cli/api" +) + +// TODO make gist create use this file + +type GistFile struct { + Filename string `json:"filename"` + Type string `json:"type,omitempty"` + Language string `json:"language,omitempty"` + Content string `json:"content"` +} + +type Gist struct { + ID string `json:"id,omitempty"` + Description string `json:"description"` + Files map[string]*GistFile `json:"files"` + UpdatedAt time.Time `json:"updated_at"` + Public bool `json:"public"` +} + +func GetGist(client *http.Client, hostname, gistID string) (*Gist, error) { + gist := Gist{} + path := fmt.Sprintf("gists/%s", gistID) + + apiClient := api.NewClientFromHTTP(client) + err := apiClient.REST(hostname, "GET", path, nil, &gist) + if err != nil { + return nil, err + } + + return &gist, nil +} diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go new file mode 100644 index 000000000..fecb69adb --- /dev/null +++ b/pkg/cmd/gist/view/view.go @@ -0,0 +1,135 @@ +package view + +import ( + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + Selector string + Filename string + Raw bool + Web bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "view { | }", + Short: "View a gist", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Selector = args[0] + + if !opts.IO.IsStdoutTTY() { + opts.Raw = true + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Raw, "raw", "r", false, "do not try and render markdown") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open gist in browser") + cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "display a single file of the gist") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + gistID := opts.Selector + + if opts.Web { + gistURL := gistID + if !strings.Contains(gistURL, "/") { + hostname := ghinstance.OverridableDefault() + gistURL = ghinstance.GistPrefix(hostname) + gistID + } + if opts.IO.IsStderrTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(gistURL)) + } + return utils.OpenInBrowser(gistURL) + } + + u, err := url.Parse(opts.Selector) + if err == nil { + if strings.HasPrefix(u.Path, "/") { + gistID = u.Path[1:] + } + } + + client, err := opts.HttpClient() + if err != nil { + return err + } + + gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + if gist.Description != "" { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Bold(gist.Description)) + } + + if opts.Filename != "" { + gistFile, ok := gist.Files[opts.Filename] + if !ok { + return fmt.Errorf("gist has no such file %q", opts.Filename) + } + + gist.Files = map[string]*shared.GistFile{ + opts.Filename: gistFile, + } + } + + showFilenames := len(gist.Files) > 1 + + outs := []string{} // to ensure consistent ordering + + for filename, gistFile := range gist.Files { + out := "" + if showFilenames { + out += fmt.Sprintf("%s\n\n", cs.Gray(filename)) + } + content := gistFile.Content + if strings.Contains(gistFile.Type, "markdown") && !opts.Raw { + rendered, err := utils.RenderMarkdown(gistFile.Content) + if err == nil { + content = rendered + } + } + out += fmt.Sprintf("%s\n\n", content) + + outs = append(outs, out) + } + + sort.Strings(outs) + + for _, out := range outs { + fmt.Fprint(opts.IO.Out, out) + } + + return nil +} diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go new file mode 100644 index 000000000..0ddc33181 --- /dev/null +++ b/pkg/cmd/gist/view/view_test.go @@ -0,0 +1,217 @@ +package view + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdView(t *testing.T) { + tests := []struct { + name string + cli string + wants ViewOptions + tty bool + }{ + { + name: "tty no arguments", + tty: true, + cli: "123", + wants: ViewOptions{ + Raw: false, + Selector: "123", + }, + }, + { + name: "nontty no arguments", + cli: "123", + wants: ViewOptions{ + Raw: true, + Selector: "123", + }, + }, + { + name: "filename passed", + cli: "-fcool.txt 123", + tty: true, + wants: ViewOptions{ + Raw: false, + Selector: "123", + Filename: "cool.txt", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ViewOptions + cmd := NewCmdView(f, func(opts *ViewOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Raw, gotOpts.Raw) + assert.Equal(t, tt.wants.Selector, gotOpts.Selector) + assert.Equal(t, tt.wants.Filename, gotOpts.Filename) + }) + } +} + +func Test_viewRun(t *testing.T) { + tests := []struct { + name string + opts *ViewOptions + wantOut string + gist *shared.Gist + wantErr bool + }{ + { + name: "no such gist", + wantErr: true, + }, + { + name: "one file", + gist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + }, + wantOut: "bwhiizzzbwhuiiizzzz\n\n", + }, + { + name: "filename selected", + opts: &ViewOptions{ + Filename: "cicada.txt", + }, + gist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "# foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "bwhiizzzbwhuiiizzzz\n\n", + }, + { + name: "multiple files, no description", + gist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "# foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n # foo \n\n\n\n", + }, + { + name: "multiple files, description", + gist: &shared.Gist{ + Description: "some files", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "- foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n \n • foo \n\n\n\n", + }, + { + name: "raw", + opts: &ViewOptions{ + Raw: true, + }, + gist: &shared.Gist{ + Description: "some files", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + "foo.md": { + Content: "- foo", + Type: "application/markdown", + }, + }, + }, + wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n- foo\n\n", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.gist == nil { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.StatusStringResponse(404, "Not Found")) + } else { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.JSONResponse(tt.gist)) + } + + if tt.opts == nil { + tt.opts = &ViewOptions{} + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(true) + tt.opts.IO = io + + tt.opts.Selector = "1234" + + t.Run(tt.name, func(t *testing.T) { + err := viewRun(tt.opts) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index ffa5d3365..2216f5b1d 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -191,15 +191,7 @@ func TestIssueCreate_metadata(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true, - "viewerPermission": "WRITE" - } } } - `)) + http.StubRepoInfoResponse("OWNER", "REPO", "main") http.Register( httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), httpmock.StringResponse(` diff --git a/pkg/cmd/issue/list/fixtures/issueList.json b/pkg/cmd/issue/list/fixtures/issueList.json index 4b19f3930..1f878f7a5 100644 --- a/pkg/cmd/issue/list/fixtures/issueList.json +++ b/pkg/cmd/issue/list/fixtures/issueList.json @@ -9,6 +9,7 @@ "number": 1, "title": "number won", "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", "labels": { "nodes": [ { @@ -22,6 +23,7 @@ "number": 2, "title": "number too", "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", "labels": { "nodes": [ { @@ -35,6 +37,7 @@ "number": 4, "title": "number fore", "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", "labels": { "nodes": [ { diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 1065e61be..017556af6 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -116,10 +116,16 @@ func listRun(opts *ListOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if isTerminal { hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != "" title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) - fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } issueShared.PrintIssues(opts.IO, "", len(listResult.Issues), listResult.Issues) diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 6646a10f7..ff202b6f3 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -7,8 +7,10 @@ import ( "net/http" "os/exec" "reflect" + "regexp" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" @@ -97,15 +99,19 @@ func TestIssueList_tty(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.Stderr(), ` -Showing 3 of 3 open issues in OWNER/REPO + out := output.String() + timeRE := regexp.MustCompile(`\d+ years`) + out = timeRE.ReplaceAllString(out, "X years") -`) + assert.Equal(t, heredoc.Doc(` - test.ExpectLines(t, output.String(), - "number won", - "number too", - "number fore") + Showing 3 of 3 open issues in OWNER/REPO + + #1 number won (label) about X years ago + #2 number too (label) about X years ago + #4 number fore (label) about X years ago + `), out) + assert.Equal(t, ``, output.Stderr()) } func TestIssueList_tty_withFlags(t *testing.T) { @@ -141,8 +147,8 @@ func TestIssueList_tty_withFlags(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), ` + eq(t, output.Stderr(), "") + eq(t, output.String(), ` No issues match your search in OWNER/REPO `) diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go index d1b68bc0d..42ba91823 100644 --- a/pkg/cmd/issue/status/status.go +++ b/pkg/cmd/issue/status/status.go @@ -68,6 +68,12 @@ func statusRun(opts *StatusOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + out := opts.IO.Out fmt.Fprintln(out, "") diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index aeec309a0..2795ca23b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -88,10 +88,16 @@ func viewRun(opts *ViewOptions) error { } return utils.OpenInBrowser(openURL) } + + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if opts.IO.IsStdoutTTY() { return printHumanIssuePreview(opts.IO.Out, issue) } - return printRawIssuePreview(opts.IO.Out, issue) } diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index c49fef7e0..ea9926018 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -198,11 +198,7 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { "maintainerCanModify": false } } } } `)) - http.Register(httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(` - { "data": { "repository": { - "defaultBranchRef": {"name": "master"} - } } } - `)) + http.StubRepoInfoResponse("OWNER", "REPO", "master") ranCommands := [][]string{} restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index d14be560c..3259b5561 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/context" @@ -17,6 +18,7 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/githubtemplate" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) @@ -40,6 +42,7 @@ type CreateOptions struct { Title string Body string BaseBranch string + HeadBranch string Reviewers []string Assignees []string @@ -60,13 +63,21 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "create", Short: "Create a pull request", + Long: heredoc.Doc(` + Create a pull request on GitHub. + + When the current branch isn't fully pushed to a git remote, a prompt will ask where + to push the branch and offer an option to fork the base repository. Use '--head' to + explicitly skip any forking or pushing behavior. + + A prompt will also ask for the title and the body of the pull request. Use '--title' + and '--body' to skip this, or use '--fill' to autofill these values from git commits. + `), Example: heredoc.Doc(` $ gh pr create --title "The bug is fixed" --body "Everything works again" - $ gh issue create --label "bug,help wanted" - $ gh issue create --label bug --label "help wanted" $ gh pr create --reviewer monalisa,hubot $ gh pr create --project "Roadmap" - $ gh pr create --base develop + $ gh pr create --base develop --head monalisa:feature `), Args: cmdutil.NoArgsQuoteReminder, RunE: func(cmd *cobra.Command, args []string) error { @@ -96,9 +107,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl := cmd.Flags() fl.BoolVarP(&opts.IsDraft, "draft", "d", false, "Mark pull request as a draft") - fl.StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.") - fl.StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") - fl.StringVarP(&opts.BaseBranch, "base", "B", "", "The branch into which you want your code merged") + fl.StringVarP(&opts.Title, "title", "t", "", "Title for the pull request") + fl.StringVarP(&opts.Body, "body", "b", "", "Body for the pull request") + fl.StringVarP(&opts.BaseBranch, "base", "B", "", "The `branch` into which you want your code merged") + fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default: current branch)") fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request") fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info") fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people by their `login`") @@ -127,37 +139,123 @@ func createRun(opts *CreateOptions) error { return err } - baseRepo, err := repoContext.BaseRepo() - if err != nil { + var baseRepo *api.Repository + if br, err := repoContext.BaseRepo(opts.IO); err == nil { + if r, ok := br.(*api.Repository); ok { + baseRepo = r + } else { + // TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`, + // consider piggybacking on that result instead of performing a separate lookup + var err error + baseRepo, err = api.GitHubRepo(client, br) + if err != nil { + return err + } + } + } else { return fmt.Errorf("could not determine base repository: %w", err) } - headBranch, err := opts.Branch() - if err != nil { - return fmt.Errorf("could not determine the current branch: %w", err) + isPushEnabled := false + headBranch := opts.HeadBranch + headBranchLabel := opts.HeadBranch + if headBranch == "" { + headBranch, err = opts.Branch() + if err != nil { + return fmt.Errorf("could not determine the current branch: %w", err) + } + headBranchLabel = headBranch + isPushEnabled = true + } else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 { + headBranch = headBranch[idx+1:] + } + + if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { + fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) } var headRepo ghrepo.Interface var headRemote *context.Remote - // determine whether the head branch is already pushed to a remote - headBranchPushedTo := determineTrackingBranch(remotes, headBranch) - if headBranchPushedTo != nil { - for _, r := range remotes { - if r.Name != headBranchPushedTo.RemoteName { - continue + if isPushEnabled { + // determine whether the head branch is already pushed to a remote + if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil { + isPushEnabled = false + for _, r := range remotes { + if r.Name != pushedTo.RemoteName { + continue + } + headRepo = r + headRemote = r + break } - headRepo = r - headRemote = r - break } } - // otherwise, determine the head repository with info obtained from the API - if headRepo == nil { - if r, err := repoContext.HeadRepo(); err == nil { - headRepo = r + // otherwise, ask the user for the head repository using info obtained from the API + if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() { + pushableRepos, err := repoContext.HeadRepos() + if err != nil { + return err } + + if len(pushableRepos) == 0 { + pushableRepos, err = api.RepoFindForks(client, baseRepo, 3) + if err != nil { + return err + } + } + + currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost()) + if err != nil { + return err + } + + hasOwnFork := false + var pushOptions []string + for _, r := range pushableRepos { + pushOptions = append(pushOptions, ghrepo.FullName(r)) + if r.RepoOwner() == currentLogin { + hasOwnFork = true + } + } + + if !hasOwnFork { + pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo)) + } + pushOptions = append(pushOptions, "Skip pushing the branch") + pushOptions = append(pushOptions, "Cancel") + + var selectedOption int + err = prompt.SurveyAskOne(&survey.Select{ + Message: fmt.Sprintf("Where should we push the '%s' branch?", headBranch), + Options: pushOptions, + }, &selectedOption) + if err != nil { + return err + } + + if selectedOption < len(pushableRepos) { + headRepo = pushableRepos[selectedOption] + if !ghrepo.IsSame(baseRepo, headRepo) { + headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) + } + } else if pushOptions[selectedOption] == "Skip pushing the branch" { + isPushEnabled = false + } else if pushOptions[selectedOption] == "Cancel" { + return cmdutil.SilentError + } else { + // "Create a fork of ..." + if baseRepo.IsPrivate { + return fmt.Errorf("cannot fork private repository %s", ghrepo.FullName(baseRepo)) + } + headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch) + } + } + + if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() { + fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag") + return cmdutil.SilentError } baseBranch := opts.BaseBranch @@ -168,10 +266,6 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("must be on a branch named differently than %q", baseBranch) } - if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { - fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) - } - var milestoneTitles []string if opts.Milestone != "" { milestoneTitles = []string{opts.Milestone} @@ -201,10 +295,6 @@ func createRun(opts *CreateOptions) error { } if !opts.WebMode { - headBranchLabel := headBranch - if headRepo != nil && !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) - } existingPR, err := api.PullRequestForBranch(client, baseRepo, baseBranch, headBranchLabel) var notFound *api.NotFoundError if err != nil && !errors.As(err, ¬Found) { @@ -287,10 +377,7 @@ func createRun(opts *CreateOptions) error { didForkRepo := false // if a head repository could not be determined so far, automatically create // one by forking the base repository - if headRepo == nil { - if baseRepo.IsPrivate { - return fmt.Errorf("cannot fork private repository '%s'", ghrepo.FullName(baseRepo)) - } + if headRepo == nil && isPushEnabled { headRepo, err = api.ForkRepo(client, baseRepo) if err != nil { return fmt.Errorf("error forking repo: %w", err) @@ -298,12 +385,7 @@ func createRun(opts *CreateOptions) error { didForkRepo = true } - headBranchLabel := headBranch - if !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) - } - - if headRemote == nil { + if headRemote == nil && headRepo != nil { headRemote, _ = repoContext.RemoteForRepo(headRepo) } @@ -314,7 +396,7 @@ func createRun(opts *CreateOptions) error { // // In either case, we want to add the head repo as a new git remote so we // can push to it. - if headRemote == nil { + if headRemote == nil && isPushEnabled { cfg, err := opts.Config() if err != nil { return err @@ -335,7 +417,7 @@ func createRun(opts *CreateOptions) error { } // automatically push the branch if it hasn't been pushed anywhere yet - if headBranchPushedTo == nil { + if isPushEnabled { pushTries := 0 maxPushTries := 3 for { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 1eecab69b..935675f5d 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -5,8 +5,6 @@ import ( "encoding/json" "io/ioutil" "net/http" - "os" - "path" "reflect" "strings" "testing" @@ -56,8 +54,11 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, remotes context.Remot } return context.Remotes{ { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), + Remote: &git.Remote{ + Name: "origin", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, @@ -97,31 +98,23 @@ func TestPRCreate_nontty_web(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) + http.StubRepoInfoResponse("OWNER", "REPO", "master") cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git push cs.Stub("") // browser - output, err := runCommand(http, nil, "feature", false, `--web`) + output, err := runCommand(http, nil, "feature", false, `--web --head=feature`) require.NoError(t, err) eq(t, output.String(), "") eq(t, output.Stderr(), "") - eq(t, len(cs.Calls), 6) - eq(t, strings.Join(cs.Calls[4].Args, " "), "git push --set-upstream origin HEAD:feature") - browserCall := cs.Calls[5].Args + eq(t, len(cs.Calls), 3) + browserCall := cs.Calls[2].Args eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") } @@ -144,11 +137,7 @@ func TestPRCreate_nontty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) + http.StubRepoInfoResponse("OWNER", "REPO", "master") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes" : [ ] } } } } @@ -162,16 +151,13 @@ func TestPRCreate_nontty(t *testing.T) { cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git push - output, err := runCommand(http, nil, "feature", false, `-t "my title" -b "my body"`) + output, err := runCommand(http, nil, "feature", false, `-t "my title" -b "my body" -H feature`) require.NoError(t, err) - bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) + bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) reqBody := struct { Variables struct { Input struct { @@ -199,20 +185,30 @@ func TestPRCreate(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) + http.StubRepoInfoResponse("OWNER", "REPO", "master") http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` { "data": { "repository": { "pullRequests": { "nodes" : [ ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` + `)) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12" } } } } - `)) + `, func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "my title", input["title"].(string)) + assert.Equal(t, "my body", input["body"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "feature", input["headRefName"].(string)) + })) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -223,49 +219,51 @@ func TestPRCreate(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push + ask, cleanupAsk := prompt.InitAskStubber() + defer cleanupAsk() + ask.StubOne(0) + output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body"`) require.NoError(t, err) - bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "my title") - eq(t, reqBody.Variables.Input.Body, "my body") - eq(t, reqBody.Variables.Input.BaseRefName, "master") - eq(t, reqBody.Variables.Input.HeadRefName, "feature") - - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) + assert.Equal(t, "\nCreating pull request for feature into master in OWNER/REPO\n\n", output.Stderr()) } -func TestPRCreate_nonLegacyTemplate(t *testing.T) { + +func TestPRCreate_createFork(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) + http.StubRepoInfoResponse("OWNER", "REPO", "master") http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` { "data": { "repository": { "pullRequests": { "nodes" : [ ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` + `)) + http.Register( + httpmock.REST("POST", "repos/OWNER/REPO/forks"), + httpmock.StatusStringResponse(201, ` + { "node_id": "NODEID", + "name": "REPO", + "owner": {"login": "monalisa"} + } + `)) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` { "data": { "createPullRequest": { "pullRequest": { "URL": "https://github.com/OWNER/REPO/pull/12" } } } } - `)) + `, func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "monalisa:feature", input["headRefName"].(string)) + })) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -274,8 +272,50 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { cs.Stub("") // git show-ref --verify (determineTrackingBranch) cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + cs.Stub("") // git remote add cs.Stub("") // git push + ask, cleanupAsk := prompt.InitAskStubber() + defer cleanupAsk() + ask.StubOne(1) + + output, err := runCommand(http, nil, "feature", true, `-t title -b body`) + require.NoError(t, err) + + assert.Equal(t, []string{"git", "remote", "add", "-f", "fork", "https://github.com/monalisa/REPO.git"}, cs.Calls[4].Args) + assert.Equal(t, []string{"git", "push", "--set-upstream", "fork", "HEAD:feature"}, cs.Calls[5].Args) + + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) +} + +func TestPRCreate_nonLegacyTemplate(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.StubRepoInfoResponse("OWNER", "REPO", "master") + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } } + `)) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `, func(input map[string]interface{}) { + assert.Equal(t, "my title", input["title"].(string)) + assert.Equal(t, "- commit 1\n- commit 0\n\nFixes a bug and Closes an issue", input["body"].(string)) + })) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git status + cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + as, teardown := prompt.InitAskStubber() defer teardown() as.Stub([]*prompt.QuestionStub{ @@ -297,29 +337,9 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { }, }) - output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title"`, "./fixtures/repoWithNonLegacyPRTemplates") + output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title" -H feature`, "./fixtures/repoWithNonLegacyPRTemplates") require.NoError(t, err) - bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "my title") - eq(t, reqBody.Variables.Input.Body, "- commit 1\n- commit 0\n\nFixes a bug and Closes an issue") - eq(t, reqBody.Variables.Input.BaseRefName, "master") - eq(t, reqBody.Variables.Input.HeadRefName, "feature") - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } @@ -327,15 +347,7 @@ func TestPRCreate_metadata(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query RepositoryNetwork\b`), - httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) - http.Register( - httpmock.GraphQL(`query RepositoryFindFork\b`), - httpmock.StringResponse(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) + http.StubRepoInfoResponse("OWNER", "REPO", "master") http.Register( httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(` @@ -434,71 +446,20 @@ func TestPRCreate_metadata(t *testing.T) { cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git push - output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) + output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -H feature -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) eq(t, err, nil) eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } -func TestPRCreate_withForking(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponseWithPermission("OWNER", "REPO", "READ") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "node_id": "NODEID", - "name": "REPO", - "owner": {"login": "myself"}, - "clone_url": "http://example.com", - "created_at": "2008-02-25T20:21:40Z" - } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git remote add - cs.Stub("") // git push - - output, err := runCommand(http, nil, "feature", true, `-t title -b body`) - require.NoError(t, err) - - eq(t, http.Requests[3].URL.Path, "/repos/OWNER/REPO/forks") - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") -} - func TestPRCreate_alreadyExists(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) + http.StubRepoInfoResponse("OWNER", "REPO", "master") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -515,7 +476,7 @@ func TestPRCreate_alreadyExists(t *testing.T) { cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - _, err := runCommand(http, nil, "feature", true, ``) + _, err := runCommand(http, nil, "feature", true, `-H feature`) if err == nil { t.Fatal("error expected, got nil") } @@ -524,48 +485,15 @@ func TestPRCreate_alreadyExists(t *testing.T) { } } -func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString("{}")) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git rev-parse - - _, err := runCommand(http, nil, "feature", true, `-BanotherBase -t"cool" -b"nah"`) - if err != nil { - t.Errorf("got unexpected error %q", err) - } -} - func TestPRCreate_web(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) + http.StubRepoInfoResponse("OWNER", "REPO", "master") http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -577,6 +505,10 @@ func TestPRCreate_web(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser + ask, cleanupAsk := prompt.InitAskStubber() + defer cleanupAsk() + ask.StubOne(0) + output, err := runCommand(http, nil, "feature", true, `--web`) require.NoError(t, err) @@ -589,552 +521,6 @@ func TestPRCreate_web(t *testing.T) { eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") } -func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub(" M git/git.go") // git status - cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git push - - output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body"`) - eq(t, err, nil) - - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") - test.ExpectLines(t, output.Stderr(), `Warning: 1 uncommitted change`, `Creating pull request for.*feature.*into.*master.*in OWNER/REPO`) -} - -func TestPRCreate_cross_repo_same_branch(t *testing.T) { - remotes := context.Remotes{ - { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), - }, - { - Remote: &git.Remote{Name: "fork"}, - Repo: ghrepo.New("MYSELF", "REPO"), - }, - } - - http := initFakeHTTP() - defer http.Verify(t) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repo_000": { - "id": "REPOID0", - "name": "REPO", - "owner": {"login": "OWNER"}, - "defaultBranchRef": { - "name": "default" - }, - "viewerPermission": "READ" - }, - "repo_001" : { - "parent": { - "id": "REPOID0", - "name": "REPO", - "owner": {"login": "OWNER"}, - "defaultBranchRef": { - "name": "default" - }, - "viewerPermission": "READ" - }, - "id": "REPOID1", - "name": "REPO", - "owner": {"login": "MYSELF"}, - "defaultBranchRef": { - "name": "default" - }, - "viewerPermission": "WRITE" - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git push - - output, err := runCommand(http, remotes, "default", true, `-t "cross repo" -b "same branch"`) - require.NoError(t, err) - - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID0") - eq(t, reqBody.Variables.Input.Title, "cross repo") - eq(t, reqBody.Variables.Input.Body, "same branch") - eq(t, reqBody.Variables.Input.BaseRefName, "default") - eq(t, reqBody.Variables.Input.HeadRefName, "MYSELF:default") - - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") - - // goal: only care that gql is formatted properly -} - -func TestPRCreate_survey_defaults_multicommit(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - cs.Stub("") // git rev-parse - cs.Stub("") // git push - - as, surveyTeardown := prompt.InitAskStubber() - defer surveyTeardown() - - as.Stub([]*prompt.QuestionStub{ - { - Name: "title", - Default: true, - }, - { - Name: "body", - Default: true, - }, - }) - as.Stub([]*prompt.QuestionStub{ - { - Name: "confirmation", - Value: 0, - }, - }) - - output, err := runCommand(http, nil, "cool_bug-fixes", true, ``) - require.NoError(t, err) - - bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - expectedBody := "- commit 1\n- commit 0\n" - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "cool bug fixes") - eq(t, reqBody.Variables.Input.Body, expectedBody) - eq(t, reqBody.Variables.Input.BaseRefName, "master") - eq(t, reqBody.Variables.Input.HeadRefName, "cool_bug-fixes") - - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") -} - -func TestPRCreate_survey_defaults_monocommit(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.Register(httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) - http.Register(httpmock.GraphQL(`query RepositoryFindFork\b`), httpmock.StringResponse(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.Register(httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `, func(inputs map[string]interface{}) { - eq(t, inputs["repositoryId"], "REPOID") - eq(t, inputs["title"], "the sky above the port") - eq(t, inputs["body"], "was the color of a television, turned to a dead channel") - eq(t, inputs["baseRefName"], "master") - eq(t, inputs["headRefName"], "feature") - })) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,the sky above the port") // git log - cs.Stub("was the color of a television, turned to a dead channel") // git show - cs.Stub("") // git rev-parse - cs.Stub("") // git push - - as, surveyTeardown := prompt.InitAskStubber() - defer surveyTeardown() - - as.Stub([]*prompt.QuestionStub{ - { - Name: "title", - Default: true, - }, - { - Name: "body", - Default: true, - }, - }) - as.Stub([]*prompt.QuestionStub{ - { - Name: "confirmation", - Value: 0, - }, - }) - - output, err := runCommand(http, nil, "feature", true, ``) - eq(t, err, nil) - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") -} - -func TestPRCreate_survey_defaults_monocommit_template(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.Register(httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) - http.Register(httpmock.GraphQL(`query RepositoryFindFork\b`), httpmock.StringResponse(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.Register(httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `, func(inputs map[string]interface{}) { - eq(t, inputs["repositoryId"], "REPOID") - eq(t, inputs["title"], "the sky above the port") - eq(t, inputs["body"], "was the color of a television\n\n... turned to a dead channel") - eq(t, inputs["baseRefName"], "master") - eq(t, inputs["headRefName"], "feature") - })) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - tmpdir, err := ioutil.TempDir("", "gh-cli") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tmpdir) - - templateFp := path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE.md") - _ = os.MkdirAll(path.Dir(templateFp), 0700) - _ = ioutil.WriteFile(templateFp, []byte("... turned to a dead channel"), 0700) - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,the sky above the port") // git log - cs.Stub("was the color of a television") // git show - cs.Stub(tmpdir) // git rev-parse - cs.Stub("") // git push - - as, surveyTeardown := prompt.InitAskStubber() - defer surveyTeardown() - - as.Stub([]*prompt.QuestionStub{ - { - Name: "title", - Default: true, - }, - { - Name: "body", - Default: true, - }, - }) - as.Stub([]*prompt.QuestionStub{ - { - Name: "confirmation", - Value: 0, - }, - }) - - output, err := runCommand(http, nil, "feature", true, ``) - eq(t, err, nil) - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") -} - -func TestPRCreate_survey_autofill_nontty(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,the sky above the port") // git log - cs.Stub("was the color of a television, turned to a dead channel") // git show - cs.Stub("") // git rev-parse - cs.Stub("") // git push - cs.Stub("") // browser open - - output, err := runCommand(http, nil, "feature", false, `-f`) - require.NoError(t, err) - - bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - expectedBody := "was the color of a television, turned to a dead channel" - - assert.Equal(t, "REPOID", reqBody.Variables.Input.RepositoryID) - assert.Equal(t, "the sky above the port", reqBody.Variables.Input.Title) - assert.Equal(t, expectedBody, reqBody.Variables.Input.Body) - assert.Equal(t, "master", reqBody.Variables.Input.BaseRefName) - assert.Equal(t, "feature", reqBody.Variables.Input.HeadRefName) - - assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) - - assert.Equal(t, "", output.Stderr()) -} - -func TestPRCreate_survey_autofill(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("1234567890,the sky above the port") // git log - cs.Stub("was the color of a television, turned to a dead channel") // git show - cs.Stub("") // git rev-parse - cs.Stub("") // git push - cs.Stub("") // browser open - - output, err := runCommand(http, nil, "feature", true, `-f`) - require.NoError(t, err) - - bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - expectedBody := "was the color of a television, turned to a dead channel" - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "the sky above the port") - eq(t, reqBody.Variables.Input.Body, expectedBody) - eq(t, reqBody.Variables.Input.BaseRefName, "master") - eq(t, reqBody.Variables.Input.HeadRefName, "feature") - - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") -} - -func TestPRCreate_defaults_error_autofill(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("") // git log - - _, err := runCommand(http, nil, "feature", true, "-f") - - eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature") -} - -func TestPRCreate_defaults_error_web(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("") // git log - - _, err := runCommand(http, nil, "feature", true, "-w") - - eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature") -} - -func TestPRCreate_defaults_error_interactive(t *testing.T) { - http := initFakeHTTP() - defer http.Verify(t) - - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp (determineTrackingBranch) - cs.Stub("") // git show-ref --verify (determineTrackingBranch) - cs.Stub("") // git status - cs.Stub("") // git log - cs.Stub("") // git rev-parse - cs.Stub("") // git push - cs.Stub("") // browser open - - as, surveyTeardown := prompt.InitAskStubber() - defer surveyTeardown() - - as.Stub([]*prompt.QuestionStub{ - { - Name: "title", - Default: true, - }, - { - Name: "body", - Value: "social distancing", - }, - }) - as.Stub([]*prompt.QuestionStub{ - { - Name: "confirmation", - Value: 1, - }, - }) - - output, err := runCommand(http, nil, "feature", true, ``) - require.NoError(t, err) - - stderr := string(output.Stderr()) - eq(t, strings.Contains(stderr, "warning: could not compute title or body defaults: could not find any commits"), true) -} - func Test_determineTrackingBranch_empty(t *testing.T) { cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index c6902e5b5..2bea8072b 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -6,9 +6,8 @@ import ( "fmt" "io" "net/http" - "os" - "os/exec" "strings" + "syscall" "github.com/cli/cli/api" "github.com/cli/cli/context" @@ -16,7 +15,6 @@ import ( "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" - "github.com/google/shlex" "github.com/spf13/cobra" ) @@ -93,15 +91,18 @@ func diffRun(opts *DiffOptions) error { } defer diff.Close() - if opts.UseColor == "never" { - _, err = io.Copy(opts.IO.Out, diff) + err = opts.IO.StartPager() + if err != nil { return err } + defer opts.IO.StopPager() - if opts.IO.IsStdoutTTY() { - if pager := os.Getenv("PAGER"); pager != "" { - return runPager(pager, diff, opts.IO.Out) + if opts.UseColor == "never" { + _, err = io.Copy(opts.IO.Out, diff) + if errors.Is(err, syscall.EPIPE) { + return nil } + return err } diffLines := bufio.NewScanner(diff) @@ -148,14 +149,3 @@ func isRemovalLine(dl string) bool { func validColorFlag(c string) bool { return c == "auto" || c == "always" || c == "never" } - -var runPager = func(pager string, diff io.Reader, out io.Writer) error { - args, err := shlex.Split(pager) - if err != nil { - return err - } - pagerCmd := exec.Command(args[0], args[1:]...) - pagerCmd.Stdin = diff - pagerCmd.Stdout = out - return pagerCmd.Run() -} diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index dc93970b6..5e362ac4d 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -2,10 +2,8 @@ package diff import ( "bytes" - "io" "io/ioutil" "net/http" - "os" "testing" "github.com/cli/cli/context" @@ -214,13 +212,8 @@ func TestPRDiff_notty(t *testing.T) { } func TestPRDiff_tty(t *testing.T) { - pager := os.Getenv("PAGER") http := &httpmock.Registry{} - defer func() { - os.Setenv("PAGER", pager) - http.Verify(t) - }() - os.Setenv("PAGER", "") + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -237,38 +230,6 @@ func TestPRDiff_tty(t *testing.T) { assert.Contains(t, output.String(), "\x1b[32m+site: bin/gh\x1b[m") } -func TestPRDiff_pager(t *testing.T) { - realRunPager := runPager - pager := os.Getenv("PAGER") - http := &httpmock.Registry{} - defer func() { - runPager = realRunPager - os.Setenv("PAGER", pager) - http.Verify(t) - }() - runPager = func(pager string, diff io.Reader, out io.Writer) error { - _, err := io.Copy(out, diff) - return err - } - os.Setenv("PAGER", "fakepager") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`)) - http.StubResponse(200, bytes.NewBufferString(testDiff)) - output, err := runCommand(http, nil, true, "") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - if diff := cmp.Diff(testDiff, output.String()); diff != "" { - t.Errorf("command output did not match:\n%s", diff) - } -} - const testDiff = `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 73974448..b7fc0154 100644 --- a/.github/workflows/releases.yml diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index fd1ebb2b9..e4fe318c8 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -133,10 +133,16 @@ func listRun(opts *ListOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if opts.IO.IsStdoutTTY() { hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.BaseBranch != "" || opts.Assignee != "" title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, hasFilters) - fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } table := utils.NewTablePrinter(opts.IO) diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 5faf42b57..ab99e4aa9 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -6,10 +6,10 @@ import ( "net/http" "os/exec" "reflect" - "regexp" "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmdutil" @@ -76,23 +76,15 @@ func TestPRList(t *testing.T) { t.Fatal(err) } - assert.Equal(t, ` -Showing 3 of 3 open pull requests in OWNER/REPO + assert.Equal(t, heredoc.Doc(` -`, output.Stderr()) - - lines := strings.Split(output.String(), "\n") - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } - } + Showing 3 of 3 open pull requests in OWNER/REPO + + #32 New feature feature + #29 Fixed bad bug hubot:bug-fix + #28 Improve documentation docs + `), output.String()) + assert.Equal(t, ``, output.Stderr()) } func TestPRList_nontty(t *testing.T) { @@ -130,8 +122,8 @@ func TestPRList_filtering(t *testing.T) { t.Fatal(err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), ` + eq(t, output.Stderr(), "") + eq(t, output.String(), ` No pull requests match your search in OWNER/REPO `) @@ -150,19 +142,12 @@ func TestPRList_filteringRemoveDuplicate(t *testing.T) { t.Fatal(err) } - lines := strings.Split(output.String(), "\n") - - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } + out := output.String() + idx := strings.Index(out, "New feature") + if idx < 0 { + t.Fatalf("text %q not found in %q", "New feature", out) } + assert.Equal(t, idx, strings.LastIndex(out, "New feature")) } func TestPRList_filteringClosed(t *testing.T) { diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index dffd2b412..2c012d712 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -97,6 +97,12 @@ func statusRun(opts *StatusOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + out := opts.IO.Out fmt.Fprintln(out, "") diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index c42048043..a1a15ec42 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -99,6 +99,12 @@ func viewRun(opts *ViewOptions) error { return utils.OpenInBrowser(openURL) } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if connectedToTerminal { return printHumanPrPreview(opts.IO.Out, pr) } diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 1fd99c035..303119f36 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -322,14 +322,7 @@ func TestRepoCreate_template(t *testing.T) { } } } }`)) - reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { - "repository": { - "id": "REPOID", - "description": "DESCRIPTION" - } } }`)) + reg.StubRepoInfoResponse("OWNER", "REPO", "main") reg.Register( httpmock.GraphQL(`query UserCurrent\b`), diff --git a/pkg/cmd/repo/garden/garden.go b/pkg/cmd/repo/garden/garden.go new file mode 100644 index 000000000..46761b253 --- /dev/null +++ b/pkg/cmd/repo/garden/garden.go @@ -0,0 +1,480 @@ +package garden + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type Geometry struct { + Width int + Height int + Density float64 + Repository ghrepo.Interface +} + +type Player struct { + X int + Y int + Char string + Geo *Geometry + ShoeMoistureContent int +} + +type Commit struct { + Email string + Handle string + Sha string + Char string +} + +type Cell struct { + Char string + StatusLine string +} + +const ( + DirUp = iota + DirDown + DirLeft + DirRight +) + +type Direction = int + +func (p *Player) move(direction Direction) bool { + switch direction { + case DirUp: + if p.Y == 0 { + return false + } + p.Y-- + case DirDown: + if p.Y == p.Geo.Height-1 { + return false + } + p.Y++ + case DirLeft: + if p.X == 0 { + return false + } + p.X-- + case DirRight: + if p.X == p.Geo.Width-1 { + return false + } + p.X++ + } + + return true +} + +type GardenOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RepoArg string +} + +func NewCmdGarden(f *cmdutil.Factory, runF func(*GardenOptions) error) *cobra.Command { + opts := GardenOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + BaseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "garden []", + Short: "Explore a git repository as a garden", + Long: "Use arrow keys, WASD or vi keys to move. q to quit.", + Hidden: true, + RunE: func(c *cobra.Command, args []string) error { + if len(args) > 0 { + opts.RepoArg = args[0] + } + if runF != nil { + return runF(&opts) + } + return gardenRun(&opts) + }, + } + + return cmd +} + +func gardenRun(opts *GardenOptions) error { + out := opts.IO.Out + + if runtime.GOOS == "windows" { + return errors.New("sorry :( this command only works on linux and macos") + } + + if !opts.IO.IsStdoutTTY() { + return errors.New("must be connected to a terminal") + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + var toView ghrepo.Interface + apiClient := api.NewClientFromHTTP(httpClient) + if opts.RepoArg == "" { + var err error + toView, err = opts.BaseRepo() + if err != nil { + return err + } + } else { + var err error + viewURL := opts.RepoArg + if !strings.Contains(viewURL, "/") { + currentUser, err := api.CurrentLoginName(apiClient, ghinstance.Default()) + if err != nil { + return err + } + viewURL = currentUser + "/" + viewURL + } + toView, err = ghrepo.FromFullName(viewURL) + if err != nil { + return fmt.Errorf("argument error: %w", err) + } + } + + seed := computeSeed(ghrepo.FullName(toView)) + rand.Seed(seed) + + termWidth, termHeight, err := utils.TerminalSize(out) + if err != nil { + return err + } + + termWidth -= 10 + termHeight -= 10 + + geo := &Geometry{ + Width: termWidth, + Height: termHeight, + Repository: toView, + // TODO based on number of commits/cells instead of just hardcoding + Density: 0.3, + } + + maxCommits := (geo.Width * geo.Height) / 2 + + opts.IO.StartProgressIndicator() + fmt.Fprintln(out, "gathering commits; this could take a minute...") + commits, err := getCommits(httpClient, toView, maxCommits) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + player := &Player{0, 0, utils.Bold("@"), geo, 0} + + garden := plantGarden(commits, geo) + clear(opts.IO) + drawGarden(out, garden, player) + + // thanks stackoverflow https://stackoverflow.com/a/17278776 + if runtime.GOOS == "darwin" { + _ = exec.Command("stty", "-f", "/dev/tty", "cbreak", "min", "1").Run() + _ = exec.Command("stty", "-f", "/dev/tty", "-echo").Run() + } else { + _ = exec.Command("stty", "-F", "/dev/tty", "cbreak", "min", "1").Run() + _ = exec.Command("stty", "-F", "/dev/tty", "-echo").Run() + } + + var b []byte = make([]byte, 3) + for { + _, _ = opts.IO.In.Read(b) + + oldX := player.X + oldY := player.Y + moved := false + quitting := false + continuing := false + + switch { + case isLeft(b): + moved = player.move(DirLeft) + case isRight(b): + moved = player.move(DirRight) + case isUp(b): + moved = player.move(DirUp) + case isDown(b): + moved = player.move(DirDown) + case isQuit(b): + quitting = true + default: + continuing = true + } + + if quitting { + break + } + + if !moved || continuing { + continue + } + + underPlayer := garden[player.Y][player.X] + previousCell := garden[oldY][oldX] + + // print whatever was just under player + + fmt.Fprint(out, "\033[;H") // move to top left + for x := 0; x < oldX && x < player.Geo.Width; x++ { + fmt.Fprint(out, "\033[C") + } + for y := 0; y < oldY && y < player.Geo.Height; y++ { + fmt.Fprint(out, "\033[B") + } + fmt.Fprint(out, previousCell.Char) + + // print player character + fmt.Fprint(out, "\033[;H") // move to top left + for x := 0; x < player.X && x < player.Geo.Width; x++ { + fmt.Fprint(out, "\033[C") + } + for y := 0; y < player.Y && y < player.Geo.Height; y++ { + fmt.Fprint(out, "\033[B") + } + fmt.Fprint(out, player.Char) + + // handle stream wettening + + if strings.Contains(underPlayer.StatusLine, "stream") { + player.ShoeMoistureContent = 5 + } else { + if player.ShoeMoistureContent > 0 { + player.ShoeMoistureContent-- + } + } + + // status line stuff + sl := statusLine(garden, player) + + fmt.Fprint(out, "\033[;H") // move to top left + for y := 0; y < player.Geo.Height-1; y++ { + fmt.Fprint(out, "\033[B") + } + fmt.Fprintln(out) + fmt.Fprintln(out) + + fmt.Fprint(out, utils.Bold(sl)) + } + + clear(opts.IO) + fmt.Fprint(out, "\033[?25h") + fmt.Fprintln(out) + fmt.Fprintln(out, utils.Bold("You turn and walk away from the wildflower garden...")) + + return nil +} + +func isLeft(b []byte) bool { + left := []byte{27, 91, 68} + r := rune(b[0]) + return bytes.EqualFold(b, left) || r == 'a' || r == 'h' +} + +func isRight(b []byte) bool { + right := []byte{27, 91, 67} + r := rune(b[0]) + return bytes.EqualFold(b, right) || r == 'd' || r == 'l' +} + +func isDown(b []byte) bool { + down := []byte{27, 91, 66} + r := rune(b[0]) + return bytes.EqualFold(b, down) || r == 's' || r == 'j' +} + +func isUp(b []byte) bool { + up := []byte{27, 91, 65} + r := rune(b[0]) + return bytes.EqualFold(b, up) || r == 'w' || r == 'k' +} + +func isQuit(b []byte) bool { + return rune(b[0]) == 'q' +} + +func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell { + cellIx := 0 + grassCell := &Cell{RGB(0, 200, 0, ","), "You're standing on a patch of grass in a field of wildflowers."} + garden := [][]*Cell{} + streamIx := rand.Intn(geo.Width - 1) + if streamIx == geo.Width/2 { + streamIx-- + } + tint := 0 + for y := 0; y < geo.Height; y++ { + if cellIx == len(commits)-1 { + break + } + garden = append(garden, []*Cell{}) + for x := 0; x < geo.Width; x++ { + if (y > 0 && (x == 0 || x == geo.Width-1)) || y == geo.Height-1 { + garden[y] = append(garden[y], &Cell{ + Char: RGB(0, 150, 0, "^"), + StatusLine: "You're standing under a tall, leafy tree.", + }) + continue + } + if x == streamIx { + garden[y] = append(garden[y], &Cell{ + Char: RGB(tint, tint, 255, "#"), + StatusLine: "You're standing in a shallow stream. It's refreshing.", + }) + tint += 15 + streamIx-- + if rand.Float64() < 0.5 { + streamIx++ + } + if streamIx < 0 { + streamIx = 0 + } + if streamIx > geo.Width { + streamIx = geo.Width + } + continue + } + if y == 0 && (x < geo.Width/2 || x > geo.Width/2) { + garden[y] = append(garden[y], &Cell{ + Char: RGB(0, 200, 0, ","), + StatusLine: "You're standing by a wildflower garden. There is a light breeze.", + }) + continue + } else if y == 0 && x == geo.Width/2 { + garden[y] = append(garden[y], &Cell{ + Char: RGB(139, 69, 19, "+"), + StatusLine: fmt.Sprintf("You're standing in front of a weather-beaten sign that says %s.", ghrepo.FullName(geo.Repository)), + }) + continue + } + + if cellIx == len(commits)-1 { + garden[y] = append(garden[y], grassCell) + continue + } + + chance := rand.Float64() + if chance <= geo.Density { + commit := commits[cellIx] + garden[y] = append(garden[y], &Cell{ + Char: commits[cellIx].Char, + StatusLine: fmt.Sprintf("You're standing at a flower called %s planted by %s.", commit.Sha[0:6], commit.Handle), + }) + cellIx++ + } else { + garden[y] = append(garden[y], grassCell) + } + } + } + + return garden +} + +func drawGarden(out io.Writer, garden [][]*Cell, player *Player) { + fmt.Fprint(out, "\033[?25l") // hide cursor. it needs to be restored at command exit. + sl := "" + for y, gardenRow := range garden { + for x, gardenCell := range gardenRow { + char := "" + underPlayer := (player.X == x && player.Y == y) + if underPlayer { + sl = gardenCell.StatusLine + char = utils.Bold(player.Char) + + if strings.Contains(gardenCell.StatusLine, "stream") { + player.ShoeMoistureContent = 5 + } + } else { + char = gardenCell.Char + } + + fmt.Fprint(out, char) + } + fmt.Fprintln(out) + } + + fmt.Println() + fmt.Fprintln(out, utils.Bold(sl)) +} + +func statusLine(garden [][]*Cell, player *Player) string { + statusLine := garden[player.Y][player.X].StatusLine + " " + if player.ShoeMoistureContent > 1 { + statusLine += "\nYour shoes squish with water from the stream." + } else if player.ShoeMoistureContent == 1 { + statusLine += "\nYour shoes seem to have dried out." + } else { + statusLine += "\n " + } + + return statusLine +} + +func shaToColorFunc(sha string) func(string) string { + return func(c string) string { + red, err := strconv.ParseInt(sha[0:2], 16, 64) + if err != nil { + panic(err) + } + + green, err := strconv.ParseInt(sha[2:4], 16, 64) + if err != nil { + panic(err) + } + + blue, err := strconv.ParseInt(sha[4:6], 16, 64) + if err != nil { + panic(err) + } + + return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", red, green, blue, c) + } +} + +func computeSeed(seed string) int64 { + lol := "" + + for _, r := range seed { + lol += fmt.Sprintf("%d", int(r)) + } + + result, err := strconv.ParseInt(lol[0:10], 10, 64) + if err != nil { + panic(err) + } + + return result +} + +func clear(io *iostreams.IOStreams) { + cmd := exec.Command("clear") + cmd.Stdout = io.Out + _ = cmd.Run() +} + +func RGB(r, g, b int, x string) string { + return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, x) +} diff --git a/pkg/cmd/repo/garden/http.go b/pkg/cmd/repo/garden/http.go new file mode 100644 index 000000000..ff7f47fe0 --- /dev/null +++ b/pkg/cmd/repo/garden/http.go @@ -0,0 +1,105 @@ +package garden + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" +) + +func getCommits(client *http.Client, repo ghrepo.Interface, maxCommits int) ([]*Commit, error) { + type Item struct { + Author struct { + Login string + } + Sha string + } + + type Result []Item + + commits := []*Commit{} + + pathF := func(page int) string { + return fmt.Sprintf("repos/%s/%s/commits?per_page=100&page=%d", repo.RepoOwner(), repo.RepoName(), page) + } + + page := 1 + paginating := true + for paginating { + if len(commits) >= maxCommits { + break + } + result := Result{} + resp, err := getResponse(client, pathF(page), &result) + if err != nil { + return nil, err + } + for _, r := range result { + colorFunc := shaToColorFunc(r.Sha) + handle := r.Author.Login + if handle == "" { + handle = "a mysterious stranger" + } + commits = append(commits, &Commit{ + Handle: handle, + Sha: r.Sha, + Char: colorFunc(string(handle[0])), + }) + } + link := resp.Header["Link"] + if !strings.Contains(link[0], "last") { + paginating = false + } + page++ + time.Sleep(500) + } + + // reverse to get older commits first + for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 { + commits[i], commits[j] = commits[j], commits[i] + } + + return commits, nil +} + +func getResponse(client *http.Client, path string, data interface{}) (*http.Response, error) { + url := ghinstance.RESTPrefix(ghinstance.OverridableDefault()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return nil, errors.New("api call failed") + } + + if resp.StatusCode == http.StatusNoContent { + return resp, nil + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(b, &data) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 551c96641..02e6c368b 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -6,6 +6,7 @@ import ( repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create" creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork" + gardenCmd "github.com/cli/cli/pkg/cmd/repo/garden" repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -36,6 +37,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoCloneCmd.NewCmdClone(f, nil)) cmd.AddCommand(repoCreateCmd.NewCmdCreate(f, nil)) cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) + cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil)) return cmd } diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index 7896d1d71..95da1ad0c 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -1,9 +1,11 @@ package view import ( + "errors" "fmt" "net/http" "strings" + "syscall" "text/template" "github.com/MakeNowJust/heredoc" @@ -107,6 +109,12 @@ func viewRun(opts *ViewOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + stdout := opts.IO.Out if !opts.IO.IsStdoutTTY() { @@ -166,7 +174,7 @@ func viewRun(opts *ViewOptions) error { } err = tmpl.Execute(stdout, repoData) - if err != nil { + if err != nil && !errors.Is(err, syscall.EPIPE) { return err } diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go index 626ff62ed..55e8ace6b 100644 --- a/pkg/cmd/repo/view/view_test.go +++ b/pkg/cmd/repo/view/view_test.go @@ -104,9 +104,7 @@ func Test_RepoView_Web(t *testing.T) { for _, tt := range tests { reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(`{}`)) + reg.StubRepoInfoResponse("OWNER", "REPO", "main") opts := &ViewOptions{ Web: true, diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index 019ec53cd..400be3834 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -139,7 +139,7 @@ func rootHelpFunc(command *cobra.Command, args []string) { helpEntries = append(helpEntries, helpEntry{"ENVIRONMENT VARIABLES", command.Annotations["help:environment"]}) } helpEntries = append(helpEntries, helpEntry{"LEARN MORE", ` -Use "gh --help" for more information about a command. +Use 'gh --help' for more information about a command. Read the manual at https://cli.github.com/manual`}) if _, ok := command.Annotations["help:feedback"]; ok { helpEntries = append(helpEntries, helpEntry{"FEEDBACK", command.Annotations["help:feedback"]}) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go new file mode 100644 index 000000000..aacdfc5e5 --- /dev/null +++ b/pkg/cmd/root/help_topic.go @@ -0,0 +1,65 @@ +package root + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +func NewHelpTopic(topic string) *cobra.Command { + topicContent := make(map[string]string) + + topicContent["environment"] = heredoc.Doc(` + GITHUB_TOKEN: an authentication token for github.com API requests. Setting this avoids + being prompted to authenticate and takes precedence over previously stored credentials. + + GITHUB_ENTERPRISE_TOKEN: an authentication token for API requests to GitHub Enterprise. + + GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands + that otherwise operate on a local repository. + + GH_HOST: specify the GitHub hostname for commands that would otherwise assume + the "github.com" host when not in a context of an existing repository. + + GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use + for authoring text. + + BROWSER: the web browser to use for opening links. + + DEBUG: set to any value to enable verbose output to standard error. Include values "api" + or "oauth" to print detailed information about HTTP requests or authentication flow. + + PAGER: a terminal paging program to send standard output to, e.g. "less". + + GLAMOUR_STYLE: the style to use for rendering Markdown. See + https://github.com/charmbracelet/glamour#styles + + NO_COLOR: set to any value to avoid printing ANSI escape sequences for color output. + + CLICOLOR: set to "0" to disable printing ANSI colors in output. + + CLICOLOR_FORCE: set to a value other than "0" to keep ANSI colors in output + even when the output is piped. + `) + + cmd := &cobra.Command{ + Use: topic, + Long: topicContent[topic], + Hidden: true, + Args: cobra.NoArgs, + Run: helpTopicHelpFunc, + } + + cmd.SetHelpFunc(helpTopicHelpFunc) + cmd.SetUsageFunc(helpTopicUsageFunc) + + return cmd +} + +func helpTopicHelpFunc(command *cobra.Command, args []string) { + command.Print(command.Long) +} + +func helpTopicUsageFunc(command *cobra.Command) error { + command.Printf("Usage: gh help %s", command.Use) + return nil +} diff --git a/pkg/cmd/root/help_topic_test.go b/pkg/cmd/root/help_topic_test.go new file mode 100644 index 000000000..f194541ac --- /dev/null +++ b/pkg/cmd/root/help_topic_test.go @@ -0,0 +1,79 @@ +package root + +import ( + "testing" + + "github.com/cli/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +func TestNewHelpTopic(t *testing.T) { + tests := []struct { + name string + topic string + args []string + flags []string + wantsErr bool + }{ + { + name: "valid topic", + topic: "environment", + args: []string{}, + flags: []string{}, + wantsErr: false, + }, + { + name: "invalid topic", + topic: "invalid", + args: []string{}, + flags: []string{}, + wantsErr: false, + }, + { + name: "more than zero args", + topic: "environment", + args: []string{"invalid"}, + flags: []string{}, + wantsErr: true, + }, + { + name: "more than zero flags", + topic: "environment", + args: []string{}, + flags: []string{"--invalid"}, + wantsErr: true, + }, + { + name: "help arg", + topic: "environment", + args: []string{"help"}, + flags: []string{}, + wantsErr: true, + }, + { + name: "help flag", + topic: "environment", + args: []string{}, + flags: []string{"--help"}, + wantsErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, stdout, stderr := iostreams.Test() + + cmd := NewHelpTopic(tt.topic) + cmd.SetArgs(append(tt.args, tt.flags...)) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + _, err := cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 0eadbd575..334ee418d 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -41,32 +41,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { `), Annotations: map[string]string{ "help:feedback": heredoc.Doc(` - Open an issue using “gh issue create -R cli/cli” + Open an issue using 'gh issue create -R cli/cli' `), "help:environment": heredoc.Doc(` - GITHUB_TOKEN: an authentication token for github.com API requests. Setting this avoids - being prompted to authenticate and takes precedence over previously stored credentials. - - GITHUB_ENTERPRISE_TOKEN: an authentication token for API requests to GitHub Enterprise. - - GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands - that otherwise operate on a local repository. - - GH_HOST: specify the GitHub hostname for commands that would otherwise assume - the "github.com" host when not in a context of an existing repository. - - GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use - for authoring text. - - BROWSER: the web browser to use for opening links. - - DEBUG: set to any value to enable verbose output to standard error. Include values "api" - or "oauth" to print detailed information about HTTP requests or authentication flow. - - GLAMOUR_STYLE: the style to use for rendering Markdown. See - https://github.com/charmbracelet/glamour#styles - - NO_COLOR: avoid printing ANSI escape sequences for color output. + See 'gh help environment' for the list of supported environment variables. `), }, } @@ -104,8 +82,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmdutil.DisableAuthCheck(cmd) - // CHILD COMMANDS - + // Child commands cmd.AddCommand(aliasCmd.NewCmdAlias(f)) cmd.AddCommand(authCmd.NewCmdAuth(f)) cmd.AddCommand(configCmd.NewCmdConfig(f)) @@ -113,6 +90,9 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(gistCmd.NewCmdGist(f)) cmd.AddCommand(NewCmdCompletion(f.IOStreams)) + // Help topics + cmd.AddCommand(NewHelpTopic("environment")) + // the `api` command should not inherit any extra HTTP headers bareHTTPCmdFactory := *f bareHTTPCmdFactory.HttpClient = func() (*http.Client, error) { @@ -154,7 +134,7 @@ func resolvedBaseRepo(f *cmdutil.Factory) func() (ghrepo.Interface, error) { if err != nil { return nil, err } - baseRepo, err := repoContext.BaseRepo() + baseRepo, err := repoContext.BaseRepo(f.IOStreams) if err != nil { return nil, err } diff --git a/pkg/cmdutil/repo_override.go b/pkg/cmdutil/repo_override.go index 8b3d36489..aede57ea6 100644 --- a/pkg/cmdutil/repo_override.go +++ b/pkg/cmdutil/repo_override.go @@ -8,7 +8,7 @@ import ( ) func EnableRepoOverride(cmd *cobra.Command, f *Factory) { - cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `[HOST/]OWNER/REPO` format") + cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { repoOverride, _ := cmd.Flags().GetString("repo") diff --git a/pkg/httpmock/legacy.go b/pkg/httpmock/legacy.go index 0d42005d6..9b5d5afef 100644 --- a/pkg/httpmock/legacy.go +++ b/pkg/httpmock/legacy.go @@ -48,6 +48,22 @@ func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() { } } +func (r *Registry) StubRepoInfoResponse(owner, repo, branch string) { + r.Register( + GraphQL(`query RepositoryInfo\b`), + StringResponse(fmt.Sprintf(` + { "data": { "repository": { + "id": "REPOID", + "name": "%s", + "owner": {"login": "%s"}, + "description": "", + "defaultBranchRef": {"name": "%s"}, + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } } + `, repo, owner, branch))) +} + func (r *Registry) StubRepoResponse(owner, repo string) { r.StubRepoResponseWithPermission(owner, repo, "WRITE") } diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 6bbc429c5..2efe93c8b 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -1,6 +1,12 @@ package iostreams -import "github.com/mgutz/ansi" +import ( + "fmt" + "os" + "strings" + + "github.com/mgutz/ansi" +) var ( magenta = ansi.ColorFunc("magenta") @@ -11,14 +17,42 @@ var ( green = ansi.ColorFunc("green") gray = ansi.ColorFunc("black+h") bold = ansi.ColorFunc("default+b") + + gray256 = func(t string) string { + return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t) + } ) -func NewColorScheme(enabled bool) *ColorScheme { - return &ColorScheme{enabled: enabled} +func EnvColorDisabled() bool { + return os.Getenv("NO_COLOR") != "" || os.Getenv("CLICOLOR") == "0" +} + +func EnvColorForced() bool { + return os.Getenv("CLICOLOR_FORCE") != "" && os.Getenv("CLICOLOR_FORCE") != "0" +} + +func Is256ColorSupported() bool { + term := os.Getenv("TERM") + colorterm := os.Getenv("COLORTERM") + + return strings.Contains(term, "256") || + strings.Contains(term, "24bit") || + strings.Contains(term, "truecolor") || + strings.Contains(colorterm, "256") || + strings.Contains(colorterm, "24bit") || + strings.Contains(colorterm, "truecolor") +} + +func NewColorScheme(enabled, is256enabled bool) *ColorScheme { + return &ColorScheme{ + enabled: enabled, + is256enabled: is256enabled, + } } type ColorScheme struct { - enabled bool + enabled bool + is256enabled bool } func (c *ColorScheme) Bold(t string) string { @@ -53,6 +87,9 @@ func (c *ColorScheme) Gray(t string) string { if !c.enabled { return t } + if c.is256enabled { + return gray256(t) + } return gray(t) } diff --git a/pkg/iostreams/color_test.go b/pkg/iostreams/color_test.go new file mode 100644 index 000000000..90b3ae024 --- /dev/null +++ b/pkg/iostreams/color_test.go @@ -0,0 +1,145 @@ +package iostreams + +import ( + "os" + "testing" +) + +func TestEnvColorDisabled(t *testing.T) { + orig_NO_COLOR := os.Getenv("NO_COLOR") + orig_CLICOLOR := os.Getenv("CLICOLOR") + orig_CLICOLOR_FORCE := os.Getenv("CLICOLOR_FORCE") + t.Cleanup(func() { + os.Setenv("NO_COLOR", orig_NO_COLOR) + os.Setenv("CLICOLOR", orig_CLICOLOR) + os.Setenv("CLICOLOR_FORCE", orig_CLICOLOR_FORCE) + }) + + tests := []struct { + name string + NO_COLOR string + CLICOLOR string + CLICOLOR_FORCE string + want bool + }{ + { + name: "pristine env", + NO_COLOR: "", + CLICOLOR: "", + CLICOLOR_FORCE: "", + want: false, + }, + { + name: "NO_COLOR enabled", + NO_COLOR: "1", + CLICOLOR: "", + CLICOLOR_FORCE: "", + want: true, + }, + { + name: "CLICOLOR disabled", + NO_COLOR: "", + CLICOLOR: "0", + CLICOLOR_FORCE: "", + want: true, + }, + { + name: "CLICOLOR enabled", + NO_COLOR: "", + CLICOLOR: "1", + CLICOLOR_FORCE: "", + want: false, + }, + { + name: "CLICOLOR_FORCE has no effect", + NO_COLOR: "", + CLICOLOR: "", + CLICOLOR_FORCE: "1", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("NO_COLOR", tt.NO_COLOR) + os.Setenv("CLICOLOR", tt.CLICOLOR) + os.Setenv("CLICOLOR_FORCE", tt.CLICOLOR_FORCE) + + if got := EnvColorDisabled(); got != tt.want { + t.Errorf("EnvColorDisabled(): want %v, got %v", tt.want, got) + } + }) + } +} + +func TestEnvColorForced(t *testing.T) { + orig_NO_COLOR := os.Getenv("NO_COLOR") + orig_CLICOLOR := os.Getenv("CLICOLOR") + orig_CLICOLOR_FORCE := os.Getenv("CLICOLOR_FORCE") + t.Cleanup(func() { + os.Setenv("NO_COLOR", orig_NO_COLOR) + os.Setenv("CLICOLOR", orig_CLICOLOR) + os.Setenv("CLICOLOR_FORCE", orig_CLICOLOR_FORCE) + }) + + tests := []struct { + name string + NO_COLOR string + CLICOLOR string + CLICOLOR_FORCE string + want bool + }{ + { + name: "pristine env", + NO_COLOR: "", + CLICOLOR: "", + CLICOLOR_FORCE: "", + want: false, + }, + { + name: "NO_COLOR enabled", + NO_COLOR: "1", + CLICOLOR: "", + CLICOLOR_FORCE: "", + want: false, + }, + { + name: "CLICOLOR disabled", + NO_COLOR: "", + CLICOLOR: "0", + CLICOLOR_FORCE: "", + want: false, + }, + { + name: "CLICOLOR enabled", + NO_COLOR: "", + CLICOLOR: "1", + CLICOLOR_FORCE: "", + want: false, + }, + { + name: "CLICOLOR_FORCE enabled", + NO_COLOR: "", + CLICOLOR: "", + CLICOLOR_FORCE: "1", + want: true, + }, + { + name: "CLICOLOR_FORCE disabled", + NO_COLOR: "", + CLICOLOR: "", + CLICOLOR_FORCE: "0", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("NO_COLOR", tt.NO_COLOR) + os.Setenv("CLICOLOR", tt.CLICOLOR) + os.Setenv("CLICOLOR_FORCE", tt.CLICOLOR_FORCE) + + if got := EnvColorForced(); got != tt.want { + t.Errorf("EnvColorForced(): want %v, got %v", tt.want, got) + } + }) + } +} diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 9a0ce4ff8..968f06e06 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -12,6 +12,7 @@ import ( "time" "github.com/briandowns/spinner" + "github.com/google/shlex" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "golang.org/x/crypto/ssh/terminal" @@ -25,6 +26,7 @@ type IOStreams struct { // the original (non-colorable) output stream originalOut io.Writer colorEnabled bool + is256enabled bool progressIndicatorEnabled bool progressIndicator *spinner.Spinner @@ -36,6 +38,9 @@ type IOStreams struct { stderrTTYOverride bool stderrIsTTY bool + pagerCommand string + pagerProcess *os.Process + neverPrompt bool } @@ -43,6 +48,10 @@ func (s *IOStreams) ColorEnabled() bool { return s.colorEnabled } +func (s *IOStreams) ColorSupport256() bool { + return s.is256enabled +} + func (s *IOStreams) SetStdinTTY(isTTY bool) { s.stdinTTYOverride = true s.stdinIsTTY = isTTY @@ -88,6 +97,60 @@ func (s *IOStreams) IsStderrTTY() bool { return false } +func (s *IOStreams) SetPager(cmd string) { + s.pagerCommand = cmd +} + +func (s *IOStreams) StartPager() error { + if s.pagerCommand == "" || !s.IsStdoutTTY() { + return nil + } + + pagerArgs, err := shlex.Split(s.pagerCommand) + if err != nil { + return err + } + + pagerEnv := os.Environ() + for i := len(pagerEnv) - 1; i >= 0; i-- { + if strings.HasPrefix(pagerEnv[i], "PAGER=") { + pagerEnv = append(pagerEnv[0:i], pagerEnv[i+1:]...) + } + } + if _, ok := os.LookupEnv("LESS"); !ok { + pagerEnv = append(pagerEnv, "LESS=FRX") + } + if _, ok := os.LookupEnv("LV"); !ok { + pagerEnv = append(pagerEnv, "LV=-c") + } + + pagerCmd := exec.Command(pagerArgs[0], pagerArgs[1:]...) + pagerCmd.Env = pagerEnv + pagerCmd.Stdout = s.Out + pagerCmd.Stderr = s.ErrOut + pagedOut, err := pagerCmd.StdinPipe() + if err != nil { + return err + } + s.Out = pagedOut + err = pagerCmd.Start() + if err != nil { + return err + } + s.pagerProcess = pagerCmd.Process + return nil +} + +func (s *IOStreams) StopPager() { + if s.pagerProcess == nil { + return + } + + s.Out.(io.ReadCloser).Close() + _, _ = s.pagerProcess.Wait() + s.pagerProcess = nil +} + func (s *IOStreams) CanPrompt() bool { if s.neverPrompt { return false @@ -142,7 +205,7 @@ func (s *IOStreams) TerminalWidth() int { } func (s *IOStreams) ColorScheme() *ColorScheme { - return NewColorScheme(s.ColorEnabled()) + return NewColorScheme(s.ColorEnabled(), s.ColorSupport256()) } func System() *IOStreams { @@ -154,7 +217,9 @@ func System() *IOStreams { originalOut: os.Stdout, Out: colorable.NewColorable(os.Stdout), ErrOut: colorable.NewColorable(os.Stderr), - colorEnabled: os.Getenv("NO_COLOR") == "" && stdoutIsTTY, + colorEnabled: EnvColorForced() || (!EnvColorDisabled() && stdoutIsTTY), + is256enabled: Is256ColorSupported(), + pagerCommand: os.Getenv("PAGER"), } if stdoutIsTTY && stderrIsTTY { diff --git a/utils/color.go b/utils/color.go index 123e0927c..3e875acc4 100644 --- a/utils/color.go +++ b/utils/color.go @@ -1,9 +1,11 @@ package utils import ( + "fmt" "io" "os" + "github.com/cli/cli/pkg/iostreams" "github.com/mattn/go-colorable" "github.com/mgutz/ansi" ) @@ -32,6 +34,9 @@ func makeColorFunc(color string) func(string) string { cf := ansi.ColorFunc(color) return func(arg string) string { if isColorEnabled() { + if color == "black+h" && iostreams.Is256ColorSupported() { + return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, arg) + } return cf(arg) } return arg @@ -39,7 +44,11 @@ func makeColorFunc(color string) func(string) string { } func isColorEnabled() bool { - if os.Getenv("NO_COLOR") != "" { + if iostreams.EnvColorForced() { + return true + } + + if iostreams.EnvColorDisabled() { return false }