From 9a149d7694b7ce767dcd84f9dfa91e0c89b4d0ca Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Thu, 11 Feb 2021 19:43:08 -0300 Subject: [PATCH 01/10] Add `repo list` command --- pkg/cmd/repo/list/http.go | 176 ++++++++++++++++++++++++++++++ pkg/cmd/repo/list/list.go | 224 ++++++++++++++++++++++++++++++++++++++ pkg/cmd/repo/repo.go | 2 + 3 files changed, 402 insertions(+) create mode 100644 pkg/cmd/repo/list/http.go create mode 100644 pkg/cmd/repo/list/list.go diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go new file mode 100644 index 000000000..3409990c8 --- /dev/null +++ b/pkg/cmd/repo/list/http.go @@ -0,0 +1,176 @@ +package list + +import ( + "fmt" + "strings" + + "github.com/cli/cli/api" + "github.com/shurcooL/githubv4" +) + +type RepositoryList struct { + Repositories []Repository + TotalCount int +} + +func listRepos(client *api.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { + type reposBlock struct { + TotalCount int + RepositoryCount int + Nodes []Repository + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + + type response struct { + RepositoryOwner struct { + Repositories reposBlock + } + Search reposBlock + } + + fragment := ` + fragment repo on Repository { + nameWithOwner + description + isFork + isPrivate + isArchived + updatedAt + }` + + // If `--archived` wasn't specified, use `repositoryOwner.repositores` + query := fragment + ` + query RepoList($owner: String!, $per_page: Int!, $endCursor: String, $fork: Boolean, $privacy: RepositoryPrivacy) { + repositoryOwner(login: $owner) { + repositories( + first: $per_page, + after: $endCursor, + privacy: $privacy, + isFork: $fork, + ownerAffiliations: OWNER, + orderBy: { field: UPDATED_AT, direction: DESC }) { + totalCount + nodes { + ...repo + } + pageInfo { + hasNextPage + endCursor + } + } + } + }` + + perPage := limit + if perPage > 100 { + perPage = 100 + } + + variables := map[string]interface{}{ + "per_page": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), + } + + hasArchivedFilter := filter.Archived + + if hasArchivedFilter { + // If `--archived` was specified, use the `search` API rather than + // `repositoryOwner.repositories` + query = fragment + ` + query RepoList($per_page: Int!, $endCursor: String, $query: String!) { + search(first: $per_page, after:$endCursor, type: REPOSITORY, query: $query) { + repositoryCount + nodes { + ... on Repository { + ...repo + } + } + pageInfo { + hasNextPage + endCursor + } + } + }` + + search := []string{fmt.Sprintf("user:%s archived:true fork:true sort:updated-desc", owner)} + + switch filter.Visibility { + case "private": + search = append(search, "is:private") + case "public": + search = append(search, "is:public") + default: + search = append(search, "is:all") + } + + variables["query"] = strings.Join(search, " ") + } else { + variables["owner"] = githubv4.String(owner) + + if filter.Visibility != "" { + variables["privacy"] = githubv4.RepositoryPrivacy(strings.ToUpper(filter.Visibility)) + } else { + variables["privacy"] = (*githubv4.RepositoryPrivacy)(nil) + } + + if filter.Fork { + variables["fork"] = githubv4.Boolean(true) + } else if filter.Source { + variables["fork"] = githubv4.Boolean(false) + } else { + variables["fork"] = (*githubv4.Boolean)(nil) + } + } + + var repos []Repository + var totalCount int + + var result response + +pagination: + for { + err := client.GraphQL(hostname, query, variables, &result) + if err != nil { + return nil, err + } + + if hasArchivedFilter { + repos = append(repos, result.Search.Nodes...) + } else { + repos = append(repos, result.RepositoryOwner.Repositories.Nodes...) + } + + if len(repos) >= limit { + if len(repos) > limit { + repos = repos[:limit] + } + break pagination + } + + if !result.RepositoryOwner.Repositories.PageInfo.HasNextPage { + if !result.Search.PageInfo.HasNextPage { + break + } + } + + variables["endCursor"] = githubv4.String(result.RepositoryOwner.Repositories.PageInfo.EndCursor) + if hasArchivedFilter { + variables["endCursor"] = githubv4.String(result.Search.PageInfo.EndCursor) + } + } + + totalCount = result.RepositoryOwner.Repositories.TotalCount + if hasArchivedFilter { + totalCount = result.Search.RepositoryCount + } + + listResult := &RepositoryList{ + Repositories: repos, + TotalCount: totalCount, + } + + return listResult, nil +} diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go new file mode 100644 index 000000000..b066764b3 --- /dev/null +++ b/pkg/cmd/repo/list/list.go @@ -0,0 +1,224 @@ +package list + +import ( + "fmt" + "net/http" + "strings" + "time" + "unicode" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type FilterOptions struct { + Visibility string // private, public + Fork bool + Source bool + Archived bool +} + +type ListOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + + Limit int + Owner string + + Visibility string + Fork bool + Source bool + Archived bool +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + var ( + flagPublic bool + flagPrivate bool + flagSource bool + flagFork bool + flagArchived bool + ) + + cmd := &cobra.Command{ + Use: "list", + Args: cobra.MaximumNArgs(1), + Short: "List repositories from a user or organization", + RunE: func(c *cobra.Command, args []string) error { + if opts.Limit < 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.Limit)} + } + + if flagPrivate && flagPublic { + return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of `--public` or `--private`")} + } + if flagSource && flagFork { + return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of `--source` or `--fork`")} + } + + if flagPrivate { + opts.Visibility = "private" + } else if flagPublic { + opts.Visibility = "public" + } + + opts.Archived = flagArchived + opts.Fork = flagFork + opts.Source = flagSource + + if len(args) > 0 { + opts.Owner = args[0] + } + + if runF != nil { + return runF(&opts) + } + return listRun(&opts) + }, + } + + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of repositories to list") + cmd.Flags().BoolVar(&flagPrivate, "private", false, "Show only private repositories") + cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public repositories") + cmd.Flags().BoolVar(&flagSource, "source", false, "Show only source repositories") + cmd.Flags().BoolVar(&flagArchived, "archived", false, "Show only archived repositories") + cmd.Flags().BoolVar(&flagFork, "fork", false, "Show only forks") + + return cmd +} + +func listRun(opts *ListOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + isTerminal := opts.IO.IsStdoutTTY() + + owner := opts.Owner + if owner == "" { + owner, err = api.CurrentLoginName(apiClient, ghinstance.OverridableDefault()) + if err != nil { + return err + } + } + + filter := FilterOptions{ + Visibility: opts.Visibility, + Fork: opts.Fork, + Source: opts.Source, + Archived: opts.Archived, + } + + listResult, err := listRepos(apiClient, ghinstance.OverridableDefault(), opts.Limit, owner, filter) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + + tp := utils.NewTablePrinter(opts.IO) + + notArchived := (filter.Fork || filter.Source) && !filter.Archived + + matchCount := len(listResult.Repositories) + now := time.Now() + + for _, repo := range listResult.Repositories { + if notArchived && repo.IsArchived { + matchCount-- + listResult.TotalCount-- + continue + } + + nameWithOwner := repo.NameWithOwner + + info := repo.Info() + infoColor := cs.Gray + visibility := "Public" + + if repo.IsPrivate { + infoColor = cs.Yellow + visibility = "Private" + } + + description := repo.Description + updatedAt := repo.UpdatedAt.Format(time.RFC3339) + + if tp.IsTTY() { + tp.AddField(nameWithOwner, nil, cs.Bold) + tp.AddField(info, nil, infoColor) + tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) + tp.AddField(utils.FuzzyAgoAbbr(now, repo.UpdatedAt), nil, nil) + } else { + tp.AddField(nameWithOwner, nil, nil) + tp.AddField(visibility, nil, nil) + tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) + tp.AddField(updatedAt, nil, nil) + } + tp.EndRow() + } + + if isTerminal { + hasFilters := filter.Visibility != "" || filter.Fork || filter.Source || filter.Archived + title := listHeader(owner, matchCount, listResult.TotalCount, hasFilters) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) + } + + return tp.Render() +} + +func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool) string { + if totalMatchCount == 0 { + if hasFilters { + return "No results match your search" + } + return "There are no repositories in @" + owner + } + + return fmt.Sprintf("Showing %d of %d repositories in @%s", matchCount, totalMatchCount, owner) +} + +type Repository struct { + NameWithOwner string + Description string + IsFork bool + IsPrivate bool + IsArchived bool + UpdatedAt time.Time +} + +func (r Repository) Info() string { + var info string + + if r.IsPrivate { + info = "private" + } + if r.IsFork { + info += " fork" + } + if r.IsArchived { + info += " archived" + } + + if info != "" { + info = strings.TrimPrefix(info, " ") + infoRunes := []rune(info) + infoRunes[0] = unicode.ToUpper(infoRunes[0]) + info = fmt.Sprintf("(%s)", string(infoRunes)) + } + + return info +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 02e6c368b..4fee2b19c 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -7,6 +7,7 @@ import ( creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork" gardenCmd "github.com/cli/cli/pkg/cmd/repo/garden" + repoListCmd "github.com/cli/cli/pkg/cmd/repo/list" repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -36,6 +37,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoForkCmd.NewCmdFork(f, nil)) cmd.AddCommand(repoCloneCmd.NewCmdClone(f, nil)) cmd.AddCommand(repoCreateCmd.NewCmdCreate(f, nil)) + cmd.AddCommand(repoListCmd.NewCmdList(f, nil)) cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil)) From b7c2865d0f021f581ce5d14b18d3e81dd711af30 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Thu, 18 Feb 2021 17:34:00 -0300 Subject: [PATCH 02/10] Remove archived filter from repo list --- pkg/cmd/repo/list/http.go | 116 ++++++++++---------------------------- pkg/cmd/repo/list/list.go | 18 ++---- 2 files changed, 35 insertions(+), 99 deletions(-) diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 3409990c8..4669e72a3 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -1,7 +1,6 @@ package list import ( - "fmt" "strings" "github.com/cli/cli/api" @@ -14,35 +13,21 @@ type RepositoryList struct { } func listRepos(client *api.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { - type reposBlock struct { - TotalCount int - RepositoryCount int - Nodes []Repository - PageInfo struct { - HasNextPage bool - EndCursor string - } - } - type response struct { RepositoryOwner struct { - Repositories reposBlock + Repositories struct { + TotalCount int + RepositoryCount int + Nodes []Repository + PageInfo struct { + HasNextPage bool + EndCursor string + } + } } - Search reposBlock } - fragment := ` - fragment repo on Repository { - nameWithOwner - description - isFork - isPrivate - isArchived - updatedAt - }` - - // If `--archived` wasn't specified, use `repositoryOwner.repositores` - query := fragment + ` + query := ` query RepoList($owner: String!, $per_page: Int!, $endCursor: String, $fork: Boolean, $privacy: RepositoryPrivacy) { repositoryOwner(login: $owner) { repositories( @@ -54,7 +39,12 @@ func listRepos(client *api.Client, hostname string, limit int, owner string, fil orderBy: { field: UPDATED_AT, direction: DESC }) { totalCount nodes { - ...repo + nameWithOwner + description + isFork + isPrivate + isArchived + updatedAt } pageInfo { hasNextPage @@ -70,59 +60,23 @@ func listRepos(client *api.Client, hostname string, limit int, owner string, fil } variables := map[string]interface{}{ + "owner": githubv4.String(owner), "per_page": githubv4.Int(perPage), "endCursor": (*githubv4.String)(nil), } - hasArchivedFilter := filter.Archived - - if hasArchivedFilter { - // If `--archived` was specified, use the `search` API rather than - // `repositoryOwner.repositories` - query = fragment + ` - query RepoList($per_page: Int!, $endCursor: String, $query: String!) { - search(first: $per_page, after:$endCursor, type: REPOSITORY, query: $query) { - repositoryCount - nodes { - ... on Repository { - ...repo - } - } - pageInfo { - hasNextPage - endCursor - } - } - }` - - search := []string{fmt.Sprintf("user:%s archived:true fork:true sort:updated-desc", owner)} - - switch filter.Visibility { - case "private": - search = append(search, "is:private") - case "public": - search = append(search, "is:public") - default: - search = append(search, "is:all") - } - - variables["query"] = strings.Join(search, " ") + if filter.Visibility != "" { + variables["privacy"] = githubv4.RepositoryPrivacy(strings.ToUpper(filter.Visibility)) } else { - variables["owner"] = githubv4.String(owner) + variables["privacy"] = (*githubv4.RepositoryPrivacy)(nil) + } - if filter.Visibility != "" { - variables["privacy"] = githubv4.RepositoryPrivacy(strings.ToUpper(filter.Visibility)) - } else { - variables["privacy"] = (*githubv4.RepositoryPrivacy)(nil) - } - - if filter.Fork { - variables["fork"] = githubv4.Boolean(true) - } else if filter.Source { - variables["fork"] = githubv4.Boolean(false) - } else { - variables["fork"] = (*githubv4.Boolean)(nil) - } + if filter.Fork { + variables["fork"] = githubv4.Boolean(true) + } else if filter.Source { + variables["fork"] = githubv4.Boolean(false) + } else { + variables["fork"] = (*githubv4.Boolean)(nil) } var repos []Repository @@ -137,11 +91,7 @@ pagination: return nil, err } - if hasArchivedFilter { - repos = append(repos, result.Search.Nodes...) - } else { - repos = append(repos, result.RepositoryOwner.Repositories.Nodes...) - } + repos = append(repos, result.RepositoryOwner.Repositories.Nodes...) if len(repos) >= limit { if len(repos) > limit { @@ -151,21 +101,13 @@ pagination: } if !result.RepositoryOwner.Repositories.PageInfo.HasNextPage { - if !result.Search.PageInfo.HasNextPage { - break - } + break } variables["endCursor"] = githubv4.String(result.RepositoryOwner.Repositories.PageInfo.EndCursor) - if hasArchivedFilter { - variables["endCursor"] = githubv4.String(result.Search.PageInfo.EndCursor) - } } totalCount = result.RepositoryOwner.Repositories.TotalCount - if hasArchivedFilter { - totalCount = result.Search.RepositoryCount - } listResult := &RepositoryList{ Repositories: repos, diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index b066764b3..55a713962 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -20,7 +20,6 @@ type FilterOptions struct { Visibility string // private, public Fork bool Source bool - Archived bool } type ListOptions struct { @@ -33,7 +32,6 @@ type ListOptions struct { Visibility string Fork bool Source bool - Archived bool } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -43,11 +41,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } var ( - flagPublic bool - flagPrivate bool - flagSource bool - flagFork bool - flagArchived bool + flagPublic bool + flagPrivate bool + flagSource bool + flagFork bool ) cmd := &cobra.Command{ @@ -72,7 +69,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts.Visibility = "public" } - opts.Archived = flagArchived opts.Fork = flagFork opts.Source = flagSource @@ -91,7 +87,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().BoolVar(&flagPrivate, "private", false, "Show only private repositories") cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public repositories") cmd.Flags().BoolVar(&flagSource, "source", false, "Show only source repositories") - cmd.Flags().BoolVar(&flagArchived, "archived", false, "Show only archived repositories") cmd.Flags().BoolVar(&flagFork, "fork", false, "Show only forks") return cmd @@ -119,7 +114,6 @@ func listRun(opts *ListOptions) error { Visibility: opts.Visibility, Fork: opts.Fork, Source: opts.Source, - Archived: opts.Archived, } listResult, err := listRepos(apiClient, ghinstance.OverridableDefault(), opts.Limit, owner, filter) @@ -131,7 +125,7 @@ func listRun(opts *ListOptions) error { tp := utils.NewTablePrinter(opts.IO) - notArchived := (filter.Fork || filter.Source) && !filter.Archived + notArchived := filter.Fork || filter.Source matchCount := len(listResult.Repositories) now := time.Now() @@ -172,7 +166,7 @@ func listRun(opts *ListOptions) error { } if isTerminal { - hasFilters := filter.Visibility != "" || filter.Fork || filter.Source || filter.Archived + hasFilters := filter.Visibility != "" || filter.Fork || filter.Source title := listHeader(owner, matchCount, listResult.TotalCount, hasFilters) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } From cad875a05fa006bf1ff8cee0f62e6d99f1d666b6 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Thu, 18 Feb 2021 19:02:59 -0300 Subject: [PATCH 03/10] repo list: render repo tags into the 3rd column instead of the 2nd --- pkg/cmd/repo/list/list.go | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 55a713962..06c79daa2 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -5,7 +5,6 @@ import ( "net/http" "strings" "time" - "unicode" "github.com/cli/cli/api" "github.com/cli/cli/internal/ghinstance" @@ -153,13 +152,13 @@ func listRun(opts *ListOptions) error { if tp.IsTTY() { tp.AddField(nameWithOwner, nil, cs.Bold) - tp.AddField(info, nil, infoColor) tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) + tp.AddField(info, nil, infoColor) tp.AddField(utils.FuzzyAgoAbbr(now, repo.UpdatedAt), nil, nil) } else { tp.AddField(nameWithOwner, nil, nil) - tp.AddField(visibility, nil, nil) tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) + tp.AddField(visibility, nil, nil) tp.AddField(updatedAt, nil, nil) } tp.EndRow() @@ -196,22 +195,21 @@ type Repository struct { func (r Repository) Info() string { var info string + var tags []string if r.IsPrivate { - info = "private" + tags = append(tags, "private") } if r.IsFork { - info += " fork" + tags = append(tags, "fork") } if r.IsArchived { - info += " archived" + tags = append(tags, "archived") } - if info != "" { - info = strings.TrimPrefix(info, " ") - infoRunes := []rune(info) - infoRunes[0] = unicode.ToUpper(infoRunes[0]) - info = fmt.Sprintf("(%s)", string(infoRunes)) + if len(tags) > 0 { + tags[0] = strings.Title(tags[0]) + info = strings.Join(tags, ", ") } return info From 2284ef43d079c1c5ae280848e098f04e6ee1ddf3 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Fri, 19 Feb 2021 17:34:17 -0300 Subject: [PATCH 04/10] repo list: add tests --- pkg/cmd/repo/list/fixtures/repoList.json | 39 +++++ pkg/cmd/repo/list/list.go | 5 +- pkg/cmd/repo/list/list_test.go | 192 +++++++++++++++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/repo/list/fixtures/repoList.json create mode 100644 pkg/cmd/repo/list/list_test.go diff --git a/pkg/cmd/repo/list/fixtures/repoList.json b/pkg/cmd/repo/list/fixtures/repoList.json new file mode 100644 index 000000000..ca0faa85c --- /dev/null +++ b/pkg/cmd/repo/list/fixtures/repoList.json @@ -0,0 +1,39 @@ +{ + "data": { + "repositoryOwner": { + "repositories": { + "totalCount": 3, + "nodes": [ + { + "nameWithOwner": "octocat/hello-world", + "description": "My first repository", + "isFork": false, + "isPrivate": false, + "isArchived": false, + "updatedAt": "2021-02-19T06:34:58Z" + }, + { + "nameWithOwner": "octocat/cli", + "description": "GitHub CLI", + "isFork": true, + "isPrivate": false, + "isArchived": false, + "updatedAt": "2021-02-19T06:06:06Z" + }, + { + "nameWithOwner": "octocat/testing", + "description": null, + "isFork": false, + "isPrivate": true, + "isArchived": false, + "updatedAt": "2021-02-11T22:32:05Z" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "" + } + } + } + } +} diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 06c79daa2..631a50bf3 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -31,12 +31,15 @@ type ListOptions struct { Visibility string Fork bool Source bool + + Now func() time.Time } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := ListOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Now: time.Now, } var ( @@ -127,7 +130,7 @@ func listRun(opts *ListOptions) error { notArchived := filter.Fork || filter.Source matchCount := len(listResult.Repositories) - now := time.Now() + now := opts.Now() for _, repo := range listResult.Repositories { if notArchived && repo.IsArchived { diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go new file mode 100644 index 000000000..7ffe6de17 --- /dev/null +++ b/pkg/cmd/repo/list/list_test.go @@ -0,0 +1,192 @@ +package list + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + } + + cmd := NewCmdList(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestRepoList_nontty(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStdinTTY(false) + io.SetStderrTTY(false) + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), + ) + httpReg.Register( + httpmock.GraphQL(`query RepoList\b`), + httpmock.FileResponse("./fixtures/repoList.json"), + ) + + opts := ListOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "19 Feb 21 15:00 UTC") + return t + }, + Limit: 30, + } + + err := listRun(&opts) + assert.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, heredoc.Doc(` + octocat/hello-world My first repository Public 2021-02-19T06:34:58Z + octocat/cli GitHub CLI Public 2021-02-19T06:06:06Z + octocat/testing Private 2021-02-11T22:32:05Z + `), stdout.String()) +} + +func TestRepoList_tty(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), + ) + httpReg.Register( + httpmock.GraphQL(`query RepoList\b`), + httpmock.FileResponse("./fixtures/repoList.json"), + ) + + opts := ListOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "19 Feb 21 15:00 UTC") + return t + }, + Limit: 30, + } + + err := listRun(&opts) + assert.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, heredoc.Doc(` + + Showing 3 of 3 repositories in @octocat + + octocat/hello-world My first repository 8h + octocat/cli GitHub CLI Fork 8h + octocat/testing Private 7d + `), stdout.String()) +} + +func TestRepoList_filtering(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), + ) + http.Register( + httpmock.GraphQL(`query RepoList\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, "PRIVATE", params["privacy"]) + assert.Equal(t, float64(2), params["per_page"]) + }), + ) + + output, err := runCommand(http, true, `--private --limit 2 `) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, "\nNo results match your search\n\n", output.String()) +} + +func TestRepoList_withInvalidFlagCombinations(t *testing.T) { + tests := []struct { + name string + cli string + wantStderr string + }{ + { + name: "invalid limit", + cli: "--limit 0", + wantStderr: "invalid limit: 0", + }, + { + name: "both private and public", + cli: "--private --public", + wantStderr: "specify only one of `--public` or `--private`", + }, + { + name: "both source and fork", + cli: "--source --fork", + wantStderr: "specify only one of `--source` or `--fork`", + }, + } + + for _, tt := range tests { + httpReg := &httpmock.Registry{} + + _, err := runCommand(httpReg, true, tt.cli) + assert.EqualError(t, err, tt.wantStderr) + } +} From 4da02614ed6bc7ff22307723bda6d15c87ff324e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Sat, 27 Feb 2021 13:17:59 +0100 Subject: [PATCH 05/10] Switch `repo list` to query via `graphql` package Also order results by PUSHED_AT instead of UPDATED_AT to match the web interface. --- pkg/cmd/repo/list/fixtures/repoList.json | 6 +- pkg/cmd/repo/list/http.go | 108 +++++++++++------------ pkg/cmd/repo/list/list.go | 83 +++-------------- pkg/cmd/repo/list/list_test.go | 20 ++--- 4 files changed, 75 insertions(+), 142 deletions(-) diff --git a/pkg/cmd/repo/list/fixtures/repoList.json b/pkg/cmd/repo/list/fixtures/repoList.json index ca0faa85c..8ee348f57 100644 --- a/pkg/cmd/repo/list/fixtures/repoList.json +++ b/pkg/cmd/repo/list/fixtures/repoList.json @@ -10,7 +10,7 @@ "isFork": false, "isPrivate": false, "isArchived": false, - "updatedAt": "2021-02-19T06:34:58Z" + "pushedAt": "2021-02-19T06:34:58Z" }, { "nameWithOwner": "octocat/cli", @@ -18,7 +18,7 @@ "isFork": true, "isPrivate": false, "isArchived": false, - "updatedAt": "2021-02-19T06:06:06Z" + "pushedAt": "2021-02-19T06:06:06Z" }, { "nameWithOwner": "octocat/testing", @@ -26,7 +26,7 @@ "isFork": false, "isPrivate": true, "isArchived": false, - "updatedAt": "2021-02-11T22:32:05Z" + "pushedAt": "2021-02-11T22:32:05Z" } ], "pageInfo": { diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 4669e72a3..8413b479f 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -1,59 +1,62 @@ package list import ( + "context" + "net/http" "strings" + "time" - "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" ) +type Repository struct { + NameWithOwner string + Description string + IsFork bool + IsPrivate bool + IsArchived bool + PushedAt time.Time +} + +func (r Repository) Info() string { + var tags []string + + if r.IsPrivate { + tags = append(tags, "private") + } else { + tags = append(tags, "public") + } + if r.IsFork { + tags = append(tags, "fork") + } + if r.IsArchived { + tags = append(tags, "archived") + } + + return strings.Join(tags, ", ") +} + type RepositoryList struct { Repositories []Repository TotalCount int } -func listRepos(client *api.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { - type response struct { +func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { + type query struct { RepositoryOwner struct { Repositories struct { - TotalCount int - RepositoryCount int - Nodes []Repository - PageInfo struct { + Nodes []Repository + TotalCount int + PageInfo struct { HasNextPage bool EndCursor string } - } - } + } `graphql:"repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC })"` + } `graphql:"repositoryOwner(login: $owner)"` } - query := ` - query RepoList($owner: String!, $per_page: Int!, $endCursor: String, $fork: Boolean, $privacy: RepositoryPrivacy) { - repositoryOwner(login: $owner) { - repositories( - first: $per_page, - after: $endCursor, - privacy: $privacy, - isFork: $fork, - ownerAffiliations: OWNER, - orderBy: { field: UPDATED_AT, direction: DESC }) { - totalCount - nodes { - nameWithOwner - description - isFork - isPrivate - isArchived - updatedAt - } - pageInfo { - hasNextPage - endCursor - } - } - } - }` - perPage := limit if perPage > 100 { perPage = 100 @@ -61,7 +64,7 @@ func listRepos(client *api.Client, hostname string, limit int, owner string, fil variables := map[string]interface{}{ "owner": githubv4.String(owner), - "per_page": githubv4.Int(perPage), + "perPage": githubv4.Int(perPage), "endCursor": (*githubv4.String)(nil), } @@ -79,40 +82,29 @@ func listRepos(client *api.Client, hostname string, limit int, owner string, fil variables["fork"] = (*githubv4.Boolean)(nil) } - var repos []Repository - var totalCount int - - var result response - + listResult := RepositoryList{} pagination: for { - err := client.GraphQL(hostname, query, variables, &result) + var result query + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) + err := gql.QueryNamed(context.Background(), "RepositoryList", &result, variables) if err != nil { return nil, err } - repos = append(repos, result.RepositoryOwner.Repositories.Nodes...) - - if len(repos) >= limit { - if len(repos) > limit { - repos = repos[:limit] + listResult.TotalCount = result.RepositoryOwner.Repositories.TotalCount + for _, repo := range result.RepositoryOwner.Repositories.Nodes { + listResult.Repositories = append(listResult.Repositories, repo) + if len(listResult.Repositories) >= limit { + break pagination } - break pagination } if !result.RepositoryOwner.Repositories.PageInfo.HasNextPage { break } - variables["endCursor"] = githubv4.String(result.RepositoryOwner.Repositories.PageInfo.EndCursor) } - totalCount = result.RepositoryOwner.Repositories.TotalCount - - listResult := &RepositoryList{ - Repositories: repos, - TotalCount: totalCount, - } - - return listResult, nil + return &listResult, nil } diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 631a50bf3..4fd74f827 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -3,7 +3,6 @@ package list import ( "fmt" "net/http" - "strings" "time" "github.com/cli/cli/api" @@ -45,14 +44,12 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman var ( flagPublic bool flagPrivate bool - flagSource bool - flagFork bool ) cmd := &cobra.Command{ - Use: "list", + Use: "list []", Args: cobra.MaximumNArgs(1), - Short: "List repositories from a user or organization", + Short: "List repositories owned by user or organization", RunE: func(c *cobra.Command, args []string) error { if opts.Limit < 1 { return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.Limit)} @@ -61,7 +58,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if flagPrivate && flagPublic { return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of `--public` or `--private`")} } - if flagSource && flagFork { + if opts.Source && opts.Fork { return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of `--source` or `--fork`")} } @@ -71,9 +68,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts.Visibility = "public" } - opts.Fork = flagFork - opts.Source = flagSource - if len(args) > 0 { opts.Owner = args[0] } @@ -88,8 +82,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of repositories to list") cmd.Flags().BoolVar(&flagPrivate, "private", false, "Show only private repositories") cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public repositories") - cmd.Flags().BoolVar(&flagSource, "source", false, "Show only source repositories") - cmd.Flags().BoolVar(&flagFork, "fork", false, "Show only forks") + cmd.Flags().BoolVar(&opts.Source, "source", false, "Show only non-forks") + cmd.Flags().BoolVar(&opts.Fork, "fork", false, "Show only forks") return cmd } @@ -118,58 +112,36 @@ func listRun(opts *ListOptions) error { Source: opts.Source, } - listResult, err := listRepos(apiClient, ghinstance.OverridableDefault(), opts.Limit, owner, filter) + listResult, err := listRepos(httpClient, ghinstance.OverridableDefault(), opts.Limit, owner, filter) if err != nil { return err } cs := opts.IO.ColorScheme() - tp := utils.NewTablePrinter(opts.IO) - - notArchived := filter.Fork || filter.Source - - matchCount := len(listResult.Repositories) now := opts.Now() for _, repo := range listResult.Repositories { - if notArchived && repo.IsArchived { - matchCount-- - listResult.TotalCount-- - continue - } - - nameWithOwner := repo.NameWithOwner - info := repo.Info() infoColor := cs.Gray - visibility := "Public" - if repo.IsPrivate { infoColor = cs.Yellow - visibility = "Private" } - description := repo.Description - updatedAt := repo.UpdatedAt.Format(time.RFC3339) - + tp.AddField(repo.NameWithOwner, nil, cs.Bold) + tp.AddField(text.ReplaceExcessiveWhitespace(repo.Description), nil, nil) + tp.AddField(info, nil, infoColor) if tp.IsTTY() { - tp.AddField(nameWithOwner, nil, cs.Bold) - tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) - tp.AddField(info, nil, infoColor) - tp.AddField(utils.FuzzyAgoAbbr(now, repo.UpdatedAt), nil, nil) + tp.AddField(utils.FuzzyAgoAbbr(now, repo.PushedAt), nil, cs.Gray) } else { - tp.AddField(nameWithOwner, nil, nil) - tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) - tp.AddField(visibility, nil, nil) - tp.AddField(updatedAt, nil, nil) + tp.AddField(repo.PushedAt.Format(time.RFC3339), nil, nil) } tp.EndRow() } if isTerminal { hasFilters := filter.Visibility != "" || filter.Fork || filter.Source - title := listHeader(owner, matchCount, listResult.TotalCount, hasFilters) + title := listHeader(owner, len(listResult.Repositories), listResult.TotalCount, hasFilters) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } @@ -186,34 +158,3 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool) return fmt.Sprintf("Showing %d of %d repositories in @%s", matchCount, totalMatchCount, owner) } - -type Repository struct { - NameWithOwner string - Description string - IsFork bool - IsPrivate bool - IsArchived bool - UpdatedAt time.Time -} - -func (r Repository) Info() string { - var info string - var tags []string - - if r.IsPrivate { - tags = append(tags, "private") - } - if r.IsFork { - tags = append(tags, "fork") - } - if r.IsArchived { - tags = append(tags, "archived") - } - - if len(tags) > 0 { - tags[0] = strings.Title(tags[0]) - info = strings.Join(tags, ", ") - } - - return info -} diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index 7ffe6de17..d6bc37afe 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -62,7 +62,7 @@ func TestRepoList_nontty(t *testing.T) { httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), ) httpReg.Register( - httpmock.GraphQL(`query RepoList\b`), + httpmock.GraphQL(`query RepositoryList\b`), httpmock.FileResponse("./fixtures/repoList.json"), ) @@ -84,9 +84,9 @@ func TestRepoList_nontty(t *testing.T) { assert.Equal(t, "", stderr.String()) assert.Equal(t, heredoc.Doc(` - octocat/hello-world My first repository Public 2021-02-19T06:34:58Z - octocat/cli GitHub CLI Public 2021-02-19T06:06:06Z - octocat/testing Private 2021-02-11T22:32:05Z + octocat/hello-world My first repository public 2021-02-19T06:34:58Z + octocat/cli GitHub CLI public, fork 2021-02-19T06:06:06Z + octocat/testing private 2021-02-11T22:32:05Z `), stdout.String()) } @@ -104,7 +104,7 @@ func TestRepoList_tty(t *testing.T) { httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), ) httpReg.Register( - httpmock.GraphQL(`query RepoList\b`), + httpmock.GraphQL(`query RepositoryList\b`), httpmock.FileResponse("./fixtures/repoList.json"), ) @@ -129,9 +129,9 @@ func TestRepoList_tty(t *testing.T) { Showing 3 of 3 repositories in @octocat - octocat/hello-world My first repository 8h - octocat/cli GitHub CLI Fork 8h - octocat/testing Private 7d + octocat/hello-world My first repository public 8h + octocat/cli GitHub CLI public, fork 8h + octocat/testing private 7d `), stdout.String()) } @@ -144,10 +144,10 @@ func TestRepoList_filtering(t *testing.T) { httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), ) http.Register( - httpmock.GraphQL(`query RepoList\b`), + httpmock.GraphQL(`query RepositoryList\b`), httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { assert.Equal(t, "PRIVATE", params["privacy"]) - assert.Equal(t, float64(2), params["per_page"]) + assert.Equal(t, float64(2), params["perPage"]) }), ) From 1fa763f51472075d26a67f0fc89e2696bd867280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Sat, 27 Feb 2021 14:21:26 +0100 Subject: [PATCH 06/10] Avoid having to first query for username in `repo list` Dynamically construct the GraphQL query by using the `viewer` connection if the owner isn't set and the `repositoryOwner(login:"...")` connection if the owner was set. --- pkg/cmd/repo/list/fixtures/repoList.json | 1 + pkg/cmd/repo/list/http.go | 58 ++++++++++++++++-------- pkg/cmd/repo/list/list.go | 19 ++------ pkg/cmd/repo/list/list_test.go | 12 ----- 4 files changed, 42 insertions(+), 48 deletions(-) diff --git a/pkg/cmd/repo/list/fixtures/repoList.json b/pkg/cmd/repo/list/fixtures/repoList.json index 8ee348f57..18bb5eff8 100644 --- a/pkg/cmd/repo/list/fixtures/repoList.json +++ b/pkg/cmd/repo/list/fixtures/repoList.json @@ -1,6 +1,7 @@ { "data": { "repositoryOwner": { + "login": "octocat", "repositories": { "totalCount": 3, "nodes": [ diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 8413b479f..97bf584fb 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -3,6 +3,7 @@ package list import ( "context" "net/http" + "reflect" "strings" "time" @@ -39,31 +40,18 @@ func (r Repository) Info() string { } type RepositoryList struct { + Owner string Repositories []Repository TotalCount int } func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { - type query struct { - RepositoryOwner struct { - Repositories struct { - Nodes []Repository - TotalCount int - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC })"` - } `graphql:"repositoryOwner(login: $owner)"` - } - perPage := limit if perPage > 100 { perPage = 100 } variables := map[string]interface{}{ - "owner": githubv4.String(owner), "perPage": githubv4.Int(perPage), "endCursor": (*githubv4.String)(nil), } @@ -82,28 +70,58 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi variables["fork"] = (*githubv4.Boolean)(nil) } + var ownerConnection string + if owner == "" { + ownerConnection = `graphql:"repositoryOwner: viewer"` + } else { + ownerConnection = `graphql:"repositoryOwner(login: $owner)"` + variables["owner"] = githubv4.String(owner) + } + + type repositoryOwner struct { + Login string + Repositories struct { + Nodes []Repository + TotalCount int + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC })"` + } + query := reflect.StructOf([]reflect.StructField{ + { + Name: "RepositoryOwner", + Type: reflect.TypeOf(repositoryOwner{}), + Tag: reflect.StructTag(ownerConnection), + }, + }) + listResult := RepositoryList{} pagination: for { - var result query + result := reflect.New(query) gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) - err := gql.QueryNamed(context.Background(), "RepositoryList", &result, variables) + err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables) if err != nil { return nil, err } - listResult.TotalCount = result.RepositoryOwner.Repositories.TotalCount - for _, repo := range result.RepositoryOwner.Repositories.Nodes { + owner := result.Elem().FieldByName("RepositoryOwner").Interface().(repositoryOwner) + listResult.TotalCount = owner.Repositories.TotalCount + listResult.Owner = owner.Login + + for _, repo := range owner.Repositories.Nodes { listResult.Repositories = append(listResult.Repositories, repo) if len(listResult.Repositories) >= limit { break pagination } } - if !result.RepositoryOwner.Repositories.PageInfo.HasNextPage { + if !owner.Repositories.PageInfo.HasNextPage { break } - variables["endCursor"] = githubv4.String(result.RepositoryOwner.Repositories.PageInfo.EndCursor) + variables["endCursor"] = githubv4.String(owner.Repositories.PageInfo.EndCursor) } return &listResult, nil diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 4fd74f827..62e16a596 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -5,7 +5,6 @@ import ( "net/http" "time" - "github.com/cli/cli/api" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -94,25 +93,13 @@ func listRun(opts *ListOptions) error { return err } - apiClient := api.NewClientFromHTTP(httpClient) - - isTerminal := opts.IO.IsStdoutTTY() - - owner := opts.Owner - if owner == "" { - owner, err = api.CurrentLoginName(apiClient, ghinstance.OverridableDefault()) - if err != nil { - return err - } - } - filter := FilterOptions{ Visibility: opts.Visibility, Fork: opts.Fork, Source: opts.Source, } - listResult, err := listRepos(httpClient, ghinstance.OverridableDefault(), opts.Limit, owner, filter) + listResult, err := listRepos(httpClient, ghinstance.OverridableDefault(), opts.Limit, opts.Owner, filter) if err != nil { return err } @@ -139,9 +126,9 @@ func listRun(opts *ListOptions) error { tp.EndRow() } - if isTerminal { + if opts.IO.IsStdoutTTY() { hasFilters := filter.Visibility != "" || filter.Fork || filter.Source - title := listHeader(owner, len(listResult.Repositories), listResult.TotalCount, hasFilters) + title := listHeader(listResult.Owner, len(listResult.Repositories), listResult.TotalCount, hasFilters) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index d6bc37afe..fb55cb11c 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -57,10 +57,6 @@ func TestRepoList_nontty(t *testing.T) { httpReg := &httpmock.Registry{} defer httpReg.Verify(t) - httpReg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), - ) httpReg.Register( httpmock.GraphQL(`query RepositoryList\b`), httpmock.FileResponse("./fixtures/repoList.json"), @@ -99,10 +95,6 @@ func TestRepoList_tty(t *testing.T) { httpReg := &httpmock.Registry{} defer httpReg.Verify(t) - httpReg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), - ) httpReg.Register( httpmock.GraphQL(`query RepositoryList\b`), httpmock.FileResponse("./fixtures/repoList.json"), @@ -139,10 +131,6 @@ func TestRepoList_filtering(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`), - ) http.Register( httpmock.GraphQL(`query RepositoryList\b`), httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { From 2bdffc85e21b7e7161164d8570bc2b102a2f2d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Sat, 27 Feb 2021 14:39:06 +0100 Subject: [PATCH 07/10] Isolate flag processing tests in `repo list` --- pkg/cmd/repo/list/list_test.go | 166 +++++++++++++++++++++++++++------ 1 file changed, 135 insertions(+), 31 deletions(-) diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index fb55cb11c..7c7cbf938 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -14,8 +14,143 @@ import ( "github.com/cli/cli/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants ListOptions + wantsErr string + }{ + { + name: "no arguments", + cli: "", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + }, + }, + { + name: "with owner", + cli: "monalisa", + wants: ListOptions{ + Limit: 30, + Owner: "monalisa", + Visibility: "", + Fork: false, + Source: false, + }, + }, + { + name: "with limit", + cli: "-L 101", + wants: ListOptions{ + Limit: 101, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + }, + }, + { + name: "only public", + cli: "--public", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "public", + Fork: false, + Source: false, + }, + }, + { + name: "only private", + cli: "--private", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "private", + Fork: false, + Source: false, + }, + }, + { + name: "only forks", + cli: "--fork", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "", + Fork: true, + Source: false, + }, + }, + { + name: "only sources", + cli: "--source", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: true, + }, + }, + { + name: "no public and private", + cli: "--public --private", + wantsErr: "specify only one of `--public` or `--private`", + }, + { + name: "no forks with sources", + cli: "--fork --source", + wantsErr: "specify only one of `--source` or `--fork`", + }, + { + name: "too many arguments", + cli: "monalisa hubot", + wantsErr: "accepts at most 1 arg(s), received 2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) 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.wantsErr != "" { + assert.EqualError(t, err, tt.wantsErr) + return + } + require.NoError(t, err) + + assert.Equal(t, tt.wants.Limit, gotOpts.Limit) + assert.Equal(t, tt.wants.Owner, gotOpts.Owner) + assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) + assert.Equal(t, tt.wants.Fork, gotOpts.Fork) + assert.Equal(t, tt.wants.Source, gotOpts.Source) + }) + } +} + func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -147,34 +282,3 @@ func TestRepoList_filtering(t *testing.T) { assert.Equal(t, "", output.Stderr()) assert.Equal(t, "\nNo results match your search\n\n", output.String()) } - -func TestRepoList_withInvalidFlagCombinations(t *testing.T) { - tests := []struct { - name string - cli string - wantStderr string - }{ - { - name: "invalid limit", - cli: "--limit 0", - wantStderr: "invalid limit: 0", - }, - { - name: "both private and public", - cli: "--private --public", - wantStderr: "specify only one of `--public` or `--private`", - }, - { - name: "both source and fork", - cli: "--source --fork", - wantStderr: "specify only one of `--source` or `--fork`", - }, - } - - for _, tt := range tests { - httpReg := &httpmock.Registry{} - - _, err := runCommand(httpReg, true, tt.cli) - assert.EqualError(t, err, tt.wantStderr) - } -} From f75144dd1f1d45dfa7b6ec2adf2942f5b06313e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Sat, 27 Feb 2021 15:05:11 +0100 Subject: [PATCH 08/10] Enable pager for `repo list` output --- pkg/cmd/repo/list/list.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 62e16a596..158b12ce8 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -104,6 +104,11 @@ func listRun(opts *ListOptions) error { return err } + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + cs := opts.IO.ColorScheme() tp := utils.NewTablePrinter(opts.IO) now := opts.Now() From 5da8301d5d6490c2082e228398e884db41e52bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Sat, 27 Feb 2021 16:51:45 +0100 Subject: [PATCH 09/10] Enable filtering `repo list` by coding language --- pkg/cmd/repo/list/fixtures/repoSearch.json | 37 ++++++ pkg/cmd/repo/list/http.go | 100 ++++++++++++++- pkg/cmd/repo/list/http_test.go | 141 +++++++++++++++++++++ pkg/cmd/repo/list/list.go | 21 +-- pkg/cmd/repo/list/list_test.go | 19 +++ 5 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 pkg/cmd/repo/list/fixtures/repoSearch.json create mode 100644 pkg/cmd/repo/list/http_test.go diff --git a/pkg/cmd/repo/list/fixtures/repoSearch.json b/pkg/cmd/repo/list/fixtures/repoSearch.json new file mode 100644 index 000000000..eecae8ac4 --- /dev/null +++ b/pkg/cmd/repo/list/fixtures/repoSearch.json @@ -0,0 +1,37 @@ +{ + "data": { + "search": { + "repositoryCount": 3, + "nodes": [ + { + "nameWithOwner": "octocat/hello-world", + "description": "My first repository", + "isFork": false, + "isPrivate": false, + "isArchived": false, + "pushedAt": "2021-02-19T06:34:58Z" + }, + { + "nameWithOwner": "octocat/cli", + "description": "GitHub CLI", + "isFork": true, + "isPrivate": false, + "isArchived": false, + "pushedAt": "2021-02-19T06:06:06Z" + }, + { + "nameWithOwner": "octocat/testing", + "description": null, + "isFork": false, + "isPrivate": true, + "isArchived": false, + "pushedAt": "2021-02-11T22:32:05Z" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "" + } + } + } +} diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 97bf584fb..7aefeaf9d 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -2,6 +2,7 @@ package list import ( "context" + "fmt" "net/http" "reflect" "strings" @@ -45,7 +46,18 @@ type RepositoryList struct { TotalCount int } +type FilterOptions struct { + Visibility string // private, public + Fork bool + Source bool + Language string +} + func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { + if filter.Language != "" { + return searchRepos(client, hostname, limit, owner, filter) + } + perPage := limit if perPage > 100 { perPage = 100 @@ -97,11 +109,11 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi }, }) + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) listResult := RepositoryList{} pagination: for { result := reflect.New(query) - gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables) if err != nil { return nil, err @@ -126,3 +138,89 @@ pagination: return &listResult, nil } + +func searchRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { + type query struct { + Search struct { + RepositoryCount int + Nodes []struct { + Repository Repository `graphql:"...on Repository"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor)"` + } + + perPage := limit + if perPage > 100 { + perPage = 100 + } + + variables := map[string]interface{}{ + "query": githubv4.String(searchQuery(owner, filter)), + "perPage": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) + listResult := RepositoryList{} +pagination: + for { + var result query + err := gql.QueryNamed(context.Background(), "RepositoryListSearch", &result, variables) + if err != nil { + return nil, err + } + + listResult.TotalCount = result.Search.RepositoryCount + for _, node := range result.Search.Nodes { + if listResult.Owner == "" { + idx := strings.IndexRune(node.Repository.NameWithOwner, '/') + listResult.Owner = node.Repository.NameWithOwner[:idx] + } + listResult.Repositories = append(listResult.Repositories, node.Repository) + if len(listResult.Repositories) >= limit { + break pagination + } + } + + if !result.Search.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(result.Search.PageInfo.EndCursor) + } + + return &listResult, nil +} + +func searchQuery(owner string, filter FilterOptions) string { + queryParts := []string{"sort:updated-desc"} + if owner == "" { + queryParts = append(queryParts, "user:@me") + } else { + queryParts = append(queryParts, "user:"+owner) + } + + if filter.Fork { + queryParts = append(queryParts, "fork:only") + } else if filter.Source { + queryParts = append(queryParts, "fork:false") + } else { + queryParts = append(queryParts, "fork:true") + } + + if filter.Language != "" { + queryParts = append(queryParts, fmt.Sprintf("language:%q", filter.Language)) + } + + switch filter.Visibility { + case "public": + queryParts = append(queryParts, "is:public") + case "private": + queryParts = append(queryParts, "is:private") + } + + return strings.Join(queryParts, " ") +} diff --git a/pkg/cmd/repo/list/http_test.go b/pkg/cmd/repo/list/http_test.go new file mode 100644 index 000000000..8c6b8d5f4 --- /dev/null +++ b/pkg/cmd/repo/list/http_test.go @@ -0,0 +1,141 @@ +package list + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "os" + "testing" + + "github.com/cli/cli/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_listReposWithLanguage(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + var searchData struct { + Query string + Variables map[string]interface{} + } + reg.Register( + httpmock.GraphQL(`query RepositoryListSearch\b`), + func(req *http.Request) (*http.Response, error) { + jsonData, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(jsonData, &searchData) + if err != nil { + return nil, err + } + + respBody, err := os.Open("./fixtures/repoSearch.json") + if err != nil { + return nil, err + } + + return &http.Response{ + StatusCode: 200, + Request: req, + Body: respBody, + }, nil + }, + ) + + client := http.Client{Transport: ®} + res, err := listRepos(&client, "github.com", 10, "", FilterOptions{ + Language: "go", + }) + require.NoError(t, err) + + assert.Equal(t, 3, res.TotalCount) + assert.Equal(t, "octocat", res.Owner) + assert.Equal(t, "octocat/hello-world", res.Repositories[0].NameWithOwner) + + assert.Equal(t, float64(10), searchData.Variables["perPage"]) + assert.Equal(t, `sort:updated-desc user:@me fork:true language:"go"`, searchData.Variables["query"]) +} + +func Test_searchQuery(t *testing.T) { + type args struct { + owner string + filter FilterOptions + } + tests := []struct { + name string + args args + want string + }{ + { + name: "blank", + want: "sort:updated-desc user:@me fork:true", + }, + { + name: "in org", + args: args{ + owner: "cli", + }, + want: "sort:updated-desc user:cli fork:true", + }, + { + name: "only public", + args: args{ + owner: "", + filter: FilterOptions{ + Visibility: "public", + }, + }, + want: "sort:updated-desc user:@me fork:true is:public", + }, + { + name: "only private", + args: args{ + owner: "", + filter: FilterOptions{ + Visibility: "private", + }, + }, + want: "sort:updated-desc user:@me fork:true is:private", + }, + { + name: "only forks", + args: args{ + owner: "", + filter: FilterOptions{ + Fork: true, + }, + }, + want: "sort:updated-desc user:@me fork:only", + }, + { + name: "no forks", + args: args{ + owner: "", + filter: FilterOptions{ + Source: true, + }, + }, + want: "sort:updated-desc user:@me fork:false", + }, + { + name: "with language", + args: args{ + owner: "", + filter: FilterOptions{ + Language: "ruby", + }, + }, + want: "sort:updated-desc user:@me fork:true language:\"ruby\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := searchQuery(tt.args.owner, tt.args.filter); got != tt.want { + t.Errorf("searchQuery() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 158b12ce8..39b330a04 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -13,12 +13,6 @@ import ( "github.com/spf13/cobra" ) -type FilterOptions struct { - Visibility string // private, public - Fork bool - Source bool -} - type ListOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -29,6 +23,7 @@ type ListOptions struct { Visibility string Fork bool Source bool + Language string Now func() time.Time } @@ -83,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public repositories") cmd.Flags().BoolVar(&opts.Source, "source", false, "Show only non-forks") cmd.Flags().BoolVar(&opts.Fork, "fork", false, "Show only forks") + cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language") return cmd } @@ -97,6 +93,7 @@ func listRun(opts *ListOptions) error { Visibility: opts.Visibility, Fork: opts.Fork, Source: opts.Source, + Language: opts.Language, } listResult, err := listRepos(httpClient, ghinstance.OverridableDefault(), opts.Limit, opts.Owner, filter) @@ -132,7 +129,7 @@ func listRun(opts *ListOptions) error { } if opts.IO.IsStdoutTTY() { - hasFilters := filter.Visibility != "" || filter.Fork || filter.Source + hasFilters := filter.Visibility != "" || filter.Fork || filter.Source || filter.Language != "" title := listHeader(listResult.Owner, len(listResult.Repositories), listResult.TotalCount, hasFilters) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } @@ -144,9 +141,15 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool) if totalMatchCount == 0 { if hasFilters { return "No results match your search" + } else if owner != "" { + return "There are no repositories in @" + owner } - return "There are no repositories in @" + owner + return "No results" } - return fmt.Sprintf("Showing %d of %d repositories in @%s", matchCount, totalMatchCount, owner) + var matchStr string + if hasFilters { + matchStr = " that match your search" + } + return fmt.Sprintf("Showing %d of %d repositories in @%s%s", matchCount, totalMatchCount, owner, matchStr) } diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index 7c7cbf938..86ccdf420 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -33,6 +33,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: false, + Language: "", }, }, { @@ -44,6 +45,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: false, + Language: "", }, }, { @@ -55,6 +57,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: false, + Language: "", }, }, { @@ -66,6 +69,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "public", Fork: false, Source: false, + Language: "", }, }, { @@ -77,6 +81,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "private", Fork: false, Source: false, + Language: "", }, }, { @@ -88,6 +93,7 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: true, Source: false, + Language: "", }, }, { @@ -99,6 +105,19 @@ func TestNewCmdList(t *testing.T) { Visibility: "", Fork: false, Source: true, + Language: "", + }, + }, + { + name: "with language", + cli: "-l go", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + Language: "go", }, }, { From e27a77fc99fd7e76c93fc45bdf9ad0901a372dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Sat, 27 Feb 2021 17:20:06 +0100 Subject: [PATCH 10/10] Add ability to filter by archived in `repo list` Like `--language`, archived filters also use the Search API. --- pkg/cmd/repo/list/http.go | 21 +++-- pkg/cmd/repo/list/http_test.go | 21 +++++ pkg/cmd/repo/list/list.go | 34 +++++--- pkg/cmd/repo/list/list_test.go | 152 ++++++++++++++++++++++----------- 4 files changed, 164 insertions(+), 64 deletions(-) diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 7aefeaf9d..7e2ccecd2 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -44,17 +44,20 @@ type RepositoryList struct { Owner string Repositories []Repository TotalCount int + FromSearch bool } type FilterOptions struct { - Visibility string // private, public - Fork bool - Source bool - Language string + Visibility string // private, public + Fork bool + Source bool + Language string + Archived bool + NonArchived bool } func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) { - if filter.Language != "" { + if filter.Language != "" || filter.Archived || filter.NonArchived { return searchRepos(client, hostname, limit, owner, filter) } @@ -165,7 +168,7 @@ func searchRepos(client *http.Client, hostname string, limit int, owner string, } gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client) - listResult := RepositoryList{} + listResult := RepositoryList{FromSearch: true} pagination: for { var result query @@ -222,5 +225,11 @@ func searchQuery(owner string, filter FilterOptions) string { queryParts = append(queryParts, "is:private") } + if filter.Archived { + queryParts = append(queryParts, "archived:true") + } else if filter.NonArchived { + queryParts = append(queryParts, "archived:false") + } + return strings.Join(queryParts, " ") } diff --git a/pkg/cmd/repo/list/http_test.go b/pkg/cmd/repo/list/http_test.go index 8c6b8d5f4..0544a750b 100644 --- a/pkg/cmd/repo/list/http_test.go +++ b/pkg/cmd/repo/list/http_test.go @@ -52,6 +52,7 @@ func Test_listReposWithLanguage(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, res.TotalCount) + assert.Equal(t, true, res.FromSearch) assert.Equal(t, "octocat", res.Owner) assert.Equal(t, "octocat/hello-world", res.Repositories[0].NameWithOwner) @@ -130,6 +131,26 @@ func Test_searchQuery(t *testing.T) { }, want: "sort:updated-desc user:@me fork:true language:\"ruby\"", }, + { + name: "only archived", + args: args{ + owner: "", + filter: FilterOptions{ + Archived: true, + }, + }, + want: "sort:updated-desc user:@me fork:true archived:true", + }, + { + name: "only non-archived", + args: args{ + owner: "", + filter: FilterOptions{ + NonArchived: true, + }, + }, + want: "sort:updated-desc user:@me fork:true archived:false", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 39b330a04..0b0b5fd5e 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -20,10 +20,12 @@ type ListOptions struct { Limit int Owner string - Visibility string - Fork bool - Source bool - Language string + Visibility string + Fork bool + Source bool + Language string + Archived bool + NonArchived bool Now func() time.Time } @@ -55,6 +57,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if opts.Source && opts.Fork { return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of `--source` or `--fork`")} } + if opts.Archived && opts.NonArchived { + return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of `--archived` or `--no-archived`")} + } if flagPrivate { opts.Visibility = "private" @@ -79,6 +84,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().BoolVar(&opts.Source, "source", false, "Show only non-forks") cmd.Flags().BoolVar(&opts.Fork, "fork", false, "Show only forks") cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language") + cmd.Flags().BoolVar(&opts.Archived, "archived", false, "Show only archived repositories") + cmd.Flags().BoolVar(&opts.NonArchived, "no-archived", false, "Omit archived repositories") return cmd } @@ -90,10 +97,12 @@ func listRun(opts *ListOptions) error { } filter := FilterOptions{ - Visibility: opts.Visibility, - Fork: opts.Fork, - Source: opts.Source, - Language: opts.Language, + Visibility: opts.Visibility, + Fork: opts.Fork, + Source: opts.Source, + Language: opts.Language, + Archived: opts.Archived, + NonArchived: opts.NonArchived, } listResult, err := listRepos(httpClient, ghinstance.OverridableDefault(), opts.Limit, opts.Owner, filter) @@ -117,13 +126,18 @@ func listRun(opts *ListOptions) error { infoColor = cs.Yellow } + t := repo.PushedAt + // if listResult.FromSearch { + // t = repo.UpdatedAt + // } + tp.AddField(repo.NameWithOwner, nil, cs.Bold) tp.AddField(text.ReplaceExcessiveWhitespace(repo.Description), nil, nil) tp.AddField(info, nil, infoColor) if tp.IsTTY() { - tp.AddField(utils.FuzzyAgoAbbr(now, repo.PushedAt), nil, cs.Gray) + tp.AddField(utils.FuzzyAgoAbbr(now, t), nil, cs.Gray) } else { - tp.AddField(repo.PushedAt.Format(time.RFC3339), nil, nil) + tp.AddField(t.Format(time.RFC3339), nil, nil) } tp.EndRow() } diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index 86ccdf420..59552beda 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -28,96 +28,140 @@ func TestNewCmdList(t *testing.T) { name: "no arguments", cli: "", wants: ListOptions{ - Limit: 30, - Owner: "", - Visibility: "", - Fork: false, - Source: false, - Language: "", + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + Language: "", + Archived: false, + NonArchived: false, }, }, { name: "with owner", cli: "monalisa", wants: ListOptions{ - Limit: 30, - Owner: "monalisa", - Visibility: "", - Fork: false, - Source: false, - Language: "", + Limit: 30, + Owner: "monalisa", + Visibility: "", + Fork: false, + Source: false, + Language: "", + Archived: false, + NonArchived: false, }, }, { name: "with limit", cli: "-L 101", wants: ListOptions{ - Limit: 101, - Owner: "", - Visibility: "", - Fork: false, - Source: false, - Language: "", + Limit: 101, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + Language: "", + Archived: false, + NonArchived: false, }, }, { name: "only public", cli: "--public", wants: ListOptions{ - Limit: 30, - Owner: "", - Visibility: "public", - Fork: false, - Source: false, - Language: "", + Limit: 30, + Owner: "", + Visibility: "public", + Fork: false, + Source: false, + Language: "", + Archived: false, + NonArchived: false, }, }, { name: "only private", cli: "--private", wants: ListOptions{ - Limit: 30, - Owner: "", - Visibility: "private", - Fork: false, - Source: false, - Language: "", + Limit: 30, + Owner: "", + Visibility: "private", + Fork: false, + Source: false, + Language: "", + Archived: false, + NonArchived: false, }, }, { name: "only forks", cli: "--fork", wants: ListOptions{ - Limit: 30, - Owner: "", - Visibility: "", - Fork: true, - Source: false, - Language: "", + Limit: 30, + Owner: "", + Visibility: "", + Fork: true, + Source: false, + Language: "", + Archived: false, + NonArchived: false, }, }, { name: "only sources", cli: "--source", wants: ListOptions{ - Limit: 30, - Owner: "", - Visibility: "", - Fork: false, - Source: true, - Language: "", + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: true, + Language: "", + Archived: false, + NonArchived: false, }, }, { name: "with language", cli: "-l go", wants: ListOptions{ - Limit: 30, - Owner: "", - Visibility: "", - Fork: false, - Source: false, - Language: "go", + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + Language: "go", + Archived: false, + NonArchived: false, + }, + }, + { + name: "only archived", + cli: "--archived", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + Language: "", + Archived: true, + NonArchived: false, + }, + }, + { + name: "only non-archived", + cli: "--no-archived", + wants: ListOptions{ + Limit: 30, + Owner: "", + Visibility: "", + Fork: false, + Source: false, + Language: "", + Archived: false, + NonArchived: true, }, }, { @@ -130,11 +174,21 @@ func TestNewCmdList(t *testing.T) { cli: "--fork --source", wantsErr: "specify only one of `--source` or `--fork`", }, + { + name: "conflicting archived", + cli: "--archived --no-archived", + wantsErr: "specify only one of `--archived` or `--no-archived`", + }, { name: "too many arguments", cli: "monalisa hubot", wantsErr: "accepts at most 1 arg(s), received 2", }, + { + name: "invalid limit", + cli: "-L 0", + wantsErr: "invalid limit: 0", + }, } for _, tt := range tests { @@ -166,6 +220,8 @@ func TestNewCmdList(t *testing.T) { assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) assert.Equal(t, tt.wants.Fork, gotOpts.Fork) assert.Equal(t, tt.wants.Source, gotOpts.Source) + assert.Equal(t, tt.wants.Archived, gotOpts.Archived) + assert.Equal(t, tt.wants.NonArchived, gotOpts.NonArchived) }) } }