Skip to content

Commit 81f678f

Browse files
maxbeizerCopilot
andcommitted
Add --editor flag to gh issue edit and gh pr edit
Add a -e/--editor flag to both `gh issue edit` and `gh pr edit` that opens the system editor pre-filled with the current title and body. The first line is the title and the remaining text is the body, matching the UX of `gh issue create --editor` and `gh pr create --editor`. This flag: - Skips the interactive field-selection prompts and metadata fetch - Is mutually exclusive with --body and --body-file - Respects GH_EDITOR, config editor, and prefer_editor_prompt settings - Does not support multiple issues (same restriction as interactive mode) - When enabled via prefer_editor_prompt config, silently yields to explicit flags (--body, --add-label, etc.) so scripted usage works Closes #11108 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 37800dd commit 81f678f

4 files changed

Lines changed: 296 additions & 37 deletions

File tree

pkg/cmd/issue/edit/edit.go

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ type EditOptions struct {
3131
FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error
3232
EditFieldsSurvey func(prShared.EditPrompter, *prShared.Editable, string) error
3333
FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable, gh.ProjectsV1Support) error
34+
TitledEditSurvey func(string, string) (string, string, error)
3435

3536
IssueNumbers []int
3637
Interactive bool
38+
EditorMode bool
3739

3840
prShared.Editable
3941
}
@@ -47,6 +49,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
4749
EditFieldsSurvey: prShared.EditFieldsSurvey,
4850
FetchOptions: prShared.FetchOptions,
4951
Prompter: f.Prompter,
52+
TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}),
5053
}
5154

5255
var bodyFile string
@@ -76,6 +79,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
7679
$ gh issue edit 23 --remove-milestone
7780
$ gh issue edit 23 --body-file body.txt
7881
$ gh issue edit 23 34 --add-label "help wanted"
82+
$ gh issue edit 23 --editor
7983
`),
8084
Args: cobra.MinimumNArgs(1),
8185
RunE: func(cmd *cobra.Command, args []string) error {
@@ -148,7 +152,24 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
148152
// see the `Editable.MilestoneId` method.
149153
}
150154

151-
if !opts.Editable.Dirty() {
155+
opts.EditorMode, err = prShared.InitEditorMode(f, opts.EditorMode, false, opts.IO.CanPrompt())
156+
if err != nil {
157+
return err
158+
}
159+
160+
// When editor mode was enabled via config (prefer_editor_prompt)
161+
// rather than the --editor flag, silently disable it if the user
162+
// provided flags that conflict, so scripted usage isn't broken.
163+
editorFlagExplicit := flags.Changed("editor")
164+
if opts.EditorMode && !editorFlagExplicit && (opts.Editable.Dirty() || len(opts.IssueNumbers) > 1) {
165+
opts.EditorMode = false
166+
}
167+
168+
if opts.EditorMode && (bodyProvided || bodyFileProvided) {
169+
return cmdutil.FlagErrorf("specify only one of `--body`, `--body-file`, or `--editor`")
170+
}
171+
172+
if !opts.Editable.Dirty() && !opts.EditorMode {
152173
opts.Interactive = true
153174
}
154175

@@ -160,6 +181,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
160181
return cmdutil.FlagErrorf("multiple issues cannot be edited interactively")
161182
}
162183

184+
if opts.EditorMode && len(opts.IssueNumbers) > 1 {
185+
return cmdutil.FlagErrorf("multiple issues cannot be edited with --editor")
186+
}
187+
163188
if runF != nil {
164189
return runF(opts)
165190
}
@@ -179,6 +204,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
179204
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the issue from projects by `title`")
180205
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the issue belongs to by `name`")
181206
cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the issue")
207+
cmd.Flags().BoolVarP(&opts.EditorMode, "editor", "e", false, "Skip prompts and open the text editor to write the title and body in.\nThe first line is the title and the remaining text is the body.")
182208

183209
return cmd
184210
}
@@ -202,6 +228,10 @@ func editRun(opts *EditOptions) error {
202228
return err
203229
}
204230
}
231+
if opts.EditorMode {
232+
editable.Title.Edited = true
233+
editable.Body.Edited = true
234+
}
205235

206236
if opts.Detector == nil {
207237
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
@@ -247,12 +277,15 @@ func editRun(opts *EditOptions) error {
247277
}
248278

249279
// Fetch editable shared fields once for all issues.
250-
apiClient := api.NewClientFromHTTP(httpClient)
251-
opts.IO.StartProgressIndicatorWithLabel("Fetching repository information")
252-
err = opts.FetchOptions(apiClient, baseRepo, &editable, opts.Detector.ProjectsV1())
253-
opts.IO.StopProgressIndicator()
254-
if err != nil {
255-
return err
280+
// Skip when in editor mode since we only need title and body.
281+
if !opts.EditorMode {
282+
apiClient := api.NewClientFromHTTP(httpClient)
283+
opts.IO.StartProgressIndicatorWithLabel("Fetching repository information")
284+
err = opts.FetchOptions(apiClient, baseRepo, &editable, opts.Detector.ProjectsV1())
285+
opts.IO.StopProgressIndicator()
286+
if err != nil {
287+
return err
288+
}
256289
}
257290

258291
// Update all issues in parallel.
@@ -261,7 +294,7 @@ func editRun(opts *EditOptions) error {
261294
g := sync.WaitGroup{}
262295

263296
// Only show progress if we will not prompt below or the survey will break up the progress indicator.
264-
if !opts.Interactive {
297+
if !opts.Interactive && !opts.EditorMode {
265298
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating %d issues", len(issues)))
266299
}
267300

@@ -291,7 +324,12 @@ func editRun(opts *EditOptions) error {
291324
}
292325

293326
// Allow interactive prompts for one issue; failed earlier if multiple issues specified.
294-
if opts.Interactive {
327+
if opts.EditorMode {
328+
editable.Title.Value, editable.Body.Value, err = opts.TitledEditSurvey(issue.Title, issue.Body)
329+
if err != nil {
330+
return err
331+
}
332+
} else if opts.Interactive {
295333
editorCommand, err := opts.DetermineEditor()
296334
if err != nil {
297335
return err

pkg/cmd/issue/edit/edit_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010

1111
"github.com/MakeNowJust/heredoc"
1212
"github.com/cli/cli/v2/api"
13+
"github.com/cli/cli/v2/internal/config"
1314
fd "github.com/cli/cli/v2/internal/featuredetection"
15+
"github.com/cli/cli/v2/internal/gh"
1416
"github.com/cli/cli/v2/internal/ghrepo"
1517
"github.com/cli/cli/v2/internal/run"
1618
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
@@ -34,6 +36,7 @@ func TestNewCmdEdit(t *testing.T) {
3436
output EditOptions
3537
expectedBaseRepo ghrepo.Interface
3638
wantsErr bool
39+
config func() (gh.Config, error)
3740
}{
3841
{
3942
name: "no argument",
@@ -281,6 +284,66 @@ func TestNewCmdEdit(t *testing.T) {
281284
input: "23 34",
282285
wantsErr: true,
283286
},
287+
{
288+
name: "editor flag",
289+
input: "23 --editor",
290+
output: EditOptions{
291+
IssueNumbers: []int{23},
292+
EditorMode: true,
293+
},
294+
wantsErr: false,
295+
},
296+
{
297+
name: "editor flag with body flag",
298+
input: "23 --editor --body test",
299+
wantsErr: true,
300+
},
301+
{
302+
name: "editor flag with body-file flag",
303+
input: fmt.Sprintf("23 --editor --body-file '%s'", tmpFile),
304+
wantsErr: true,
305+
},
306+
{
307+
name: "editor flag with multiple issues",
308+
input: "23 34 --editor",
309+
wantsErr: true,
310+
},
311+
{
312+
name: "prefer_editor_prompt config with body flag",
313+
input: "23 --body test",
314+
config: func() (gh.Config, error) {
315+
return config.NewFromString("prefer_editor_prompt: enabled"), nil
316+
},
317+
output: EditOptions{
318+
IssueNumbers: []int{23},
319+
EditorMode: false,
320+
Editable: prShared.Editable{
321+
Body: prShared.EditableString{
322+
Value: "test",
323+
Edited: true,
324+
},
325+
},
326+
},
327+
wantsErr: false,
328+
},
329+
{
330+
name: "prefer_editor_prompt config with multiple issues and label",
331+
input: "23 34 --add-label bug",
332+
config: func() (gh.Config, error) {
333+
return config.NewFromString("prefer_editor_prompt: enabled"), nil
334+
},
335+
output: EditOptions{
336+
IssueNumbers: []int{23, 34},
337+
EditorMode: false,
338+
Editable: prShared.Editable{
339+
Labels: prShared.EditableSlice{
340+
Add: []string{"bug"},
341+
Edited: true,
342+
},
343+
},
344+
},
345+
wantsErr: false,
346+
},
284347
}
285348
for _, tt := range tests {
286349
t.Run(tt.name, func(t *testing.T) {
@@ -293,8 +356,16 @@ func TestNewCmdEdit(t *testing.T) {
293356
_, _ = stdin.WriteString(tt.stdin)
294357
}
295358

359+
cfgFunc := tt.config
360+
if cfgFunc == nil {
361+
cfgFunc = func() (gh.Config, error) {
362+
return config.NewBlankConfig(), nil
363+
}
364+
}
365+
296366
f := &cmdutil.Factory{
297367
IOStreams: ios,
368+
Config: cfgFunc,
298369
}
299370

300371
argv, err := shlex.Split(tt.input)
@@ -321,6 +392,7 @@ func TestNewCmdEdit(t *testing.T) {
321392
require.NoError(t, err)
322393
assert.Equal(t, tt.output.IssueNumbers, gotOpts.IssueNumbers)
323394
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
395+
assert.Equal(t, tt.output.EditorMode, gotOpts.EditorMode)
324396
assert.Equal(t, tt.output.Editable, gotOpts.Editable)
325397
if tt.expectedBaseRepo != nil {
326398
baseRepo, err := gotOpts.BaseRepo()
@@ -625,6 +697,41 @@ func Test_editRun(t *testing.T) {
625697
},
626698
stdout: "https://github.com/OWNER/REPO/issue/123\n",
627699
},
700+
{
701+
name: "editor mode",
702+
input: &EditOptions{
703+
Detector: &fd.EnabledDetectorMock{},
704+
IssueNumbers: []int{123},
705+
EditorMode: true,
706+
TitledEditSurvey: func(title, body string) (string, string, error) {
707+
return "edited title", "edited body", nil
708+
},
709+
FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
710+
t.Fatal("FieldsToEditSurvey should not be called in editor mode")
711+
return nil
712+
},
713+
EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error {
714+
t.Fatal("EditFieldsSurvey should not be called in editor mode")
715+
return nil
716+
},
717+
FetchOptions: func(client *api.Client, repo ghrepo.Interface, editable *prShared.Editable, v1 gh.ProjectsV1Support) error {
718+
return nil
719+
},
720+
},
721+
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
722+
mockIssueGet(t, reg)
723+
reg.Register(
724+
httpmock.GraphQL(`mutation IssueUpdate\b`),
725+
httpmock.GraphQLMutation(`
726+
{ "data": { "updateIssue": { "__typename": "" } } }`,
727+
func(inputs map[string]interface{}) {
728+
assert.Equal(t, "edited title", inputs["title"])
729+
assert.Equal(t, "edited body", inputs["body"])
730+
}),
731+
)
732+
},
733+
stdout: "https://github.com/OWNER/REPO/issue/123\n",
734+
},
628735
{
629736
name: "interactive prompts with actor assignee display names when actors available",
630737
input: &EditOptions{

0 commit comments

Comments
 (0)