Add --query flag to project item-list (#12696)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Babak K. Shandiz <babakks@github.com>
This commit is contained in:
William Martin 2026-02-18 16:34:13 +01:00 committed by GitHub
parent 3a73c39cc6
commit 8dcfd330e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 719 additions and 60 deletions

View file

@ -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
}
@ -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
}

View file

@ -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)
@ -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.
@ -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.

View file

@ -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 {
@ -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"}]}}}`

View file

@ -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"
@ -17,13 +20,15 @@ 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 {
@ -31,9 +36,25 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
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 {
@ -60,11 +81,26 @@ 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")
@ -72,6 +108,16 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
}
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 {
@ -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
}

View file

@ -4,6 +4,7 @@ import (
"testing"
"github.com/MakeNowJust/heredoc"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
@ -52,6 +53,14 @@ func TestNewCmdList(t *testing.T) {
},
wantsExporter: true,
},
{
name: "query",
cli: `--query "assignee:octocat"`,
wants: listOpts{
limit: 30,
query: "assignee:octocat",
},
},
}
t.Setenv("GH_TOKEN", "auth-token")
@ -83,6 +92,7 @@ func TestNewCmdList(t *testing.T) {
assert.Equal(t, tt.wants.number, gotOpts.number)
assert.Equal(t, tt.wants.owner, gotOpts.owner)
assert.Equal(t, tt.wants.query, gotOpts.query)
assert.Equal(t, tt.wantsExporter, gotOpts.exporter != nil)
assert.Equal(t, tt.wants.limit, gotOpts.limit)
})
@ -618,3 +628,109 @@ func TestRunList_JSON(t *testing.T) {
`{"items":[{"content":{"type":"Issue","body":"","title":"an issue","number":1,"repository":"cli/go-gh","url":""},"id":"issue ID"},{"content":{"type":"PullRequest","body":"","title":"a pull request","number":2,"repository":"cli/go-gh","url":""},"id":"pull request ID"},{"content":{"type":"DraftIssue","body":"","title":"draft issue","id":"draft issue ID"},"id":"draft issue ID"}],"totalCount":3}`,
stdout.String())
}
func TestRunList_WithQuery(t *testing.T) {
defer gock.Off()
// get user ID
gock.New("https://api.github.com").
Post("/graphql").
MatchType("json").
JSON(map[string]interface{}{
"query": "query UserOrgOwner.*",
"variables": map[string]interface{}{
"login": "monalisa",
},
}).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"user": map[string]interface{}{
"id": "an ID",
},
},
"errors": []interface{}{
map[string]interface{}{
"type": "NOT_FOUND",
"path": []string{"organization"},
},
},
})
// list project items with query
gock.New("https://api.github.com").
Post("/graphql").
JSON(map[string]interface{}{
"query": "query UserProjectWithItems.*",
"variables": map[string]interface{}{
"firstItems": queries.LimitDefault,
"afterItems": nil,
"firstFields": queries.LimitMax,
"afterFields": nil,
"login": "monalisa",
"number": 1,
"query": "assignee:octocat -status:Done",
},
}).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"user": map[string]interface{}{
"projectV2": map[string]interface{}{
"items": map[string]interface{}{
"nodes": []map[string]interface{}{
{
"id": "issue ID",
"content": map[string]interface{}{
"__typename": "Issue",
"title": "an issue",
"number": 1,
"repository": map[string]string{
"nameWithOwner": "cli/go-gh",
},
},
},
},
},
},
},
},
})
client := queries.NewTestClient()
ios, _, stdout, _ := iostreams.Test()
config := listConfig{
opts: listOpts{
number: 1,
owner: "monalisa",
query: "assignee:octocat -status:Done",
},
client: client,
detector: &fd.EnabledDetectorMock{},
io: ios,
}
err := runList(config)
assert.NoError(t, err)
assert.Equal(
t,
"Issue\tan issue\t1\tcli/go-gh\tissue ID\n",
stdout.String())
}
func TestRunList_QueryUnsupported(t *testing.T) {
ios, _, _, _ := iostreams.Test()
config := listConfig{
opts: listOpts{
number: 1,
owner: "monalisa",
query: "assignee:octocat",
},
detector: &fd.DisabledDetectorMock{},
io: ios,
}
err := runList(config)
assert.EqualError(t, err, "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")
}

View file

@ -134,7 +134,7 @@ type Project struct {
PageInfo PageInfo
TotalCount int
Nodes []ProjectItem
} `graphql:"items(first: $firstItems, after: $afterItems)"`
} `graphql:"items(first: $firstItems, after: $afterItems, query: $query)"`
Fields ProjectFields `graphql:"fields(first: $firstFields, after: $afterFields)"`
Owner struct {
TypeName string `graphql:"__typename"`
@ -147,6 +147,81 @@ type Project struct {
}
}
type projectDTOBase struct {
Number int32
URL string
ShortDescription string
Public bool
Closed bool
Title string
ID string
Readme string
Owner struct {
TypeName string `graphql:"__typename"`
User struct {
Login string
} `graphql:"... on User"`
Organization struct {
Login string
} `graphql:"... on Organization"`
}
}
type projectDTOWithItemQuery struct {
projectDTOBase
Items struct {
PageInfo PageInfo
TotalCount int
Nodes []ProjectItem
} `graphql:"items(first: $firstItems, after: $afterItems, query: $query)"`
Fields ProjectFields `graphql:"fields(first: $firstFields, after: $afterFields)"`
}
type projectDTOWithoutItemQuery struct {
projectDTOBase
Items struct {
PageInfo PageInfo
TotalCount int
Nodes []ProjectItem
} `graphql:"items(first: $firstItems, after: $afterItems)"`
Fields ProjectFields `graphql:"fields(first: $firstFields, after: $afterFields)"`
}
func newProjectFromDTOBase(source projectDTOBase) *Project {
project := &Project{
Number: source.Number,
URL: source.URL,
ShortDescription: source.ShortDescription,
Public: source.Public,
Closed: source.Closed,
Title: source.Title,
ID: source.ID,
Readme: source.Readme,
}
project.Owner.TypeName = source.Owner.TypeName
project.Owner.User.Login = source.Owner.User.Login
project.Owner.Organization.Login = source.Owner.Organization.Login
return project
}
func newProjectFromDTOWithItemQuery(source projectDTOWithItemQuery) *Project {
project := newProjectFromDTOBase(source.projectDTOBase)
project.Items.PageInfo = source.Items.PageInfo
project.Items.TotalCount = source.Items.TotalCount
project.Items.Nodes = source.Items.Nodes
project.Fields = source.Fields
return project
}
func newProjectFromDTOWithoutItemQuery(source projectDTOWithoutItemQuery) *Project {
project := newProjectFromDTOBase(source.projectDTOBase)
project.Items.PageInfo = source.Items.PageInfo
project.Items.TotalCount = source.Items.TotalCount
project.Items.Nodes = source.Items.Nodes
project.Fields = source.Fields
return project
}
func (p Project) DetailedItems() map[string]interface{} {
return map[string]interface{}{
"items": serializeProjectWithItems(&p),
@ -508,8 +583,10 @@ func (p ProjectItem) ExportData(_ []string) map[string]interface{} {
}
// ProjectItems returns the items of a project. If the OwnerType is VIEWER, no login is required.
// If limit is 0, the default limit is used.
func (c *Client) ProjectItems(o *Owner, number int32, limit int) (*Project, error) {
// If limit is 0, the default limit is used. The queryStr parameter is passed as a server-side
// filter to the items connection, using the same syntax as the GitHub Projects filter bar
// (e.g. "assignee:octocat", "status:done").
func (c *Client) ProjectItems(o *Owner, number int32, limit int, queryStr string) (*Project, error) {
project := &Project{}
if limit == 0 {
limit = LimitDefault
@ -528,20 +605,35 @@ func (c *Client) ProjectItems(o *Owner, number int32, limit int) (*Project, erro
"afterFields": (*githubv4.String)(nil),
"number": githubv4.Int(number),
}
if queryStr != "" {
variables["query"] = githubv4.String(queryStr)
}
var query pager[ProjectItem]
var queryName string
switch o.Type {
case UserOwner:
variables["login"] = githubv4.String(o.Login)
query = &userOwnerWithItems{} // must be a pointer to work with graphql queries
if queryStr == "" {
query = &userOwnerWithItemsNoQuery{} // must be a pointer to work with graphql queries
} else {
query = &userOwnerWithItems{} // must be a pointer to work with graphql queries
}
queryName = "UserProjectWithItems"
case OrgOwner:
variables["login"] = githubv4.String(o.Login)
query = &orgOwnerWithItems{} // must be a pointer to work with graphql queries
if queryStr == "" {
query = &orgOwnerWithItemsNoQuery{} // must be a pointer to work with graphql queries
} else {
query = &orgOwnerWithItems{} // must be a pointer to work with graphql queries
}
queryName = "OrgProjectWithItems"
case ViewerOwner:
query = &viewerOwnerWithItems{} // must be a pointer to work with graphql queries
if queryStr == "" {
query = &viewerOwnerWithItemsNoQuery{} // must be a pointer to work with graphql queries
} else {
query = &viewerOwnerWithItems{} // must be a pointer to work with graphql queries
}
queryName = "ViewerProjectWithItems"
}
err := c.doQueryWithProgressIndicator(queryName, query, variables)
@ -567,6 +659,23 @@ type pager[N projectAttribute] interface {
Project() *Project
}
// userOwnerWithItemsNoQuery
func (q userOwnerWithItemsNoQuery) HasNextPage() bool {
return q.Owner.Project.Items.PageInfo.HasNextPage
}
func (q userOwnerWithItemsNoQuery) EndCursor() string {
return string(q.Owner.Project.Items.PageInfo.EndCursor)
}
func (q userOwnerWithItemsNoQuery) Nodes() []ProjectItem {
return q.Owner.Project.Items.Nodes
}
func (q userOwnerWithItemsNoQuery) Project() *Project {
return newProjectFromDTOWithoutItemQuery(q.Owner.Project)
}
// userOwnerWithItems
func (q userOwnerWithItems) HasNextPage() bool {
return q.Owner.Project.Items.PageInfo.HasNextPage
@ -581,7 +690,7 @@ func (q userOwnerWithItems) Nodes() []ProjectItem {
}
func (q userOwnerWithItems) Project() *Project {
return &q.Owner.Project
return newProjectFromDTOWithItemQuery(q.Owner.Project)
}
// orgOwnerWithItems
@ -598,7 +707,24 @@ func (q orgOwnerWithItems) Nodes() []ProjectItem {
}
func (q orgOwnerWithItems) Project() *Project {
return &q.Owner.Project
return newProjectFromDTOWithItemQuery(q.Owner.Project)
}
// orgOwnerWithItemsNoQuery
func (q orgOwnerWithItemsNoQuery) HasNextPage() bool {
return q.Owner.Project.Items.PageInfo.HasNextPage
}
func (q orgOwnerWithItemsNoQuery) EndCursor() string {
return string(q.Owner.Project.Items.PageInfo.EndCursor)
}
func (q orgOwnerWithItemsNoQuery) Nodes() []ProjectItem {
return q.Owner.Project.Items.Nodes
}
func (q orgOwnerWithItemsNoQuery) Project() *Project {
return newProjectFromDTOWithoutItemQuery(q.Owner.Project)
}
// viewerOwnerWithItems
@ -615,7 +741,24 @@ func (q viewerOwnerWithItems) Nodes() []ProjectItem {
}
func (q viewerOwnerWithItems) Project() *Project {
return &q.Owner.Project
return newProjectFromDTOWithItemQuery(q.Owner.Project)
}
// viewerOwnerWithItemsNoQuery
func (q viewerOwnerWithItemsNoQuery) HasNextPage() bool {
return q.Owner.Project.Items.PageInfo.HasNextPage
}
func (q viewerOwnerWithItemsNoQuery) EndCursor() string {
return string(q.Owner.Project.Items.PageInfo.EndCursor)
}
func (q viewerOwnerWithItemsNoQuery) Nodes() []ProjectItem {
return q.Owner.Project.Items.Nodes
}
func (q viewerOwnerWithItemsNoQuery) Project() *Project {
return newProjectFromDTOWithoutItemQuery(q.Owner.Project)
}
// userOwnerWithFields
@ -632,7 +775,7 @@ func (q userOwnerWithFields) Nodes() []ProjectField {
}
func (q userOwnerWithFields) Project() *Project {
return &q.Owner.Project
return newProjectFromDTOWithoutItemQuery(q.Owner.Project)
}
// orgOwnerWithFields
@ -649,7 +792,7 @@ func (q orgOwnerWithFields) Nodes() []ProjectField {
}
func (q orgOwnerWithFields) Project() *Project {
return &q.Owner.Project
return newProjectFromDTOWithoutItemQuery(q.Owner.Project)
}
// viewerOwnerWithFields
@ -666,7 +809,7 @@ func (q viewerOwnerWithFields) Nodes() []ProjectField {
}
func (q viewerOwnerWithFields) Project() *Project {
return &q.Owner.Project
return newProjectFromDTOWithoutItemQuery(q.Owner.Project)
}
type projectAttribute interface {
@ -893,70 +1036,77 @@ type viewerLoginOrgs struct {
}
}
type ownerWithLogin struct {
Project projectDTOWithoutItemQuery `graphql:"projectV2(number: $number)"`
Login string
}
type ownerWithProjectWithItemQuery struct {
Project projectDTOWithItemQuery `graphql:"projectV2(number: $number)"`
}
type ownerWithProjectWithoutItemQuery struct {
Project projectDTOWithoutItemQuery `graphql:"projectV2(number: $number)"`
}
// userOwner is used to query the project of a user.
type userOwner struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
Login string
} `graphql:"user(login: $login)"`
Owner ownerWithLogin `graphql:"user(login: $login)"`
}
// userOwnerWithItems is used to query the project of a user with its items.
type userOwnerWithItems struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
} `graphql:"user(login: $login)"`
Owner ownerWithProjectWithItemQuery `graphql:"user(login: $login)"`
}
// userOwnerWithItemsNoQuery is used to query the project of a user with its items, without query support.
type userOwnerWithItemsNoQuery struct {
Owner ownerWithProjectWithoutItemQuery `graphql:"user(login: $login)"`
}
// userOwnerWithFields is used to query the project of a user with its fields.
type userOwnerWithFields struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
} `graphql:"user(login: $login)"`
Owner ownerWithProjectWithoutItemQuery `graphql:"user(login: $login)"`
}
// orgOwner is used to query the project of an organization.
type orgOwner struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
Login string
} `graphql:"organization(login: $login)"`
Owner ownerWithLogin `graphql:"organization(login: $login)"`
}
// orgOwnerWithItems is used to query the project of an organization with its items.
type orgOwnerWithItems struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
} `graphql:"organization(login: $login)"`
Owner ownerWithProjectWithItemQuery `graphql:"organization(login: $login)"`
}
// orgOwnerWithItemsNoQuery is used to query the project of an organization with its items, without query support.
type orgOwnerWithItemsNoQuery struct {
Owner ownerWithProjectWithoutItemQuery `graphql:"organization(login: $login)"`
}
// orgOwnerWithFields is used to query the project of an organization with its fields.
type orgOwnerWithFields struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
} `graphql:"organization(login: $login)"`
Owner ownerWithProjectWithoutItemQuery `graphql:"organization(login: $login)"`
}
// viewerOwner is used to query the project of the viewer.
type viewerOwner struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
Login string
} `graphql:"viewer"`
Owner ownerWithLogin `graphql:"viewer"`
}
// viewerOwnerWithItems is used to query the project of the viewer with its items.
type viewerOwnerWithItems struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
} `graphql:"viewer"`
Owner ownerWithProjectWithItemQuery `graphql:"viewer"`
}
// viewerOwnerWithItemsNoQuery is used to query the project of the viewer with its items, without query support.
type viewerOwnerWithItemsNoQuery struct {
Owner ownerWithProjectWithoutItemQuery `graphql:"viewer"`
}
// viewerOwnerWithFields is used to query the project of the viewer with its fields.
type viewerOwnerWithFields struct {
Owner struct {
Project Project `graphql:"projectV2(number: $number)"`
} `graphql:"viewer"`
Owner ownerWithProjectWithoutItemQuery `graphql:"viewer"`
}
// OwnerType is the type of the owner of a project, which can be either a user or an organization. Viewer is the current user.
@ -1062,7 +1212,7 @@ type userProjects struct {
Projects struct {
TotalCount int
PageInfo PageInfo
Nodes []Project
Nodes []projectDTOWithoutItemQuery
} `graphql:"projectsV2(first: $first, after: $after)"`
Login string
} `graphql:"user(login: $login)"`
@ -1074,7 +1224,7 @@ type orgProjects struct {
Projects struct {
TotalCount int
PageInfo PageInfo
Nodes []Project
Nodes []projectDTOWithoutItemQuery
} `graphql:"projectsV2(first: $first, after: $after)"`
Login string
} `graphql:"organization(login: $login)"`
@ -1086,7 +1236,7 @@ type viewerProjects struct {
Projects struct {
TotalCount int
PageInfo PageInfo
Nodes []Project
Nodes []projectDTOWithoutItemQuery
} `graphql:"projectsV2(first: $first, after: $after)"`
Login string
} `graphql:"viewer"`
@ -1240,16 +1390,16 @@ func (c *Client) NewProject(canPrompt bool, o *Owner, number int32, fields bool)
var query userOwner
variables["login"] = githubv4.String(o.Login)
err := c.doQueryWithProgressIndicator("UserProject", &query, variables)
return &query.Owner.Project, err
return newProjectFromDTOWithoutItemQuery(query.Owner.Project), err
} else if o.Type == OrgOwner {
variables["login"] = githubv4.String(o.Login)
var query orgOwner
err := c.doQueryWithProgressIndicator("OrgProject", &query, variables)
return &query.Owner.Project, err
return newProjectFromDTOWithoutItemQuery(query.Owner.Project), err
} else if o.Type == ViewerOwner {
var query viewerOwner
err := c.doQueryWithProgressIndicator("ViewerProject", &query, variables)
return &query.Owner.Project, err
return newProjectFromDTOWithoutItemQuery(query.Owner.Project), err
}
return nil, errors.New("unknown owner type")
}
@ -1326,7 +1476,9 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr
if err := c.doQueryWithProgressIndicator("UserProjects", &query, variables); err != nil {
return projects, err
}
projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
for _, p := range query.Owner.Projects.Nodes {
projects.Nodes = append(projects.Nodes, *newProjectFromDTOWithoutItemQuery(p))
}
hasNextPage = query.Owner.Projects.PageInfo.HasNextPage
cursor = &query.Owner.Projects.PageInfo.EndCursor
projects.TotalCount = query.Owner.Projects.TotalCount
@ -1335,7 +1487,9 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr
if err := c.doQueryWithProgressIndicator("OrgProjects", &query, variables); err != nil {
return projects, err
}
projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
for _, p := range query.Owner.Projects.Nodes {
projects.Nodes = append(projects.Nodes, *newProjectFromDTOWithoutItemQuery(p))
}
hasNextPage = query.Owner.Projects.PageInfo.HasNextPage
cursor = &query.Owner.Projects.PageInfo.EndCursor
projects.TotalCount = query.Owner.Projects.TotalCount
@ -1344,7 +1498,9 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr
if err := c.doQueryWithProgressIndicator("ViewerProjects", &query, variables); err != nil {
return projects, err
}
projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...)
for _, p := range query.Owner.Projects.Nodes {
projects.Nodes = append(projects.Nodes, *newProjectFromDTOWithoutItemQuery(p))
}
hasNextPage = query.Owner.Projects.PageInfo.HasNextPage
cursor = &query.Owner.Projects.PageInfo.EndCursor
projects.TotalCount = query.Owner.Projects.TotalCount

View file

@ -1,13 +1,23 @@
package queries
import (
"io"
"net/http"
"reflect"
"strings"
"testing"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
)
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestProjectItems_DefaultLimit(t *testing.T) {
defer gock.Off()
gock.Observe(gock.DumpRequest)
@ -56,7 +66,7 @@ func TestProjectItems_DefaultLimit(t *testing.T) {
Login: "monalisa",
ID: "user ID",
}
project, err := client.ProjectItems(owner, 1, LimitMax)
project, err := client.ProjectItems(owner, 1, LimitMax, "")
assert.NoError(t, err)
assert.Len(t, project.Items.Nodes, 3)
}
@ -106,7 +116,7 @@ func TestProjectItems_LowerLimit(t *testing.T) {
Login: "monalisa",
ID: "user ID",
}
project, err := client.ProjectItems(owner, 1, 2)
project, err := client.ProjectItems(owner, 1, 2, "")
assert.NoError(t, err)
assert.Len(t, project.Items.Nodes, 2)
}
@ -159,11 +169,197 @@ func TestProjectItems_NoLimit(t *testing.T) {
Login: "monalisa",
ID: "user ID",
}
project, err := client.ProjectItems(owner, 1, 0)
project, err := client.ProjectItems(owner, 1, 0, "")
assert.NoError(t, err)
assert.Len(t, project.Items.Nodes, 3)
}
func TestProjectItems_WithQuery(t *testing.T) {
tests := []struct {
name string
owner *Owner
queryName string
dataKey string
vars map[string]interface{}
}{
{
name: "user owner",
owner: &Owner{
Type: UserOwner,
Login: "monalisa",
ID: "user ID",
},
queryName: "UserProjectWithItems",
dataKey: "user",
vars: map[string]interface{}{
"firstItems": LimitMax,
"afterItems": nil,
"firstFields": LimitMax,
"afterFields": nil,
"login": "monalisa",
"number": 1,
"query": "assignee:octocat",
},
},
{
name: "org owner",
owner: &Owner{
Type: OrgOwner,
Login: "github",
ID: "org ID",
},
queryName: "OrgProjectWithItems",
dataKey: "organization",
vars: map[string]interface{}{
"firstItems": LimitMax,
"afterItems": nil,
"firstFields": LimitMax,
"afterFields": nil,
"login": "github",
"number": 1,
"query": "assignee:octocat",
},
},
{
name: "viewer owner",
owner: &Owner{
Type: ViewerOwner,
ID: "viewer ID",
},
queryName: "ViewerProjectWithItems",
dataKey: "viewer",
vars: map[string]interface{}{
"firstItems": LimitMax,
"afterItems": nil,
"firstFields": LimitMax,
"afterFields": nil,
"number": 1,
"query": "assignee:octocat",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer gock.Off()
gock.Observe(gock.DumpRequest)
gock.New("https://api.github.com").
Post("/graphql").
JSON(map[string]interface{}{
"query": "query " + tt.queryName + ".*",
"variables": tt.vars,
}).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
tt.dataKey: map[string]interface{}{
"projectV2": map[string]interface{}{
"items": map[string]interface{}{
"nodes": []map[string]interface{}{
{
"id": "issue ID",
},
},
},
},
},
},
})
client := NewTestClient()
project, err := client.ProjectItems(tt.owner, 1, LimitMax, "assignee:octocat")
assert.NoError(t, err)
assert.Len(t, project.Items.Nodes, 1)
})
}
}
func TestProjectItems_NoQueryDoesNotUseQueryItems(t *testing.T) {
ios, _, _, _ := iostreams.Test()
httpClient := &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
assert.NoError(t, err)
assert.NotContains(t, string(body), "$query")
return &http.Response{
StatusCode: 200,
Header: http.Header{
"Content-Type": []string{"application/json"},
},
Body: io.NopCloser(strings.NewReader(`{
"data": {
"user": {
"projectV2": {
"items": {
"nodes": [
{"id": "issue ID"}
]
}
}
}
}
}`)),
}, nil
}),
}
client := NewClient(httpClient, "github.com", ios)
owner := &Owner{
Type: UserOwner,
Login: "monalisa",
ID: "user ID",
}
project, err := client.ProjectItems(owner, 1, LimitMax, "")
assert.NoError(t, err)
assert.Len(t, project.Items.Nodes, 1)
}
func TestProjects_ViewerQueryDoesNotUseQueryItems(t *testing.T) {
ios, _, _, _ := iostreams.Test()
httpClient := &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
assert.NoError(t, err)
assert.NotContains(t, string(body), "$query")
return &http.Response{
StatusCode: 200,
Header: http.Header{
"Content-Type": []string{"application/json"},
},
Body: io.NopCloser(strings.NewReader(`{
"data": {
"viewer": {
"projectsV2": {
"totalCount": 1,
"pageInfo": {
"hasNextPage": false,
"endCursor": ""
},
"nodes": [
{
"number": 1,
"title": "Roadmap"
}
]
}
}
}
}`)),
}, nil
}),
}
client := NewClient(httpClient, "github.com", ios)
projects, err := client.Projects("", ViewerOwner, 1, false)
assert.NoError(t, err)
assert.Len(t, projects.Nodes, 1)
assert.Equal(t, int32(1), projects.Nodes[0].Number)
assert.Equal(t, "Roadmap", projects.Nodes[0].Title)
}
func TestProjectFields_LowerLimit(t *testing.T) {
defer gock.Off()
@ -422,7 +618,7 @@ func TestProjectItems_FieldTitle(t *testing.T) {
Login: "monalisa",
ID: "user ID",
}
project, err := client.ProjectItems(owner, 1, LimitMax)
project, err := client.ProjectItems(owner, 1, LimitMax, "")
assert.NoError(t, err)
assert.Len(t, project.Items.Nodes, 1)
assert.Len(t, project.Items.Nodes[0].FieldValues.Nodes, 2)