Merge branch 'trunk' into gh-search-code

This commit is contained in:
Josh Kraft 2023-05-08 18:10:27 -06:00 committed by GitHub
commit 99cff8f8eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 810 additions and 75 deletions

View file

@ -39,6 +39,8 @@ type PullRequest struct {
ClosedAt *time.Time
MergedAt *time.Time
AutoMergeRequest *AutoMergeRequest
MergeCommit *Commit
PotentialMergeCommit *Commit
@ -136,6 +138,16 @@ type PRRepository struct {
Name string `json:"name"`
}
type AutoMergeRequest struct {
AuthorEmail *string `json:"authorEmail"`
CommitBody *string `json:"commitBody"`
CommitHeadline *string `json:"commitHeadline"`
// MERGE, REBASE, SQUASH
MergeMethod string `json:"mergeMethod"`
EnabledAt time.Time `json:"enabledAt"`
EnabledBy Author `json:"enabledBy"`
}
// Commit loads just the commit SHA and nothing else
type Commit struct {
OID string `json:"oid"`

View file

@ -132,6 +132,17 @@ var prCommits = shortenQuery(`
}
`)
var autoMergeRequest = shortenQuery(`
autoMergeRequest {
authorEmail,
commitBody,
commitHeadline,
mergeMethod,
enabledAt,
enabledBy{login,...on User{id,name}}
}
`)
func StatusCheckRollupGraphQL(after string) string {
var afterClause string
if after != "" {
@ -231,6 +242,7 @@ var IssueFields = []string{
var PullRequestFields = append(IssueFields,
"additions",
"autoMergeRequest",
"baseRefName",
"changedFiles",
"commits",
@ -285,6 +297,8 @@ func IssueGraphQL(fields []string) string {
q = append(q, `mergeCommit{oid}`)
case "potentialMergeCommit":
q = append(q, `potentialMergeCommit{oid}`)
case "autoMergeRequest":
q = append(q, autoMergeRequest)
case "comments":
q = append(q, issueComments)
case "lastComment": // pseudo-field

View file

@ -128,11 +128,18 @@ func (a *API) GetUser(ctx context.Context) (*User, error) {
return &response, nil
}
// RepositoryOwner represents owner of a repository
type RepositoryOwner struct {
Type string `json:"type"`
Login string `json:"login"`
}
// Repository represents a GitHub repository.
type Repository struct {
ID int `json:"id"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
ID int `json:"id"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
Owner RepositoryOwner `json:"owner"`
}
// GetRepository returns the repository associated with the given owner and name.

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.21.12
// protoc v3.12.4
// source: codespace/codespace_host_service.v1.proto
package codespace

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.21.12
// - protoc v3.12.4
// source: codespace/codespace_host_service.v1.proto
package codespace

View file

@ -20,11 +20,11 @@ function generate {
local contract="$dir/$proto"
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract"
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract" --experimental_allow_proto3_optional
echo "Generated protocol buffers for $contract"
services=$(cat "$contract" | grep -Eo "service .+ {" | awk '{print $2 "Server"}')
moq -out $contract.mock.go $dir $services
services=$(grep -Eo "service .+ {" <$contract | awk '{print $2 "Server"}')
moq -out "$contract.mock.go" "$dir" "$services"
echo "Generated mock protocols for $contract"
}

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.21.12
// protoc v3.12.4
// source: jupyter/jupyter_server_host_service.v1.proto
package jupyter

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.21.12
// - protoc v3.12.4
// source: jupyter/jupyter_server_host_service.v1.proto
package jupyter

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.21.12
// protoc v3.12.4
// source: ssh/ssh_server_host_service.v1.proto
package ssh

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.21.12
// - protoc v3.12.4
// source: ssh/ssh_server_host_service.v1.proto
package ssh

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

@ -15,6 +15,7 @@ type CodespaceSelector struct {
repoName string
codespaceName string
repoOwner string
}
var errNoFilteredCodespaces = errors.New("you have no codespaces meeting the filter criteria")
@ -25,8 +26,10 @@ func AddCodespaceSelector(cmd *cobra.Command, api apiClient) *CodespaceSelector
cmd.PersistentFlags().StringVarP(&cs.codespaceName, "codespace", "c", "", "Name of the codespace")
cmd.PersistentFlags().StringVarP(&cs.repoName, "repo", "R", "", "Filter codespace selection by repository name (user/repo)")
cmd.PersistentFlags().StringVar(&cs.repoOwner, "repo-owner", "", "Filter codespace selection by repository owner (username or org)")
cmd.MarkFlagsMutuallyExclusive("codespace", "repo")
cmd.MarkFlagsMutuallyExclusive("codespace", "repo-owner")
return cs
}
@ -102,6 +105,10 @@ func (cs *CodespaceSelector) fetchCodespaces(ctx context.Context) (codespaces []
codespaces = filteredCodespaces
}
if cs.repoOwner != "" {
codespaces = filterCodespacesByRepoOwner(codespaces, cs.repoOwner)
}
if len(codespaces) == 0 {
return nil, errNoFilteredCodespaces
}

View file

@ -48,68 +48,101 @@ func TestSelectNameWithCodespaceName(t *testing.T) {
func TestFetchCodespaces(t *testing.T) {
var (
repoA1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/A"}}
repoA2 = &api.Codespace{Name: "2", Repository: api.Repository{FullName: "mock/A"}}
octocatOwner = api.RepositoryOwner{Login: "octocat"}
cliOwner = api.RepositoryOwner{Login: "cli"}
octocatA = &api.Codespace{
Name: "1",
Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner},
}
repoB1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/B"}}
octocatA2 = &api.Codespace{
Name: "2",
Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner},
}
cliA = &api.Codespace{
Name: "3",
Repository: api.Repository{FullName: "cli/A", Owner: cliOwner},
}
octocatB = &api.Codespace{
Name: "4",
Repository: api.Repository{FullName: "octocat/B", Owner: octocatOwner},
}
)
tests := []struct {
tName string
apiCodespaces []*api.Codespace
codespaceName string
repoName string
repoOwner string
wantCodespaces []*api.Codespace
wantErr error
}{
// Empty case
{
"empty", nil, "", nil, errNoCodespaces,
tName: "empty",
apiCodespaces: nil,
wantCodespaces: nil,
wantErr: errNoCodespaces,
},
// Tests with no filtering
{
"no filtering, single codespace",
[]*api.Codespace{repoA1},
"",
[]*api.Codespace{repoA1},
nil,
tName: "no filtering, single codespaces",
apiCodespaces: []*api.Codespace{octocatA},
wantCodespaces: []*api.Codespace{octocatA},
wantErr: nil,
},
{
"no filtering, multiple codespaces",
[]*api.Codespace{repoA1, repoA2, repoB1},
"",
[]*api.Codespace{repoA1, repoA2, repoB1},
nil,
tName: "no filtering, multiple codespace",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
wantCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
},
// Test repo filtering
{
"repo filtering, single codespace",
[]*api.Codespace{repoA1},
"mock/A",
[]*api.Codespace{repoA1},
nil,
tName: "repo name filtering, single codespace",
apiCodespaces: []*api.Codespace{octocatA},
repoName: "octocat/A",
wantCodespaces: []*api.Codespace{octocatA},
wantErr: nil,
},
{
"repo filtering, multiple codespaces",
[]*api.Codespace{repoA1, repoA2, repoB1},
"mock/A",
[]*api.Codespace{repoA1, repoA2},
nil,
tName: "repo name filtering, multiple codespace",
apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB},
repoName: "octocat/A",
wantCodespaces: []*api.Codespace{octocatA, octocatA2},
wantErr: nil,
},
{
"repo filtering, multiple codespaces 2",
[]*api.Codespace{repoA1, repoA2, repoB1},
"mock/B",
[]*api.Codespace{repoB1},
nil,
tName: "repo name filtering, multiple codespace 2",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
repoName: "octocat/B",
wantCodespaces: []*api.Codespace{octocatB},
wantErr: nil,
},
{
"repo filtering, no matches",
[]*api.Codespace{repoA1, repoA2, repoB1},
"mock/C",
nil,
errNoFilteredCodespaces,
tName: "repo name filtering, no matches",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
repoName: "Unknown/unknown",
wantCodespaces: nil,
wantErr: errNoFilteredCodespaces,
},
{
tName: "repo filtering, match with repo owner",
apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB},
repoOwner: "octocat",
wantCodespaces: []*api.Codespace{octocatA, octocatA2, octocatB},
wantErr: nil,
},
{
tName: "repo filtering, no match with repo owner",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
repoOwner: "unknown",
wantCodespaces: []*api.Codespace{},
wantErr: errNoFilteredCodespaces,
},
}
@ -121,7 +154,11 @@ func TestFetchCodespaces(t *testing.T) {
},
}
cs := &CodespaceSelector{api: api, repoName: tt.repoName}
cs := &CodespaceSelector{
api: api,
repoName: tt.repoName,
repoOwner: tt.repoOwner,
}
codespaces, err := cs.fetchCodespaces(context.Background())

View file

@ -10,6 +10,7 @@ import (
"log"
"os"
"sort"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
@ -266,3 +267,14 @@ func addDeprecatedRepoShorthand(cmd *cobra.Command, target *string) error {
return nil
}
// filterCodespacesByRepoOwner filters a list of codespaces by the owner of the repository.
func filterCodespacesByRepoOwner(codespaces []*api.Codespace, repoOwner string) []*api.Codespace {
filtered := make([]*api.Codespace, 0, len(codespaces))
for _, c := range codespaces {
if strings.EqualFold(c.Repository.Owner.Login, repoOwner) {
filtered = append(filtered, c)
}
}
return filtered
}

View file

@ -23,6 +23,7 @@ type deleteOptions struct {
keepDays uint16
orgName string
userName string
repoOwner string
isInteractive bool
now func() time.Time
@ -60,10 +61,12 @@ func newDeleteCmd(app *App) *cobra.Command {
// After the admin subcommand is added (see https://github.com/cli/cli/pull/6944#issuecomment-1419553639) we can revisit this.
opts.codespaceName = selector.codespaceName
opts.repoFilter = selector.repoName
opts.repoOwner = selector.repoOwner
if opts.deleteAll && opts.repoFilter != "" {
return cmdutil.FlagErrorf("both `--all` and `--repo` is not supported")
}
if opts.orgName != "" && opts.codespaceName != "" && opts.userName == "" {
return cmdutil.FlagErrorf("using `--org` with `--codespace` requires `--user`")
}
@ -99,6 +102,9 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
userName = currentUser.Login
}
codespaces, fetchErr = a.apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{OrgName: opts.orgName, UserName: userName})
if opts.repoOwner != "" {
codespaces = filterCodespacesByRepoOwner(codespaces, opts.repoOwner)
}
return
})
if err != nil {
@ -139,6 +145,7 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
if opts.repoFilter != "" && !strings.EqualFold(c.Repository.FullName, opts.repoFilter) {
continue
}
if opts.keepDays > 0 {
t, err := time.Parse(time.RFC3339, c.LastUsedAt)
if err != nil {

View file

@ -217,6 +217,44 @@ func TestDelete(t *testing.T) {
wantDeleted: []string{"monalisa-spoonknife-123"},
wantStdout: "",
},
{
name: "by repo owner",
opts: deleteOptions{
deleteAll: true,
repoOwner: "octocat",
},
codespaces: []*api.Codespace{
{
Name: "octocat-spoonknife-123",
Repository: api.Repository{
FullName: "octocat/Spoon-Knife",
Owner: api.RepositoryOwner{
Login: "octocat",
},
},
},
{
Name: "cli-robawt-abc",
Repository: api.Repository{
FullName: "cli/ROBAWT",
Owner: api.RepositoryOwner{
Login: "cli",
},
},
},
{
Name: "octocat-spoonknife-c4f3",
Repository: api.Repository{
FullName: "octocat/Spoon-Knife",
Owner: api.RepositoryOwner{
Login: "octocat",
},
},
},
},
wantDeleted: []string{"octocat-spoonknife-123", "octocat-spoonknife-c4f3"},
wantStdout: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

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
}