From 8ed2fc913cafe804819d4bd8a421ebd0a7e0e8ef Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:11:57 -0700 Subject: [PATCH 1/2] Clarify --clobber flag deletes assets before re-uploading Update the help text and flag description for `gh release upload --clobber` to make it clear that existing assets are deleted before new ones are uploaded, and that original assets will be lost if the upload fails. Fixes #8822 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/release/upload/upload.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index 42419ccf399..827dcdc6421 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -43,6 +43,9 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co To define a display label for an asset, append text starting with %[1]s#%[1]s after the file name. + + When using %[1]s--clobber%[1]s, existing assets are deleted before new assets are uploaded. + If the upload fails, the original assets will be lost. `, "`"), Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { @@ -66,7 +69,7 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co }, } - cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing assets of the same name") + cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Delete and re-upload existing assets of the same name") return cmd } From 8dcfd330e7a00f465d913fc41f3499ef1907c913 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 18 Feb 2026 16:34:13 +0100 Subject: [PATCH 2/2] Add `--query` flag to `project item-list` (#12696) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Babak K. Shandiz --- internal/featuredetection/detector_mock.go | 8 + .../featuredetection/feature_detection.go | 50 ++++ .../feature_detection_test.go | 87 ++++++ pkg/cmd/project/item-list/item_list.go | 56 +++- pkg/cmd/project/item-list/item_list_test.go | 116 ++++++++ pkg/cmd/project/shared/queries/queries.go | 258 ++++++++++++++---- .../project/shared/queries/queries_test.go | 204 +++++++++++++- 7 files changed, 719 insertions(+), 60 deletions(-) diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index a2dd8c2f883..552f197d9e1 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -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 } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index b2fb6d65dea..7a200e20c84 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -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. diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index f4eeb5962fa..032f5cda03c 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -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"}]}}}` diff --git a/pkg/cmd/project/item-list/item_list.go b/pkg/cmd/project/item-list/item_list.go index 3f792f3bd38..6d79ed2f1aa 100644 --- a/pkg/cmd/project/item-list/item_list.go +++ b/pkg/cmd/project/item-list/item_list.go @@ -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 []", + 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 } diff --git a/pkg/cmd/project/item-list/item_list_test.go b/pkg/cmd/project/item-list/item_list_test.go index e3f10e655fc..c5143bfb666 100644 --- a/pkg/cmd/project/item-list/item_list_test.go +++ b/pkg/cmd/project/item-list/item_list_test.go @@ -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") +} diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index 46aa584519c..28c6e9d2d93 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -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 diff --git a/pkg/cmd/project/shared/queries/queries_test.go b/pkg/cmd/project/shared/queries/queries_test.go index 57ec9ed02f2..2b8e27e4b2d 100644 --- a/pkg/cmd/project/shared/queries/queries_test.go +++ b/pkg/cmd/project/shared/queries/queries_test.go @@ -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)