From 9a1056fc87144bf4cdfd8f11f124822a97899c4c Mon Sep 17 00:00:00 2001 From: Kevin Lee <21070577+kevhlee@users.noreply.github.com> Date: Tue, 17 Jan 2023 11:35:09 -0800 Subject: [PATCH] Add `search commits` command (#6817) --- internal/tableprinter/table_printer.go | 7 +- pkg/cmd/release/list/list.go | 3 +- pkg/cmd/search/commits/commits.go | 172 ++++++++++++++ pkg/cmd/search/commits/commits_test.go | 311 +++++++++++++++++++++++++ pkg/cmd/search/search.go | 2 + pkg/search/query.go | 12 + pkg/search/query_test.go | 8 +- pkg/search/result.go | 188 +++++++++++---- pkg/search/result_test.go | 38 ++- pkg/search/searcher.go | 25 ++ pkg/search/searcher_mock.go | 44 ++++ pkg/search/searcher_test.go | 157 +++++++++++++ 12 files changed, 914 insertions(+), 53 deletions(-) create mode 100644 pkg/cmd/search/commits/commits.go create mode 100644 pkg/cmd/search/commits/commits_test.go diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go index 2e8d398eb..059203a6e 100644 --- a/internal/tableprinter/table_printer.go +++ b/internal/tableprinter/table_printer.go @@ -24,11 +24,12 @@ func (t *TablePrinter) HeaderRow(columns ...string) { t.EndRow() } -func (tp *TablePrinter) AddTimeField(t time.Time, c func(string) string) { +// In tty mode display the fuzzy time difference between now and t. +// In nontty mode just display t with the time.RFC3339 format. +func (tp *TablePrinter) AddTimeField(now, t time.Time, c func(string) string) { tf := t.Format(time.RFC3339) if tp.isTTY { - // TODO: use a static time.Now - tf = text.FuzzyAgo(time.Now(), t) + tf = text.FuzzyAgo(now, t) } tp.AddField(tf, tableprinter.WithColor(c)) } diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go index 13e0cf8f3..f2258a933 100644 --- a/pkg/cmd/release/list/list.go +++ b/pkg/cmd/release/list/list.go @@ -3,6 +3,7 @@ package list import ( "fmt" "net/http" + "time" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" @@ -107,7 +108,7 @@ func listRun(opts *ListOptions) error { if rel.PublishedAt.IsZero() { pubDate = rel.CreatedAt } - table.AddTimeField(pubDate, iofmt.Gray) + table.AddTimeField(time.Now(), pubDate, iofmt.Gray) table.EndRow() } err = table.Render() diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go new file mode 100644 index 000000000..5b24abb60 --- /dev/null +++ b/pkg/cmd/search/commits/commits.go @@ -0,0 +1,172 @@ +package commits + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/search/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/search" + "github.com/spf13/cobra" +) + +type CommitsOptions struct { + Browser browser.Browser + Exporter cmdutil.Exporter + IO *iostreams.IOStreams + Now time.Time + Query search.Query + Searcher search.Searcher + WebMode bool +} + +func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra.Command { + var order string + var sort string + opts := &CommitsOptions{ + Browser: f.Browser, + IO: f.IOStreams, + Query: search.Query{Kind: search.KindCommits}, + } + + cmd := &cobra.Command{ + Use: "commits []", + Short: "Search for commits", + Long: heredoc.Doc(` + Search for commits 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: + + `), + Example: heredoc.Doc(` + # search commits matching set of keywords "readme" and "typo" + $ gh search commits readme typo + + # search commits matching phrase "bug fix" + $ gh search commits "bug fix" + + # search commits committed by user "monalisa" + $ gh search commits --committer=monalisa + + # search commits authored by users with name "Jane Doe" + $ gh search commits --author-name="Jane Doe" + + # search commits matching hash "8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3" + $ gh search commits --hash=8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3 + + # search commits authored before February 1st, 2022 + $ gh search commits --author-date="<2022-02-01" + `), + 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 > shared.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 = shared.Searcher(f) + if err != nil { + return err + } + return commitsRun(opts) + }, + } + + // Output flags + cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.CommitFields) + 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 commits to fetch") + cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of commits returned, ignored unless '--sort' flag is specified") + cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"author-date", "committer-date"}, "Sort fetched commits") + + // Query qualifier flags + cmd.Flags().StringVar(&opts.Query.Qualifiers.Author, "author", "", "Filter by author") + cmd.Flags().StringVar(&opts.Query.Qualifiers.AuthorDate, "author-date", "", "Filter based on authored `date`") + cmd.Flags().StringVar(&opts.Query.Qualifiers.AuthorEmail, "author-email", "", "Filter on author email") + cmd.Flags().StringVar(&opts.Query.Qualifiers.AuthorName, "author-name", "", "Filter on author name") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Committer, "committer", "", "Filter by committer") + cmd.Flags().StringVar(&opts.Query.Qualifiers.CommitterDate, "committer-date", "", "Filter based on committed `date`") + cmd.Flags().StringVar(&opts.Query.Qualifiers.CommitterEmail, "committer-email", "", "Filter on committer email") + cmd.Flags().StringVar(&opts.Query.Qualifiers.CommitterName, "committer-name", "", "Filter on committer name") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Hash, "hash", "", "Filter by commit hash") + cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Merge, "merge", "", "Filter on merge commits") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Parent, "parent", "", "Filter by parent hash") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Tree, "tree", "", "Filter by tree hash") + cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner") + cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility") + + return cmd +} + +func commitsRun(opts *CommitsOptions) 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", text.DisplayURL(url)) + } + return opts.Browser.Browse(url) + } + io.StartProgressIndicator() + result, err := opts.Searcher.Commits(opts.Query) + io.StopProgressIndicator() + if err != nil { + return err + } + if len(result.Items) == 0 && opts.Exporter == nil { + return cmdutil.NewNoResultsError("no commits matched your search") + } + 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, opts.Now, result) +} + +func displayResults(io *iostreams.IOStreams, now time.Time, results search.CommitsResult) error { + if now.IsZero() { + now = time.Now() + } + cs := io.ColorScheme() + tp := tableprinter.New(io) + for _, commit := range results.Items { + tp.AddField(commit.Repo.FullName) + tp.AddField(commit.Sha) + tp.AddField(text.RemoveExcessiveWhitespace(commit.Info.Message)) + tp.AddField(commit.Author.Login) + tp.AddTimeField(now, commit.Info.Author.Date, cs.Gray) + tp.EndRow() + } + if io.IsStdoutTTY() { + header := fmt.Sprintf("Showing %d of %d commits\n\n", len(results.Items), results.Total) + fmt.Fprintf(io.Out, "\n%s", header) + } + return tp.Render() +} diff --git a/pkg/cmd/search/commits/commits_test.go b/pkg/cmd/search/commits/commits_test.go new file mode 100644 index 000000000..52e059428 --- /dev/null +++ b/pkg/cmd/search/commits/commits_test.go @@ -0,0 +1,311 @@ +package commits + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/cli/cli/v2/internal/browser" + "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 TestNewCmdCommits(t *testing.T) { + var trueBool = true + tests := []struct { + name string + input string + output CommitsOptions + wantErr bool + errMsg string + }{ + { + name: "no arguments", + input: "", + wantErr: true, + errMsg: "specify search keywords or flags", + }, + { + name: "keyword arguments", + input: "some search terms", + output: CommitsOptions{ + Query: search.Query{Keywords: []string{"some", "search", "terms"}, Kind: "commits", Limit: 30}, + }, + }, + { + name: "web flag", + input: "--web", + output: CommitsOptions{ + Query: search.Query{Keywords: []string{}, Kind: "commits", Limit: 30}, + WebMode: true, + }, + }, + { + name: "limit flag", + input: "--limit 10", + output: CommitsOptions{Query: search.Query{Keywords: []string{}, Kind: "commits", 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: CommitsOptions{ + Query: search.Query{Keywords: []string{}, Kind: "commits", 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: ` + --author=foo + --author-date=01-01-2000 + --author-email=foo@example.com + --author-name=Foo + --committer=bar + --committer-date=01-02-2000 + --committer-email=bar@example.com + --committer-name=Bar + --hash=aaa + --merge + --parent=bbb + --repo=owner/repo + --tree=ccc + --owner=owner + --visibility=public + `, + output: CommitsOptions{ + Query: search.Query{ + Keywords: []string{}, + Kind: "commits", + Limit: 30, + Qualifiers: search.Qualifiers{ + Author: "foo", + AuthorDate: "01-01-2000", + AuthorEmail: "foo@example.com", + AuthorName: "Foo", + Committer: "bar", + CommitterDate: "01-02-2000", + CommitterEmail: "bar@example.com", + CommitterName: "Bar", + Hash: "aaa", + Merge: &trueBool, + Parent: "bbb", + Repo: []string{"owner/repo"}, + Tree: "ccc", + User: "owner", + Is: []string{"public"}, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *CommitsOptions + cmd := NewCmdCommits(f, func(opts *CommitsOptions) 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 TestCommitsRun(t *testing.T) { + var now = time.Date(2023, 1, 17, 12, 30, 0, 0, time.UTC) + var author = search.CommitUser{Date: time.Date(2022, 12, 27, 11, 30, 0, 0, time.UTC)} + var committer = search.CommitUser{Date: time.Date(2022, 12, 28, 12, 30, 0, 0, time.UTC)} + var query = search.Query{ + Keywords: []string{"cli"}, + Kind: "commits", + Limit: 30, + Qualifiers: search.Qualifiers{}, + } + tests := []struct { + errMsg string + name string + opts *CommitsOptions + tty bool + wantErr bool + wantStderr string + wantStdout string + }{ + { + name: "displays results tty", + opts: &CommitsOptions{ + Query: query, + Searcher: &search.SearcherMock{ + CommitsFunc: func(query search.Query) (search.CommitsResult, error) { + return search.CommitsResult{ + IncompleteResults: false, + Items: []search.Commit{ + { + Author: search.User{Login: "monalisa"}, + Info: search.CommitInfo{Author: author, Committer: committer, Message: "hello"}, + Repo: search.Repository{FullName: "test/cli"}, + Sha: "aaaaaaaa", + }, + { + Author: search.User{Login: "johnnytest"}, + Info: search.CommitInfo{Author: author, Committer: committer, Message: "hi"}, + Repo: search.Repository{FullName: "test/cliing", IsPrivate: true}, + Sha: "bbbbbbbb", + }, + { + Author: search.User{Login: "hubot"}, + Info: search.CommitInfo{Author: author, Committer: committer, Message: "greetings"}, + Repo: search.Repository{FullName: "cli/cli"}, + Sha: "cccccccc", + }, + }, + Total: 300, + }, nil + }, + }, + }, + tty: true, + wantStdout: "\nShowing 3 of 300 commits\n\ntest/cli aaaaaaaa hello monalisa about 21 days ago\ntest/cliing bbbbbbbb hi johnnytest about 21 days ago\ncli/cli cccccccc greetings hubot about 21 days ago\n", + }, + { + name: "displays results notty", + opts: &CommitsOptions{ + Query: query, + Searcher: &search.SearcherMock{ + CommitsFunc: func(query search.Query) (search.CommitsResult, error) { + return search.CommitsResult{ + IncompleteResults: false, + Items: []search.Commit{ + { + Author: search.User{Login: "monalisa"}, + Info: search.CommitInfo{Author: author, Committer: committer, Message: "hello"}, + Repo: search.Repository{FullName: "test/cli"}, + Sha: "aaaaaaaa", + }, + { + Author: search.User{Login: "johnnytest"}, + Info: search.CommitInfo{Author: author, Committer: committer, Message: "hi"}, + Repo: search.Repository{FullName: "test/cliing", IsPrivate: true}, + Sha: "bbbbbbbb", + }, + { + Author: search.User{Login: "hubot"}, + Info: search.CommitInfo{Author: author, Committer: committer, Message: "greetings"}, + Repo: search.Repository{FullName: "cli/cli"}, + Sha: "cccccccc", + }, + }, + Total: 300, + }, nil + }, + }, + }, + wantStdout: "test/cli\taaaaaaaa\thello\tmonalisa\t2022-12-27T11:30:00Z\ntest/cliing\tbbbbbbbb\thi\tjohnnytest\t2022-12-27T11:30:00Z\ncli/cli\tcccccccc\tgreetings\thubot\t2022-12-27T11:30:00Z\n", + }, + { + name: "displays no results", + opts: &CommitsOptions{ + Query: query, + Searcher: &search.SearcherMock{ + CommitsFunc: func(query search.Query) (search.CommitsResult, error) { + return search.CommitsResult{}, nil + }, + }, + }, + wantErr: true, + errMsg: "no commits matched your search", + }, + { + name: "displays search error", + opts: &CommitsOptions{ + Query: query, + Searcher: &search.SearcherMock{ + CommitsFunc: func(query search.Query) (search.CommitsResult, error) { + return search.CommitsResult{}, fmt.Errorf("error with query") + }, + }, + }, + errMsg: "error with query", + wantErr: true, + }, + { + name: "opens browser for web mode tty", + opts: &CommitsOptions{ + Browser: &browser.Stub{}, + Query: query, + Searcher: &search.SearcherMock{ + URLFunc: func(query search.Query) string { + return "https://github.com/search?type=commits&q=cli" + }, + }, + WebMode: true, + }, + tty: true, + wantStderr: "Opening github.com/search in your browser.\n", + }, + { + name: "opens browser for web mode notty", + opts: &CommitsOptions{ + Browser: &browser.Stub{}, + Query: query, + Searcher: &search.SearcherMock{ + URLFunc: func(query search.Query) string { + return "https://github.com/search?type=commits&q=cli" + }, + }, + WebMode: true, + }, + }, + } + for _, tt := range tests { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + tt.opts.IO = ios + tt.opts.Now = now + t.Run(tt.name, func(t *testing.T) { + err := commitsRun(tt.opts) + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } else if err != nil { + t.Fatalf("commitsRun 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 index 188981670..7b9a4a653 100644 --- a/pkg/cmd/search/search.go +++ b/pkg/cmd/search/search.go @@ -4,6 +4,7 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" + searchCommitsCmd "github.com/cli/cli/v2/pkg/cmd/search/commits" searchIssuesCmd "github.com/cli/cli/v2/pkg/cmd/search/issues" searchPrsCmd "github.com/cli/cli/v2/pkg/cmd/search/prs" searchReposCmd "github.com/cli/cli/v2/pkg/cmd/search/repos" @@ -16,6 +17,7 @@ func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { Long: "Search across all of GitHub.", } + cmd.AddCommand(searchCommitsCmd.NewCmdCommits(f, nil)) cmd.AddCommand(searchIssuesCmd.NewCmdIssues(f, nil)) cmd.AddCommand(searchPrsCmd.NewCmdPrs(f, nil)) cmd.AddCommand(searchReposCmd.NewCmdRepos(f, nil)) diff --git a/pkg/search/query.go b/pkg/search/query.go index f93e1a46f..192750f35 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -11,6 +11,7 @@ import ( const ( KindRepositories = "repositories" KindIssues = "issues" + KindCommits = "commits" ) type Query struct { @@ -27,16 +28,24 @@ type Qualifiers struct { Archived *bool Assignee string Author string + AuthorDate string + AuthorEmail string + AuthorName string Base string Closed string Commenter string Comments string + Committer string + CommitterDate string + CommitterEmail string + CommitterName string Created string Draft *bool Followers string Fork string Forks string GoodFirstIssues string + Hash string Head string HelpWantedIssues string In []string @@ -47,9 +56,11 @@ type Qualifiers struct { Language string License []string Mentions string + Merge *bool Merged string Milestone string No []string + Parent string Project string Pushed string Reactions string @@ -65,6 +76,7 @@ type Qualifiers struct { TeamReviewRequested string Topic []string Topics string + Tree string Type string Updated string User string diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index 27f201508..c2b2d8605 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -20,6 +20,8 @@ func TestQueryString(t *testing.T) { Keywords: []string{"some", "keywords"}, Qualifiers: Qualifiers{ Archived: &trueBool, + AuthorEmail: "foo@example.com", + CommitterDate: "2021-02-28", Created: "created", Followers: "1", Fork: "true", @@ -38,7 +40,7 @@ func TestQueryString(t *testing.T) { Is: []string{"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 pushed:updated size:5 stars:6 topic:topic topics:7 user:user", + out: "some keywords archived:true author-email:foo@example.com committer-date:2021-02-28 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 pushed:updated size:5 stars:6 topic:topic topics:7 user:user", }, { name: "quotes keywords", @@ -74,6 +76,8 @@ func TestQualifiersMap(t *testing.T) { name: "changes qualifiers to map", qualifiers: Qualifiers{ Archived: &trueBool, + AuthorEmail: "foo@example.com", + CommitterDate: "2021-02-28", Created: "created", Followers: "1", Fork: "true", @@ -93,6 +97,8 @@ func TestQualifiersMap(t *testing.T) { }, out: map[string][]string{ "archived": {"true"}, + "author-email": {"foo@example.com"}, + "committer-date": {"2021-02-28"}, "created": {"created"}, "followers": {"1"}, "fork": {"true"}, diff --git a/pkg/search/result.go b/pkg/search/result.go index 8c2e3aa39..d7113bfda 100644 --- a/pkg/search/result.go +++ b/pkg/search/result.go @@ -6,6 +6,17 @@ import ( "time" ) +var CommitFields = []string{ + "author", + "commit", + "committer", + "sha", + "id", + "parents", + "repository", + "url", +} + var RepositoryFields = []string{ "createdAt", "defaultBranch", @@ -61,6 +72,12 @@ var PullRequestFields = append(IssueFields, "isDraft", ) +type CommitsResult struct { + IncompleteResults bool `json:"incomplete_results"` + Items []Commit `json:"items"` + Total int `json:"total_count"` +} + type RepositoriesResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Repository `json:"items"` @@ -73,6 +90,40 @@ type IssuesResult struct { Total int `json:"total_count"` } +type Commit struct { + Author User `json:"author"` + Committer User `json:"committer"` + ID string `json:"node_id"` + Info CommitInfo `json:"commit"` + Parents []Parent `json:"parents"` + Repo Repository `json:"repository"` + Sha string `json:"sha"` + URL string `json:"html_url"` +} + +type CommitInfo struct { + Author CommitUser `json:"author"` + CommentCount int `json:"comment_count"` + Committer CommitUser `json:"committer"` + Message string `json:"message"` + Tree Tree `json:"tree"` +} + +type CommitUser struct { + Date time.Time `json:"date"` + Email string `json:"email"` + Name string `json:"name"` +} + +type Tree struct { + Sha string `json:"sha"` +} + +type Parent struct { + Sha string `json:"sha"` + URL string `json:"html_url"` +} + type Repository struct { CreatedAt time.Time `json:"created_at"` DefaultBranch string `json:"default_branch"` @@ -120,13 +171,6 @@ type User struct { URL string `json:"html_url"` } -func (u *User) IsBot() bool { - // copied from api/queries_issue.go - // would ideally be shared, but it would require coordinating a "user" - // abstraction in a bunch of places. - return u.ID == "" -} - type Issue struct { Assignees []User `json:"assignees"` Author User `json:"user"` @@ -157,18 +201,6 @@ type PullRequest struct { MergedAt time.Time `json:"merged_at"` } -// the state of an issue or a pull request, -// may be either open or closed. -// for a pull request, the "merged" state is -// inferred from a value for merged_at and -// which we take return instead of the "closed" state. -func (issue Issue) State() string { - if !issue.PullRequest.MergedAt.IsZero() { - return "merged" - } - return issue.StateInternal -} - type Label struct { Color string `json:"color"` Description string `json:"description"` @@ -176,6 +208,83 @@ type Label struct { Name string `json:"name"` } +func (u User) IsBot() bool { + // copied from api/queries_issue.go + // would ideally be shared, but it would require coordinating a "user" + // abstraction in a bunch of places. + return u.ID == "" +} + +func (u User) ExportData() map[string]interface{} { + isBot := u.IsBot() + login := u.Login + if isBot { + login = "app/" + login + } + return map[string]interface{}{ + "id": u.ID, + "login": login, + "type": u.Type, + "url": u.URL, + "is_bot": isBot, + } +} + +func (commit Commit) ExportData(fields []string) map[string]interface{} { + v := reflect.ValueOf(commit) + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "author": + data[f] = commit.Author.ExportData() + case "commit": + info := commit.Info + data[f] = map[string]interface{}{ + "author": map[string]interface{}{ + "date": info.Author.Date, + "email": info.Author.Email, + "name": info.Author.Name, + }, + "committer": map[string]interface{}{ + "date": info.Committer.Date, + "email": info.Committer.Email, + "name": info.Committer.Name, + }, + "comment_count": info.CommentCount, + "message": info.Message, + "tree": map[string]interface{}{"sha": info.Tree.Sha}, + } + case "committer": + data[f] = commit.Committer.ExportData() + case "parents": + parents := make([]interface{}, 0, len(commit.Parents)) + for _, parent := range commit.Parents { + parents = append(parents, map[string]interface{}{ + "sha": parent.Sha, + "url": parent.URL, + }) + } + data[f] = parents + case "repository": + repo := commit.Repo + data[f] = map[string]interface{}{ + "description": repo.Description, + "fullName": repo.FullName, + "name": repo.Name, + "id": repo.ID, + "isFork": repo.IsFork, + "isPrivate": repo.IsPrivate, + "owner": repo.Owner.ExportData(), + "url": repo.URL, + } + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + return data +} + func (repo Repository) ExportData(fields []string) map[string]interface{} { v := reflect.ValueOf(repo) data := map[string]interface{}{} @@ -188,12 +297,7 @@ func (repo Repository) ExportData(fields []string) map[string]interface{} { "url": repo.License.URL, } case "owner": - data[f] = map[string]interface{}{ - "id": repo.Owner.ID, - "login": repo.Owner.Login, - "type": repo.Owner.Type, - "url": repo.Owner.URL, - } + data[f] = repo.Owner.ExportData() default: sf := fieldByName(v, f) data[f] = sf.Interface() @@ -202,6 +306,16 @@ func (repo Repository) ExportData(fields []string) map[string]interface{} { return data } +// The state of an issue or a pull request, may be either open or closed. +// For a pull request, the "merged" state is inferred from a value for merged_at and +// which we take return instead of the "closed" state. +func (issue Issue) State() string { + if !issue.PullRequest.MergedAt.IsZero() { + return "merged" + } + return issue.StateInternal +} + func (issue Issue) IsPullRequest() bool { return issue.PullRequest.URL != "" } @@ -214,31 +328,11 @@ func (issue Issue) ExportData(fields []string) map[string]interface{} { case "assignees": assignees := make([]interface{}, 0, len(issue.Assignees)) for _, assignee := range issue.Assignees { - isBot := assignee.IsBot() - login := assignee.Login - if isBot { - login = "app/" + login - } - assignees = append(assignees, map[string]interface{}{ - "id": assignee.ID, - "login": login, - "type": assignee.Type, - "is_bot": isBot, - }) + assignees = append(assignees, assignee.ExportData()) } data[f] = assignees case "author": - isBot := issue.Author.IsBot() - login := issue.Author.Login - if isBot { - login = "app/" + login - } - data[f] = map[string]interface{}{ - "id": issue.Author.ID, - "login": login, - "type": issue.Author.Type, - "is_bot": isBot, - } + data[f] = issue.Author.ExportData() case "isPullRequest": data[f] = issue.IsPullRequest() case "labels": diff --git a/pkg/search/result_test.go b/pkg/search/result_test.go index 756e3b908..cdf424b0b 100644 --- a/pkg/search/result_test.go +++ b/pkg/search/result_test.go @@ -11,6 +11,42 @@ import ( "github.com/stretchr/testify/require" ) +func TestCommitExportData(t *testing.T) { + var authoredAt = time.Date(2021, 2, 27, 11, 30, 0, 0, time.UTC) + var committedAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC) + tests := []struct { + name string + fields []string + commit Commit + output string + }{ + { + name: "exports requested fields", + fields: []string{"author", "commit", "committer", "sha"}, + commit: Commit{ + Author: User{Login: "foo"}, + Committer: User{Login: "bar", ID: "123"}, + Info: CommitInfo{ + Author: CommitUser{Date: authoredAt, Name: "Foo"}, + Committer: CommitUser{Date: committedAt, Name: "Bar"}, + Message: "test message", + }, + Sha: "8dd03144ffdc6c0d", + }, + output: `{"author":{"id":"","is_bot":true,"login":"app/foo","type":"","url":""},"commit":{"author":{"date":"2021-02-27T11:30:00Z","email":"","name":"Foo"},"comment_count":0,"committer":{"date":"2021-02-28T12:30:00Z","email":"","name":"Bar"},"message":"test message","tree":{"sha":""}},"committer":{"id":"123","is_bot":false,"login":"bar","type":"","url":""},"sha":"8dd03144ffdc6c0d"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exported := tt.commit.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())) + }) + } +} + func TestRepositoryExportData(t *testing.T) { var createdAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC) tests := []struct { @@ -67,7 +103,7 @@ func TestIssueExportData(t *testing.T) { Title: "title", UpdatedAt: updatedAt, }, - output: `{"assignees":[{"id":"123","is_bot":false,"login":"test","type":""},{"id":"","is_bot":true,"login":"app/foo","type":""}],"body":"body","commentsCount":1,"isLocked":true,"labels":[{"color":"","description":"","id":"","name":"label1"},{"color":"","description":"","id":"","name":"label2"}],"repository":{"name":"repo","nameWithOwner":"owner/repo"},"title":"title","updatedAt":"2021-02-28T12:30:00Z"}`, + output: `{"assignees":[{"id":"123","is_bot":false,"login":"test","type":"","url":""},{"id":"","is_bot":true,"login":"app/foo","type":"","url":""}],"body":"body","commentsCount":1,"isLocked":true,"labels":[{"color":"","description":"","id":"","name":"label1"},{"color":"","description":"","id":"","name":"label2"}],"repository":{"name":"repo","nameWithOwner":"owner/repo"},"title":"title","updatedAt":"2021-02-28T12:30:00Z"}`, }, { name: "state when issue", diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 778402621..baa84c03c 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -25,6 +25,7 @@ var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) //go:generate moq -rm -out searcher_mock.go . Searcher type Searcher interface { + Commits(Query) (CommitsResult, error) Repositories(Query) (RepositoriesResult, error) Issues(Query) (IssuesResult, error) URL(Query) string @@ -56,6 +57,30 @@ func NewSearcher(client *http.Client, host string) Searcher { } } +func (s searcher) Commits(query Query) (CommitsResult, error) { + result := CommitsResult{} + 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 := CommitsResult{} + 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) Repositories(query Query) (RepositoriesResult, error) { result := RepositoriesResult{} toRetrieve := query.Limit diff --git a/pkg/search/searcher_mock.go b/pkg/search/searcher_mock.go index 12c31350d..c1eecdaf4 100644 --- a/pkg/search/searcher_mock.go +++ b/pkg/search/searcher_mock.go @@ -17,6 +17,9 @@ var _ Searcher = &SearcherMock{} // // // make and configure a mocked Searcher // mockedSearcher := &SearcherMock{ +// CommitsFunc: func(query Query) (CommitsResult, error) { +// panic("mock out the Commits method") +// }, // IssuesFunc: func(query Query) (IssuesResult, error) { // panic("mock out the Issues method") // }, @@ -33,6 +36,9 @@ var _ Searcher = &SearcherMock{} // // } type SearcherMock struct { + // CommitsFunc mocks the Commits method. + CommitsFunc func(query Query) (CommitsResult, error) + // IssuesFunc mocks the Issues method. IssuesFunc func(query Query) (IssuesResult, error) @@ -44,6 +50,11 @@ type SearcherMock struct { // calls tracks calls to the methods. calls struct { + // Commits holds details about calls to the Commits method. + Commits []struct { + // Query is the query argument value. + Query Query + } // Issues holds details about calls to the Issues method. Issues []struct { // Query is the query argument value. @@ -60,11 +71,44 @@ type SearcherMock struct { Query Query } } + lockCommits sync.RWMutex lockIssues sync.RWMutex lockRepositories sync.RWMutex lockURL sync.RWMutex } +// Commits calls CommitsFunc. +func (mock *SearcherMock) Commits(query Query) (CommitsResult, error) { + if mock.CommitsFunc == nil { + panic("SearcherMock.CommitsFunc: method is nil but Searcher.Commits was just called") + } + callInfo := struct { + Query Query + }{ + Query: query, + } + mock.lockCommits.Lock() + mock.calls.Commits = append(mock.calls.Commits, callInfo) + mock.lockCommits.Unlock() + return mock.CommitsFunc(query) +} + +// CommitsCalls gets all the calls that were made to Commits. +// Check the length with: +// +// len(mockedSearcher.CommitsCalls()) +func (mock *SearcherMock) CommitsCalls() []struct { + Query Query +} { + var calls []struct { + Query Query + } + mock.lockCommits.RLock() + calls = mock.calls.Commits + mock.lockCommits.RUnlock() + return calls +} + // Issues calls IssuesFunc. func (mock *SearcherMock) Issues(query Query) (IssuesResult, error) { if mock.IssuesFunc == nil { diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 99ea93479..8cc90c533 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -10,6 +10,163 @@ import ( "github.com/stretchr/testify/assert" ) +func TestSearcherCommits(t *testing.T) { + query := Query{ + Keywords: []string{"keyword"}, + Kind: "commits", + Limit: 30, + Order: "desc", + Sort: "committer-date", + Qualifiers: Qualifiers{ + Author: "foobar", + CommitterDate: ">2021-02-28", + }, + } + + values := url.Values{ + "page": []string{"1"}, + "per_page": []string{"30"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, + } + + tests := []struct { + name string + host string + query Query + result CommitsResult + wantErr bool + errMsg string + httpStubs func(*httpmock.Registry) + }{ + { + name: "searches commits", + query: query, + result: CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "abc"}}, + Total: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "search/commits", values), + httpmock.JSONResponse(CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "abc"}}, + Total: 1, + }), + ) + }, + }, + { + name: "searches commits for enterprise host", + host: "enterprise.com", + query: query, + result: CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "abc"}}, + Total: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "api/v3/search/commits", values), + httpmock.JSONResponse(CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "abc"}}, + Total: 1, + }), + ) + }, + }, + { + name: "paginates results", + query: query, + result: CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "abc"}, {Sha: "def"}}, + Total: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/commits", values) + firstRes := httpmock.JSONResponse(CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "abc"}}, + Total: 2, + }, + ) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ + "page": []string{"2"}, + "per_page": []string{"29"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, + }, + ) + secondRes := httpmock.JSONResponse(CommitsResult{ + IncompleteResults: false, + Items: []Commit{{Sha: "def"}}, + 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 author:foobar committer-date:>2021-02-28". + "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/commits", 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.Commits(tt.query) + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.result, result) + }) + } +} + func TestSearcherRepositories(t *testing.T) { query := Query{ Keywords: []string{"keyword"},