Merge branch 'trunk' into gh-cache-command

This commit is contained in:
Josh Kraft 2023-05-17 14:34:10 -06:00 committed by GitHub
commit 2e5a728338
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1901 additions and 586 deletions

View file

@ -3,6 +3,7 @@ package alias
import (
"github.com/MakeNowJust/heredoc"
deleteCmd "github.com/cli/cli/v2/pkg/cmd/alias/delete"
importCmd "github.com/cli/cli/v2/pkg/cmd/alias/imports"
listCmd "github.com/cli/cli/v2/pkg/cmd/alias/list"
setCmd "github.com/cli/cli/v2/pkg/cmd/alias/set"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -23,6 +24,7 @@ func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil))
cmd.AddCommand(importCmd.NewCmdImport(f, nil))
cmd.AddCommand(listCmd.NewCmdList(f, nil))
cmd.AddCommand(setCmd.NewCmdSet(f, nil))

View file

@ -0,0 +1,195 @@
package imports
import (
"fmt"
"sort"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
type ImportOptions struct {
Config func() (config.Config, error)
IO *iostreams.IOStreams
Filename string
OverwriteExisting bool
existingCommand func(string) bool
}
func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command {
opts := &ImportOptions{
IO: f.IOStreams,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "import [<filename> | -]",
Short: "Import aliases from a YAML file",
Long: heredoc.Doc(`
Import aliases from the contents of a YAML file.
Aliases should be defined as a map in YAML, where the keys represent aliases and
the values represent the corresponding expansions. An example file should look like
the following:
bugs: issue list --label=bug
igrep: '!gh issue list --label="$1" | grep "$2"'
features: |-
issue list
--label=enhancement
Use "-" to read aliases (in YAML format) from standard input.
The output from the gh command "alias list" can be used to produce a YAML file
containing your aliases, which you can use to import them from one machine to
another. Run "gh help alias list" to learn more.
`),
Example: heredoc.Doc(`
# Import aliases from a file
$ gh alias import aliases.yml
# Import aliases from standard input
$ gh alias import -
`),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return cmdutil.FlagErrorf("too many arguments")
}
if len(args) == 0 && opts.IO.IsStdinTTY() {
return cmdutil.FlagErrorf("no filename passed and nothing on STDIN")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
opts.Filename = "-"
if len(args) > 0 {
opts.Filename = args[0]
}
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
if runF != nil {
return runF(opts)
}
return importRun(opts)
},
}
cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing aliases of the same name")
return cmd
}
func importRun(opts *ImportOptions) error {
cs := opts.IO.ColorScheme()
cfg, err := opts.Config()
if err != nil {
return err
}
aliasCfg := cfg.Aliases()
b, err := cmdutil.ReadFile(opts.Filename, opts.IO.In)
if err != nil {
return err
}
aliasMap := map[string]string{}
if err = yaml.Unmarshal(b, &aliasMap); err != nil {
return err
}
isTerminal := opts.IO.IsStdoutTTY()
if isTerminal {
if opts.Filename == "-" {
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from standard input\n")
} else {
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from file %q\n", opts.Filename)
}
}
var msg strings.Builder
for _, alias := range getSortedKeys(aliasMap) {
if opts.existingCommand(alias) {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: already a gh command\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
continue
}
expansion := aliasMap[alias]
if !(strings.HasPrefix(expansion, "!") || opts.existingCommand(expansion)) {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
continue
}
if _, err := aliasCfg.Get(alias); err == nil {
if opts.OverwriteExisting {
aliasCfg.Add(alias, expansion)
msg.WriteString(
fmt.Sprintf("%s Changed alias %s\n",
cs.WarningIcon(),
cs.Bold(alias),
),
)
} else {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: name already taken\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
}
} else {
aliasCfg.Add(alias, expansion)
msg.WriteString(
fmt.Sprintf("%s Added alias %s\n",
cs.SuccessIcon(),
cs.Bold(alias),
),
)
}
}
if err := cfg.Write(); err != nil {
return err
}
if isTerminal {
fmt.Fprintln(opts.IO.ErrOut, msg.String())
}
return nil
}
func getSortedKeys(m map[string]string) []string {
keys := []string{}
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View file

@ -0,0 +1,346 @@
package imports
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdImport(t *testing.T) {
tests := []struct {
name string
cli string
tty bool
wants ImportOptions
wantsError string
}{
{
name: "no filename and stdin tty",
cli: "",
tty: true,
wants: ImportOptions{
Filename: "",
OverwriteExisting: false,
},
wantsError: "no filename passed and nothing on STDIN",
},
{
name: "no filename and stdin is not tty",
cli: "",
tty: false,
wants: ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
},
{
name: "stdin arg",
cli: "-",
wants: ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
},
{
name: "multiple filenames",
cli: "aliases1 aliases2",
wants: ImportOptions{
Filename: "aliases1 aliases2",
OverwriteExisting: false,
},
wantsError: "too many arguments",
},
{
name: "clobber flag",
cli: "aliases --clobber",
wants: ImportOptions{
Filename: "aliases",
OverwriteExisting: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
f := &cmdutil.Factory{IOStreams: ios}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ImportOptions
cmd := NewCmdImport(f, func(opts *ImportOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_, err = cmd.ExecuteC()
if tt.wantsError != "" {
assert.EqualError(t, err, tt.wantsError)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Filename, gotOpts.Filename)
assert.Equal(t, tt.wants.OverwriteExisting, gotOpts.OverwriteExisting)
})
}
}
func TestImportRun(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "aliases.yml")
importFileMsg := fmt.Sprintf(`- Importing aliases from file %q`, tmpFile)
importStdinMsg := "- Importing aliases from standard input"
tests := []struct {
name string
opts *ImportOptions
stdin string
fileContents string
initConfig string
wantConfig string
wantStderr string
}{
{
name: "with no existing aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias igrep\n\n",
},
{
name: "with existing aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
users: |-
api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'
co: pr checkout
`),
initConfig: heredoc.Doc(`
aliases:
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantConfig: heredoc.Doc(`
aliases:
igrep: '!gh issue list --label="$1" | grep "$2"'
co: pr checkout
users: |-
api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'
editor: vim
`),
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias users\n\n",
},
{
name: "from stdin",
opts: &ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
stdin: heredoc.Doc(`
co: pr checkout
features: |-
issue list
--label=enhancement
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
features: |-
issue list
--label=enhancement
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantStderr: importStdinMsg + "\n✓ Added alias co\n✓ Added alias features\n✓ Added alias igrep\n\n",
},
{
name: "already taken aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
initConfig: heredoc.Doc(`
aliases:
co: pr checkout
editor: vim
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantStderr: importFileMsg + "\nX Could not import alias co: name already taken\n✓ Added alias igrep\n\n",
},
{
name: "override aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: true,
},
fileContents: heredoc.Doc(`
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
initConfig: heredoc.Doc(`
aliases:
co: pr checkout
editor: vim
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantStderr: importFileMsg + "\n! Changed alias co\n✓ Added alias igrep\n\n",
},
{
name: "alias is a gh command",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
pr: pr checkout
issue: issue list
api: api graphql
`),
wantStderr: strings.Join(
[]string{
importFileMsg,
"X Could not import alias api: already a gh command",
"X Could not import alias issue: already a gh command",
"X Could not import alias pr: already a gh command\n\n",
},
"\n",
),
},
{
name: "invalid expansion",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
alias1:
alias2: ps checkout
`),
wantStderr: strings.Join(
[]string{
importFileMsg,
"X Could not import alias alias1: expansion does not correspond to a gh command",
"X Could not import alias alias2: expansion does not correspond to a gh command\n\n",
},
"\n",
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.fileContents != "" {
err := os.WriteFile(tmpFile, []byte(tt.fileContents), 0600)
require.NoError(t, err)
}
ios, stdin, _, stderr := iostreams.Test()
ios.SetStdinTTY(true)
ios.SetStdoutTTY(true)
ios.SetStderrTTY(true)
tt.opts.IO = ios
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(tt.initConfig)
tt.opts.Config = func() (config.Config, error) {
return cfg, nil
}
// Create fake command factory for testing.
f := &cmdutil.Factory{
IOStreams: ios,
ExtensionManager: &extensions.ExtensionManagerMock{
ListFunc: func() []extensions.Extension {
return []extensions.Extension{}
},
},
}
// Create fake command structure for testing.
rootCmd := &cobra.Command{}
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
prCmd.AddCommand(&cobra.Command{Use: "status"})
rootCmd.AddCommand(prCmd)
issueCmd := &cobra.Command{Use: "issue"}
issueCmd.AddCommand(&cobra.Command{Use: "list"})
rootCmd.AddCommand(issueCmd)
apiCmd := &cobra.Command{Use: "api"}
apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
rootCmd.AddCommand(apiCmd)
tt.opts.existingCommand = shared.ExistingCommandFunc(f, rootCmd)
if tt.stdin != "" {
stdin.WriteString(tt.stdin)
}
err := importRun(tt.opts)
require.NoError(t, err)
configOut := bytes.Buffer{}
readConfigs(&configOut, io.Discard)
assert.Equal(t, tt.wantStderr, stderr.String())
assert.Equal(t, tt.wantConfig, configOut.String())
})
}
}

View file

@ -7,9 +7,9 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
@ -21,7 +21,7 @@ type SetOptions struct {
Expansion string
IsShell bool
validCommand func(string) bool
existingCommand func(string) bool
}
func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
@ -70,29 +70,12 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
opts.Name = args[0]
opts.Expansion = args[1]
opts.validCommand = func(args string) bool {
split, err := shlex.Split(args)
if err != nil {
return false
}
rootCmd := cmd.Root()
cmd, _, err := rootCmd.Traverse(split)
if err == nil && cmd != rootCmd {
return true
}
for _, ext := range f.ExtensionManager.List() {
if ext.Name() == split[0] {
return true
}
}
return false
}
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
if runF != nil {
return runF(opts)
}
return setRun(opts)
},
}
@ -127,11 +110,11 @@ func setRun(opts *SetOptions) error {
}
isShell = strings.HasPrefix(expansion, "!")
if opts.validCommand(opts.Name) {
if opts.existingCommand(opts.Name) {
return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
}
if !isShell && !opts.validCommand(expansion) {
if !isShell && !opts.existingCommand(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
}

View file

@ -38,7 +38,7 @@ func runCommand(cfg config.Config, isTTY bool, cli string, in string) (*test.Cmd
cmd := NewCmdSet(factory, nil)
// fake command nesting structure needed for validCommand
// Create fake command structure for testing.
rootCmd := &cobra.Command{}
rootCmd.AddCommand(cmd)
prCmd := &cobra.Command{Use: "pr"}

View file

@ -0,0 +1,32 @@
package shared
import (
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
// ExistingCommandFunc returns a function that will check if the given string
// corresponds to an existing command.
func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bool {
return func(args string) bool {
split, err := shlex.Split(args)
if err != nil || len(split) == 0 {
return false
}
rootCmd := cmd.Root()
cmd, _, err = rootCmd.Traverse(split)
if err == nil && cmd != rootCmd {
return true
}
for _, ext := range f.ExtensionManager.List() {
if ext.Name() == split[0] {
return true
}
}
return false
}
}

View file

@ -0,0 +1,40 @@
package shared
import (
"testing"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func TestExistingCommandFunc(t *testing.T) {
// Create fake command factory for testing.
factory := &cmdutil.Factory{
ExtensionManager: &extensions.ExtensionManagerMock{
ListFunc: func() []extensions.Extension {
return []extensions.Extension{}
},
},
}
// Create fake command structure for testing.
issueCmd := &cobra.Command{Use: "issue"}
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
cmd := &cobra.Command{}
cmd.AddCommand(prCmd)
cmd.AddCommand(issueCmd)
f := ExistingCommandFunc(factory, cmd)
assert.True(t, f("pr"))
assert.True(t, f("pr checkout"))
assert.True(t, f("issue"))
assert.False(t, f("ps"))
assert.False(t, f("checkout"))
assert.False(t, f("repo list"))
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/AlecAivazis/survey/v2"
@ -176,7 +177,8 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
progressLabel = "Deleting codespaces"
}
return a.RunWithProgress(progressLabel, func() error {
var deletedCodespaces uint32
err = a.RunWithProgress(progressLabel, func() error {
var g errgroup.Group
for _, c := range codespacesToDelete {
codespaceName := c.Name
@ -185,15 +187,23 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
a.errLogger.Printf("error deleting codespace %q: %v\n", codespaceName, err)
return err
}
atomic.AddUint32(&deletedCodespaces, 1)
return nil
})
}
if err := g.Wait(); err != nil {
return errors.New("some codespaces failed to delete")
return fmt.Errorf("%d codespace(s) failed to delete", len(codespacesToDelete)-int(deletedCodespaces))
}
return nil
})
if a.io.IsStdoutTTY() && deletedCodespaces > 0 {
successMsg := fmt.Sprintf("%d codespace(s) deleted successfully\n", deletedCodespaces)
fmt.Fprint(a.io.ErrOut, successMsg)
}
return err
}
func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool) (bool, error) {

View file

@ -26,7 +26,7 @@ func TestDelete(t *testing.T) {
codespaces []*api.Codespace
confirms map[string]bool
deleteErr error
wantErr bool
wantErr string
wantDeleted []string
wantStdout string
wantStderr string
@ -42,7 +42,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"hubot-robawt-abc"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "by repo",
@ -70,7 +70,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "unused",
@ -93,7 +93,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "deletion failed",
@ -109,12 +109,13 @@ func TestDelete(t *testing.T) {
},
},
deleteErr: errors.New("aborted by test"),
wantErr: true,
wantErr: "2 codespace(s) failed to delete",
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-123"},
wantStderr: heredoc.Doc(`
error deleting codespace "hubot-robawt-abc": aborted by test
error deleting codespace "monalisa-spoonknife-123": aborted by test
`),
wantStdout: "",
},
{
name: "with confirm",
@ -149,7 +150,7 @@ func TestDelete(t *testing.T) {
"Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true,
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "deletion for org codespace by admin succeeds",
@ -174,7 +175,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "deletion for org codespace by admin fails for codespace not found",
@ -200,7 +201,8 @@ func TestDelete(t *testing.T) {
},
wantDeleted: []string{},
wantStdout: "",
wantErr: true,
wantErr: "error fetching codespace information: " +
"codespace not found for user johnDoe with name monalisa-spoonknife-123",
},
{
name: "deletion for org codespace succeeds without username",
@ -215,7 +217,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "by repo owner",
@ -253,6 +255,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"octocat-spoonknife-123", "octocat-spoonknife-c4f3"},
wantStderr: "2 codespace(s) deleted successfully\n",
wantStdout: "",
},
}
@ -306,8 +309,8 @@ func TestDelete(t *testing.T) {
ios.SetStdoutTTY(true)
app := NewApp(ios, nil, apiMock, nil, nil)
err := app.Delete(context.Background(), opts)
if (err != nil) != tt.wantErr {
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
if (err != nil) && tt.wantErr != err.Error() {
t.Errorf("delete() error = %v, wantErr = %v", err, tt.wantErr)
}
for _, listArgs := range apiMock.ListCodespacesCalls() {
if listArgs.Opts.OrgName != "" && listArgs.Opts.UserName == "" {

View file

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
@ -129,6 +130,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.TitleProvided = cmd.Flags().Changed("title")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
// Workaround: Due to the way this command is implemented, we need to manually check GH_REPO.
// Commands should use the standard BaseRepoOverride functionality to handle this behavior instead.
if opts.RepoOverride == "" {
opts.RepoOverride = os.Getenv("GH_REPO")
}
noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit")
opts.MaintainerCanModify = !noMaintainerEdit
@ -318,8 +325,6 @@ func createRun(opts *CreateOptions) (err error) {
if template != nil {
templateContent = string(template.Body())
} else {
templateContent = string(tpl.LegacyBody())
}
}

View file

@ -15,7 +15,8 @@
"headRepositoryOwner": {
"login": "OWNER"
},
"isCrossRepository": false
"isCrossRepository": false,
"autoMergeRequest": null
}
}
]
@ -31,7 +32,8 @@
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "strawberries",
"isDraft": false
"isDraft": false,
"autoMergeRequest": null
}
}
]
@ -46,7 +48,17 @@
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/9",
"headRefName": "apples",
"isDraft": false
"isDraft": false,
"autoMergeRequest": {
"authorEmail": null,
"commitBody": null,
"commitHeadline": null,
"mergeMethod": "SQUASH",
"enabledAt": "2020-08-27T19:00:12Z",
"enabledBy": {
"login": "hubot"
}
}
} }, {
"node": {
"number": 11,
@ -54,7 +66,8 @@
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/1",
"headRefName": "figs",
"isDraft": true
"isDraft": true,
"autoMergeRequest": null
}
}
]

View file

@ -191,7 +191,7 @@ func pullRequestFragment(hostname string, conflictStatus bool) (string, error) {
fields := []string{
"number", "title", "state", "url", "isDraft", "isCrossRepository",
"headRefName", "headRepositoryOwner", "mergeStateStatus",
"statusCheckRollup", "requiresStrictStatusChecks",
"statusCheckRollup", "requiresStrictStatusChecks", "autoMergeRequest",
}
if conflictStatus {

View file

@ -284,6 +284,10 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
}
}
if pr.AutoMergeRequest != nil {
fmt.Fprintf(w, " %s", cs.Green("✓ Auto-merge enabled"))
}
} else {
fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
}

View file

@ -91,7 +91,7 @@ func TestPRStatus(t *testing.T) {
expectedPrs := []*regexp.Regexp{
regexp.MustCompile(`#8.*\[strawberries\]`),
regexp.MustCompile(`#9.*\[apples\]`),
regexp.MustCompile(`#9.*\[apples\].*✓ Auto-merge enabled`),
regexp.MustCompile(`#10.*\[blueberries\]`),
regexp.MustCompile(`#11.*\[figs\]`),
}

View file

@ -0,0 +1,55 @@
{
"data": {
"repository": {
"pullRequest": {
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/12",
"author": {
"login": "nobody"
},
"autoMergeRequest": {
"authorEmail": null,
"commitBody": null,
"commitHeadline": null,
"mergeMethod": "SQUASH",
"enabledAt": "2020-08-27T19:00:12Z",
"enabledBy": {
"login": "hubot"
}
},
"additions": 100,
"deletions": 10,
"reviewRequests": {
"nodes": [],
"totalcount": 0
},
"assignees": {
"nodes": [],
"totalcount": 0
},
"labels": {
"nodes": [],
"totalcount": 0
},
"projectcards": {
"nodes": [],
"totalcount": 0
},
"milestone": {},
"commits": {
"totalCount": 12
},
"baseRefName": "master",
"headRefName": "blueberries",
"headRepositoryOwner": {
"login": "hubot"
},
"isCrossRepository": true,
"isDraft": false
}
}
}
}

View file

@ -77,7 +77,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
var defaultFields = []string{
"url", "number", "title", "state", "body", "author",
"url", "number", "title", "state", "body", "author", "autoMergeRequest",
"isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount",
"baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository",
"reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone",
@ -157,6 +157,15 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
fmt.Fprintf(out, "url:\t%s\n", pr.URL)
fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions)))
fmt.Fprintf(out, "deletions:\t%s\n", cs.Red(strconv.Itoa(pr.Deletions)))
var autoMerge string
if pr.AutoMergeRequest == nil {
autoMerge = "disabled"
} else {
autoMerge = fmt.Sprintf("enabled\t%s\t%s",
pr.AutoMergeRequest.EnabledBy.Login,
strings.ToLower(pr.AutoMergeRequest.MergeMethod))
}
fmt.Fprintf(out, "auto-merge:\t%s\n", autoMerge)
fmt.Fprintln(out, "--")
fmt.Fprintln(out, pr.Body)
@ -223,6 +232,29 @@ func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
fmt.Fprintln(out, pr.Milestone.Title)
}
// Auto-Merge status
autoMerge := pr.AutoMergeRequest
if autoMerge != nil {
var mergeMethod string
switch autoMerge.MergeMethod {
case "MERGE":
mergeMethod = "a merge commit"
case "REBASE":
mergeMethod = "rebase and merge"
case "SQUASH":
mergeMethod = "squash and merge"
default:
mergeMethod = fmt.Sprintf("an unknown merge method (%s)", autoMerge.MergeMethod)
}
fmt.Fprintf(out,
"%s %s by %s, using %s\n",
cs.Bold("Auto-merge:"),
cs.Green("enabled"),
autoMerge.EnabledBy.Login,
mergeMethod,
)
}
// Body
var md string
var err error

View file

@ -314,6 +314,25 @@ func TestPRView_Preview_nontty(t *testing.T) {
`\*\*blueberries taste good\*\*`,
},
},
"PR with auto-merge enabled": {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithAutoMergeEnabled.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tOPEN\n`,
`author:\tnobody\n`,
`labels:\t\n`,
`assignees:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
`additions:\t100\n`,
`deletions:\t10\n`,
`auto-merge:\tenabled\thubot\tsquash\n`,
},
},
}
for name, tc := range tests {
@ -504,6 +523,20 @@ func TestPRView_Preview(t *testing.T) {
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
"PR with auto-merge enabled": {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithAutoMergeEnabled.json",
},
expectedOutputs: []string{
`Blueberries are from a fork #12\n`,
`Open.*nobody wants to merge 12 commits into master from blueberries . about X years ago`,
`Auto-merge:.*enabled.* by hubot, using squash and merge`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
}
for name, tc := range tests {

View file

@ -140,7 +140,7 @@ func downloadRun(opts *DownloadOptions) error {
return err
}
opts.IO.StartProgressIndicator()
opts.IO.StartProgressIndicatorWithLabel("Finding assets to download")
defer opts.IO.StopProgressIndicator()
ctx := context.Background()
@ -196,7 +196,7 @@ func downloadRun(opts *DownloadOptions) error {
stdout: opts.IO.Out,
}
return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive)
return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive, opts.IO)
}
func matchAny(patterns []string, name string) bool {
@ -208,7 +208,7 @@ func matchAny(patterns []string, name string) bool {
return false
}
func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool) error {
func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool, io *iostreams.IOStreams) error {
if numWorkers == 0 {
return errors.New("the number of concurrent workers needs to be greater than 0")
}
@ -223,6 +223,7 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
io.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading %s", a.Name))
results <- downloadAsset(dest, httpClient, a.APIURL, a.Name, isArchive)
}
}()
@ -240,6 +241,8 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload
}
}
io.StopProgressIndicator()
return downloadError
}

View file

@ -730,7 +730,7 @@ func interactiveRepoInfo(client *http.Client, hostname string, prompter iprompte
name = fmt.Sprintf("%s/%s", owner, name)
}
description, err := prompter.Input("Description", defaultName)
description, err := prompter.Input("Description", "")
if err != nil {
return "", "", "", err
}

View file

@ -86,7 +86,7 @@ func NewCmdSetDefault(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *
}
}
if !opts.IO.CanPrompt() && opts.Repo == nil {
if !opts.ViewMode && !opts.IO.CanPrompt() && opts.Repo == nil {
return cmdutil.FlagErrorf("repository required when not running interactively")
}
@ -119,10 +119,10 @@ func setDefaultRun(opts *SetDefaultOptions) error {
currentDefaultRepo, _ := remotes.ResolvedRemote()
if opts.ViewMode {
if currentDefaultRepo == nil {
fmt.Fprintln(opts.IO.Out, "no default repository has been set; use `gh repo set-default` to select one")
} else {
if currentDefaultRepo != nil {
fmt.Fprintln(opts.IO.Out, displayRemoteRepoName(currentDefaultRepo))
} else if opts.IO.IsStdoutTTY() {
fmt.Fprintln(opts.IO.Out, "no default repository has been set; use `gh repo set-default` to select one")
}
return nil
}

View file

@ -166,7 +166,8 @@ func TestDefaultRun(t *testing.T) {
wantStdout: "no default repository has been set\n",
},
{
name: "view mode no current default",
name: "tty view mode no current default",
tty: true,
opts: SetDefaultOptions{ViewMode: true},
remotes: []*context.Remote{
{
@ -176,6 +177,16 @@ func TestDefaultRun(t *testing.T) {
},
wantStdout: "no default repository has been set; use `gh repo set-default` to select one\n",
},
{
name: "view mode no current default",
opts: SetDefaultOptions{ViewMode: true},
remotes: []*context.Remote{
{
Remote: &git.Remote{Name: "origin"},
Repo: repo1,
},
},
},
{
name: "view mode with base resolved current default",
opts: SetDefaultOptions{ViewMode: true},

View file

@ -139,8 +139,8 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
if c := findCommand(command, "actions"); c != nil {
helpTopics = append(helpTopics, rpad(c.Name()+":", namePadding)+c.Short)
}
for topic, params := range HelpTopics {
helpTopics = append(helpTopics, rpad(topic+":", namePadding)+params["short"])
for _, helpTopic := range HelpTopics {
helpTopics = append(helpTopics, rpad(helpTopic.name+":", namePadding)+helpTopic.short)
}
sort.Strings(helpTopics)
helpEntries = append(helpEntries, helpEntry{"HELP TOPICS", strings.Join(helpTopics, "\n")})

View file

@ -11,7 +11,9 @@ import (
"github.com/spf13/cobra"
)
func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) {
// longPager provides a pager over a commands Long message.
// It is currently only used for the reference command
func longPager(io *iostreams.IOStreams) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, args []string) {
wrapWidth := 0
if io.IsStdoutTTY() {
@ -38,7 +40,7 @@ func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) {
}
}
func referenceLong(cmd *cobra.Command) string {
func stringifyReference(cmd *cobra.Command) string {
buf := bytes.NewBufferString("# gh reference\n\n")
for _, c := range cmd.Commands() {
if c.Hidden {

View file

@ -10,10 +10,18 @@ import (
"github.com/spf13/cobra"
)
var HelpTopics = map[string]map[string]string{
"mintty": {
"short": "Information about using gh with MinTTY",
"long": heredoc.Doc(`
type helpTopic struct {
name string
short string
long string
example string
}
var HelpTopics = []helpTopic{
{
name: "mintty",
short: "Information about using gh with MinTTY",
long: heredoc.Doc(`
MinTTY is the terminal emulator that comes by default with Git
for Windows. It has known issues with gh's ability to prompt a
user for input.
@ -30,9 +38,10 @@ var HelpTopics = map[string]map[string]string{
NOTE: this can lead to some UI bugs.
`),
},
"environment": {
"short": "Environment variables that can be used with gh",
"long": heredoc.Doc(`
{
name: "environment",
short: "Environment variables that can be used with gh",
long: heredoc.Doc(`
GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for github.com
API requests. Setting this avoids being prompted to authenticate and takes precedence over
previously stored credentials.
@ -41,7 +50,8 @@ var HelpTopics = map[string]map[string]string{
token for API requests to GitHub Enterprise. When setting this, also set GH_HOST.
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.
"github.com" host when not in a context of an existing repository. When setting this,
also set GH_ENTERPRISE_TOKEN.
GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands
that otherwise operate on a local repository.
@ -91,12 +101,14 @@ var HelpTopics = map[string]map[string]string{
its own path such as in the cygwin terminal.
`),
},
"reference": {
"short": "A comprehensive reference of all gh commands",
{
name: "reference",
short: "A comprehensive reference of all gh commands",
},
"formatting": {
"short": "Formatting options for JSON data exported from gh",
"long": heredoc.Docf(`
{
name: "formatting",
short: "Formatting options for JSON data exported from gh",
long: heredoc.Docf(`
By default, the result of %[1]sgh%[1]s commands are output in line-based plain text format.
Some commands support passing the %[1]s--json%[1]s flag, which converts the output to JSON format.
Once in JSON, the output can be further formatted according to a required formatting string by
@ -132,7 +144,7 @@ var HelpTopics = map[string]map[string]string{
To learn more about Go templates, see: <https://golang.org/pkg/text/template/>.
`, "`"),
"example": heredoc.Doc(`
example: heredoc.Doc(`
# default output format
$ gh pr list
Showing 23 of 23 open pull requests in cli/cli
@ -242,9 +254,10 @@ var HelpTopics = map[string]map[string]string{
mislav COMMENTED This is going along great! Thanks for working on this
`),
},
"exit-codes": {
"short": "Exit codes used by gh",
"long": heredoc.Doc(`
{
name: "exit-codes",
short: "Exit codes used by gh",
long: heredoc.Doc(`
gh follows normal conventions regarding exit codes.
- If a command completes successfully, the exit code will be 0
@ -262,30 +275,31 @@ var HelpTopics = map[string]map[string]string{
},
}
func NewHelpTopic(ios *iostreams.IOStreams, topic string) *cobra.Command {
func NewCmdHelpTopic(ios *iostreams.IOStreams, ht helpTopic) *cobra.Command {
cmd := &cobra.Command{
Use: topic,
Short: HelpTopics[topic]["short"],
Long: HelpTopics[topic]["long"],
Example: HelpTopics[topic]["example"],
Use: ht.name,
Short: ht.short,
Long: ht.long,
Example: ht.example,
Hidden: true,
Annotations: map[string]string{
"markdown:generate": "true",
"markdown:basename": "gh_help_" + topic,
"markdown:basename": "gh_help_" + ht.name,
},
}
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
helpTopicHelpFunc(ios.Out, c, args)
})
cmd.SetUsageFunc(func(c *cobra.Command) error {
return helpTopicUsageFunc(ios.ErrOut, c)
})
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
helpTopicHelpFunc(ios.Out, c)
})
return cmd
}
func helpTopicHelpFunc(w io.Writer, command *cobra.Command, args []string) {
func helpTopicHelpFunc(w io.Writer, command *cobra.Command) {
fmt.Fprint(w, command.Long)
if command.Example != "" {
fmt.Fprintf(w, "\n\nEXAMPLES\n")

View file

@ -4,76 +4,85 @@ import (
"testing"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHelpTopic(t *testing.T) {
func TestCmdHelpTopic(t *testing.T) {
t.Parallel()
tests := []struct {
name string
topic string
args []string
flags []string
wantsErr bool
name string
topic helpTopic
args []string
flags []string
errorAssertion require.ErrorAssertionFunc
expectedAnnotations map[string]string
}{
{
name: "valid topic",
topic: "environment",
args: []string{},
flags: []string{},
wantsErr: false,
name: "when there are no args or flags, prints the long message to stdout",
topic: helpTopic{name: "test-topic", long: "test-topic-long"},
args: []string{},
flags: []string{},
errorAssertion: require.NoError,
},
{
name: "invalid topic",
topic: "invalid",
args: []string{},
flags: []string{},
wantsErr: false,
name: "when an arg is provided, it is ignored and help is printed",
topic: helpTopic{name: "test-topic"},
args: []string{"anything"},
flags: []string{},
errorAssertion: require.NoError,
},
{
name: "more than zero args",
topic: "environment",
args: []string{"invalid"},
flags: []string{},
wantsErr: false,
name: "when a flag is provided, returns an error",
topic: helpTopic{name: "test-topic"},
args: []string{},
flags: []string{"--anything"},
errorAssertion: require.Error,
},
{
name: "more than zero flags",
topic: "environment",
args: []string{},
flags: []string{"--invalid"},
wantsErr: true,
name: "when there is an example, include it in the stdout",
topic: helpTopic{name: "test-topic", example: "test-topic-example"},
args: []string{},
flags: []string{},
errorAssertion: require.NoError,
},
{
name: "help arg",
topic: "environment",
args: []string{"help"},
flags: []string{},
wantsErr: false,
},
{
name: "help flag",
topic: "environment",
args: []string{},
flags: []string{"--help"},
wantsErr: false,
name: "sets important markdown annotations on the command",
topic: helpTopic{name: "test-topic"},
args: []string{},
flags: []string{},
errorAssertion: require.NoError,
expectedAnnotations: map[string]string{
"markdown:generate": "true",
"markdown:basename": "gh_help_test-topic",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
t.Parallel()
cmd := NewHelpTopic(ios, tt.topic)
ios, _, stdout, _ := iostreams.Test()
cmd := NewCmdHelpTopic(ios, tt.topic)
cmd.SetArgs(append(tt.args, tt.flags...))
cmd.SetOut(stderr)
cmd.SetErr(stderr)
_, err := cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
tt.errorAssertion(t, err)
if tt.topic.long != "" {
require.Contains(t, stdout.String(), tt.topic.long)
}
if tt.topic.example != "" {
require.Contains(t, stdout.String(), tt.topic.example)
}
if len(tt.expectedAnnotations) > 0 {
require.Equal(t, cmd.Annotations, tt.expectedAnnotations)
}
assert.NoError(t, err)
})
}
}

View file

@ -127,18 +127,26 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory))
// Help topics
cmd.AddCommand(NewHelpTopic(f.IOStreams, "environment"))
cmd.AddCommand(NewHelpTopic(f.IOStreams, "formatting"))
cmd.AddCommand(NewHelpTopic(f.IOStreams, "mintty"))
cmd.AddCommand(NewHelpTopic(f.IOStreams, "exit-codes"))
referenceCmd := NewHelpTopic(f.IOStreams, "reference")
referenceCmd.SetHelpFunc(referenceHelpFn(f.IOStreams))
cmd.AddCommand(referenceCmd)
var referenceCmd *cobra.Command
for _, ht := range HelpTopics {
helpTopicCmd := NewCmdHelpTopic(f.IOStreams, ht)
cmd.AddCommand(helpTopicCmd)
// See bottom of the function for why we explicitly care about the reference cmd
if ht.name == "reference" {
referenceCmd = helpTopicCmd
}
}
cmdutil.DisableAuthCheck(cmd)
// this needs to appear last:
referenceCmd.Long = referenceLong(cmd)
// The reference command produces paged output that displays information on every other command.
// Therefore, we explicitly set the Long text and HelpFunc here after all other commands are registered.
// We experimented with producing the paged output dynamically when the HelpFunc is called but since
// doc generation makes use of the Long text, it is simpler to just be explicit here that this command
// is special.
referenceCmd.Long = stringifyReference(cmd)
referenceCmd.SetHelpFunc(longPager(f.IOStreams))
return cmd
}