From c82266c2dcc3285033605501837434af1faeddfe Mon Sep 17 00:00:00 2001 From: imkp1 Date: Fri, 17 Jul 2026 01:09:23 +0530 Subject: [PATCH 1/4] Warn instead of failing when issue fields can't be set `gh issue create` creates the issue and then applies the deferred fields (type, parent, blocked-by, blocking) in a second step. When one of those mutations failed, createRun returned the error. The deferred PreserveInput saw a non-nil error, wrote a recovery file and printed X operation failed. To restore: gh issue create --recover and the issue URL was never printed. The issue did exist, so the message was misleading and invited a duplicate submission. This is reproducible by creating an issue on a repository where you cannot set the issue type: the UpdateIssueIssueType mutation fails with a permissions error. The issue already exists once api.IssueCreate returns, and api.DeferredUpdateIssue already joins its failures so one failing mutation does not abort the rest. Report the failure as a warning on stderr and still print the issue URL. Resolving the deferred fields is left alone: it runs before any mutation and reports invalid input such as `type "Bugz" not found`. Fixes #13804 --- pkg/cmd/issue/create/create.go | 9 ++++-- pkg/cmd/issue/create/create_test.go | 44 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 23f332d7048..7043edea21d 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -417,8 +417,13 @@ func createRun(opts *CreateOptions) (err error) { if err != nil { return } - if err = api.DeferredUpdateIssue(apiClient, updateOpts); err != nil { - return + // The issue exists by now, so failing to apply the deferred fields is + // not a failure of the whole operation. Returning the error here would + // make PreserveInput write a recovery file and print "operation + // failed", implying the issue was never created. + if updateErr := api.DeferredUpdateIssue(apiClient, updateOpts); updateErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Issue created, but not all fields could be set: %s\n", + opts.IO.ColorScheme().WarningIcon(), updateErr) } fmt.Fprintln(opts.IO.Out, newIssue.URL) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index bd39dfd9bdb..27c136a7e56 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -821,6 +821,50 @@ func Test_createRun(t *testing.T) { }, wantsErr: `type "Bugz" not found; available types: Bug, Feature, Task`, }, + { + name: "create with type that cannot be set", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "bug title", + Body: "bug body", + IssueType: "Bug", + }, + httpStubs: func(_ *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" } + ] } } } }`)) + // The issue exists by now; the type cannot be applied because + // the user lacks permission on the repository. + r.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.StringResponse(` + { "errors": [ + { + "type": "FORBIDDEN", + "message": "monalisa does not have the correct permissions to execute `+"`UpdateIssueIssueType`"+`" + } + ] }`)) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n! Issue created, but not all fields could be set: GraphQL: monalisa does not have the correct permissions to execute `UpdateIssueIssueType`\n", + }, { name: "create with parent", opts: CreateOptions{ From 8fbdb9b0f4e293af8a30ab1e0b79511fe1e37630 Mon Sep 17 00:00:00 2001 From: imkp1 Date: Fri, 17 Jul 2026 10:38:51 +0530 Subject: [PATCH 2/4] Skip issue type picker without triage access The interactive type picker is gated only on the repository having issue types. A viewer without triage access is offered the picker, picks a type, and UpdateIssueIssueType then fails after the issue already exists. Gate the picker on ViewerCanTriage. IssueRepoInfo already selects viewerPermission, so this costs no extra request, and it skips the RepositoryIssueTypes fetch for viewers who cannot use the result. ConfirmIssueSubmission already gates the metadata option the same way. --- pkg/cmd/issue/create/create.go | 6 +- pkg/cmd/issue/create/create_test.go | 100 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 7043edea21d..f12a447d8f7 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -304,8 +304,10 @@ func createRun(opts *CreateOptions) (err error) { } } - // Interactive issue type selection - if opts.IssueType == "" { + // Interactive issue type selection. Setting a type requires triage + // access, so don't offer the picker to a viewer who cannot apply it: + // the mutation would only fail once the issue already exists. + if opts.IssueType == "" && repo.ViewerCanTriage() { issueTypes, typesErr := api.RepoIssueTypes(apiClient, baseRepo) if typesErr == nil && len(issueTypes) > 0 { typeNames := make([]string, len(issueTypes)) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 27c136a7e56..0d5d20fe287 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -787,6 +787,106 @@ func Test_createRun(t *testing.T) { wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", wantsStderr: "\nCreating issue in OWNER/REPO\n\n", }, + { + // Setting a type requires triage access. Without it the picker is + // skipped entirely, so the types are never even fetched. + name: "interactive does not prompt for type without triage access", + opts: CreateOptions{ + Interactive: true, + Detector: &fd.EnabledDetectorMock{}, + Title: "feature request", + Body: "would be nice to have", + }, + promptStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "What's next?": + return prompter.IndexFor(options, "Submit") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true, + "viewerPermission": "READ" + } } }`)) + // A failed types fetch also silently skips the picker, so + // asserting on the prompt alone would pass even ungated. + // Exclude proves the gate short-circuits before the fetch. + r.Exclude(t, httpmock.GraphQL(`query RepositoryIssueTypes\b`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, + { + // Triage is the lowest role that can set a type, so the picker is + // still offered to it. + name: "interactive prompts for type with triage access", + opts: CreateOptions{ + Interactive: true, + Detector: &fd.EnabledDetectorMock{}, + Title: "feature request", + Body: "would be nice to have", + }, + promptStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "Issue type": + return prompter.IndexFor(options, "Feature") + case "What's next?": + return prompter.IndexFor(options, "Submit") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true, + "viewerPermission": "TRIAGE" + } } }`)) + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" }, + { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" } + ] } } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "ISSUE_ID_123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "IT_2", inputs["issueTypeId"]) + })) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, { name: "create with type not found", opts: CreateOptions{ From 745dc6816d3178220ca73b34836f72b08750254b Mon Sep 17 00:00:00 2001 From: imkp1 Date: Tue, 21 Jul 2026 07:50:33 +0530 Subject: [PATCH 3/4] Resolve deferred issue fields before creating the issue Resolution of --type/--parent/--blocked-by/--blocking ran after api.IssueCreate, so a bad flag value failed fatally with the issue already created: no URL printed, and a recovery file written in interactive mode. Resolve into DeferredUpdateIssueOptions before the create mutation and attach the issue ID afterwards. Only the post-create mutations stay downgraded to a warning. --- pkg/cmd/issue/create/create.go | 25 +++++++++++-------- pkg/cmd/issue/create/create_test.go | 38 +++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index f12a447d8f7..1538a6b4036 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -395,6 +395,15 @@ func createRun(opts *CreateOptions) (err error) { } return opts.Browser.Browse(openURL) } else if action == prShared.SubmitAction { + // Resolve the deferred fields before creating anything. These lookups + // can fail on user error (an unknown --type, a bad issue reference) and + // such a failure must not leave a created issue behind. + var updateOpts api.DeferredUpdateIssueOptions + updateOpts, err = resolveDeferredUpdateIssueOptions(apiClient, baseRepo, opts) + if err != nil { + return + } + params := map[string]interface{}{ "title": tb.Title, "body": tb.Body, @@ -414,11 +423,7 @@ func createRun(opts *CreateOptions) (err error) { return } - var updateOpts api.DeferredUpdateIssueOptions - updateOpts, err = deferredUpdateIssueOptions(apiClient, baseRepo, newIssue, opts) - if err != nil { - return - } + updateOpts.IssueID = newIssue.ID // The issue exists by now, so failing to apply the deferred fields is // not a failure of the whole operation. Returning the error here would // make PreserveInput write a recovery file and print "operation @@ -441,12 +446,12 @@ func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prS return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support) } -// deferredUpdateIssueOptions resolves the user-supplied --type / --parent / -// --blocked-by / --blocking flags into the IDs that DeferredUpdateIssue -// expects. -func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, opts *CreateOptions) (api.DeferredUpdateIssueOptions, error) { +// resolveDeferredUpdateIssueOptions resolves the user-supplied --type / +// --parent / --blocked-by / --blocking flags into the IDs that +// DeferredUpdateIssue expects. It performs reads only and needs no issue ID, so +// callers run it before creating the issue and set IssueID afterwards. +func resolveDeferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, opts *CreateOptions) (api.DeferredUpdateIssueOptions, error) { updateOpts := api.DeferredUpdateIssueOptions{ - IssueID: issue.ID, Hostname: baseRepo.RepoHost(), } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 0d5d20fe287..03e40208043 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -895,7 +895,7 @@ func Test_createRun(t *testing.T) { Body: "bug body", IssueType: "Bugz", }, - httpStubs: func(_ *testing.T, r *httpmock.Registry) { + httpStubs: func(t *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -903,13 +903,6 @@ func Test_createRun(t *testing.T) { "id": "REPOID", "hasIssuesEnabled": true } } }`)) - r.Register( - httpmock.GraphQL(`mutation IssueCreate\b`), - httpmock.StringResponse(` - { "data": { "createIssue": { "issue": { - "id": "ISSUE_ID_123", - "URL": "https://github.com/OWNER/REPO/issues/123" - } } } }`)) r.Register( httpmock.GraphQL(`query RepositoryIssueTypes\b`), httpmock.StringResponse(` @@ -918,9 +911,38 @@ func Test_createRun(t *testing.T) { { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" }, { "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" } ] } } } }`)) + // An unknown type is user error, so it must fail before the + // issue exists rather than leave one behind. + r.Exclude(t, httpmock.GraphQL(`mutation IssueCreate\b`)) }, wantsErr: `type "Bugz" not found; available types: Bug, Feature, Task`, }, + { + // A reference that cannot be resolved is user error too, and is + // resolved in the same pre-create step. + name: "create with unresolvable parent", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "child issue", + Body: "child body", + Parent: "999", + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "errors": [ { "type": "NOT_FOUND", "message": "Could not resolve to an Issue with the number of 999." } ] }`)) + r.Exclude(t, httpmock.GraphQL(`mutation IssueCreate\b`)) + }, + wantsErr: `resolving --parent reference "999": GraphQL: Could not resolve to an Issue with the number of 999.`, + }, { name: "create with type that cannot be set", opts: CreateOptions{ From 0834fed05f9b44187c5258716eec029dabe524f9 Mon Sep 17 00:00:00 2001 From: imkp1 Date: Tue, 21 Jul 2026 08:01:12 +0530 Subject: [PATCH 4/4] Test unresolvable ref later in the blocked-by loop Refs resolve in a loop, so cover a later ref failing after an earlier one succeeded. Excludes the IssueCreate mutation to assert no issue is left behind. --- pkg/cmd/issue/create/create_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 03e40208043..a04b9163b5d 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -943,6 +943,35 @@ func Test_createRun(t *testing.T) { }, wantsErr: `resolving --parent reference "999": GraphQL: Could not resolve to an Issue with the number of 999.`, }, + { + // Refs are resolved in a loop, so a later ref failing after an + // earlier one succeeded must still abort before the issue exists. + name: "create with unresolvable blocked-by among several", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "blocked issue", + Body: "blocked body", + BlockedBy: []string{"200", "999"}, + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + issueNodeIDByNumberMatcher(200), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKER_ID_200" } } } }`)) + r.Register( + issueNodeIDByNumberMatcher(999), + httpmock.StringResponse(` + { "errors": [ { "type": "NOT_FOUND", "message": "Could not resolve to an Issue with the number of 999." } ] }`)) + r.Exclude(t, httpmock.GraphQL(`mutation IssueCreate\b`)) + }, + wantsErr: `resolving --blocked-by reference "999": GraphQL: Could not resolve to an Issue with the number of 999.`, + }, { name: "create with type that cannot be set", opts: CreateOptions{