Add search commits command (#6817)

This commit is contained in:
Kevin Lee 2023-01-17 11:35:09 -08:00 committed by GitHub
parent ba27e5bfb8
commit 9a1056fc87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 914 additions and 53 deletions

View file

@ -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))
}

View file

@ -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()

View file

@ -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 [<query>]",
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:
<https://docs.github.com/search-github/searching-on-github/searching-commits>
`),
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()
}

View file

@ -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())
})
}
}

View file

@ -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))

View file

@ -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

View file

@ -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"},

View file

@ -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":

View file

@ -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",

View file

@ -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

View file

@ -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 {

View file

@ -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", `<https://api.github.com/search/commits?page=2&per_page=100&q=org%3Agithub>; 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"},