From 6bbe6e5baccf2fdc28699d414844db4b5f54573c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 12 May 2026 20:28:56 -0600 Subject: [PATCH] Add --remove-type flag to gh issue edit Pair --type with --remove-type so callers can clear an issue's type without going through the interactive editor, mirroring the --milestone / --remove-milestone and --parent / --remove-parent patterns. The two type flags are mutually exclusive. UpdateIssueIssueType now sends a null issueTypeId when the caller passes an empty string, which is what the API requires to clear the field. The orchestrator fires the mutation when either IssueTypeID is non-empty or RemoveIssueType is set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_issue.go | 20 ++++++++++++------ pkg/cmd/issue/edit/edit.go | 18 +++++++++++++++-- pkg/cmd/issue/edit/edit_test.go | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 497528fee..f17c55a72 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -481,11 +481,12 @@ func (i Issue) CurrentUserComments() []Comment { return i.Comments.CurrentUserComments() } -// UpdateIssueIssueType sets the issue type on an issue. +// UpdateIssueIssueType sets or clears the issue type on an issue. Pass an +// empty issueTypeID to clear the issue type. func UpdateIssueIssueType(client *Client, hostname string, issueID string, issueTypeID string) error { type UpdateIssueIssueTypeInput struct { - IssueID githubv4.ID `json:"issueId"` - IssueTypeID githubv4.ID `json:"issueTypeId"` + IssueID githubv4.ID `json:"issueId"` + IssueTypeID *githubv4.ID `json:"issueTypeId"` } var mutation struct { @@ -496,10 +497,16 @@ func UpdateIssueIssueType(client *Client, hostname string, issueID string, issue } `graphql:"updateIssueIssueType(input: $input)"` } + var typeID *githubv4.ID + if issueTypeID != "" { + id := githubv4.ID(issueTypeID) + typeID = &id + } + variables := map[string]interface{}{ "input": UpdateIssueIssueTypeInput{ IssueID: githubv4.ID(issueID), - IssueTypeID: githubv4.ID(issueTypeID), + IssueTypeID: typeID, }, } @@ -614,7 +621,8 @@ type DeferredUpdateIssueOptions struct { IssueID string Hostname string - IssueTypeID string + IssueTypeID string + RemoveIssueType bool ParentID string ReplaceExistingParent bool @@ -639,7 +647,7 @@ type DeferredUpdateIssueOptions struct { func DeferredUpdateIssue(client *Client, opts DeferredUpdateIssueOptions) error { var mutations []func() error - if opts.IssueTypeID != "" { + if opts.IssueTypeID != "" || opts.RemoveIssueType { mutations = append(mutations, func() error { return UpdateIssueIssueType(client, opts.Hostname, opts.IssueID, opts.IssueTypeID) }) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 21f96866a..7c2306a63 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -36,6 +36,8 @@ type EditOptions struct { IssueNumbers []int Interactive bool + RemoveIssueType bool + Parent string RemoveParent bool AddSubIssues []string @@ -87,6 +89,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman $ gh issue edit 23 --body-file body.txt $ gh issue edit 23 34 --add-label "help wanted" $ gh issue edit 23 --type Bug + $ gh issue edit 23 --remove-type $ gh issue edit 23 --parent 100 $ gh issue edit 23 --remove-parent $ gh issue edit 100 --add-sub-issue 123,124 @@ -142,6 +145,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return err } + if err := cmdutil.MutuallyExclusive( + "specify only one of `--type` or `--remove-type`", + flags.Changed("type"), + opts.RemoveIssueType, + ); err != nil { + return err + } + if err := cmdutil.MutuallyExclusive( "specify only one of --parent or --remove-parent", flags.Changed("parent"), @@ -174,13 +185,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts.Editable.IssueType.Edited = true } - hasRelationshipFlags := flags.Changed("parent") || opts.RemoveParent || + hasDeferredFlags := opts.RemoveIssueType || + flags.Changed("parent") || opts.RemoveParent || len(opts.AddSubIssues) > 0 || len(opts.RemoveSubIssues) > 0 || len(opts.AddBlockedBy) > 0 || len(opts.RemoveBlockedBy) > 0 || len(opts.AddBlocking) > 0 || len(opts.RemoveBlocking) > 0 // Drop into interactive mode only if the user passed no edit flags at all. - if !opts.Editable.Dirty() && !hasRelationshipFlags { + if !opts.Editable.Dirty() && !hasDeferredFlags { opts.Interactive = true } @@ -216,6 +228,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the issue belongs to by `name`") cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the issue") cmd.Flags().StringVar(&opts.Editable.IssueType.Value, "type", "", "Set the issue type by `name`") + cmd.Flags().BoolVar(&opts.RemoveIssueType, "remove-type", false, "Remove the issue type from the issue") cmd.Flags().StringVar(&opts.Parent, "parent", "", "Set the parent issue by `number` or URL") cmd.Flags().BoolVar(&opts.RemoveParent, "remove-parent", false, "Remove the parent issue") cmd.Flags().StringSliceVar(&opts.AddSubIssues, "add-sub-issue", nil, "Add sub-issues by `number` or URL") @@ -458,6 +471,7 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i IssueID: issue.ID, Hostname: baseRepo.RepoHost(), IssueTypeID: issueTypeID, + RemoveIssueType: editOpts.RemoveIssueType, ReplaceExistingParent: true, } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 4d411cc54..9d1ea8a59 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -297,6 +297,19 @@ func TestNewCmdEdit(t *testing.T) { }, }, }, + { + name: "remove-type flag", + input: "23 --remove-type", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveIssueType: true, + }, + }, + { + name: "both type and remove-type flags", + input: "23 --type Bug --remove-type", + wantsErr: true, + }, { name: "parent flag", input: "23 --parent 100", @@ -855,6 +868,29 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issue/123\n", }, + { + name: "remove type", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + RemoveIssueType: true, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Nil(t, inputs["issueTypeId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, { name: "interactive edit type prompt", input: &EditOptions{