commands/issuable/list/issuable_list.go (415 lines of code) (raw):
package list
import (
"encoding/json"
"errors"
"fmt"
"strings"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"github.com/MakeNowJust/heredoc/v2"
"gitlab.com/gitlab-org/cli/internal/glrepo"
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/commands/flag"
"gitlab.com/gitlab-org/cli/commands/issuable"
"gitlab.com/gitlab-org/cli/commands/issue/issueutils"
"gitlab.com/gitlab-org/cli/pkg/utils"
"github.com/spf13/cobra"
gitlab "gitlab.com/gitlab-org/api/client-go"
)
type ListOptions struct {
// metadata
Assignee string
NotAssignee string
Author string
NotAuthor string
Labels []string
NotLabels []string
Milestone string
Mine bool
Search string
Group string
Epic int
IssueType string
Iteration int
// issue states
State string
Closed bool
Opened bool
All bool
Confidential bool
// Pagination
Page int
PerPage int
// Other
In string
// display opts
ListType string
TitleQualifier string
OutputFormat string
Output string
IO *iostreams.IOStreams
BaseRepo func() (glrepo.Interface, error)
HTTPClient func() (*gitlab.Client, error)
JSONOutput bool
}
func NewCmdList(f *cmdutils.Factory, runE func(opts *ListOptions) error, issueType issuable.IssueType) *cobra.Command {
opts := &ListOptions{
IO: f.IO,
IssueType: string(issueType),
}
issueListCmd := &cobra.Command{
Use: "list [flags]",
Short: fmt.Sprintf(`List project %ss.`, issueType),
Long: ``,
Aliases: []string{"ls"},
Example: heredoc.Doc(fmt.Sprintf(`
- glab %[1]s list --all
- glab %[1]s ls --all
- glab %[1]s list --assignee=@me
- glab %[1]s list --milestone release-2.0.0 --opened
`, issueType)),
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
// support repo override
opts.BaseRepo = f.BaseRepo
opts.HTTPClient = f.HttpClient
if len(opts.Labels) != 0 && len(opts.NotLabels) != 0 {
return cmdutils.FlagError{
Err: errors.New("flags --label and --not-label are mutually exclusive."),
}
}
if opts.Author != "" && len(opts.NotAuthor) != 0 {
return cmdutils.FlagError{
Err: errors.New("flags --author and --not-author are mutually exclusive."),
}
}
if opts.Assignee != "" && len(opts.NotAssignee) != 0 {
return cmdutils.FlagError{
Err: errors.New("flags --assignee and --not-assignee are mutually exclusive."),
}
}
if opts.All {
opts.State = "all"
} else if opts.Closed {
opts.State = "closed"
opts.TitleQualifier = "closed"
} else {
opts.State = "opened"
opts.TitleQualifier = "open"
}
group, err := flag.GroupOverride(cmd)
if err != nil {
return err
}
opts.Group = group
if opts.Epic != 0 && opts.Group == "" {
repo, err := opts.BaseRepo()
if err != nil {
return err
}
opts.Group = repo.RepoOwner()
}
if opts.Epic != 0 && opts.Group == "" {
return cmdutils.FlagError{
Err: errors.New("flag --epic requires flag --group"),
}
}
// The underlying API, ListEpicIssues, does not support filtering, so we do the filtering client-side.
// That means to implement pagination, we'd still need to request all previous issues and filter them.
// That means client side pagination is more expensive (O(n^2)) than requesting all issues belonging to an epic (O(n)).
if opts.Epic != 0 && opts.Page > 1 {
return cmdutils.FlagError{
Err: errors.New("--epic does not support the --page flag"),
}
}
if runE != nil {
return runE(opts)
}
return listRun(opts)
},
}
cmdutils.EnableRepoOverride(issueListCmd, f)
issueListCmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", fmt.Sprintf("Filter %s by assignee <username>.", issueType))
issueListCmd.Flags().StringVar(&opts.NotAssignee, "not-assignee", "", fmt.Sprintf("Filter %s by not being assigned to <username>.", issueType))
issueListCmd.Flags().StringVar(&opts.Author, "author", "", fmt.Sprintf("Filter %s by author <username>.", issueType))
issueListCmd.Flags().StringVar(&opts.NotAuthor, "not-author", "", fmt.Sprintf("Filter %s by not being by author(s) <username>.", issueType))
issueListCmd.Flags().StringVar(&opts.Search, "search", "", "Search <string> in the fields defined by '--in'.")
issueListCmd.Flags().StringVar(&opts.In, "in", "title,description", "search in: title, description.")
issueListCmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", []string{}, fmt.Sprintf("Filter %s by label <name>.", issueType))
issueListCmd.Flags().StringSliceVar(&opts.NotLabels, "not-label", []string{}, fmt.Sprintf("Filter %s by lack of label <name>.", issueType))
issueListCmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", fmt.Sprintf("Filter %s by milestone <id>.", issueType))
issueListCmd.Flags().BoolVarP(&opts.All, "all", "A", false, fmt.Sprintf("Get all %ss.", issueType))
issueListCmd.Flags().BoolVarP(&opts.Closed, "closed", "c", false, fmt.Sprintf("Get only closed %ss.", issueType))
issueListCmd.Flags().BoolVarP(&opts.Confidential, "confidential", "C", false, fmt.Sprintf("Filter by confidential %ss.", issueType))
issueListCmd.Flags().StringVarP(&opts.OutputFormat, "output-format", "F", "details", "Options: 'details', 'ids', 'urls'.")
issueListCmd.Flags().StringVarP(&opts.Output, "output", "O", "text", "Options: 'text' or 'json'.")
issueListCmd.Flags().IntVarP(&opts.Page, "page", "p", 1, "Page number.")
issueListCmd.Flags().IntVarP(&opts.PerPage, "per-page", "P", 30, "Number of items to list per page.")
issueListCmd.PersistentFlags().StringP("group", "g", "", "Select a group or subgroup. Ignored if a repo argument is set.")
issueListCmd.Flags().IntVarP(&opts.Epic, "epic", "e", 0, "List issues belonging to a given epic (requires --group, no pagination support).")
issueListCmd.MarkFlagsMutuallyExclusive("output", "output-format")
if issueType == issuable.TypeIssue {
issueListCmd.Flags().StringVarP(&opts.IssueType, "issue-type", "t", "", "Filter issue by its type. Options: issue, incident, test_case.")
issueListCmd.Flags().IntVarP(&opts.Iteration, "iteration", "i", 0, "Filter issue by iteration <id>.")
}
issueListCmd.Flags().BoolP("opened", "o", false, fmt.Sprintf("Get only open %ss.", issueType))
_ = issueListCmd.Flags().MarkHidden("opened")
_ = issueListCmd.Flags().MarkDeprecated("opened", "default if --closed is not used.")
issueListCmd.Flags().BoolVarP(&opts.Mine, "mine", "M", false, fmt.Sprintf("Filter only %ss assigned to me.", issueType))
_ = issueListCmd.Flags().MarkHidden("mine")
_ = issueListCmd.Flags().MarkDeprecated("mine", "use --assignee=@me")
return issueListCmd
}
func listRun(opts *ListOptions) error {
apiClient, err := opts.HTTPClient()
if err != nil {
return err
}
repo, err := opts.BaseRepo()
if err != nil {
return err
}
listOpts := &gitlab.ListProjectIssuesOptions{
State: gitlab.Ptr(opts.State),
In: gitlab.Ptr(opts.In),
}
listOpts.Page = 1
listOpts.PerPage = 30
if opts.Assignee == "" && opts.Mine {
opts.Assignee = "@me"
}
if opts.Assignee != "" {
uid, err := userID(apiClient, opts.Assignee)
if err != nil {
return err
}
listOpts.AssigneeID = gitlab.Ptr(uid)
}
if opts.NotAssignee != "" {
uid, err := userID(apiClient, opts.NotAssignee)
if err != nil {
return err
}
listOpts.NotAssigneeID = gitlab.Ptr(uid)
}
if opts.Author != "" {
uid, err := userID(apiClient, opts.Author)
if err != nil {
return err
}
listOpts.AuthorID = gitlab.Ptr(uid)
}
if opts.NotAuthor != "" {
uid, err := userID(apiClient, opts.NotAuthor)
if err != nil {
return err
}
listOpts.NotAuthorID = gitlab.Ptr(uid)
}
if opts.Search != "" {
listOpts.Search = gitlab.Ptr(opts.Search)
opts.ListType = "search"
}
if len(opts.Labels) != 0 {
listOpts.Labels = (*gitlab.LabelOptions)(&opts.Labels)
opts.ListType = "search"
}
if len(opts.NotLabels) != 0 {
listOpts.NotLabels = (*gitlab.LabelOptions)(&opts.NotLabels)
opts.ListType = "search"
}
if opts.Milestone != "" {
listOpts.Milestone = gitlab.Ptr(opts.Milestone)
opts.ListType = "search"
}
if opts.Confidential {
listOpts.Confidential = gitlab.Ptr(opts.Confidential)
opts.ListType = "search"
}
if opts.Page != 0 {
listOpts.Page = opts.Page
opts.ListType = "search"
}
if opts.PerPage != 0 {
listOpts.PerPage = opts.PerPage
opts.ListType = "search"
}
issueType := "issue"
if opts.IssueType != "" {
listOpts.IssueType = gitlab.Ptr(opts.IssueType)
opts.ListType = "search"
issueType = opts.IssueType
}
if issueType == "issue" && opts.Iteration != 0 {
listOpts.IterationID = gitlab.Ptr(opts.Iteration)
}
var issues []*gitlab.Issue
title := utils.NewListTitle(fmt.Sprintf("%s %s", opts.TitleQualifier, issueType))
title.RepoName = repo.FullName()
switch {
case opts.Epic != 0:
issues, err = listEpicIssues(apiClient, opts, listOpts)
if err != nil {
return err
}
title.RepoName = fmt.Sprintf("%s&%d", opts.Group, opts.Epic)
case opts.Group != "":
issues, err = api.ListGroupIssues(apiClient, opts.Group, api.ProjectListIssueOptionsToGroup(listOpts))
if err != nil {
return err
}
title.RepoName = opts.Group
default:
issues, err = api.ListProjectIssues(apiClient, repo.FullName(), listOpts)
if err != nil {
return err
}
}
title.Page = listOpts.Page
title.ListActionType = opts.ListType
title.CurrentPageTotal = len(issues)
if opts.Output == "json" {
issueListJSON, _ := json.Marshal(issues)
fmt.Fprintln(opts.IO.StdOut, string(issueListJSON))
return nil
}
if opts.OutputFormat == "ids" {
for _, i := range issues {
fmt.Fprintf(opts.IO.StdOut, "%d\n", i.IID)
}
return nil
}
if opts.OutputFormat == "urls" {
for _, i := range issues {
fmt.Fprintf(opts.IO.StdOut, "%s\n", i.WebURL)
}
return nil
}
if opts.IO.StartPager() != nil {
return fmt.Errorf("failed to start pager: %q", err)
}
defer opts.IO.StopPager()
fmt.Fprintf(opts.IO.StdOut, "%s\n%s\n", title.Describe(), issueutils.DisplayIssueList(opts.IO, issues, repo.FullName()))
return nil
}
func userID(client *gitlab.Client, username string) (int, error) {
if username == "@me" {
me, err := api.CurrentUser(nil)
if err != nil {
return 0, err
}
return me.ID, nil
}
u, err := api.UserByName(client, username)
if err != nil {
return 0, err
}
return u.ID, nil
}
// listEpicIssues is a wrapper around the API call of the same name.
// Since the GitLab API doesn't support filtering for this method, it implements client-side filtering of issues instead.
func listEpicIssues(client *gitlab.Client, opts *ListOptions, projListOpts *gitlab.ListProjectIssuesOptions) ([]*gitlab.Issue, error) {
var (
listOpts = gitlab.ListOptions{
Page: 1,
}
issues []*gitlab.Issue
)
maxIssues := opts.PerPage
if maxIssues <= 0 {
maxIssues = api.DefaultListLimit
}
listOpts.PerPage = min(maxIssues, api.MaxPerPage)
for {
is, req, err := client.EpicIssues.ListEpicIssues(opts.Group, opts.Epic, &listOpts) //nolint:staticcheck
if err != nil {
return nil, err
}
// The "list issues for an epic" api doesn't support filtering, requiring client-side filtering.
is = filterIssues(is, projListOpts)
// If the number of issues exceeds the page size, trim the list.
if len(issues)+len(is) > maxIssues {
is = is[:maxIssues-len(issues)]
}
issues = append(issues, is...)
if len(issues) >= maxIssues || req.NextPage == 0 {
break
}
listOpts.Page = req.NextPage
}
return issues, nil
}
func filterIssues(issues []*gitlab.Issue, opts *gitlab.ListProjectIssuesOptions) []*gitlab.Issue {
var ret []*gitlab.Issue
for _, issue := range issues {
if isMatch(issue, opts) {
ret = append(ret, issue)
}
}
return ret
}
func isMatch(issue *gitlab.Issue, opts *gitlab.ListProjectIssuesOptions) bool {
if opts.AssigneeID != nil && !hasAssignee(issue, *opts.AssigneeID) {
return false
}
if opts.NotAssigneeID != nil && hasAssignee(issue, *opts.NotAssigneeID) {
return false
}
if opts.AuthorID != nil && (issue.Author == nil || issue.Author.ID != *opts.AuthorID) {
return false
}
if opts.NotAuthorID != nil && issue.Author != nil && issue.Author.ID == *opts.NotAuthorID {
return false
}
if opts.Labels != nil && !hasAllLabels(issue, []string(*opts.Labels)) {
return false
}
if opts.NotLabels != nil && hasAnyLabel(issue, []string(*opts.NotLabels)) {
return false
}
if opts.Milestone != nil && (issue.Milestone == nil || !strings.EqualFold(issue.Milestone.Title, *opts.Milestone)) {
return false
}
if opts.Search != nil && !strings.Contains(strings.ToLower(issue.Title), strings.ToLower(*opts.Search)) {
return false
}
if opts.IterationID != nil && (issue.Iteration == nil || issue.Iteration.ID != *opts.IterationID) {
return false
}
if !stateMatches(issue, opts) {
return false
}
if opts.Confidential != nil && *opts.Confidential != issue.Confidential {
return false
}
return true
}
func hasAssignee(issue *gitlab.Issue, userID int) bool {
for _, assignee := range issue.Assignees {
if assignee.ID == userID {
return true
}
}
return false
}
func stateMatches(issue *gitlab.Issue, opts *gitlab.ListProjectIssuesOptions) bool {
switch {
case opts.State == nil:
return true
case *opts.State == "all":
return true
default:
return *opts.State == issue.State
}
}
func hasAllLabels(issue *gitlab.Issue, labels []string) bool {
issueLabels := make(map[string]bool)
for _, l := range issue.Labels {
issueLabels[l] = true
}
for _, l := range labels {
if _, ok := issueLabels[l]; !ok {
return false
}
}
return true
}
func hasAnyLabel(issue *gitlab.Issue, labels []string) bool {
issueLabels := make(map[string]bool)
for _, l := range issue.Labels {
issueLabels[l] = true
}
for _, l := range labels {
if _, ok := issueLabels[l]; ok {
return true
}
}
return false
}