commit
f2febbc46e
2 changed files with 423 additions and 0 deletions
|
|
@ -11,6 +11,8 @@ import (
|
|||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/extension/browse"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
|
|
@ -25,6 +27,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
|||
prompter := f.Prompter
|
||||
config := f.Config
|
||||
browser := f.Browser
|
||||
httpClient := f.HttpClient
|
||||
|
||||
extCmd := cobra.Command{
|
||||
Use: "extension",
|
||||
|
|
@ -45,6 +48,186 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
|||
}
|
||||
|
||||
extCmd.AddCommand(
|
||||
func() *cobra.Command {
|
||||
query := search.Query{
|
||||
Kind: search.KindRepositories,
|
||||
}
|
||||
qualifiers := search.Qualifiers{
|
||||
Topic: []string{"gh-extension"},
|
||||
}
|
||||
var order string
|
||||
var sort string
|
||||
var webMode bool
|
||||
var exporter cmdutil.Exporter
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search [<query>]",
|
||||
Short: "Search extensions to the GitHub CLI",
|
||||
Long: heredoc.Doc(`
|
||||
Search for gh extensions.
|
||||
|
||||
With no arguments, this command prints out the first 30 extensions
|
||||
available to install sorted by number of stars. More extensions can
|
||||
be fetched by specifying a higher limit with the --limit flag.
|
||||
|
||||
When connected to a terminal, this command prints out three columns.
|
||||
The first has a ✓ if the extension is already installed locally. The
|
||||
second is the full name of the extension repository in NAME/OWNER
|
||||
format. The third is the extension's description.
|
||||
|
||||
When not connected to a terminal, the ✓ character is rendered as the
|
||||
word "installed" but otherwise the order and content of the columns
|
||||
is the same.
|
||||
|
||||
This command behaves similarly to 'gh search repos' but does not
|
||||
support as many search qualifiers. For a finer grained search of
|
||||
extensions, try using:
|
||||
|
||||
gh search repos --topic "gh-extension"
|
||||
|
||||
and adding qualifiers as needed. See 'gh help search repos' to learn
|
||||
more about repository search.
|
||||
|
||||
For listing just the extensions that are already installed locally,
|
||||
see:
|
||||
|
||||
gh ext list
|
||||
`),
|
||||
Example: heredoc.Doc(`
|
||||
# List the first 30 extensions sorted by star count, descending
|
||||
$ gh ext search
|
||||
|
||||
# List more extensions
|
||||
$ gh ext search --limit 300
|
||||
|
||||
# List extensions matching the term "branch"
|
||||
$ gh ext search branch
|
||||
|
||||
# List extensions owned by organization "github"
|
||||
$ gh ext search --owner github
|
||||
|
||||
# List extensions, sorting by recently updated, ascending
|
||||
$ gh ext search --sort updated --order asc
|
||||
|
||||
# List extensions, filtering by license
|
||||
$ gh ext search --license MIT
|
||||
|
||||
# Open search results in the browser
|
||||
$ gh ext search -w
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := httpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("order") {
|
||||
query.Order = order
|
||||
}
|
||||
if cmd.Flags().Changed("sort") {
|
||||
query.Sort = sort
|
||||
}
|
||||
|
||||
query.Keywords = args
|
||||
query.Qualifiers = qualifiers
|
||||
|
||||
host, _ := cfg.DefaultHost()
|
||||
searcher := search.NewSearcher(client, host)
|
||||
|
||||
if webMode {
|
||||
url := searcher.URL(query)
|
||||
if io.IsStdoutTTY() {
|
||||
fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(url))
|
||||
}
|
||||
return browser.Browse(url)
|
||||
}
|
||||
|
||||
io.StartProgressIndicator()
|
||||
result, err := searcher.Repositories(query)
|
||||
io.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exporter != nil {
|
||||
return exporter.Write(io, result.Items)
|
||||
}
|
||||
|
||||
if io.IsStdoutTTY() {
|
||||
if len(result.Items) == 0 {
|
||||
return errors.New("no extensions found")
|
||||
}
|
||||
fmt.Fprintf(io.Out, "Showing %d of %d extensions\n", len(result.Items), result.Total)
|
||||
fmt.Fprintln(io.Out)
|
||||
}
|
||||
|
||||
cs := io.ColorScheme()
|
||||
installedExts := m.List()
|
||||
|
||||
isInstalled := func(repo search.Repository) bool {
|
||||
searchRepo, err := ghrepo.FromFullName(repo.FullName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range installedExts {
|
||||
// TODO consider a Repo() on Extension interface
|
||||
if u, err := git.ParseURL(e.URL()); err == nil {
|
||||
if r, err := ghrepo.FromURL(u); err == nil {
|
||||
if ghrepo.IsSame(searchRepo, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
tp := tableprinter.New(io)
|
||||
tp.HeaderRow("", "REPO", "DESCRIPTION")
|
||||
|
||||
for _, repo := range result.Items {
|
||||
if !strings.HasPrefix(repo.Name, "gh-") {
|
||||
continue
|
||||
}
|
||||
|
||||
installed := ""
|
||||
if isInstalled(repo) {
|
||||
if io.IsStdoutTTY() {
|
||||
installed = "✓"
|
||||
} else {
|
||||
installed = "installed"
|
||||
}
|
||||
}
|
||||
|
||||
tp.AddField(installed, tableprinter.WithColor(cs.Green))
|
||||
tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold))
|
||||
tp.AddField(repo.Description)
|
||||
tp.EndRow()
|
||||
}
|
||||
|
||||
return tp.Render()
|
||||
},
|
||||
}
|
||||
|
||||
// Output flags
|
||||
cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Open the search query in the web browser")
|
||||
cmdutil.AddJSONFlags(cmd, &exporter, search.RepositoryFields)
|
||||
|
||||
// Query parameter flags
|
||||
cmd.Flags().IntVarP(&query.Limit, "limit", "L", 30, "Maximum number of extensions to fetch")
|
||||
cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of repositories returned, ignored unless '--sort' flag is specified")
|
||||
cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"forks", "help-wanted-issues", "stars", "updated"}, "Sort fetched repositories")
|
||||
|
||||
// Qualifier flags
|
||||
cmd.Flags().StringSliceVar(&qualifiers.License, "license", nil, "Filter based on license type")
|
||||
cmd.Flags().StringVar(&qualifiers.User, "owner", "", "Filter on owner")
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed extension commands",
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
|
|
@ -17,6 +19,7 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/search"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
|
@ -32,12 +35,193 @@ func TestNewCmdExtension(t *testing.T) {
|
|||
args []string
|
||||
managerStubs func(em *extensions.ExtensionManagerMock) func(*testing.T)
|
||||
prompterStubs func(pm *prompter.PrompterMock)
|
||||
httpStubs func(reg *httpmock.Registry)
|
||||
browseStubs func(*browser.Stub) func(*testing.T)
|
||||
isTTY bool
|
||||
wantErr bool
|
||||
errMsg string
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "search for extensions",
|
||||
args: []string{"search"},
|
||||
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
|
||||
em.ListFunc = func() []extensions.Extension {
|
||||
return []extensions.Extension{
|
||||
&extensions.ExtensionMock{
|
||||
URLFunc: func() string {
|
||||
return "https://github.com/vilmibm/gh-screensaver"
|
||||
},
|
||||
},
|
||||
&extensions.ExtensionMock{
|
||||
URLFunc: func() string {
|
||||
return "https://github.com/github/gh-gei"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return func(t *testing.T) {
|
||||
listCalls := em.ListCalls()
|
||||
assert.Equal(t, 1, len(listCalls))
|
||||
}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
values := url.Values{
|
||||
"page": []string{"1"},
|
||||
"per_page": []string{"30"},
|
||||
"q": []string{"topic:gh-extension"},
|
||||
}
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "search/repositories", values),
|
||||
httpmock.JSONResponse(searchResults()),
|
||||
)
|
||||
},
|
||||
isTTY: true,
|
||||
wantStdout: "Showing 4 of 4 extensions\n\n REPO DESCRIPTION\n✓ vilmibm/gh-screensaver terminal animations\n cli/gh-cool it's just cool ok\n samcoe/gh-triage helps with triage\n✓ github/gh-gei something something enterprise\n",
|
||||
},
|
||||
{
|
||||
name: "search for extensions non-tty",
|
||||
args: []string{"search"},
|
||||
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
|
||||
em.ListFunc = func() []extensions.Extension {
|
||||
return []extensions.Extension{
|
||||
&extensions.ExtensionMock{
|
||||
URLFunc: func() string {
|
||||
return "https://github.com/vilmibm/gh-screensaver"
|
||||
},
|
||||
},
|
||||
&extensions.ExtensionMock{
|
||||
URLFunc: func() string {
|
||||
return "https://github.com/github/gh-gei"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return func(t *testing.T) {
|
||||
listCalls := em.ListCalls()
|
||||
assert.Equal(t, 1, len(listCalls))
|
||||
}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
values := url.Values{
|
||||
"page": []string{"1"},
|
||||
"per_page": []string{"30"},
|
||||
"q": []string{"topic:gh-extension"},
|
||||
}
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "search/repositories", values),
|
||||
httpmock.JSONResponse(searchResults()),
|
||||
)
|
||||
},
|
||||
wantStdout: "installed\tvilmibm/gh-screensaver\tterminal animations\n\tcli/gh-cool\tit's just cool ok\n\tsamcoe/gh-triage\thelps with triage\ninstalled\tgithub/gh-gei\tsomething something enterprise\n",
|
||||
},
|
||||
{
|
||||
name: "search for extensions with keywords",
|
||||
args: []string{"search", "screen"},
|
||||
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
|
||||
em.ListFunc = func() []extensions.Extension {
|
||||
return []extensions.Extension{
|
||||
&extensions.ExtensionMock{
|
||||
URLFunc: func() string {
|
||||
return "https://github.com/vilmibm/gh-screensaver"
|
||||
},
|
||||
},
|
||||
&extensions.ExtensionMock{
|
||||
URLFunc: func() string {
|
||||
return "https://github.com/github/gh-gei"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return func(t *testing.T) {
|
||||
listCalls := em.ListCalls()
|
||||
assert.Equal(t, 1, len(listCalls))
|
||||
}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
values := url.Values{
|
||||
"page": []string{"1"},
|
||||
"per_page": []string{"30"},
|
||||
"q": []string{"screen topic:gh-extension"},
|
||||
}
|
||||
results := searchResults()
|
||||
results.Total = 1
|
||||
results.Items = []search.Repository{results.Items[0]}
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "search/repositories", values),
|
||||
httpmock.JSONResponse(results),
|
||||
)
|
||||
},
|
||||
wantStdout: "installed\tvilmibm/gh-screensaver\tterminal animations\n",
|
||||
},
|
||||
{
|
||||
name: "search for extensions with parameter flags",
|
||||
args: []string{"search", "--limit", "1", "--order", "asc", "--sort", "stars"},
|
||||
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
|
||||
em.ListFunc = func() []extensions.Extension {
|
||||
return []extensions.Extension{}
|
||||
}
|
||||
return func(t *testing.T) {
|
||||
listCalls := em.ListCalls()
|
||||
assert.Equal(t, 1, len(listCalls))
|
||||
}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
values := url.Values{
|
||||
"page": []string{"1"},
|
||||
"order": []string{"asc"},
|
||||
"sort": []string{"stars"},
|
||||
"per_page": []string{"1"},
|
||||
"q": []string{"topic:gh-extension"},
|
||||
}
|
||||
results := searchResults()
|
||||
results.Total = 1
|
||||
results.Items = []search.Repository{results.Items[0]}
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "search/repositories", values),
|
||||
httpmock.JSONResponse(results),
|
||||
)
|
||||
},
|
||||
wantStdout: "\tvilmibm/gh-screensaver\tterminal animations\n",
|
||||
},
|
||||
{
|
||||
name: "search for extensions with qualifier flags",
|
||||
args: []string{"search", "--license", "GPLv3", "--owner", "jillvalentine"},
|
||||
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
|
||||
em.ListFunc = func() []extensions.Extension {
|
||||
return []extensions.Extension{}
|
||||
}
|
||||
return func(t *testing.T) {
|
||||
listCalls := em.ListCalls()
|
||||
assert.Equal(t, 1, len(listCalls))
|
||||
}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
values := url.Values{
|
||||
"page": []string{"1"},
|
||||
"per_page": []string{"30"},
|
||||
"q": []string{"license:GPLv3 topic:gh-extension user:jillvalentine"},
|
||||
}
|
||||
results := searchResults()
|
||||
results.Total = 1
|
||||
results.Items = []search.Repository{results.Items[0]}
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "search/repositories", values),
|
||||
httpmock.JSONResponse(results),
|
||||
)
|
||||
},
|
||||
wantStdout: "\tvilmibm/gh-screensaver\tterminal animations\n",
|
||||
},
|
||||
{
|
||||
name: "search for extensions with web mode",
|
||||
args: []string{"search", "--web"},
|
||||
browseStubs: func(b *browser.Stub) func(*testing.T) {
|
||||
return func(t *testing.T) {
|
||||
b.Verify(t, "https://github.com/search?q=topic%3Agh-extension&type=repositories")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "install an extension",
|
||||
args: []string{"install", "owner/gh-some-ext"},
|
||||
|
|
@ -601,6 +785,16 @@ func TestNewCmdExtension(t *testing.T) {
|
|||
defer reg.Verify(t)
|
||||
client := http.Client{Transport: ®}
|
||||
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(®)
|
||||
}
|
||||
|
||||
var assertBrowserFunc func(*testing.T)
|
||||
browseStub := &browser.Stub{}
|
||||
if tt.browseStubs != nil {
|
||||
assertBrowserFunc = tt.browseStubs(browseStub)
|
||||
}
|
||||
|
||||
f := cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
|
|
@ -608,6 +802,7 @@ func TestNewCmdExtension(t *testing.T) {
|
|||
IOStreams: ios,
|
||||
ExtensionManager: em,
|
||||
Prompter: pm,
|
||||
Browser: browseStub,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &client, nil
|
||||
},
|
||||
|
|
@ -629,6 +824,10 @@ func TestNewCmdExtension(t *testing.T) {
|
|||
assertFunc(t)
|
||||
}
|
||||
|
||||
if assertBrowserFunc != nil {
|
||||
assertBrowserFunc(t)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
})
|
||||
|
|
@ -714,3 +913,44 @@ func Test_checkValidExtension(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func searchResults() search.RepositoriesResult {
|
||||
return search.RepositoriesResult{
|
||||
IncompleteResults: false,
|
||||
Items: []search.Repository{
|
||||
{
|
||||
FullName: "vilmibm/gh-screensaver",
|
||||
Name: "gh-screensaver",
|
||||
Description: "terminal animations",
|
||||
Owner: search.User{
|
||||
Login: "vilmibm",
|
||||
},
|
||||
},
|
||||
{
|
||||
FullName: "cli/gh-cool",
|
||||
Name: "gh-cool",
|
||||
Description: "it's just cool ok",
|
||||
Owner: search.User{
|
||||
Login: "cli",
|
||||
},
|
||||
},
|
||||
{
|
||||
FullName: "samcoe/gh-triage",
|
||||
Name: "gh-triage",
|
||||
Description: "helps with triage",
|
||||
Owner: search.User{
|
||||
Login: "samcoe",
|
||||
},
|
||||
},
|
||||
{
|
||||
FullName: "github/gh-gei",
|
||||
Name: "gh-gei",
|
||||
Description: "something something enterprise",
|
||||
Owner: search.User{
|
||||
Login: "github",
|
||||
},
|
||||
},
|
||||
},
|
||||
Total: 4,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue