diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go index 43aff835a..6eb44caf4 100644 --- a/pkg/cmd/project/item-edit/item_edit.go +++ b/pkg/cmd/project/item-edit/item_edit.go @@ -16,9 +16,11 @@ import ( type editItemOpts struct { // updateDraftIssue - title string - body string - itemID string + title string + titleChanged bool + body string + bodyChanged bool + itemID string // updateItem fieldID string projectID string @@ -45,6 +47,12 @@ type EditProjectDraftIssue struct { } `graphql:"updateProjectV2DraftIssue(input:$input)"` } +type DraftIssueQuery struct { + DraftIssueNode struct { + DraftIssue queries.DraftIssue `graphql:"... on DraftIssue"` + } `graphql:"node(id: $id)"` +} + type UpdateProjectV2FieldValue struct { Update struct { Item queries.ProjectItem `graphql:"projectV2Item"` @@ -78,6 +86,8 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) `), RunE: func(cmd *cobra.Command, args []string) error { opts.numberChanged = cmd.Flags().Changed("number") + opts.titleChanged = cmd.Flags().Changed("title") + opts.bodyChanged = cmd.Flags().Changed("body") if err := cmdutil.MutuallyExclusive( "only one of `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` may be used", opts.text != "", @@ -143,7 +153,7 @@ func runEditItem(config editItemConfig) error { } // update draft issue - if config.opts.title != "" || config.opts.body != "" { + if config.opts.titleChanged || config.opts.bodyChanged { return updateDraftIssue(config) } @@ -158,13 +168,41 @@ func runEditItem(config editItemConfig) error { return cmdutil.SilentError } -func buildEditDraftIssue(config editItemConfig) (*EditProjectDraftIssue, map[string]interface{}) { +func fetchDraftIssueByID(config editItemConfig, draftIssueID string) (*queries.DraftIssue, error) { + var query DraftIssueQuery + variables := map[string]interface{}{ + "id": githubv4.ID(draftIssueID), + } + + err := config.client.Query("DraftIssueByID", &query, variables) + if err != nil { + return nil, err + } + + return &query.DraftIssueNode.DraftIssue, nil +} + +func buildEditDraftIssue(config editItemConfig, currentDraftIssue *queries.DraftIssue) (*EditProjectDraftIssue, map[string]interface{}) { + input := githubv4.UpdateProjectV2DraftIssueInput{ + DraftIssueID: githubv4.ID(config.opts.itemID), + } + + if config.opts.titleChanged { + input.Title = githubv4.NewString(githubv4.String(config.opts.title)) + } else if currentDraftIssue != nil { + // Preserve existing if title is not provided + input.Title = githubv4.NewString(githubv4.String(currentDraftIssue.Title)) + } + + if config.opts.bodyChanged { + input.Body = githubv4.NewString(githubv4.String(config.opts.body)) + } else if currentDraftIssue != nil { + // Preserve existing if body is not provided + input.Body = githubv4.NewString(githubv4.String(currentDraftIssue.Body)) + } + return &EditProjectDraftIssue{}, map[string]interface{}{ - "input": githubv4.UpdateProjectV2DraftIssueInput{ - Body: githubv4.NewString(githubv4.String(config.opts.body)), - DraftIssueID: githubv4.ID(config.opts.itemID), - Title: githubv4.NewString(githubv4.String(config.opts.title)), - }, + "input": input, } } @@ -250,9 +288,19 @@ func updateDraftIssue(config editItemConfig) error { return cmdutil.FlagErrorf("ID must be the ID of the draft issue content which is prefixed with `DI_`") } - query, variables := buildEditDraftIssue(config) + // Fetch current draft issue to preserve fields that aren't being updated + var currentDraftIssue *queries.DraftIssue + var err error + if !config.opts.titleChanged || !config.opts.bodyChanged { + currentDraftIssue, err = fetchDraftIssueByID(config, config.opts.itemID) + if err != nil { + return err + } + } - err := config.client.Mutate("EditDraftIssueItem", query, variables) + query, variables := buildEditDraftIssue(config, currentDraftIssue) + + err = config.client.Mutate("EditDraftIssueItem", query, variables) if err != nil { return err } diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go index 916bb5899..64852bad5 100644 --- a/pkg/cmd/project/item-edit/item_edit_test.go +++ b/pkg/cmd/project/item-edit/item_edit_test.go @@ -129,6 +129,15 @@ func TestNewCmdeditItem(t *testing.T) { }, wantsExporter: true, }, + { + name: "draft issue body only", + cli: "--id 123 --body foobar", + wants: editItemOpts{ + itemID: "123", + body: "foobar", + bodyChanged: true, + }, + }, } t.Setenv("GH_TOKEN", "auth-token") @@ -170,6 +179,9 @@ func TestNewCmdeditItem(t *testing.T) { assert.Equal(t, tt.wants.singleSelectOptionID, gotOpts.singleSelectOptionID) assert.Equal(t, tt.wants.iterationID, gotOpts.iterationID) assert.Equal(t, tt.wants.clear, gotOpts.clear) + assert.Equal(t, tt.wants.titleChanged, gotOpts.titleChanged) + assert.Equal(t, tt.wants.bodyChanged, gotOpts.bodyChanged) + assert.Equal(t, tt.wants.body, gotOpts.body) }) } } @@ -202,9 +214,11 @@ func TestRunItemEdit_Draft(t *testing.T) { config := editItemConfig{ io: ios, opts: editItemOpts{ - title: "a title", - body: "a new body", - itemID: "DI_item_id", + title: "a title", + titleChanged: true, + body: "a new body", + bodyChanged: true, + itemID: "DI_item_id", }, client: client, } @@ -217,6 +231,154 @@ func TestRunItemEdit_Draft(t *testing.T) { stdout.String()) } +func TestRunItemEdit_DraftTitleOnly(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"query DraftIssueByID.*","variables":{"id":"DI_item_id"}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "id": "DI_item_id", + "title": "existing title", + "body": "existing body", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation EditDraftIssueItem.*","variables":{"input":{"draftIssueId":"DI_item_id","title":"new title","body":"existing body"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2DraftIssue": map[string]interface{}{ + "draftIssue": map[string]interface{}{ + "title": "new title", + "body": "existing body", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + title: "new title", + titleChanged: true, + bodyChanged: false, + itemID: "DI_item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Edited draft issue \"new title\"\n", + stdout.String()) +} + +func TestRunItemEdit_DraftBodyOnly(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"query DraftIssueByID.*","variables":{"id":"DI_item_id"}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "id": "DI_item_id", + "title": "existing title", + "body": "existing body", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation EditDraftIssueItem.*","variables":{"input":{"draftIssueId":"DI_item_id","title":"existing title","body":"new body"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2DraftIssue": map[string]interface{}{ + "draftIssue": map[string]interface{}{ + "title": "existing title", + "body": "new body", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + titleChanged: false, + body: "new body", + bodyChanged: true, + itemID: "DI_item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Edited draft issue \"existing title\"\n", + stdout.String()) +} + +func TestRunItemEdit_DraftFetchError(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"query DraftIssueByID.*","variables":{"id":"DI_item_id"}}`). + Reply(200). + JSON(map[string]interface{}{ + "errors": []map[string]interface{}{ + { + "type": "NOT_FOUND", + "message": "Could not resolve to a node with the global id of 'DI_item_id' (node)", + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + title: "new title", + titleChanged: true, + bodyChanged: false, + itemID: "DI_item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Could not resolve to a node") +} + func TestRunItemEdit_Text(t *testing.T) { defer gock.Off() // gock.Observe(gock.DumpRequest) @@ -232,10 +394,9 @@ func TestRunItemEdit_Text(t *testing.T) { "projectV2Item": map[string]interface{}{ "ID": "item_id", "content": map[string]interface{}{ - "__typename": "Issue", - "body": "body", - "title": "title", - "number": 1, + "body": "body", + "title": "title", + "number": 1, "repository": map[string]interface{}{ "nameWithOwner": "my-repo", }, @@ -544,9 +705,11 @@ func TestRunItemEdit_InvalidID(t *testing.T) { client := queries.NewTestClient() config := editItemConfig{ opts: editItemOpts{ - title: "a title", - body: "a new body", - itemID: "item_id", + title: "a title", + titleChanged: true, + body: "a new body", + bodyChanged: true, + itemID: "item_id", }, client: client, } @@ -630,10 +793,12 @@ func TestRunItemEdit_JSON(t *testing.T) { config := editItemConfig{ io: ios, opts: editItemOpts{ - title: "a title", - body: "a new body", - itemID: "DI_item_id", - exporter: cmdutil.NewJSONExporter(), + title: "a title", + titleChanged: true, + body: "a new body", + bodyChanged: true, + itemID: "DI_item_id", + exporter: cmdutil.NewJSONExporter(), }, client: client, } diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index d56d61107..9a3bd4909 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -103,6 +103,11 @@ func (c *Client) Mutate(operationName string, query interface{}, variables map[s return handleError(err) } +func (c *Client) Query(operationName string, query interface{}, variables map[string]interface{}) error { + err := c.apiClient.Query(operationName, query, variables) + return handleError(err) +} + // PageInfo is a PageInfo GraphQL object https://docs.github.com/en/graphql/reference/objects#pageinfo. type PageInfo struct { EndCursor githubv4.String