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>
This commit is contained in:
Kynan Ware 2026-05-12 20:28:56 -06:00
parent eb7397695f
commit 6bbe6e5bac
3 changed files with 66 additions and 8 deletions

View file

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

View file

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

View file

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