add gh ext search

This commit is contained in:
vilmibm 2022-11-04 11:20:54 -07:00
parent 8617eb7df9
commit 4a2c5f222a
2 changed files with 388 additions and 0 deletions

View file

@ -9,8 +9,11 @@ import (
"github.com/MakeNowJust/heredoc"
"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/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/search"
"github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
)
@ -19,6 +22,9 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
m := f.ExtensionManager
io := f.IOStreams
prompter := f.Prompter
config := f.Config
browser := f.Browser
httpClient := f.HttpClient
extCmd := cobra.Command{
Use: "extension",
@ -39,6 +45,148 @@ 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 available gh extensions",
Long: heredoc.Doc(`
Search for gh extensions.
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.
`),
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)
tp := tableprinter.New(io)
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)
}
result, err := searcher.Repositories(query)
if err != nil {
return err
}
if io.CanPrompt() {
fmt.Fprintf(io.Out, "Showing %d of %d gh extensions\n", query.Limit, result.Total)
fmt.Fprintln(io.Out)
tp.HeaderRow("NAME", "REPO", "INSTALLED", "OFFICIAL", "DESCRIPTION")
}
cs := io.ColorScheme()
installedExts := m.List()
isInstalled := func(repoFullName string) bool {
for _, e := range installedExts {
// TODO consider a Repo() on Extension interface
var installedRepo string
if u, err := git.ParseURL(e.URL()); err == nil {
if r, err := ghrepo.FromURL(u); err == nil {
installedRepo = ghrepo.FullName(r)
}
}
if repoFullName == installedRepo {
return true
}
}
return false
}
for _, repo := range result.Items {
if !strings.HasPrefix(repo.Name, "gh-") {
continue
}
official := ""
if repo.Owner.Login == "cli" || repo.Owner.Login == "github" {
if io.IsStdoutTTY() {
official = "✓"
} else {
official = "official"
}
}
installed := ""
if isInstalled(repo.FullName) {
if io.IsStdoutTTY() {
installed = "✓"
} else {
installed = "installed"
}
}
cmdName := strings.TrimPrefix(repo.Name, "gh-")
tp.AddField(cmdName, tableprinter.WithColor(cs.Bold))
tp.AddField(repo.FullName)
tp.AddField(installed, tableprinter.WithColor(cs.Green))
tp.AddField(official, tableprinter.WithColor(cs.Yellow))
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().StringVar(&qualifiers.Language, "language", "", "Filter based on the coding language")
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",

View file

@ -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: "screensaver vilmibm/gh-screensaver ✓ terminal animations\ncool cli/gh-cool ✓ it's just cool ok\ntriage samcoe/gh-triage helps with triage\ngei 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: "screensaver\tvilmibm/gh-screensaver\tinstalled\t\tterminal animations\ncool\tcli/gh-cool\t\tofficial\tit's just cool ok\ntriage\tsamcoe/gh-triage\t\t\thelps with triage\ngei\tgithub/gh-gei\tinstalled\tofficial\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: "screensaver\tvilmibm/gh-screensaver\tinstalled\t\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: "screensaver\tvilmibm/gh-screensaver\t\t\tterminal animations\n",
},
{
name: "search for extensions with qualifier flags",
args: []string{"search", "--language", "Go", "--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{"language:Go 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: "screensaver\tvilmibm/gh-screensaver\t\t\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"},
@ -595,6 +779,16 @@ func TestNewCmdExtension(t *testing.T) {
defer reg.Verify(t)
client := http.Client{Transport: &reg}
if tt.httpStubs != nil {
tt.httpStubs(&reg)
}
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
@ -602,6 +796,7 @@ func TestNewCmdExtension(t *testing.T) {
IOStreams: ios,
ExtensionManager: em,
Prompter: pm,
Browser: browseStub,
HttpClient: func() (*http.Client, error) {
return &client, nil
},
@ -623,6 +818,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())
})
@ -708,3 +907,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,
}
}