Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/featuredetection/detector_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
return gh.ProjectsV1Unsupported
}

func (md *DisabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
return ProjectFeatures{}, nil
}

func (md *DisabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
return advancedIssueSearchNotSupported, nil
}
Expand Down Expand Up @@ -50,6 +54,10 @@ func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
return gh.ProjectsV1Supported
}

func (md *EnabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
return allProjectFeatures, nil
}

func (md *EnabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
return advancedIssueSearchNotSupported, nil
}
Expand Down
50 changes: 50 additions & 0 deletions internal/featuredetection/feature_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Detector interface {
PullRequestFeatures() (PullRequestFeatures, error)
RepositoryFeatures() (RepositoryFeatures, error)
ProjectsV1() gh.ProjectsV1Support
ProjectFeatures() (ProjectFeatures, error)
SearchFeatures() (SearchFeatures, error)
ReleaseFeatures() (ReleaseFeatures, error)
ActionsFeatures() (ActionsFeatures, error)
Expand Down Expand Up @@ -58,6 +59,16 @@ var allRepositoryFeatures = RepositoryFeatures{
AutoMerge: true,
}

type ProjectFeatures struct {
// ProjectItemQuery indicates support for the `query` argument on
// ProjectV2.items (supported on github.com and GHES 3.20+).
ProjectItemQuery bool
}

var allProjectFeatures = ProjectFeatures{
ProjectItemQuery: true,
}

type SearchFeatures struct {
// AdvancedIssueSearch indicates whether the host supports advanced issue
// search via API calls.
Expand Down Expand Up @@ -279,6 +290,45 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support {
return gh.ProjectsV1Unsupported
}

func (d *detector) ProjectFeatures() (ProjectFeatures, error) {
if !ghauth.IsEnterprise(d.host) {
return allProjectFeatures, nil
}

var features ProjectFeatures

var featureDetection struct {
ProjectV2 struct {
Fields []struct {
Name string
Args []struct {
Name string
}
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"ProjectV2: __type(name: \"ProjectV2\")"`
}

gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "ProjectV2_fields", &featureDetection, nil)
if err != nil {
return features, err
}

for _, field := range featureDetection.ProjectV2.Fields {
if field.Name == "items" {
for _, arg := range field.Args {
if arg.Name == "query" {
features.ProjectItemQuery = true
break
}
}
break
}
}

return features, nil
}

const (
// enterpriseAdvancedIssueSearchSupport is the minimum version of GHES that
// supports advanced issue search and gh should use it.
Expand Down
87 changes: 87 additions & 0 deletions internal/featuredetection/feature_detection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func TestIssueFeatures(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
Expand Down Expand Up @@ -586,6 +587,92 @@ func TestAdvancedIssueSearchSupport(t *testing.T) {
}
}

func TestProjectFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures ProjectFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: ProjectFeatures{
ProjectItemQuery: true,
},
},
{
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: ProjectFeatures{
ProjectItemQuery: true,
},
},
{
name: "GHE empty response",
hostname: "git.my.org",
queryResponse: map[string]string{
`query ProjectV2_fields\b`: `{"data": {}}`,
},
wantFeatures: ProjectFeatures{},
},
{
name: "GHE items field without query arg",
hostname: "git.my.org",
queryResponse: map[string]string{
`query ProjectV2_fields\b`: heredoc.Doc(`
{ "data": { "ProjectV2": { "fields": [
{"name": "items", "args": [
{"name": "after"},
{"name": "first"}
]}
] } } }
`),
},
wantFeatures: ProjectFeatures{},
},
{
name: "GHE items field with query arg",
hostname: "git.my.org",
queryResponse: map[string]string{
`query ProjectV2_fields\b`: heredoc.Doc(`
{ "data": { "ProjectV2": { "fields": [
{"name": "items", "args": [
{"name": "after"},
{"name": "first"},
{"name": "query"}
]}
] } } }
`),
},
wantFeatures: ProjectFeatures{
ProjectItemQuery: true,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.ProjectFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}

func TestReleaseFeatures(t *testing.T) {
withImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"},{"name":"immutable"}]}}}`
withoutImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"}]}}}`
Expand Down
56 changes: 51 additions & 5 deletions pkg/cmd/project/item-list/item_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package itemlist
import (
"fmt"
"strconv"
"time"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
Expand All @@ -17,23 +20,41 @@ type listOpts struct {
limit int
owner string
number int32
query string
exporter cmdutil.Exporter
}

type listConfig struct {
io *iostreams.IOStreams
client *queries.Client
opts listOpts
io *iostreams.IOStreams
client *queries.Client
opts listOpts
detector fd.Detector
}

func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.Command {
opts := listOpts{}
listCmd := &cobra.Command{
Short: "List the items in a project",
Use: "item-list [<number>]",
Long: heredoc.Doc(`
List the items in a project.

If supported by the API host (github.com and GHES 3.20+), the --query option can
be used to perform advanced search. For the full syntax, see:
https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects
`),
Example: heredoc.Doc(`
# List the items in the current users's project "1"
$ gh project item-list 1 --owner "@me"

# List items assigned to a specific user
$ gh project item-list 1 --owner "@me" --query "assignee:monalisa"

# List open issues assigned to yourself
$ gh project item-list 1 --owner "@me" --query "assignee:@me is:issue is:open"

# List items with the "bug" label that are not done
$ gh project item-list 1 --owner "@me" --query "label:bug -status:Done"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -60,18 +81,43 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
if runF != nil {
return runF(config)
}

if opts.query != "" {
httpClient, err := f.HttpClient()
if err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
host, _ := cfg.Authentication().DefaultHost()
config.detector = fd.NewDetector(api.NewCachedHTTPClient(httpClient, time.Hour*24), host)
}

return runList(config)
},
}

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

return listCmd
}

func runList(config listConfig) error {
if config.opts.query != "" {
features, err := config.detector.ProjectFeatures()
if err != nil {
return err
}
if !features.ProjectItemQuery {
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")
}
}

canPrompt := config.io.CanPrompt()
owner, err := config.client.NewOwner(canPrompt, config.opts.owner)
if err != nil {
Expand All @@ -87,7 +133,7 @@ func runList(config listConfig) error {
config.opts.number = project.Number
}

project, err := config.client.ProjectItems(owner, config.opts.number, config.opts.limit)
project, err := config.client.ProjectItems(owner, config.opts.number, config.opts.limit, config.opts.query)
if err != nil {
return err
}
Expand Down
Loading
Loading