From 4dd50cf60c139e3d965dbf889ca146e95f491171 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 23 Feb 2026 07:04:33 -0700 Subject: [PATCH] Fix project mutation query variable usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/project/close/close.go | 4 +- pkg/cmd/project/copy/copy.go | 4 +- pkg/cmd/project/create/create.go | 4 +- pkg/cmd/project/delete/delete.go | 4 +- pkg/cmd/project/edit/edit.go | 4 +- .../project/mark-template/mark_template.go | 6 +- pkg/cmd/project/shared/queries/queries.go | 62 +++++++++++++++++++ .../project/shared/queries/queries_test.go | 47 ++++++++++++++ 8 files changed, 122 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/project/close/close.go b/pkg/cmd/project/close/close.go index 3e85596c02a..352a3361527 100644 --- a/pkg/cmd/project/close/close.go +++ b/pkg/cmd/project/close/close.go @@ -30,7 +30,7 @@ type closeConfig struct { // the close command relies on the updateProjectV2 mutation type updateProjectMutation struct { UpdateProjectV2 struct { - ProjectV2 queries.Project `graphql:"projectV2"` + ProjectV2 queries.ProjectMutationQuery `graphql:"projectV2"` } `graphql:"updateProjectV2(input:$input)"` } @@ -123,7 +123,7 @@ func closeArgs(config closeConfig) (*updateProjectMutation, map[string]interface } } -func printResults(config closeConfig, project queries.Project) error { +func printResults(config closeConfig, project queries.ProjectMutationQuery) error { if !config.io.IsStdoutTTY() { return nil } diff --git a/pkg/cmd/project/copy/copy.go b/pkg/cmd/project/copy/copy.go index c0f255f96d9..f020451cca4 100644 --- a/pkg/cmd/project/copy/copy.go +++ b/pkg/cmd/project/copy/copy.go @@ -32,7 +32,7 @@ type copyConfig struct { type copyProjectMutation struct { CopyProjectV2 struct { - ProjectV2 queries.Project `graphql:"projectV2"` + ProjectV2 queries.ProjectMutationQuery `graphql:"projectV2"` } `graphql:"copyProjectV2(input:$input)"` } @@ -134,7 +134,7 @@ func copyArgs(config copyConfig) (*copyProjectMutation, map[string]interface{}) } } -func printResults(config copyConfig, project queries.Project) error { +func printResults(config copyConfig, project queries.ProjectMutationQuery) error { if !config.io.IsStdoutTTY() { return nil } diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go index 268a2555081..bbaae8a645c 100644 --- a/pkg/cmd/project/create/create.go +++ b/pkg/cmd/project/create/create.go @@ -27,7 +27,7 @@ type createConfig struct { type createProjectMutation struct { CreateProjectV2 struct { - ProjectV2 queries.Project `graphql:"projectV2"` + ProjectV2 queries.ProjectMutationQuery `graphql:"projectV2"` } `graphql:"createProjectV2(input:$input)"` } @@ -104,7 +104,7 @@ func createArgs(config createConfig) (*createProjectMutation, map[string]interfa } } -func printResults(config createConfig, project queries.Project) error { +func printResults(config createConfig, project queries.ProjectMutationQuery) error { if !config.io.IsStdoutTTY() { return nil } diff --git a/pkg/cmd/project/delete/delete.go b/pkg/cmd/project/delete/delete.go index 993b94d30c9..b396a2f1f44 100644 --- a/pkg/cmd/project/delete/delete.go +++ b/pkg/cmd/project/delete/delete.go @@ -28,7 +28,7 @@ type deleteConfig struct { type deleteProjectMutation struct { DeleteProject struct { - Project queries.Project `graphql:"projectV2"` + Project queries.ProjectMutationQuery `graphql:"projectV2"` } `graphql:"deleteProjectV2(input:$input)"` } @@ -115,7 +115,7 @@ func deleteItemArgs(config deleteConfig) (*deleteProjectMutation, map[string]int } } -func printResults(config deleteConfig, project queries.Project) error { +func printResults(config deleteConfig, project queries.ProjectMutationQuery) error { if !config.io.IsStdoutTTY() { return nil } diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go index 4eb41f98f82..2dc8aa572e1 100644 --- a/pkg/cmd/project/edit/edit.go +++ b/pkg/cmd/project/edit/edit.go @@ -32,7 +32,7 @@ type editConfig struct { type updateProjectMutation struct { UpdateProjectV2 struct { - ProjectV2 queries.Project `graphql:"projectV2"` + ProjectV2 queries.ProjectMutationQuery `graphql:"projectV2"` } `graphql:"updateProjectV2(input:$input)"` } @@ -144,7 +144,7 @@ func editArgs(config editConfig) (*updateProjectMutation, map[string]interface{} } } -func printResults(config editConfig, project queries.Project) error { +func printResults(config editConfig, project queries.ProjectMutationQuery) error { if !config.io.IsStdoutTTY() { return nil } diff --git a/pkg/cmd/project/mark-template/mark_template.go b/pkg/cmd/project/mark-template/mark_template.go index 616f8428d75..170dda82180 100644 --- a/pkg/cmd/project/mark-template/mark_template.go +++ b/pkg/cmd/project/mark-template/mark_template.go @@ -29,12 +29,12 @@ type markTemplateConfig struct { type markProjectTemplateMutation struct { TemplateProject struct { - Project queries.Project `graphql:"projectV2"` + Project queries.ProjectMutationQuery `graphql:"projectV2"` } `graphql:"markProjectV2AsTemplate(input:$input)"` } type unmarkProjectTemplateMutation struct { TemplateProject struct { - Project queries.Project `graphql:"projectV2"` + Project queries.ProjectMutationQuery `graphql:"projectV2"` } `graphql:"unmarkProjectV2AsTemplate(input:$input)"` } @@ -150,7 +150,7 @@ func unmarkTemplateArgs(config markTemplateConfig) (*unmarkProjectTemplateMutati } } -func printResults(config markTemplateConfig, project queries.Project) error { +func printResults(config markTemplateConfig, project queries.ProjectMutationQuery) error { if !config.io.IsStdoutTTY() { return nil } diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index f4a91286499..d56d611079c 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -147,6 +147,34 @@ type Project struct { } } +// ProjectMutationQuery is a ProjectV2 response shape for mutation payloads. +// It intentionally avoids the queryable items connection to prevent requiring a $query variable. +type ProjectMutationQuery struct { + Number int32 + URL string + ShortDescription string + Public bool + Closed bool + Title string + ID string + Readme string + Items struct { + TotalCount int + } `graphql:"items(first: $firstItems, after: $afterItems)"` + Fields struct { + TotalCount int + } `graphql:"fields(first: $firstFields, after: $afterFields)"` + Owner struct { + TypeName string `graphql:"__typename"` + User struct { + Login string + } `graphql:"... on User"` + Organization struct { + Login string + } `graphql:"... on Organization"` + } +} + // Below, you will find the query structs to represent fetching a project via the GraphQL API. // Prior to GHES 3.20, the query argument did not exist on the items connection, so we have // one base struct and two structs that embed and add the Items connection with and without the query argument. @@ -265,6 +293,40 @@ func (p Project) OwnerLogin() string { return p.Owner.Organization.Login } +func (p ProjectMutationQuery) ExportData(_ []string) map[string]interface{} { + return map[string]interface{}{ + "number": p.Number, + "url": p.URL, + "shortDescription": p.ShortDescription, + "public": p.Public, + "closed": p.Closed, + "title": p.Title, + "id": p.ID, + "readme": p.Readme, + "items": map[string]interface{}{ + "totalCount": p.Items.TotalCount, + }, + "fields": map[string]interface{}{ + "totalCount": p.Fields.TotalCount, + }, + "owner": map[string]interface{}{ + "type": p.OwnerType(), + "login": p.OwnerLogin(), + }, + } +} + +func (p ProjectMutationQuery) OwnerType() string { + return p.Owner.TypeName +} + +func (p ProjectMutationQuery) OwnerLogin() string { + if p.OwnerType() == "User" { + return p.Owner.User.Login + } + return p.Owner.Organization.Login +} + type Projects struct { Nodes []Project TotalCount int diff --git a/pkg/cmd/project/shared/queries/queries_test.go b/pkg/cmd/project/shared/queries/queries_test.go index 2b8e27e4b2d..cc4850d8620 100644 --- a/pkg/cmd/project/shared/queries/queries_test.go +++ b/pkg/cmd/project/shared/queries/queries_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) @@ -18,6 +19,52 @@ func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func TestProjectMutationQuery_DoesNotRequireQueryVariable(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": { + "updateProjectV2": { + "projectV2": { + "id": "project ID", + "url": "http://example.com" + } + } + } + }`)), + }, nil + }), + } + + client := NewClient(httpClient, "github.com", ios) + mutation := struct { + UpdateProjectV2 struct { + ProjectV2 ProjectMutationQuery `graphql:"projectV2"` + } `graphql:"updateProjectV2(input:$input)"` + }{} + + err := client.Mutate("UpdateProjectV2", &mutation, map[string]interface{}{ + "input": githubv4.UpdateProjectV2Input{ + ProjectID: githubv4.ID("project ID"), + }, + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + }) + assert.NoError(t, err) +} + func TestProjectItems_DefaultLimit(t *testing.T) { defer gock.Off() gock.Observe(gock.DumpRequest)