Repository: cli/cli
Pull request #10427 (2025-02-12 20:22:18)

[gh issue/pr comment] Create a comment if no comment already exists 
Fixes https://github.com/cli/cli/issues/10370


I've run the following test scenarios:

pr comment:

```
// 1
gh pr comment --edit-last --create-if-none --body "test-comment"
https://github.com/latzskim/gqlc/pull/1#issuecomment-2654734453

// 2 
 gh pr comment --edit-last
? No comments found. Create one? Yes
- Press Enter to draft your comment in nano...
? Submit? Yes
https://github.com/latzskim/gqlc/pull/1#issuecomment-2654741928

// 3
 gh pr comment --edit-last --create-if-none
No comments found. Creating a new comment.
- Press Enter to draft your comment in nano...
? Submit? Yes
https://github.com/latzskim/gqlc/pull/1#issuecomment-2654744152

// 4
gh pr comment --edit-last
? No comments found. Create one? No
no comments found for current user

// 5
gh pr comment --create-if-none
`--create-if-none` can only be used with `--edit-last`
```

issue comment:
```
// 1
gh issue comment 2 --edit-last --create-if-none --body "test-comment"
https://github.com/latzskim/gqlc/issues/2#issuecomment-2654748574

// 2
 gh issue comment 2 --edit-last
? No comments found. Create one? Yes
- Press Enter to draft your comment in nano...
? Submit? Yes
https://github.com/latzskim/gqlc/issues/2#issuecomment-2654751145

// 3
 gh issue comment 2 --edit-last --create-if-none
No comments found. Creating a new comment.
- Press Enter to draft your comment in nano...
? Submit? Yes
https://github.com/latzskim/gqlc/issues/2#issuecomment-2654752583

// 4
 gh issue comment 2 --edit-last
? No comments found. Create one? No
no comments found for current user

// 5
 gh issue comment 2 --create-if-none
`--create-if-none` can only be used with `--edit-last`

```


This is my first open-source contribution. Please be verbose if anything needs to be adjusted.


Patch:
diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go
index c91cf79c9..090b0748c 100644
--- a/pkg/cmd/issue/comment/comment.go
+++ b/pkg/cmd/issue/comment/comment.go
@@ -11,12 +11,13 @@ import (
 
 func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) error) *cobra.Command {
 	opts := &prShared.CommentableOptions{
-		IO:                    f.IOStreams,
-		HttpClient:            f.HttpClient,
-		EditSurvey:            prShared.CommentableEditSurvey(f.Config, f.IOStreams),
-		InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
-		ConfirmSubmitSurvey:   prShared.CommentableConfirmSubmitSurvey(f.Prompter),
-		OpenInBrowser:         f.Browser.Browse,
+		IO:                        f.IOStreams,
+		HttpClient:                f.HttpClient,
+		EditSurvey:                prShared.CommentableEditSurvey(f.Config, f.IOStreams),
+		InteractiveEditSurvey:     prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
+		ConfirmSubmitSurvey:       prShared.CommentableConfirmSubmitSurvey(f.Prompter),
+		ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
+		OpenInBrowser:             f.Browser.Browse,
 	}
 
 	var bodyFile string
@@ -69,6 +70,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
 	cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
 	cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
 	cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
+	cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last")
 
 	return cmd
 }
diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go
index 31a2bc25c..a2ab4bf9e 100644
--- a/pkg/cmd/pr/comment/comment.go
+++ b/pkg/cmd/pr/comment/comment.go
@@ -10,12 +10,13 @@ import (
 
 func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) error) *cobra.Command {
 	opts := &shared.CommentableOptions{
-		IO:                    f.IOStreams,
-		HttpClient:            f.HttpClient,
-		EditSurvey:            shared.CommentableEditSurvey(f.Config, f.IOStreams),
-		InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
-		ConfirmSubmitSurvey:   shared.CommentableConfirmSubmitSurvey(f.Prompter),
-		OpenInBrowser:         f.Browser.Browse,
+		IO:                        f.IOStreams,
+		HttpClient:                f.HttpClient,
+		EditSurvey:                shared.CommentableEditSurvey(f.Config, f.IOStreams),
+		InteractiveEditSurvey:     shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
+		ConfirmSubmitSurvey:       shared.CommentableConfirmSubmitSurvey(f.Prompter),
+		ConfirmCreateIfNoneSurvey: shared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
+		OpenInBrowser:             f.Browser.Browse,
 	}
 
 	var bodyFile string
@@ -75,6 +76,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
 	cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
 	cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
 	cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
+	cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last")
 
 	return cmd
 }
diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go
index 7a38286d2..46e3072e2 100644
--- a/pkg/cmd/pr/shared/commentable.go
+++ b/pkg/cmd/pr/shared/commentable.go
@@ -17,6 +17,8 @@ import (
 	"github.com/spf13/cobra"
 )
 
+var errNoUserComments = errors.New("no comments found for current user")
+
 type InputType int
 
 const (
@@ -32,19 +34,21 @@ type Commentable interface {
 }
 
 type CommentableOptions struct {
-	IO                    *iostreams.IOStreams
-	HttpClient            func() (*http.Client, error)
-	RetrieveCommentable   func() (Commentable, ghrepo.Interface, error)
-	EditSurvey            func(string) (string, error)
-	InteractiveEditSurvey func(string) (string, error)
-	ConfirmSubmitSurvey   func() (bool, error)
-	OpenInBrowser         func(string) error
-	Interactive           bool
-	InputType             InputType
-	Body                  string
-	EditLast              bool
-	Quiet                 bool
-	Host                  string
+	IO                        *iostreams.IOStreams
+	HttpClient                func() (*http.Client, error)
+	RetrieveCommentable       func() (Commentable, ghrepo.Interface, error)
+	EditSurvey                func(string) (string, error)
+	InteractiveEditSurvey     func(string) (string, error)
+	ConfirmSubmitSurvey       func() (bool, error)
+	ConfirmCreateIfNoneSurvey func() (bool, error)
+	OpenInBrowser             func(string) error
+	Interactive               bool
+	InputType                 InputType
+	Body                      string
+	EditLast                  bool
+	CreateIfNone              bool
+	Quiet                     bool
+	Host                      string
 }
 
 func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
@@ -66,6 +70,10 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
 		inputFlags++
 	}
 
+	if opts.CreateIfNone && !opts.EditLast {
+		return cmdutil.FlagErrorf("`--create-if-none` can only be used with `--edit-last`")
+	}
+
 	if inputFlags == 0 {
 		if !opts.IO.CanPrompt() {
 			return cmdutil.FlagErrorf("flags required when not running interactively")
@@ -85,7 +93,24 @@ func CommentableRun(opts *CommentableOptions) error {
 	}
 	opts.Host = repo.RepoHost()
 	if opts.EditLast {
-		return updateComment(commentable, opts)
+		err := updateComment(commentable, opts)
+		if !errors.Is(err, errNoUserComments) {
+			return err
+		}
+
+		if opts.Interactive {
+			if opts.CreateIfNone {
+				fmt.Fprintln(opts.IO.ErrOut, "No comments found. Creating a new comment.")
+			} else {
+				ok, err := opts.ConfirmCreateIfNoneSurvey()
+				if err != nil {
+					return err
+				}
+				if !ok {
+					return errNoUserComments
+				}
+			}
+		}
 	}
 	return createComment(commentable, opts)
 }
@@ -144,7 +169,7 @@ func createComment(commentable Commentable, opts *CommentableOptions) error {
 func updateComment(commentable Commentable, opts *CommentableOptions) error {
 	comments := commentable.CurrentUserComments()
 	if len(comments) == 0 {
-		return fmt.Errorf("no comments found for current user")
+		return errNoUserComments
 	}
 
 	lastComment := &comments[len(comments)-1]
@@ -219,6 +244,12 @@ func CommentableInteractiveEditSurvey(cf func() (gh.Config, error), io *iostream
 	}
 }
 
+func CommentableInteractiveCreateIfNoneSurvey(p Prompt) func() (bool, error) {
+	return func() (bool, error) {
+		return p.Confirm("No comments found. Create one?", true)
+	}
+}
+
 func CommentableEditSurvey(cf func() (gh.Config, error), io *iostreams.IOStreams) func(string) (string, error) {
 	return func(initialValue string) (string, error) {
 		editorCommand, err := cmdutil.DetermineEditor(cf)


Test patch:
diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go
index 668d758e5..461ae1065 100644
--- a/pkg/cmd/issue/comment/comment_test.go
+++ b/pkg/cmd/issue/comment/comment_test.go
@@ -109,6 +109,29 @@ func TestNewCmdComment(t *testing.T) {
 			},
 			wantsErr: false,
 		},
+		{
+			name:  "edit last flag",
+			input: "1 --edit-last",
+			output: shared.CommentableOptions{
+				Interactive: true,
+				InputType:   shared.InputTypeEditor,
+				Body:        "",
+				EditLast:    true,
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "edit last flag with create if none",
+			input: "1 --edit-last --create-if-none",
+			output: shared.CommentableOptions{
+				Interactive:  true,
+				InputType:    shared.InputTypeEditor,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+			},
+			wantsErr: false,
+		},
 		{
 			name:     "body and body-file flags",
 			input:    "1 --body 'test' --body-file 'test-file.txt'",
@@ -139,6 +162,12 @@ func TestNewCmdComment(t *testing.T) {
 			output:   shared.CommentableOptions{},
 			wantsErr: true,
 		},
+		{
+			name:     "create-if-none flag without edit-last",
+			input:    "1 --create-if-none",
+			output:   shared.CommentableOptions{},
+			wantsErr: true,
+		},
 	}
 
 	for _, tt := range tests {
@@ -188,11 +217,12 @@ func TestNewCmdComment(t *testing.T) {
 
 func Test_commentRun(t *testing.T) {
 	tests := []struct {
-		name      string
-		input     *shared.CommentableOptions
-		httpStubs func(*testing.T, *httpmock.Registry)
-		stdout    string
-		stderr    string
+		name          string
+		input         *shared.CommentableOptions
+		emptyComments bool
+		httpStubs     func(*testing.T, *httpmock.Registry)
+		stdout        string
+		stderr        string
 	}{
 		{
 			name: "interactive editor",
@@ -225,6 +255,24 @@ func Test_commentRun(t *testing.T) {
 			},
 			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
 		},
+		{
+			name: "interactive editor with edit last and create if none",
+			input: &shared.CommentableOptions{
+				Interactive:  true,
+				InputType:    0,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				InteractiveEditSurvey:     func(string) (string, error) { return "comment body", nil },
+				ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, nil },
+				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockCommentUpdate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
+		},
 		{
 			name: "non-interactive web",
 			input: &shared.CommentableOptions{
@@ -248,6 +296,39 @@ func Test_commentRun(t *testing.T) {
 			},
 			stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
 		},
+		{
+			name: "non-interactive web with edit last and create if none for empty comments",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeWeb,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				OpenInBrowser: func(u string) error {
+					assert.Contains(t, u, "#issuecomment-new")
+					return nil
+				},
+			},
+			emptyComments: true,
+			stderr:        "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
+		},
+		{
+			name: "non-interactive web with edit last and create if none",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeWeb,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				OpenInBrowser: func(u string) error {
+					assert.Contains(t, u, "#issuecomment-111")
+					return nil
+				},
+			},
+			stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
+		},
 		{
 			name: "non-interactive editor",
 			input: &shared.CommentableOptions{
@@ -277,6 +358,23 @@ func Test_commentRun(t *testing.T) {
 			},
 			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
 		},
+		{
+			name: "non-interactive editor with edit last and create if none",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeEditor,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				EditSurvey: func(string) (string, error) { return "comment body", nil },
+			},
+			emptyComments: true,
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
+		},
 		{
 			name: "non-interactive inline",
 			input: &shared.CommentableOptions{
@@ -319,14 +417,21 @@ func Test_commentRun(t *testing.T) {
 		tt.input.HttpClient = func() (*http.Client, error) {
 			return &http.Client{Transport: reg}, nil
 		}
+
+		comments := api.Comments{Nodes: []api.Comment{
+			{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true},
+			{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"},
+		}}
+
+		if tt.emptyComments {
+			comments.Nodes = []api.Comment{}
+		}
+
 		tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
 			return &api.Issue{
-				ID:  "ISSUE-ID",
-				URL: "https://github.com/OWNER/REPO/issues/123",
-				Comments: api.Comments{Nodes: []api.Comment{
-					{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true},
-					{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"},
-				}},
+				ID:       "ISSUE-ID",
+				URL:      "https://github.com/OWNER/REPO/issues/123",
+				Comments: comments,
 			}, ghrepo.New("OWNER", "REPO"), nil
 		}
 
diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go
index 56cf58d21..a6cc36abf 100644
--- a/pkg/cmd/pr/comment/comment_test.go
+++ b/pkg/cmd/pr/comment/comment_test.go
@@ -129,6 +129,29 @@ func TestNewCmdComment(t *testing.T) {
 			},
 			wantsErr: false,
 		},
+		{
+			name:  "edit last flag",
+			input: "1 --edit-last",
+			output: shared.CommentableOptions{
+				Interactive: true,
+				InputType:   shared.InputTypeEditor,
+				Body:        "",
+				EditLast:    true,
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "edit last flag with create if none",
+			input: "1 --edit-last --create-if-none",
+			output: shared.CommentableOptions{
+				Interactive:  true,
+				InputType:    shared.InputTypeEditor,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+			},
+			wantsErr: false,
+		},
 		{
 			name:     "body and body-file flags",
 			input:    "1 --body 'test' --body-file 'test-file.txt'",
@@ -159,6 +182,12 @@ func TestNewCmdComment(t *testing.T) {
 			output:   shared.CommentableOptions{},
 			wantsErr: true,
 		},
+		{
+			name:     "create-if-none flag without edit-last",
+			input:    "1 --create-if-none",
+			output:   shared.CommentableOptions{},
+			wantsErr: true,
+		},
 	}
 
 	for _, tt := range tests {
@@ -208,11 +237,12 @@ func TestNewCmdComment(t *testing.T) {
 
 func Test_commentRun(t *testing.T) {
 	tests := []struct {
-		name      string
-		input     *shared.CommentableOptions
-		httpStubs func(*testing.T, *httpmock.Registry)
-		stdout    string
-		stderr    string
+		name          string
+		input         *shared.CommentableOptions
+		emptyComments bool
+		httpStubs     func(*testing.T, *httpmock.Registry)
+		stdout        string
+		stderr        string
 	}{
 		{
 			name: "interactive editor",
@@ -245,6 +275,24 @@ func Test_commentRun(t *testing.T) {
 			},
 			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
 		},
+		{
+			name: "interactive editor with edit last and create if none",
+			input: &shared.CommentableOptions{
+				Interactive:  true,
+				InputType:    0,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				InteractiveEditSurvey:     func(string) (string, error) { return "comment body", nil },
+				ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, nil },
+				ConfirmSubmitSurvey:       func() (bool, error) { return true, nil },
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockCommentUpdate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
+		},
 		{
 			name: "non-interactive web",
 			input: &shared.CommentableOptions{
@@ -264,7 +312,43 @@ func Test_commentRun(t *testing.T) {
 				Body:        "",
 				EditLast:    true,
 
-				OpenInBrowser: func(string) error { return nil },
+				OpenInBrowser: func(u string) error {
+					assert.Contains(t, u, "#issuecomment-111")
+					return nil
+				},
+			},
+			stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
+		},
+		{
+			name: "non-interactive web with edit last and create if none for empty comments",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeWeb,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				OpenInBrowser: func(u string) error {
+					assert.Contains(t, u, "#issuecomment-new")
+					return nil
+				},
+			},
+			emptyComments: true,
+			stderr:        "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
+		},
+		{
+			name: "non-interactive web with edit last and create if none",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeWeb,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				OpenInBrowser: func(u string) error {
+					assert.Contains(t, u, "#issuecomment-111")
+					return nil
+				},
 			},
 			stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
 		},
@@ -297,6 +381,23 @@ func Test_commentRun(t *testing.T) {
 			},
 			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
 		},
+		{
+			name: "non-interactive editor with edit last and create if none",
+			input: &shared.CommentableOptions{
+				Interactive:  false,
+				InputType:    shared.InputTypeEditor,
+				Body:         "",
+				EditLast:     true,
+				CreateIfNone: true,
+
+				EditSurvey: func(string) (string, error) { return "comment body", nil },
+			},
+			emptyComments: true,
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+		},
 		{
 			name: "non-interactive inline",
 			input: &shared.CommentableOptions{
@@ -339,14 +440,20 @@ func Test_commentRun(t *testing.T) {
 
 		tt.input.IO = ios
 		tt.input.HttpClient = httpClient
+
+		comments := api.Comments{Nodes: []api.Comment{
+			{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true},
+			{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
+		}}
+		if tt.emptyComments {
+			comments.Nodes = []api.Comment{}
+		}
+
 		tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
 			return &api.PullRequest{
-				Number: 123,
-				URL:    "https://github.com/OWNER/REPO/pull/123",
-				Comments: api.Comments{Nodes: []api.Comment{
-					{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true},
-					{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
-				}},
+				Number:   123,
+				URL:      "https://github.com/OWNER/REPO/pull/123",
+				Comments: comments,
 			}, ghrepo.New("OWNER", "REPO"), nil
 		}