diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 6ea865fe3..f77d17b55 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -25,6 +25,7 @@ import ( repoCmd "github.com/cli/cli/v2/pkg/cmd/repo" creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits" runCmd "github.com/cli/cli/v2/pkg/cmd/run" + searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" versionCmd "github.com/cli/cli/v2/pkg/cmd/version" @@ -79,6 +80,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(gpgKeyCmd.NewCmdGPGKey(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) cmd.AddCommand(extensionCmd.NewCmdExtension(f)) + cmd.AddCommand(searchCmd.NewCmdSearch(f)) cmd.AddCommand(secretCmd.NewCmdSecret(f)) cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f)) cmd.AddCommand(newCodespaceCmd(f)) diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index fb18d04d2..a3d2b06de 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -108,24 +108,6 @@ func TestNewCmdList(t *testing.T) { } func TestListRun(t *testing.T) { - // helper to match mocked requests by their query params along with method and path - queryMatcher := func(method string, path string, query url.Values) httpmock.Matcher { - return func(req *http.Request) bool { - if !httpmock.REST(method, path)(req) { - return false - } - - actualQuery := req.URL.Query() - - for param := range query { - if !(actualQuery.Get(param) == query.Get(param)) { - return false - } - } - - return true - } - } tests := []struct { name string opts *ListOptions @@ -244,7 +226,7 @@ func TestListRun(t *testing.T) { }, stubs: func(reg *httpmock.Registry) { reg.Register( - queryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ "branch": []string{"the-branch"}, }), httpmock.JSONResponse(shared.RunsPayload{}), @@ -261,7 +243,7 @@ func TestListRun(t *testing.T) { }, stubs: func(reg *httpmock.Registry) { reg.Register( - queryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ "actor": []string{"bak1an"}, }), httpmock.JSONResponse(shared.RunsPayload{}), diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go new file mode 100644 index 000000000..878599dc6 --- /dev/null +++ b/pkg/cmd/search/repos/repos.go @@ -0,0 +1,203 @@ +package repos + +import ( + "fmt" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/search" + "github.com/cli/cli/v2/pkg/text" + "github.com/cli/cli/v2/utils" + "github.com/spf13/cobra" +) + +const ( + // Limitation of GitHub search see: + // https://docs.github.com/en/rest/reference/search + searchMaxResults = 1000 +) + +type ReposOptions struct { + Browser cmdutil.Browser + Exporter cmdutil.Exporter + IO *iostreams.IOStreams + Query search.Query + Searcher search.Searcher + WebMode bool +} + +func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Command { + var order string + var sort string + opts := &ReposOptions{ + Browser: f.Browser, + IO: f.IOStreams, + Query: search.Query{Kind: search.KindRepositories}, + } + + cmd := &cobra.Command{ + Use: "repos []", + Short: "Search for repositories", + Long: heredoc.Doc(` + Search for repositories on GitHub. + + The command supports constructing queries using the GitHub search syntax, + using the parameter and qualifier flags, or a combination of the two. + + GitHub search syntax is documented at: + https://docs.github.com/search-github/searching-on-github/searching-for-repositories + `), + Example: heredoc.Doc(` + # search repositories matching set of keywords "cli" and "shell" + $ gh search repos cli shell + + # search repositories matching phrase "vim plugin" + $ gh search repos "vim plugin" + + # search repositories public repos in the microsoft organization + $ gh search repos --owner=microsoft --visibility=public + + # search repositories with a set of topics + $ gh search repos --topic=unix,terminal + + # search repositories by coding language and number of good first issues + $ gh search repos --language=go --good-first-issues=">=10" + `), + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 0 && c.Flags().NFlag() == 0 { + return cmdutil.FlagErrorf("specify search keywords or flags") + } + if opts.Query.Limit < 1 || opts.Query.Limit > searchMaxResults { + return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000") + } + if c.Flags().Changed("order") { + opts.Query.Order = order + } + if c.Flags().Changed("sort") { + opts.Query.Sort = sort + } + opts.Query.Keywords = args + if runF != nil { + return runF(opts) + } + var err error + opts.Searcher, err = searcher(f) + if err != nil { + return err + } + return reposRun(opts) + }, + } + + // Output flags + cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.RepositoryFields) + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the search query in the web browser") + + // Query parameter flags + cmd.Flags().IntVarP(&opts.Query.Limit, "limit", "L", 30, "Maximum number of repositories 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") + + // Query qualifier flags + cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Archived, "archived", "", "Filter based on archive state") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Created, "created", "", "Filter based on created at `date`") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Followers, "followers", "", "Filter based on `number` of followers") + cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Fork, "include-forks", "", "", []string{"false", "true", "only"}, "Include forks in fetched repositories") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Forks, "forks", "", "Filter on `number` of forks") + cmd.Flags().StringVar(&opts.Query.Qualifiers.GoodFirstIssues, "good-first-issues", "", "Filter on `number` of issues with the 'good first issue' label") + cmd.Flags().StringVar(&opts.Query.Qualifiers.HelpWantedIssues, "help-wanted-issues", "", "Filter on `number` of issues with the 'help wanted' label") + cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"name", "description", "readme"}, "Restrict search to specific field of repository") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Language, "language", "", "Filter based on the coding language") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.License, "license", nil, "Filter based on license type") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Org, "owner", "", "Filter on owner") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Pushed, "updated", "", "Filter on last updated at `date`") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Size, "size", "", "Filter on a size range, in kilobytes") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Stars, "stars", "", "Filter on `number` of stars") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Topic, "topic", nil, "Filter on topic") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Topics, "number-topics", "", "Filter on `number` of topics") + cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", "", []string{"public", "private", "internal"}, "Filter based on visibility") + + return cmd +} + +func reposRun(opts *ReposOptions) error { + io := opts.IO + if opts.WebMode { + url := opts.Searcher.URL(opts.Query) + if io.IsStdoutTTY() { + fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(url)) + } + return opts.Browser.Browse(url) + } + io.StartProgressIndicator() + result, err := opts.Searcher.Repositories(opts.Query) + io.StopProgressIndicator() + if err != nil { + return err + } + if err := io.StartPager(); err == nil { + defer io.StopPager() + } else { + fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err) + } + if opts.Exporter != nil { + return opts.Exporter.Write(io, result.Items) + } + return displayResults(io, result) +} + +func displayResults(io *iostreams.IOStreams, results search.RepositoriesResult) error { + cs := io.ColorScheme() + tp := utils.NewTablePrinter(io) + for _, repo := range results.Items { + tags := []string{repo.Visibility} + if repo.IsFork { + tags = append(tags, "fork") + } + if repo.IsArchived { + tags = append(tags, "archived") + } + info := strings.Join(tags, ", ") + infoColor := cs.Gray + if repo.IsPrivate { + infoColor = cs.Yellow + } + tp.AddField(repo.FullName, nil, cs.Bold) + description := repo.Description + tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) + tp.AddField(info, nil, infoColor) + if tp.IsTTY() { + tp.AddField(utils.FuzzyAgoAbbr(time.Now(), repo.UpdatedAt), nil, cs.Gray) + } else { + tp.AddField(repo.UpdatedAt.Format(time.RFC3339), nil, nil) + } + tp.EndRow() + } + if io.IsStdoutTTY() { + header := "No repositories matched your search\n" + if len(results.Items) > 0 { + header = fmt.Sprintf("Showing %d of %d repositories\n\n", len(results.Items), results.Total) + } + fmt.Fprintf(io.Out, "\n%s", header) + } + return tp.Render() +} + +func searcher(f *cmdutil.Factory) (search.Searcher, error) { + cfg, err := f.Config() + if err != nil { + return nil, err + } + host, err := cfg.DefaultHost() + if err != nil { + return nil, err + } + client, err := f.HttpClient() + if err != nil { + return nil, err + } + return search.NewSearcher(client, host), nil +} diff --git a/pkg/cmd/search/repos/repos_test.go b/pkg/cmd/search/repos/repos_test.go new file mode 100644 index 000000000..872171b01 --- /dev/null +++ b/pkg/cmd/search/repos/repos_test.go @@ -0,0 +1,295 @@ +package repos + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/search" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdRepos(t *testing.T) { + var trueBool = true + tests := []struct { + name string + input string + output ReposOptions + wantErr bool + errMsg string + }{ + { + name: "no arguments", + input: "", + wantErr: true, + errMsg: "specify search keywords or flags", + }, + { + name: "keyword arguments", + input: "some search terms", + output: ReposOptions{ + Query: search.Query{Keywords: []string{"some", "search", "terms"}, Kind: "repositories", Limit: 30}, + }, + }, + { + name: "web flag", + input: "--web", + output: ReposOptions{ + Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 30}, + WebMode: true, + }, + }, + { + name: "limit flag", + input: "--limit 10", + output: ReposOptions{Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 10}}, + }, + { + name: "invalid limit flag", + input: "--limit 1001", + wantErr: true, + errMsg: "`--limit` must be between 1 and 1000", + }, + { + name: "order flag", + input: "--order asc", + output: ReposOptions{ + Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 30, Order: "asc"}, + }, + }, + { + name: "invalid order flag", + input: "--order invalid", + wantErr: true, + errMsg: "invalid argument \"invalid\" for \"--order\" flag: valid values are {asc|desc}", + }, + { + name: "qualifier flags", + input: ` + --archived + --created=created + --followers=1 + --include-forks=true + --forks=2 + --good-first-issues=3 + --help-wanted-issues=4 + --match=description,readme + --language=language + --license=license + --owner=owner + --updated=updated + --size=5 + --stars=6 + --topic=topic + --number-topics=7 + --visibility=public + `, + output: ReposOptions{ + Query: search.Query{ + Keywords: []string{}, + Kind: "repositories", + Limit: 30, + Qualifiers: search.Qualifiers{ + Archived: &trueBool, + Created: "created", + Followers: "1", + Fork: "true", + Forks: "2", + GoodFirstIssues: "3", + HelpWantedIssues: "4", + In: []string{"description", "readme"}, + Language: "language", + License: []string{"license"}, + Org: "owner", + Pushed: "updated", + Size: "5", + Stars: "6", + Topic: []string{"topic"}, + Topics: "7", + Is: "public", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *ReposOptions + cmd := NewCmdRepos(f, func(opts *ReposOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.output.Query, gotOpts.Query) + assert.Equal(t, tt.output.WebMode, gotOpts.WebMode) + }) + } +} + +func Test_ReposRun(t *testing.T) { + var query = search.Query{ + Keywords: []string{"cli"}, + Kind: "repositories", + Limit: 30, + Qualifiers: search.Qualifiers{ + Stars: ">50", + Topic: []string{"golang"}, + }, + } + var updatedAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC) + tests := []struct { + errMsg string + name string + opts *ReposOptions + tty bool + wantErr bool + wantStderr string + wantStdout string + }{ + { + name: "displays results tty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{ + IncompleteResults: false, + Items: []search.Repository{ + {FullName: "test/cli", Description: "of course", IsPrivate: true, IsArchived: true, UpdatedAt: updatedAt, Visibility: "private"}, + {FullName: "test/cliing", Description: "wow", IsFork: true, UpdatedAt: updatedAt, Visibility: "public"}, + {FullName: "cli/cli", Description: "so much", IsArchived: false, UpdatedAt: updatedAt, Visibility: "internal"}, + }, + Total: 300, + }, nil + }, + }, + }, + tty: true, + wantStdout: "\nShowing 3 of 300 repositories\n\ntest/cli of course private, archived Feb 28, 2021\ntest/cliing wow public, fork Feb 28, 2021\ncli/cli so much internal Feb 28, 2021\n", + }, + { + name: "displays no results tty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{}, nil + }, + }, + }, + tty: true, + wantStdout: "\nNo repositories matched your search\n", + }, + { + name: "displays results notty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{ + IncompleteResults: false, + Items: []search.Repository{ + {FullName: "test/cli", Description: "of course", IsPrivate: true, IsArchived: true, UpdatedAt: updatedAt, Visibility: "private"}, + {FullName: "test/cliing", Description: "wow", IsFork: true, UpdatedAt: updatedAt, Visibility: "public"}, + {FullName: "cli/cli", Description: "so much", IsArchived: false, UpdatedAt: updatedAt, Visibility: "internal"}, + }, + Total: 300, + }, nil + }, + }, + }, + wantStdout: "test/cli\tof course\tprivate, archived\t2021-02-28T12:30:00Z\ntest/cliing\twow\tpublic, fork\t2021-02-28T12:30:00Z\ncli/cli\tso much\tinternal\t2021-02-28T12:30:00Z\n", + }, + { + name: "displays no results notty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{}, nil + }, + }, + }, + }, + { + name: "displays search error", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{}, fmt.Errorf("error with query") + }, + }, + }, + errMsg: "error with query", + wantErr: true, + }, + { + name: "opens browser for web mode tty", + opts: &ReposOptions{ + Browser: &cmdutil.TestBrowser{}, + Query: query, + Searcher: &search.SearcherMock{ + URLFunc: func(query search.Query) string { + return "https://github.com/search?type=repositories&q=cli" + }, + }, + WebMode: true, + }, + tty: true, + wantStderr: "Opening github.com/search in your browser.\n", + }, + { + name: "opens browser for web mode notty", + opts: &ReposOptions{ + Browser: &cmdutil.TestBrowser{}, + Query: query, + Searcher: &search.SearcherMock{ + URLFunc: func(query search.Query) string { + return "https://github.com/search?type=repositories&q=cli" + }, + }, + WebMode: true, + }, + }, + } + for _, tt := range tests { + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + io.SetStderrTTY(tt.tty) + tt.opts.IO = io + t.Run(tt.name, func(t *testing.T) { + err := reposRun(tt.opts) + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } else if err != nil { + t.Fatalf("reposRun unexpected error: %v", err) + } + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/search/search.go b/pkg/cmd/search/search.go new file mode 100644 index 000000000..5342175c8 --- /dev/null +++ b/pkg/cmd/search/search.go @@ -0,0 +1,20 @@ +package search + +import ( + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" + + searchReposCmd "github.com/cli/cli/v2/pkg/cmd/search/repos" +) + +func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "search ", + Short: "Search for repositories, issues, pull requests and users", + Long: "Search across all of GitHub.", + } + + cmd.AddCommand(searchReposCmd.NewCmdRepos(f, nil)) + + return cmd +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index dee70069e..2a73a79de 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -34,6 +34,16 @@ func StringEnumFlag(cmd *cobra.Command, p *string, name, shorthand, defaultValue return f } +func StringSliceEnumFlag(cmd *cobra.Command, p *[]string, name, shorthand string, defaultValues, options []string, usage string) *pflag.Flag { + *p = defaultValues + val := &enumMultiValue{value: p, options: options} + f := cmd.Flags().VarPF(val, name, shorthand, fmt.Sprintf("%s: %s", usage, formatValuesForUsageDocs(options))) + _ = cmd.RegisterFlagCompletionFunc(name, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return options, cobra.ShellCompDirectiveNoFileComp + }) + return f +} + func formatValuesForUsageDocs(values []string) string { return fmt.Sprintf("{%s}", strings.Join(values, "|")) } @@ -99,14 +109,7 @@ type enumValue struct { } func (e *enumValue) Set(value string) error { - found := false - for _, opt := range e.options { - if strings.EqualFold(opt, value) { - found = true - break - } - } - if !found { + if !isIncluded(value, e.options) { return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options)) } *e.string = value @@ -120,3 +123,39 @@ func (e *enumValue) String() string { func (e *enumValue) Type() string { return "string" } + +type enumMultiValue struct { + value *[]string + options []string +} + +func (e *enumMultiValue) Set(value string) error { + items := strings.Split(value, ",") + for _, item := range items { + if !isIncluded(item, e.options) { + return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options)) + } + } + *e.value = append(*e.value, items...) + return nil +} + +func (e *enumMultiValue) String() string { + if len(*e.value) == 0 { + return "" + } + return fmt.Sprintf("{%s}", strings.Join(*e.value, ", ")) +} + +func (e *enumMultiValue) Type() string { + return "stringSlice" +} + +func isIncluded(value string, opts []string) bool { + for _, opt := range opts { + if strings.EqualFold(opt, value) { + return true + } + } + return false +} diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 5633c2caf..8970e8f49 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "net/http" + "net/url" "os" "regexp" "strings" @@ -56,6 +57,24 @@ func GraphQL(q string) Matcher { } } +func QueryMatcher(method string, path string, query url.Values) Matcher { + return func(req *http.Request) bool { + if !REST(method, path)(req) { + return false + } + + actualQuery := req.URL.Query() + + for param := range query { + if !(actualQuery.Get(param) == query.Get(param)) { + return false + } + } + + return true + } +} + func readBody(req *http.Request) ([]byte, error) { bodyCopy := &bytes.Buffer{} r := io.TeeReader(req.Body, bodyCopy) diff --git a/pkg/search/query.go b/pkg/search/query.go new file mode 100644 index 000000000..a0966ba93 --- /dev/null +++ b/pkg/search/query.go @@ -0,0 +1,111 @@ +package search + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/cli/cli/v2/pkg/text" +) + +const ( + KindRepositories = "repositories" +) + +type Query struct { + Keywords []string + Kind string + Limit int + Order string + Page int + Qualifiers Qualifiers + Sort string +} + +type Qualifiers struct { + Archived *bool + Created string + Followers string + Fork string + Forks string + GoodFirstIssues string + HelpWantedIssues string + In []string + Is string + Language string + License []string + Org string + Pushed string + Size string + Stars string + Topic []string + Topics string +} + +func (q Query) String() string { + qualifiers := formatQualifiers(q.Qualifiers) + keywords := formatKeywords(q.Keywords) + all := append(keywords, qualifiers...) + return strings.Join(all, " ") +} + +func (q Qualifiers) Map() map[string][]string { + m := map[string][]string{} + v := reflect.ValueOf(q) + t := reflect.TypeOf(q) + for i := 0; i < v.NumField(); i++ { + fieldName := t.Field(i).Name + key := text.CamelToKebab(fieldName) + typ := v.FieldByName(fieldName).Kind() + value := v.FieldByName(fieldName) + switch typ { + case reflect.Ptr: + if value.IsNil() { + continue + } + v := reflect.Indirect(value) + m[key] = []string{fmt.Sprintf("%v", v)} + case reflect.Slice: + if value.IsNil() { + continue + } + s := []string{} + for i := 0; i < value.Len(); i++ { + s = append(s, fmt.Sprintf("%v", value.Index(i))) + } + m[key] = s + default: + if value.IsZero() { + continue + } + m[key] = []string{fmt.Sprintf("%v", value)} + } + } + return m +} + +func quote(s string) string { + if strings.ContainsAny(s, " \"\t\r\n") { + return fmt.Sprintf("%q", s) + } + return s +} + +func formatQualifiers(qs Qualifiers) []string { + var all []string + for k, vs := range qs.Map() { + for _, v := range vs { + all = append(all, fmt.Sprintf("%s:%s", k, quote(v))) + } + } + sort.Strings(all) + return all +} + +func formatKeywords(ks []string) []string { + for i, k := range ks { + ks[i] = quote(k) + } + return ks +} diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go new file mode 100644 index 000000000..889acae76 --- /dev/null +++ b/pkg/search/query_test.go @@ -0,0 +1,135 @@ +package search + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var trueBool = true + +func TestQueryString(t *testing.T) { + tests := []struct { + name string + query Query + out string + }{ + { + name: "converts query to string", + query: Query{ + Keywords: []string{"some", "keywords"}, + Qualifiers: Qualifiers{ + Archived: &trueBool, + Created: "created", + Followers: "1", + Fork: "true", + Forks: "2", + GoodFirstIssues: "3", + HelpWantedIssues: "4", + In: []string{"description", "readme"}, + Language: "language", + License: []string{"license"}, + Org: "org", + Pushed: "updated", + Size: "5", + Stars: "6", + Topic: []string{"topic"}, + Topics: "7", + Is: "public", + }, + }, + out: "some keywords archived:true created:created followers:1 fork:true forks:2 good-first-issues:3 help-wanted-issues:4 in:description in:readme is:public language:language license:license org:org pushed:updated size:5 stars:6 topic:topic topics:7", + }, + { + name: "quotes keywords", + query: Query{ + Keywords: []string{"quote keywords"}, + }, + out: "\"quote keywords\"", + }, + { + name: "quotes qualifiers", + query: Query{ + Qualifiers: Qualifiers{ + Topic: []string{"quote qualifier"}, + }, + }, + out: "topic:\"quote qualifier\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, tt.query.String()) + }) + } +} + +func TestQualifiersMap(t *testing.T) { + tests := []struct { + name string + qualifiers Qualifiers + out map[string][]string + }{ + { + name: "changes qualifiers to map", + qualifiers: Qualifiers{ + Archived: &trueBool, + Created: "created", + Followers: "1", + Fork: "true", + Forks: "2", + GoodFirstIssues: "3", + HelpWantedIssues: "4", + In: []string{"readme"}, + Language: "language", + License: []string{"license"}, + Org: "org", + Pushed: "updated", + Size: "5", + Stars: "6", + Topic: []string{"topic"}, + Topics: "7", + Is: "public", + }, + out: map[string][]string{ + "archived": {"true"}, + "created": {"created"}, + "followers": {"1"}, + "fork": {"true"}, + "forks": {"2"}, + "good-first-issues": {"3"}, + "help-wanted-issues": {"4"}, + "in": {"readme"}, + "is": {"public"}, + "language": {"language"}, + "license": {"license"}, + "org": {"org"}, + "pushed": {"updated"}, + "size": {"5"}, + "stars": {"6"}, + "topic": {"topic"}, + "topics": {"7"}, + }, + }, + { + name: "excludes unset qualifiers from map", + qualifiers: Qualifiers{ + Org: "org", + Pushed: "updated", + Size: "5", + Stars: "6", + }, + out: map[string][]string{ + "org": {"org"}, + "pushed": {"updated"}, + "size": {"5"}, + "stars": {"6"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, tt.qualifiers.Map()) + }) + } +} diff --git a/pkg/search/result.go b/pkg/search/result.go new file mode 100644 index 000000000..99b3d2142 --- /dev/null +++ b/pkg/search/result.go @@ -0,0 +1,120 @@ +package search + +import ( + "reflect" + "strings" + "time" +) + +var RepositoryFields = []string{ + "createdAt", + "defaultBranch", + "description", + "forksCount", + "fullName", + "hasDownloads", + "hasIssues", + "hasPages", + "hasProjects", + "hasWiki", + "homepage", + "id", + "isArchived", + "isDisabled", + "isFork", + "isPrivate", + "language", + "license", + "name", + "openIssuesCount", + "owner", + "pushedAt", + "size", + "stargazersCount", + "updatedAt", + "visibility", + "watchersCount", +} + +type RepositoriesResult struct { + IncompleteResults bool `json:"incomplete_results"` + Items []Repository `json:"items"` + Total int `json:"total_count"` +} + +type Repository struct { + CreatedAt time.Time `json:"created_at"` + DefaultBranch string `json:"default_branch"` + Description string `json:"description"` + ForksCount int `json:"forks_count"` + FullName string `json:"full_name"` + HasDownloads bool `json:"has_downloads"` + HasIssues bool `json:"has_issues"` + HasPages bool `json:"has_pages"` + HasProjects bool `json:"has_projects"` + HasWiki bool `json:"has_wiki"` + Homepage string `json:"homepage"` + ID int64 `json:"id"` + IsArchived bool `json:"archived"` + IsDisabled bool `json:"disabled"` + IsFork bool `json:"fork"` + IsPrivate bool `json:"private"` + Language string `json:"language"` + License License `json:"license"` + MasterBranch string `json:"master_branch"` + Name string `json:"name"` + OpenIssuesCount int `json:"open_issues_count"` + Owner User `json:"owner"` + PushedAt time.Time `json:"pushed_at"` + Size int `json:"size"` + StargazersCount int `json:"stargazers_count"` + UpdatedAt time.Time `json:"updated_at"` + Visibility string `json:"visibility"` + WatchersCount int `json:"watchers_count"` +} + +type License struct { + HTMLURL string `json:"html_url"` + Key string `json:"key"` + Name string `json:"name"` + URL string `json:"url"` +} + +type User struct { + GravatarID string `json:"gravatar_id"` + ID int64 `json:"id"` + Login string `json:"login"` + SiteAdmin bool `json:"site_admin"` + Type string `json:"type"` +} + +func (repo Repository) ExportData(fields []string) map[string]interface{} { + v := reflect.ValueOf(repo) + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "license": + data[f] = map[string]interface{}{ + "key": repo.License.Key, + "name": repo.License.Name, + "url": repo.License.URL, + } + case "owner": + data[f] = map[string]interface{}{ + "id": repo.Owner.ID, + "login": repo.Owner.Login, + "type": repo.Owner.Type, + } + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + return data +} + +func fieldByName(v reflect.Value, field string) reflect.Value { + return v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(field, s) + }) +} diff --git a/pkg/search/result_test.go b/pkg/search/result_test.go new file mode 100644 index 000000000..185cc4e36 --- /dev/null +++ b/pkg/search/result_test.go @@ -0,0 +1,46 @@ +package search + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryExportData(t *testing.T) { + var createdAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC) + tests := []struct { + name string + fields []string + repo Repository + output string + }{ + { + name: "exports requested fields", + fields: []string{"createdAt", "description", "fullName", "isArchived", "isFork", "isPrivate", "pushedAt"}, + repo: Repository{ + CreatedAt: createdAt, + Description: "description", + FullName: "cli/cli", + IsArchived: true, + IsFork: false, + IsPrivate: false, + PushedAt: createdAt, + }, + output: `{"createdAt":"2021-02-28T12:30:00Z","description":"description","fullName":"cli/cli","isArchived":true,"isFork":false,"isPrivate":false,"pushedAt":"2021-02-28T12:30:00Z"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exported := tt.repo.ExportData(tt.fields) + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + require.NoError(t, enc.Encode(exported)) + assert.Equal(t, tt.output, strings.TrimSpace(buf.String())) + }) + } +} diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go new file mode 100644 index 000000000..39d2c09c5 --- /dev/null +++ b/pkg/search/searcher.go @@ -0,0 +1,184 @@ +package search + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/cli/cli/v2/internal/ghinstance" +) + +const ( + maxPerPage = 100 + orderKey = "order" + sortKey = "sort" +) + +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) +var pageRE = regexp.MustCompile(`(\?|&)page=(\d*)`) +var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) + +//go:generate moq -rm -out searcher_mock.go . Searcher +type Searcher interface { + Repositories(Query) (RepositoriesResult, error) + URL(Query) string +} + +type searcher struct { + client *http.Client + host string +} + +type httpError struct { + Errors []httpErrorItem + Message string + RequestURL *url.URL + StatusCode int +} + +type httpErrorItem struct { + Code string + Field string + Message string + Resource string +} + +func NewSearcher(client *http.Client, host string) Searcher { + return &searcher{ + client: client, + host: host, + } +} + +func (s searcher) Repositories(query Query) (RepositoriesResult, error) { + result := RepositoriesResult{} + toRetrieve := query.Limit + var resp *http.Response + var err error + for toRetrieve > 0 { + query.Limit = min(toRetrieve, maxPerPage) + query.Page = nextPage(resp) + if query.Page == 0 { + break + } + page := RepositoriesResult{} + resp, err = s.search(query, &page) + if err != nil { + return result, err + } + result.IncompleteResults = page.IncompleteResults + result.Total = page.Total + result.Items = append(result.Items, page.Items...) + toRetrieve = toRetrieve - len(page.Items) + } + return result, nil +} + +func (s searcher) search(query Query, result interface{}) (*http.Response, error) { + path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind) + qs := url.Values{} + qs.Set("page", strconv.Itoa(query.Page)) + qs.Set("per_page", strconv.Itoa(query.Limit)) + qs.Set("q", query.String()) + if query.Order != "" { + qs.Set(orderKey, query.Order) + } + if query.Sort != "" { + qs.Set(sortKey, query.Sort) + } + url := fmt.Sprintf("%s?%s", path, qs.Encode()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "application/vnd.github.v3+json") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return resp, handleHTTPError(resp) + } + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(result) + if err != nil { + return resp, err + } + return resp, nil +} + +func (s searcher) URL(query Query) string { + path := fmt.Sprintf("https://%s/search", s.host) + qs := url.Values{} + qs.Set("type", query.Kind) + qs.Set("q", query.String()) + if query.Order != "" { + qs.Set(orderKey, query.Order) + } + if query.Sort != "" { + qs.Set(sortKey, query.Sort) + } + url := fmt.Sprintf("%s?%s", path, qs.Encode()) + return url +} + +func (err httpError) Error() string { + if err.StatusCode != 422 || len(err.Errors) == 0 { + return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL) + } + query := strings.TrimSpace(err.RequestURL.Query().Get("q")) + return fmt.Sprintf("Invalid search query %q.\n%s", query, err.Errors[0].Message) +} + +func handleHTTPError(resp *http.Response) error { + httpError := httpError{ + RequestURL: resp.Request.URL, + StatusCode: resp.StatusCode, + } + if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) { + httpError.Message = resp.Status + return httpError + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if err := json.Unmarshal(body, &httpError); err != nil { + return err + } + return httpError +} + +func nextPage(resp *http.Response) (page int) { + if resp == nil { + return 1 + } + for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { + if !(len(m) > 2 && m[2] == "next") { + continue + } + p := pageRE.FindStringSubmatch(m[1]) + if len(p) == 3 { + i, err := strconv.Atoi(p[2]) + if err == nil { + return i + } + } + } + return 0 +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/search/searcher_mock.go b/pkg/search/searcher_mock.go new file mode 100644 index 000000000..9d584867b --- /dev/null +++ b/pkg/search/searcher_mock.go @@ -0,0 +1,116 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package search + +import ( + "sync" +) + +// Ensure, that SearcherMock does implement Searcher. +// If this is not the case, regenerate this file with moq. +var _ Searcher = &SearcherMock{} + +// SearcherMock is a mock implementation of Searcher. +// +// func TestSomethingThatUsesSearcher(t *testing.T) { +// +// // make and configure a mocked Searcher +// mockedSearcher := &SearcherMock{ +// RepositoriesFunc: func(query Query) (RepositoriesResult, error) { +// panic("mock out the Repositories method") +// }, +// URLFunc: func(query Query) string { +// panic("mock out the URL method") +// }, +// } +// +// // use mockedSearcher in code that requires Searcher +// // and then make assertions. +// +// } +type SearcherMock struct { + // RepositoriesFunc mocks the Repositories method. + RepositoriesFunc func(query Query) (RepositoriesResult, error) + + // URLFunc mocks the URL method. + URLFunc func(query Query) string + + // calls tracks calls to the methods. + calls struct { + // Repositories holds details about calls to the Repositories method. + Repositories []struct { + // Query is the query argument value. + Query Query + } + // URL holds details about calls to the URL method. + URL []struct { + // Query is the query argument value. + Query Query + } + } + lockRepositories sync.RWMutex + lockURL sync.RWMutex +} + +// Repositories calls RepositoriesFunc. +func (mock *SearcherMock) Repositories(query Query) (RepositoriesResult, error) { + if mock.RepositoriesFunc == nil { + panic("SearcherMock.RepositoriesFunc: method is nil but Searcher.Repositories was just called") + } + callInfo := struct { + Query Query + }{ + Query: query, + } + mock.lockRepositories.Lock() + mock.calls.Repositories = append(mock.calls.Repositories, callInfo) + mock.lockRepositories.Unlock() + return mock.RepositoriesFunc(query) +} + +// RepositoriesCalls gets all the calls that were made to Repositories. +// Check the length with: +// len(mockedSearcher.RepositoriesCalls()) +func (mock *SearcherMock) RepositoriesCalls() []struct { + Query Query +} { + var calls []struct { + Query Query + } + mock.lockRepositories.RLock() + calls = mock.calls.Repositories + mock.lockRepositories.RUnlock() + return calls +} + +// URL calls URLFunc. +func (mock *SearcherMock) URL(query Query) string { + if mock.URLFunc == nil { + panic("SearcherMock.URLFunc: method is nil but Searcher.URL was just called") + } + callInfo := struct { + Query Query + }{ + Query: query, + } + mock.lockURL.Lock() + mock.calls.URL = append(mock.calls.URL, callInfo) + mock.lockURL.Unlock() + return mock.URLFunc(query) +} + +// URLCalls gets all the calls that were made to URL. +// Check the length with: +// len(mockedSearcher.URLCalls()) +func (mock *SearcherMock) URLCalls() []struct { + Query Query +} { + var calls []struct { + Query Query + } + mock.lockURL.RLock() + calls = mock.calls.URL + mock.lockURL.RUnlock() + return calls +} diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go new file mode 100644 index 000000000..9aadc32f1 --- /dev/null +++ b/pkg/search/searcher_test.go @@ -0,0 +1,198 @@ +package search + +import ( + "net/http" + "net/url" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" +) + +var query = Query{ + Keywords: []string{"keyword"}, + Kind: "repositories", + Limit: 30, + Order: "stars", + Sort: "desc", + Qualifiers: Qualifiers{ + Stars: ">=5", + Topic: []string{"topic"}, + }, +} + +func TestSearcherRepositories(t *testing.T) { + values := url.Values{ + "page": []string{"1"}, + "per_page": []string{"30"}, + "order": []string{"stars"}, + "sort": []string{"desc"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + } + + tests := []struct { + name string + host string + query Query + result RepositoriesResult + wantErr bool + errMsg string + httpStubs func(*httpmock.Registry) + }{ + { + name: "searches repositories", + query: query, + result: RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "search/repositories", values), + httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }), + ) + }, + }, + { + name: "searches repositories for enterprise host", + host: "enterprise.com", + query: query, + result: RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "api/v3/search/repositories", values), + httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }), + ) + }, + }, + { + name: "paginates results", + query: query, + result: RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}, {Name: "cli"}}, + Total: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/repositories", values) + firstRes := httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 2, + }, + ) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"2"}, + "per_page": []string{"29"}, + "order": []string{"stars"}, + "sort": []string{"desc"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + }, + ) + secondRes := httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "cli"}}, + Total: 2, + }, + ) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "handles search errors", + query: query, + wantErr: true, + errMsg: heredoc.Doc(` + Invalid search query "keyword stars:>=5 topic:topic". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "search/repositories", values), + httpmock.WithHeader( + httpmock.StatusStringResponse(422, + `{ + "message":"Validation Failed", + "errors":[ + { + "message":"\"blah\" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.", + "resource":"Search", + "field":"q", + "code":"invalid" + } + ], + "documentation_url":"https://docs.github.com/v3/search/" + }`, + ), "Content-Type", "application/json"), + ) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + client := &http.Client{Transport: reg} + if tt.host == "" { + tt.host = "github.com" + } + searcher := NewSearcher(client, tt.host) + result, err := searcher.Repositories(tt.query) + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.result, result) + }) + } +} + +func TestSearcherURL(t *testing.T) { + tests := []struct { + name string + host string + query Query + url string + }{ + { + name: "outputs encoded query url", + query: query, + url: "https://github.com/search?order=stars&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=desc&type=repositories", + }, + { + name: "supports enterprise hosts", + host: "enterprise.com", + query: query, + url: "https://enterprise.com/search?order=stars&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=desc&type=repositories", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.host == "" { + tt.host = "github.com" + } + searcher := NewSearcher(nil, tt.host) + assert.Equal(t, tt.url, searcher.URL(tt.query)) + }) + } +} diff --git a/pkg/text/convert.go b/pkg/text/convert.go new file mode 100644 index 000000000..c5d2f401d --- /dev/null +++ b/pkg/text/convert.go @@ -0,0 +1,29 @@ +package text + +import "unicode" + +// Copied from: https://github.com/asaskevich/govalidator +func CamelToKebab(str string) string { + var output []rune + var segment []rune + for _, r := range str { + if !unicode.IsLower(r) && string(r) != "-" && !unicode.IsNumber(r) { + output = addSegment(output, segment) + segment = nil + } + segment = append(segment, unicode.ToLower(r)) + } + output = addSegment(output, segment) + return string(output) +} + +func addSegment(inrune, segment []rune) []rune { + if len(segment) == 0 { + return inrune + } + if len(inrune) != 0 { + inrune = append(inrune, '-') + } + inrune = append(inrune, segment...) + return inrune +} diff --git a/pkg/text/convert_test.go b/pkg/text/convert_test.go new file mode 100644 index 000000000..5321fbf10 --- /dev/null +++ b/pkg/text/convert_test.go @@ -0,0 +1,61 @@ +package text + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCamelToKebab(t *testing.T) { + tests := []struct { + name string + in string + out string + }{ + { + name: "single lowercase word", + in: "test", + out: "test", + }, + { + name: "multiple mixed words", + in: "testTestTest", + out: "test-test-test", + }, + { + name: "multiple uppercase words", + in: "TestTest", + out: "test-test", + }, + { + name: "multiple lowercase words", + in: "testtest", + out: "testtest", + }, + { + name: "multiple mixed words with number", + in: "test2Test", + out: "test2-test", + }, + { + name: "multiple lowercase words with number", + in: "test2test", + out: "test2test", + }, + { + name: "multiple lowercase words with dash", + in: "test-test", + out: "test-test", + }, + { + name: "multiple uppercase words with dash", + in: "Test-Test", + out: "test--test", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, CamelToKebab(tt.in)) + }) + } +}