Skip to content

Commit 8dcfd33

Browse files
williammartinCopilotbabakks
authored
Add --query flag to project item-list (cli#12696)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Babak K. Shandiz <babakks@github.com>
1 parent 3a73c39 commit 8dcfd33

7 files changed

Lines changed: 719 additions & 60 deletions

File tree

internal/featuredetection/detector_mock.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
2020
return gh.ProjectsV1Unsupported
2121
}
2222

23+
func (md *DisabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
24+
return ProjectFeatures{}, nil
25+
}
26+
2327
func (md *DisabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
2428
return advancedIssueSearchNotSupported, nil
2529
}
@@ -50,6 +54,10 @@ func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
5054
return gh.ProjectsV1Supported
5155
}
5256

57+
func (md *EnabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
58+
return allProjectFeatures, nil
59+
}
60+
5361
func (md *EnabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
5462
return advancedIssueSearchNotSupported, nil
5563
}

internal/featuredetection/feature_detection.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Detector interface {
1616
PullRequestFeatures() (PullRequestFeatures, error)
1717
RepositoryFeatures() (RepositoryFeatures, error)
1818
ProjectsV1() gh.ProjectsV1Support
19+
ProjectFeatures() (ProjectFeatures, error)
1920
SearchFeatures() (SearchFeatures, error)
2021
ReleaseFeatures() (ReleaseFeatures, error)
2122
ActionsFeatures() (ActionsFeatures, error)
@@ -58,6 +59,16 @@ var allRepositoryFeatures = RepositoryFeatures{
5859
AutoMerge: true,
5960
}
6061

62+
type ProjectFeatures struct {
63+
// ProjectItemQuery indicates support for the `query` argument on
64+
// ProjectV2.items (supported on github.com and GHES 3.20+).
65+
ProjectItemQuery bool
66+
}
67+
68+
var allProjectFeatures = ProjectFeatures{
69+
ProjectItemQuery: true,
70+
}
71+
6172
type SearchFeatures struct {
6273
// AdvancedIssueSearch indicates whether the host supports advanced issue
6374
// search via API calls.
@@ -279,6 +290,45 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support {
279290
return gh.ProjectsV1Unsupported
280291
}
281292

293+
func (d *detector) ProjectFeatures() (ProjectFeatures, error) {
294+
if !ghauth.IsEnterprise(d.host) {
295+
return allProjectFeatures, nil
296+
}
297+
298+
var features ProjectFeatures
299+
300+
var featureDetection struct {
301+
ProjectV2 struct {
302+
Fields []struct {
303+
Name string
304+
Args []struct {
305+
Name string
306+
}
307+
} `graphql:"fields(includeDeprecated: true)"`
308+
} `graphql:"ProjectV2: __type(name: \"ProjectV2\")"`
309+
}
310+
311+
gql := api.NewClientFromHTTP(d.httpClient)
312+
err := gql.Query(d.host, "ProjectV2_fields", &featureDetection, nil)
313+
if err != nil {
314+
return features, err
315+
}
316+
317+
for _, field := range featureDetection.ProjectV2.Fields {
318+
if field.Name == "items" {
319+
for _, arg := range field.Args {
320+
if arg.Name == "query" {
321+
features.ProjectItemQuery = true
322+
break
323+
}
324+
}
325+
break
326+
}
327+
}
328+
329+
return features, nil
330+
}
331+
282332
const (
283333
// enterpriseAdvancedIssueSearchSupport is the minimum version of GHES that
284334
// supports advanced issue search and gh should use it.

internal/featuredetection/feature_detection_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func TestIssueFeatures(t *testing.T) {
6969
for _, tt := range tests {
7070
t.Run(tt.name, func(t *testing.T) {
7171
reg := &httpmock.Registry{}
72+
defer reg.Verify(t)
7273
httpClient := &http.Client{}
7374
httpmock.ReplaceTripper(httpClient, reg)
7475
for query, resp := range tt.queryResponse {
@@ -586,6 +587,92 @@ func TestAdvancedIssueSearchSupport(t *testing.T) {
586587
}
587588
}
588589

590+
func TestProjectFeatures(t *testing.T) {
591+
tests := []struct {
592+
name string
593+
hostname string
594+
queryResponse map[string]string
595+
wantFeatures ProjectFeatures
596+
wantErr bool
597+
}{
598+
{
599+
name: "github.com",
600+
hostname: "github.com",
601+
wantFeatures: ProjectFeatures{
602+
ProjectItemQuery: true,
603+
},
604+
},
605+
{
606+
name: "ghec data residency (ghe.com)",
607+
hostname: "stampname.ghe.com",
608+
wantFeatures: ProjectFeatures{
609+
ProjectItemQuery: true,
610+
},
611+
},
612+
{
613+
name: "GHE empty response",
614+
hostname: "git.my.org",
615+
queryResponse: map[string]string{
616+
`query ProjectV2_fields\b`: `{"data": {}}`,
617+
},
618+
wantFeatures: ProjectFeatures{},
619+
},
620+
{
621+
name: "GHE items field without query arg",
622+
hostname: "git.my.org",
623+
queryResponse: map[string]string{
624+
`query ProjectV2_fields\b`: heredoc.Doc(`
625+
{ "data": { "ProjectV2": { "fields": [
626+
{"name": "items", "args": [
627+
{"name": "after"},
628+
{"name": "first"}
629+
]}
630+
] } } }
631+
`),
632+
},
633+
wantFeatures: ProjectFeatures{},
634+
},
635+
{
636+
name: "GHE items field with query arg",
637+
hostname: "git.my.org",
638+
queryResponse: map[string]string{
639+
`query ProjectV2_fields\b`: heredoc.Doc(`
640+
{ "data": { "ProjectV2": { "fields": [
641+
{"name": "items", "args": [
642+
{"name": "after"},
643+
{"name": "first"},
644+
{"name": "query"}
645+
]}
646+
] } } }
647+
`),
648+
},
649+
wantFeatures: ProjectFeatures{
650+
ProjectItemQuery: true,
651+
},
652+
},
653+
}
654+
655+
for _, tt := range tests {
656+
t.Run(tt.name, func(t *testing.T) {
657+
reg := &httpmock.Registry{}
658+
defer reg.Verify(t)
659+
httpClient := &http.Client{}
660+
httpmock.ReplaceTripper(httpClient, reg)
661+
for query, resp := range tt.queryResponse {
662+
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
663+
}
664+
detector := detector{host: tt.hostname, httpClient: httpClient}
665+
gotFeatures, err := detector.ProjectFeatures()
666+
if tt.wantErr {
667+
assert.Error(t, err)
668+
return
669+
}
670+
assert.NoError(t, err)
671+
assert.Equal(t, tt.wantFeatures, gotFeatures)
672+
})
673+
}
674+
}
675+
589676
func TestReleaseFeatures(t *testing.T) {
590677
withImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"},{"name":"immutable"}]}}}`
591678
withoutImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"}]}}}`

pkg/cmd/project/item-list/item_list.go

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package itemlist
33
import (
44
"fmt"
55
"strconv"
6+
"time"
67

78
"github.com/MakeNowJust/heredoc"
9+
"github.com/cli/cli/v2/api"
10+
fd "github.com/cli/cli/v2/internal/featuredetection"
811
"github.com/cli/cli/v2/internal/tableprinter"
912
"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
1013
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@@ -17,23 +20,41 @@ type listOpts struct {
1720
limit int
1821
owner string
1922
number int32
23+
query string
2024
exporter cmdutil.Exporter
2125
}
2226

2327
type listConfig struct {
24-
io *iostreams.IOStreams
25-
client *queries.Client
26-
opts listOpts
28+
io *iostreams.IOStreams
29+
client *queries.Client
30+
opts listOpts
31+
detector fd.Detector
2732
}
2833

2934
func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.Command {
3035
opts := listOpts{}
3136
listCmd := &cobra.Command{
3237
Short: "List the items in a project",
3338
Use: "item-list [<number>]",
39+
Long: heredoc.Doc(`
40+
List the items in a project.
41+
42+
If supported by the API host (github.com and GHES 3.20+), the --query option can
43+
be used to perform advanced search. For the full syntax, see:
44+
https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects
45+
`),
3446
Example: heredoc.Doc(`
3547
# List the items in the current users's project "1"
3648
$ gh project item-list 1 --owner "@me"
49+
50+
# List items assigned to a specific user
51+
$ gh project item-list 1 --owner "@me" --query "assignee:monalisa"
52+
53+
# List open issues assigned to yourself
54+
$ gh project item-list 1 --owner "@me" --query "assignee:@me is:issue is:open"
55+
56+
# List items with the "bug" label that are not done
57+
$ gh project item-list 1 --owner "@me" --query "label:bug -status:Done"
3758
`),
3859
Args: cobra.MaximumNArgs(1),
3960
RunE: func(cmd *cobra.Command, args []string) error {
@@ -60,18 +81,43 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
6081
if runF != nil {
6182
return runF(config)
6283
}
84+
85+
if opts.query != "" {
86+
httpClient, err := f.HttpClient()
87+
if err != nil {
88+
return err
89+
}
90+
cfg, err := f.Config()
91+
if err != nil {
92+
return err
93+
}
94+
host, _ := cfg.Authentication().DefaultHost()
95+
config.detector = fd.NewDetector(api.NewCachedHTTPClient(httpClient, time.Hour*24), host)
96+
}
97+
6398
return runList(config)
6499
},
65100
}
66101

67-
listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.")
102+
listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user")
103+
listCmd.Flags().StringVar(&opts.query, "query", "", `Filter items using the Projects filter syntax, e.g. "assignee:octocat -status:Done"`)
68104
cmdutil.AddFormatFlags(listCmd, &opts.exporter)
69105
listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of items to fetch")
70106

71107
return listCmd
72108
}
73109

74110
func runList(config listConfig) error {
111+
if config.opts.query != "" {
112+
features, err := config.detector.ProjectFeatures()
113+
if err != nil {
114+
return err
115+
}
116+
if !features.ProjectItemQuery {
117+
return fmt.Errorf("the `--query` flag is not supported on this GitHub host; most likely you are targeting a version of GHES that does not yet have the query field available")
118+
}
119+
}
120+
75121
canPrompt := config.io.CanPrompt()
76122
owner, err := config.client.NewOwner(canPrompt, config.opts.owner)
77123
if err != nil {
@@ -87,7 +133,7 @@ func runList(config listConfig) error {
87133
config.opts.number = project.Number
88134
}
89135

90-
project, err := config.client.ProjectItems(owner, config.opts.number, config.opts.limit)
136+
project, err := config.client.ProjectItems(owner, config.opts.number, config.opts.limit, config.opts.query)
91137
if err != nil {
92138
return err
93139
}

0 commit comments

Comments
 (0)