Merge branch 'trunk' into readme-updates
This commit is contained in:
commit
2570f13768
61 changed files with 3004 additions and 1319 deletions
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
28
.github/ISSUE_TEMPLATE/feedback.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/feedback.md
vendored
Normal file
|
|
@ -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"_
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
210
pkg/cmd/gist/edit/edit.go
Normal file
210
pkg/cmd/gist/edit/edit.go
Normal file
|
|
@ -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 {<gist ID> | <gist URL>}",
|
||||
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
|
||||
}
|
||||
244
pkg/cmd/gist/edit/edit_test.go
Normal file
244
pkg/cmd/gist/edit/edit_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
46
pkg/cmd/gist/list/http.go
Normal file
46
pkg/cmd/gist/list/http.go
Normal file
|
|
@ -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
|
||||
}
|
||||
116
pkg/cmd/gist/list/list.go
Normal file
116
pkg/cmd/gist/list/list.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
224
pkg/cmd/gist/list/list_test.go
Normal file
224
pkg/cmd/gist/list/list_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
39
pkg/cmd/gist/shared/shared.go
Normal file
39
pkg/cmd/gist/shared/shared.go
Normal file
|
|
@ -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
|
||||
}
|
||||
135
pkg/cmd/gist/view/view.go
Normal file
135
pkg/cmd/gist/view/view.go
Normal file
|
|
@ -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 {<gist id> | <gist url>}",
|
||||
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
|
||||
}
|
||||
217
pkg/cmd/gist/view/view_test.go
Normal file
217
pkg/cmd/gist/view/view_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
480
pkg/cmd/repo/garden/garden.go
Normal file
480
pkg/cmd/repo/garden/garden.go
Normal file
|
|
@ -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 [<repository>]",
|
||||
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)
|
||||
}
|
||||
105
pkg/cmd/repo/garden/http.go
Normal file
105
pkg/cmd/repo/garden/http.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <command> <subcommand> --help" for more information about a command.
|
||||
Use 'gh <command> <subcommand> --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"]})
|
||||
|
|
|
|||
65
pkg/cmd/root/help_topic.go
Normal file
65
pkg/cmd/root/help_topic.go
Normal file
|
|
@ -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
|
||||
}
|
||||
79
pkg/cmd/root/help_topic_test.go
Normal file
79
pkg/cmd/root/help_topic_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
145
pkg/iostreams/color_test.go
Normal file
145
pkg/iostreams/color_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue