From 9a149d7694b7ce767dcd84f9dfa91e0c89b4d0ca Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Thu, 11 Feb 2021 19:43:08 -0300 Subject: [PATCH] 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))