Merge pull request #12787 from ManManavadaria/fix-12726/item-edit-draft-issue-partial-update

fix: `gh project item-edit` error when editing Draft Issue with only one (title/body) flag
This commit is contained in:
Kynan Ware 2026-03-02 12:19:53 -07:00 committed by GitHub
commit 79796d2dc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 244 additions and 26 deletions

View file

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

View file

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

View file

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