Repository: cli/cli
Pull request #2776 (2021-01-13 17:06:37)

Comment on pull requests
This PR allows for commenting on pull requests. The commenting code for issues and pull requests was very similar so I refactored to extract the `commentable.go` file which contains the shared functionality.

Additionally after talking with @ampinsk we decided that the initial input type survey was not serving much of a purpose and we decided to replace it with the standard editor survey that prompts the user to open their editor.

closes https://github.com/cli/cli/issues/517

samcoe: @mislav I went ahead and refactored this to clean up the duplicate code and hopefully make it easier to maintain both comment commands in the future. It seemed like there were a couple different ways to go about this refactor so let me know if you had any other thoughts on it 🙇 

Patch:
diff --git a/api/queries_issue.go b/api/queries_issue.go
index 17f32eabd..dca859831 100644
--- a/api/queries_issue.go
+++ b/api/queries_issue.go
@@ -481,3 +481,11 @@ func milestoneNodeIdToDatabaseId(nodeId string) (string, error) {
 	}
 	return splitted[1], nil
 }
+
+func (i Issue) Link() string {
+	return i.URL
+}
+
+func (i Issue) Identifier() string {
+	return i.ID
+}
diff --git a/api/queries_pr.go b/api/queries_pr.go
index 23896ce28..309091000 100644
--- a/api/queries_pr.go
+++ b/api/queries_pr.go
@@ -136,6 +136,14 @@ func (pr PullRequest) HeadLabel() string {
 	return pr.HeadRefName
 }
 
+func (pr PullRequest) Link() string {
+	return pr.URL
+}
+
+func (pr PullRequest) Identifier() string {
+	return pr.ID
+}
+
 type PullRequestReviewStatus struct {
 	ChangesRequested bool
 	Approved         bool
diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go
index 3c6be4812..1e4264574 100644
--- a/pkg/cmd/issue/comment/comment.go
+++ b/pkg/cmd/issue/comment/comment.go
@@ -1,59 +1,28 @@
 package comment
 
 import (
-	"errors"
-	"fmt"
 	"net/http"
 
-	"github.com/AlecAivazis/survey/v2"
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/api"
-	"github.com/cli/cli/internal/config"
 	"github.com/cli/cli/internal/ghrepo"
-	"github.com/cli/cli/pkg/cmd/issue/shared"
+	issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
+	prShared "github.com/cli/cli/pkg/cmd/pr/shared"
 	"github.com/cli/cli/pkg/cmdutil"
-	"github.com/cli/cli/pkg/iostreams"
-	"github.com/cli/cli/pkg/surveyext"
 	"github.com/cli/cli/utils"
 	"github.com/spf13/cobra"
 )
 
-type CommentOptions struct {
-	HttpClient          func() (*http.Client, error)
-	IO                  *iostreams.IOStreams
-	BaseRepo            func() (ghrepo.Interface, error)
-	EditSurvey          func() (string, error)
-	InputTypeSurvey     func() (inputType, error)
-	ConfirmSubmitSurvey func() (bool, error)
-	OpenInBrowser       func(string) error
-
-	SelectorArg string
-	Interactive bool
-	InputType   inputType
-	Body        string
-}
-
-type inputType int
-
-const (
-	inputTypeEditor inputType = iota
-	inputTypeInline
-	inputTypeWeb
-)
-
-func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command {
-	opts := &CommentOptions{
-		IO:                  f.IOStreams,
-		HttpClient:          f.HttpClient,
-		EditSurvey:          editSurvey(f.Config, f.IOStreams),
-		InputTypeSurvey:     inputTypeSurvey,
-		ConfirmSubmitSurvey: confirmSubmitSurvey,
-		OpenInBrowser:       utils.OpenInBrowser,
+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,
+		OpenInBrowser:         utils.OpenInBrowser,
 	}
 
-	var webMode bool
-	var editorMode bool
-
 	cmd := &cobra.Command{
 		Use:   "comment {<number> | <url>}",
 		Short: "Create a new issue comment",
@@ -61,140 +30,40 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.
 			$ gh issue comment 22 --body "I was able to reproduce this issue, lets fix it."
 		`),
 		Args: cobra.ExactArgs(1),
-		RunE: func(cmd *cobra.Command, args []string) error {
-			// support `-R, --repo` override
-			opts.BaseRepo = f.BaseRepo
-			opts.SelectorArg = args[0]
-
-			inputFlags := 0
-			if cmd.Flags().Changed("body") {
-				opts.InputType = inputTypeInline
-				inputFlags++
-			}
-			if webMode {
-				opts.InputType = inputTypeWeb
-				inputFlags++
-			}
-			if editorMode {
-				opts.InputType = inputTypeEditor
-				inputFlags++
-			}
-
-			if inputFlags == 0 {
-				if !opts.IO.CanPrompt() {
-					return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")}
-				}
-				opts.Interactive = true
-			} else if inputFlags == 1 {
-				if !opts.IO.CanPrompt() && opts.InputType == inputTypeEditor {
-					return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")}
-				}
-			} else if inputFlags > 1 {
-				return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of --body, --editor, or --web")}
-			}
-
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			opts.RetrieveCommentable = retrieveIssue(f.HttpClient, f.BaseRepo, args[0])
+			return prShared.CommentablePreRun(cmd, opts)
+		},
+		RunE: func(_ *cobra.Command, args []string) error {
 			if runF != nil {
 				return runF(opts)
 			}
-			return commentRun(opts)
+			return prShared.CommentableRun(opts)
 		},
 	}
 
 	cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
-	cmd.Flags().BoolVarP(&editorMode, "editor", "e", false, "Add body using editor")
-	cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Add body in browser")
+	cmd.Flags().BoolP("editor", "e", false, "Add body using editor")
+	cmd.Flags().BoolP("web", "w", false, "Add body in browser")
 
 	return cmd
 }
 
-func commentRun(opts *CommentOptions) error {
-	httpClient, err := opts.HttpClient()
-	if err != nil {
-		return err
-	}
-	apiClient := api.NewClientFromHTTP(httpClient)
-
-	issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
-	if err != nil {
-		return err
-	}
-
-	if opts.Interactive {
-		inputType, err := opts.InputTypeSurvey()
+func retrieveIssue(httpClient func() (*http.Client, error),
+	baseRepo func() (ghrepo.Interface, error),
+	selector string) func() (prShared.Commentable, ghrepo.Interface, error) {
+	return func() (prShared.Commentable, ghrepo.Interface, error) {
+		httpClient, err := httpClient()
 		if err != nil {
-			return err
+			return nil, nil, err
 		}
-		opts.InputType = inputType
-	}
+		apiClient := api.NewClientFromHTTP(httpClient)
 
-	switch opts.InputType {
-	case inputTypeWeb:
-		openURL := issue.URL + "#issuecomment-new"
-		if opts.IO.IsStdoutTTY() {
-			fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
-		}
-		return opts.OpenInBrowser(openURL)
-	case inputTypeEditor:
-		body, err := opts.EditSurvey()
+		issue, repo, err := issueShared.IssueFromArg(apiClient, baseRepo, selector)
 		if err != nil {
-			return err
+			return nil, nil, err
 		}
-		opts.Body = body
-	}
-
-	if opts.Interactive {
-		cont, err := opts.ConfirmSubmitSurvey()
-		if err != nil {
-			return err
-		}
-		if !cont {
-			return fmt.Errorf("Discarding...")
-		}
-	}
-
-	params := api.CommentCreateInput{Body: opts.Body, SubjectId: issue.ID}
-	url, err := api.CommentCreate(apiClient, baseRepo.RepoHost(), params)
-	if err != nil {
-		return err
-	}
-	fmt.Fprintln(opts.IO.Out, url)
-	return nil
-}
-
-var inputTypeSurvey = func() (inputType, error) {
-	var result int
-	inputTypeQuestion := &survey.Select{
-		Message: "Where do you want to draft your comment?",
-		Options: []string{"Editor", "Web"},
-	}
-	err := survey.AskOne(inputTypeQuestion, &result)
-	if err != nil {
-		return 0, err
-	}
-
-	if result == 0 {
-		return inputTypeEditor, nil
-	} else {
-		return inputTypeWeb, nil
-	}
-}
 
-var confirmSubmitSurvey = func() (bool, error) {
-	var confirm bool
-	submit := &survey.Confirm{
-		Message: "Submit?",
-		Default: true,
-	}
-	err := survey.AskOne(submit, &confirm)
-	return confirm, err
-}
-
-var editSurvey = func(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) {
-	return func() (string, error) {
-		editorCommand, err := cmdutil.DetermineEditor(cf)
-		if err != nil {
-			return "", err
-		}
-		return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil)
+		return issue, repo, nil
 	}
 }
diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go
new file mode 100644
index 000000000..27847b4fa
--- /dev/null
+++ b/pkg/cmd/pr/comment/comment.go
@@ -0,0 +1,78 @@
+package comment
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/api"
+	"github.com/cli/cli/context"
+	"github.com/cli/cli/internal/ghrepo"
+	"github.com/cli/cli/pkg/cmd/pr/shared"
+	"github.com/cli/cli/pkg/cmdutil"
+	"github.com/cli/cli/utils"
+	"github.com/spf13/cobra"
+)
+
+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,
+		OpenInBrowser:         utils.OpenInBrowser,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "comment [<number> | <url> | <branch>]",
+		Short: "Create a new pr comment",
+		Example: heredoc.Doc(`
+			$ gh pr comment 22 --body "This looks great, lets get it deployed."
+		`),
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
+				return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
+			}
+			var selector string
+			if len(args) > 0 {
+				selector = args[0]
+			}
+			opts.RetrieveCommentable = retrievePR(f.HttpClient, f.BaseRepo, f.Branch, f.Remotes, selector)
+			return shared.CommentablePreRun(cmd, opts)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if runF != nil {
+				return runF(opts)
+			}
+			return shared.CommentableRun(opts)
+		},
+	}
+
+	cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
+	cmd.Flags().BoolP("editor", "e", false, "Add body using editor")
+	cmd.Flags().BoolP("web", "w", false, "Add body in browser")
+
+	return cmd
+}
+
+func retrievePR(httpClient func() (*http.Client, error),
+	baseRepo func() (ghrepo.Interface, error),
+	branch func() (string, error),
+	remotes func() (context.Remotes, error),
+	selector string) func() (shared.Commentable, ghrepo.Interface, error) {
+	return func() (shared.Commentable, ghrepo.Interface, error) {
+		httpClient, err := httpClient()
+		if err != nil {
+			return nil, nil, err
+		}
+		apiClient := api.NewClientFromHTTP(httpClient)
+
+		pr, repo, err := shared.PRFromArgs(apiClient, baseRepo, branch, remotes, selector)
+		if err != nil {
+			return nil, nil, err
+		}
+
+		return pr, repo, nil
+	}
+}
diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go
index e06eb8059..f1981fabe 100644
--- a/pkg/cmd/pr/pr.go
+++ b/pkg/cmd/pr/pr.go
@@ -5,6 +5,7 @@ import (
 	cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout"
 	cmdChecks "github.com/cli/cli/pkg/cmd/pr/checks"
 	cmdClose "github.com/cli/cli/pkg/cmd/pr/close"
+	cmdComment "github.com/cli/cli/pkg/cmd/pr/comment"
 	cmdCreate "github.com/cli/cli/pkg/cmd/pr/create"
 	cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff"
 	cmdList "github.com/cli/cli/pkg/cmd/pr/list"
@@ -53,6 +54,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command {
 	cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
 	cmd.AddCommand(cmdView.NewCmdView(f, nil))
 	cmd.AddCommand(cmdChecks.NewCmdChecks(f, nil))
+	cmd.AddCommand(cmdComment.NewCmdComment(f, nil))
 
 	return cmd
 }
diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go
new file mode 100644
index 000000000..55edccbcb
--- /dev/null
+++ b/pkg/cmd/pr/shared/commentable.go
@@ -0,0 +1,168 @@
+package shared
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/cli/cli/api"
+	"github.com/cli/cli/internal/config"
+	"github.com/cli/cli/internal/ghrepo"
+	"github.com/cli/cli/pkg/cmdutil"
+	"github.com/cli/cli/pkg/iostreams"
+	"github.com/cli/cli/pkg/surveyext"
+	"github.com/cli/cli/utils"
+	"github.com/spf13/cobra"
+)
+
+type InputType int
+
+const (
+	InputTypeEditor InputType = iota
+	InputTypeInline
+	InputTypeWeb
+)
+
+type Commentable interface {
+	Link() string
+	Identifier() string
+}
+
+type CommentableOptions struct {
+	IO                    *iostreams.IOStreams
+	HttpClient            func() (*http.Client, error)
+	RetrieveCommentable   func() (Commentable, ghrepo.Interface, error)
+	EditSurvey            func() (string, error)
+	InteractiveEditSurvey func() (string, error)
+	ConfirmSubmitSurvey   func() (bool, error)
+	OpenInBrowser         func(string) error
+	Interactive           bool
+	InputType             InputType
+	Body                  string
+}
+
+func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
+	inputFlags := 0
+	if cmd.Flags().Changed("body") {
+		opts.InputType = InputTypeInline
+		inputFlags++
+	}
+	if web, _ := cmd.Flags().GetBool("web"); web {
+		opts.InputType = InputTypeWeb
+		inputFlags++
+	}
+	if editor, _ := cmd.Flags().GetBool("editor"); editor {
+		opts.InputType = InputTypeEditor
+		inputFlags++
+	}
+
+	if inputFlags == 0 {
+		if !opts.IO.CanPrompt() {
+			return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")}
+		}
+		opts.Interactive = true
+	} else if inputFlags == 1 {
+		if !opts.IO.CanPrompt() && opts.InputType == InputTypeEditor {
+			return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")}
+		}
+	} else if inputFlags > 1 {
+		return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of --body, --editor, or --web")}
+	}
+
+	return nil
+}
+
+func CommentableRun(opts *CommentableOptions) error {
+	commentable, repo, err := opts.RetrieveCommentable()
+	if err != nil {
+		return err
+	}
+
+	switch opts.InputType {
+	case InputTypeWeb:
+		openURL := commentable.Link() + "#issuecomment-new"
+		if opts.IO.IsStdoutTTY() {
+			fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
+		}
+		return opts.OpenInBrowser(openURL)
+	case InputTypeEditor:
+		var body string
+		if opts.Interactive {
+			body, err = opts.InteractiveEditSurvey()
+		} else {
+			body, err = opts.EditSurvey()
+		}
+		if err != nil {
+			return err
+		}
+		opts.Body = body
+	}
+
+	if opts.Interactive {
+		cont, err := opts.ConfirmSubmitSurvey()
+		if err != nil {
+			return err
+		}
+		if !cont {
+			return errors.New("Discarding...")
+		}
+	}
+
+	httpClient, err := opts.HttpClient()
+	if err != nil {
+		return err
+	}
+	apiClient := api.NewClientFromHTTP(httpClient)
+	params := api.CommentCreateInput{Body: opts.Body, SubjectId: commentable.Identifier()}
+	url, err := api.CommentCreate(apiClient, repo.RepoHost(), params)
+	if err != nil {
+		return err
+	}
+	fmt.Fprintln(opts.IO.Out, url)
+	return nil
+}
+
+func CommentableConfirmSubmitSurvey() (bool, error) {
+	var confirm bool
+	submit := &survey.Confirm{
+		Message: "Submit?",
+		Default: true,
+	}
+	err := survey.AskOne(submit, &confirm)
+	return confirm, err
+}
+
+func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) {
+	return func() (string, error) {
+		editorCommand, err := cmdutil.DetermineEditor(cf)
+		if err != nil {
+			return "", err
+		}
+		if editorCommand == "" {
+			editorCommand = surveyext.DefaultEditorName()
+		}
+		cs := io.ColorScheme()
+		fmt.Fprintf(io.Out, "- %s to draft your comment in %s... ", cs.Bold("Press Enter"), cs.Bold(editorCommand))
+		_ = waitForEnter(io.In)
+		return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil)
+	}
+}
+
+func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) {
+	return func() (string, error) {
+		editorCommand, err := cmdutil.DetermineEditor(cf)
+		if err != nil {
+			return "", err
+		}
+		return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil)
+	}
+}
+
+func waitForEnter(r io.Reader) error {
+	scanner := bufio.NewScanner(r)
+	scanner.Scan()
+	return scanner.Err()
+}
diff --git a/pkg/surveyext/editor.go b/pkg/surveyext/editor.go
index 038cc9036..21a358aa0 100644
--- a/pkg/surveyext/editor.go
+++ b/pkg/surveyext/editor.go
@@ -157,3 +157,7 @@ func (e *GhEditor) Prompt(config *survey.PromptConfig) (interface{}, error) {
 	}
 	return e.prompt(initialValue, config)
 }
+
+func DefaultEditorName() string {
+	return filepath.Base(defaultEditor)
+}


Test patch:
diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go
index b10e20c54..bfbdd6690 100644
--- a/pkg/cmd/issue/comment/comment_test.go
+++ b/pkg/cmd/issue/comment/comment_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/cli/cli/internal/ghrepo"
+	"github.com/cli/cli/pkg/cmd/pr/shared"
 	"github.com/cli/cli/pkg/cmdutil"
 	"github.com/cli/cli/pkg/httpmock"
 	"github.com/cli/cli/pkg/iostreams"
@@ -17,20 +18,19 @@ func TestNewCmdComment(t *testing.T) {
 	tests := []struct {
 		name     string
 		input    string
-		output   CommentOptions
+		output   shared.CommentableOptions
 		wantsErr bool
 	}{
 		{
 			name:     "no arguments",
 			input:    "",
-			output:   CommentOptions{},
+			output:   shared.CommentableOptions{},
 			wantsErr: true,
 		},
 		{
 			name:  "issue number",
 			input: "1",
-			output: CommentOptions{
-				SelectorArg: "1",
+			output: shared.CommentableOptions{
 				Interactive: true,
 				InputType:   0,
 				Body:        "",
@@ -40,8 +40,7 @@ func TestNewCmdComment(t *testing.T) {
 		{
 			name:  "issue url",
 			input: "https://github.com/OWNER/REPO/issues/12",
-			output: CommentOptions{
-				SelectorArg: "https://github.com/OWNER/REPO/issues/12",
+			output: shared.CommentableOptions{
 				Interactive: true,
 				InputType:   0,
 				Body:        "",
@@ -51,10 +50,9 @@ func TestNewCmdComment(t *testing.T) {
 		{
 			name:  "body flag",
 			input: "1 --body test",
-			output: CommentOptions{
-				SelectorArg: "1",
+			output: shared.CommentableOptions{
 				Interactive: false,
-				InputType:   inputTypeInline,
+				InputType:   shared.InputTypeInline,
 				Body:        "test",
 			},
 			wantsErr: false,
@@ -62,10 +60,9 @@ func TestNewCmdComment(t *testing.T) {
 		{
 			name:  "editor flag",
 			input: "1 --editor",
-			output: CommentOptions{
-				SelectorArg: "1",
+			output: shared.CommentableOptions{
 				Interactive: false,
-				InputType:   inputTypeEditor,
+				InputType:   shared.InputTypeEditor,
 				Body:        "",
 			},
 			wantsErr: false,
@@ -73,10 +70,9 @@ func TestNewCmdComment(t *testing.T) {
 		{
 			name:  "web flag",
 			input: "1 --web",
-			output: CommentOptions{
-				SelectorArg: "1",
+			output: shared.CommentableOptions{
 				Interactive: false,
-				InputType:   inputTypeWeb,
+				InputType:   shared.InputTypeWeb,
 				Body:        "",
 			},
 			wantsErr: false,
@@ -84,25 +80,25 @@ func TestNewCmdComment(t *testing.T) {
 		{
 			name:     "editor and web flags",
 			input:    "1 --editor --web",
-			output:   CommentOptions{},
+			output:   shared.CommentableOptions{},
 			wantsErr: true,
 		},
 		{
 			name:     "editor and body flags",
 			input:    "1 --editor --body test",
-			output:   CommentOptions{},
+			output:   shared.CommentableOptions{},
 			wantsErr: true,
 		},
 		{
 			name:     "web and body flags",
 			input:    "1 --web --body test",
-			output:   CommentOptions{},
+			output:   shared.CommentableOptions{},
 			wantsErr: true,
 		},
 		{
 			name:     "editor, web, and body flags",
 			input:    "1 --editor --web --body test",
-			output:   CommentOptions{},
+			output:   shared.CommentableOptions{},
 			wantsErr: true,
 		},
 	}
@@ -121,8 +117,8 @@ func TestNewCmdComment(t *testing.T) {
 			argv, err := shlex.Split(tt.input)
 			assert.NoError(t, err)
 
-			var gotOpts *CommentOptions
-			cmd := NewCmdComment(f, func(opts *CommentOptions) error {
+			var gotOpts *shared.CommentableOptions
+			cmd := NewCmdComment(f, func(opts *shared.CommentableOptions) error {
 				gotOpts = opts
 				return nil
 			})
@@ -140,7 +136,6 @@ func TestNewCmdComment(t *testing.T) {
 			}
 
 			assert.NoError(t, err)
-			assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
 			assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
 			assert.Equal(t, tt.output.InputType, gotOpts.InputType)
 			assert.Equal(t, tt.output.Body, gotOpts.Body)
@@ -151,38 +146,20 @@ func TestNewCmdComment(t *testing.T) {
 func Test_commentRun(t *testing.T) {
 	tests := []struct {
 		name      string
-		input     *CommentOptions
+		input     *shared.CommentableOptions
 		httpStubs func(*testing.T, *httpmock.Registry)
 		stdout    string
 		stderr    string
 	}{
-		{
-			name: "interactive web",
-			input: &CommentOptions{
-				SelectorArg: "123",
-				Interactive: true,
-				InputType:   0,
-				Body:        "",
-
-				InputTypeSurvey: func() (inputType, error) { return inputTypeWeb, nil },
-				OpenInBrowser:   func(string) error { return nil },
-			},
-			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
-				mockIssueFromNumber(t, reg)
-			},
-			stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n",
-		},
 		{
 			name: "interactive editor",
-			input: &CommentOptions{
-				SelectorArg: "123",
+			input: &shared.CommentableOptions{
 				Interactive: true,
 				InputType:   0,
 				Body:        "",
 
-				EditSurvey:          func() (string, error) { return "comment body", nil },
-				InputTypeSurvey:     func() (inputType, error) { return inputTypeEditor, nil },
-				ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
+				InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
+				ConfirmSubmitSurvey:   func() (bool, error) { return true, nil },
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
 				mockIssueFromNumber(t, reg)
@@ -192,10 +169,9 @@ func Test_commentRun(t *testing.T) {
 		},
 		{
 			name: "non-interactive web",
-			input: &CommentOptions{
-				SelectorArg: "123",
+			input: &shared.CommentableOptions{
 				Interactive: false,
-				InputType:   inputTypeWeb,
+				InputType:   shared.InputTypeWeb,
 				Body:        "",
 
 				OpenInBrowser: func(string) error { return nil },
@@ -207,10 +183,9 @@ func Test_commentRun(t *testing.T) {
 		},
 		{
 			name: "non-interactive editor",
-			input: &CommentOptions{
-				SelectorArg: "123",
+			input: &shared.CommentableOptions{
 				Interactive: false,
-				InputType:   inputTypeEditor,
+				InputType:   shared.InputTypeEditor,
 				Body:        "",
 
 				EditSurvey: func() (string, error) { return "comment body", nil },
@@ -223,10 +198,9 @@ func Test_commentRun(t *testing.T) {
 		},
 		{
 			name: "non-interactive inline",
-			input: &CommentOptions{
-				SelectorArg: "123",
+			input: &shared.CommentableOptions{
 				Interactive: false,
-				InputType:   inputTypeInline,
+				InputType:   shared.InputTypeInline,
 				Body:        "comment body",
 			},
 			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@@ -246,16 +220,15 @@ func Test_commentRun(t *testing.T) {
 		defer reg.Verify(t)
 		tt.httpStubs(t, reg)
 
+		httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
+		baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
+
 		tt.input.IO = io
-		tt.input.HttpClient = func() (*http.Client, error) {
-			return &http.Client{Transport: reg}, nil
-		}
-		tt.input.BaseRepo = func() (ghrepo.Interface, error) {
-			return ghrepo.New("OWNER", "REPO"), nil
-		}
+		tt.input.HttpClient = httpClient
+		tt.input.RetrieveCommentable = retrieveIssue(tt.input.HttpClient, baseRepo, "123")
 
 		t.Run(tt.name, func(t *testing.T) {
-			err := commentRun(tt.input)
+			err := shared.CommentableRun(tt.input)
 			assert.NoError(t, err)
 			assert.Equal(t, tt.stdout, stdout.String())
 			assert.Equal(t, tt.stderr, stderr.String())
diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go
new file mode 100644
index 000000000..52256d431
--- /dev/null
+++ b/pkg/cmd/pr/comment/comment_test.go
@@ -0,0 +1,278 @@
+package comment
+
+import (
+	"bytes"
+	"net/http"
+	"testing"
+
+	"github.com/cli/cli/context"
+	"github.com/cli/cli/internal/ghrepo"
+	"github.com/cli/cli/pkg/cmd/pr/shared"
+	"github.com/cli/cli/pkg/cmdutil"
+	"github.com/cli/cli/pkg/httpmock"
+	"github.com/cli/cli/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewCmdComment(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		output   shared.CommentableOptions
+		wantsErr bool
+	}{
+		{
+			name:  "no arguments",
+			input: "",
+			output: shared.CommentableOptions{
+				Interactive: true,
+				InputType:   0,
+				Body:        "",
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "pr number",
+			input: "1",
+			output: shared.CommentableOptions{
+				Interactive: true,
+				InputType:   0,
+				Body:        "",
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "pr url",
+			input: "https://github.com/OWNER/REPO/pull/12",
+			output: shared.CommentableOptions{
+				Interactive: true,
+				InputType:   0,
+				Body:        "",
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "pr branch",
+			input: "branch-name",
+			output: shared.CommentableOptions{
+				Interactive: true,
+				InputType:   0,
+				Body:        "",
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "body flag",
+			input: "1 --body test",
+			output: shared.CommentableOptions{
+				Interactive: false,
+				InputType:   shared.InputTypeInline,
+				Body:        "test",
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "editor flag",
+			input: "1 --editor",
+			output: shared.CommentableOptions{
+				Interactive: false,
+				InputType:   shared.InputTypeEditor,
+				Body:        "",
+			},
+			wantsErr: false,
+		},
+		{
+			name:  "web flag",
+			input: "1 --web",
+			output: shared.CommentableOptions{
+				Interactive: false,
+				InputType:   shared.InputTypeWeb,
+				Body:        "",
+			},
+			wantsErr: false,
+		},
+		{
+			name:     "editor and web flags",
+			input:    "1 --editor --web",
+			output:   shared.CommentableOptions{},
+			wantsErr: true,
+		},
+		{
+			name:     "editor and body flags",
+			input:    "1 --editor --body test",
+			output:   shared.CommentableOptions{},
+			wantsErr: true,
+		},
+		{
+			name:     "web and body flags",
+			input:    "1 --web --body test",
+			output:   shared.CommentableOptions{},
+			wantsErr: true,
+		},
+		{
+			name:     "editor, web, and body flags",
+			input:    "1 --editor --web --body test",
+			output:   shared.CommentableOptions{},
+			wantsErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			io, _, _, _ := iostreams.Test()
+			io.SetStdoutTTY(true)
+			io.SetStdinTTY(true)
+			io.SetStderrTTY(true)
+
+			f := &cmdutil.Factory{
+				IOStreams: io,
+			}
+
+			argv, err := shlex.Split(tt.input)
+			assert.NoError(t, err)
+
+			var gotOpts *shared.CommentableOptions
+			cmd := NewCmdComment(f, func(opts *shared.CommentableOptions) error {
+				gotOpts = opts
+				return nil
+			})
+			cmd.Flags().BoolP("help", "x", false, "")
+
+			cmd.SetArgs(argv)
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(&bytes.Buffer{})
+			cmd.SetErr(&bytes.Buffer{})
+
+			_, err = cmd.ExecuteC()
+			if tt.wantsErr {
+				assert.Error(t, err)
+				return
+			}
+
+			assert.NoError(t, err)
+			assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
+			assert.Equal(t, tt.output.InputType, gotOpts.InputType)
+			assert.Equal(t, tt.output.Body, gotOpts.Body)
+		})
+	}
+}
+
+func Test_commentRun(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     *shared.CommentableOptions
+		httpStubs func(*testing.T, *httpmock.Registry)
+		stdout    string
+		stderr    string
+	}{
+		{
+			name: "interactive editor",
+			input: &shared.CommentableOptions{
+				Interactive: true,
+				InputType:   0,
+				Body:        "",
+
+				InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
+				ConfirmSubmitSurvey:   func() (bool, error) { return true, nil },
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockPullRequestFromNumber(t, reg)
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+		},
+		{
+			name: "non-interactive web",
+			input: &shared.CommentableOptions{
+				Interactive: false,
+				InputType:   shared.InputTypeWeb,
+				Body:        "",
+
+				OpenInBrowser: func(string) error { return nil },
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockPullRequestFromNumber(t, reg)
+			},
+			stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n",
+		},
+		{
+			name: "non-interactive editor",
+			input: &shared.CommentableOptions{
+				Interactive: false,
+				InputType:   shared.InputTypeEditor,
+				Body:        "",
+
+				EditSurvey: func() (string, error) { return "comment body", nil },
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockPullRequestFromNumber(t, reg)
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+		},
+		{
+			name: "non-interactive inline",
+			input: &shared.CommentableOptions{
+				Interactive: false,
+				InputType:   shared.InputTypeInline,
+				Body:        "comment body",
+			},
+			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+				mockPullRequestFromNumber(t, reg)
+				mockCommentCreate(t, reg)
+			},
+			stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+		},
+	}
+	for _, tt := range tests {
+		io, _, stdout, stderr := iostreams.Test()
+		io.SetStdoutTTY(true)
+		io.SetStdinTTY(true)
+		io.SetStderrTTY(true)
+
+		reg := &httpmock.Registry{}
+		defer reg.Verify(t)
+		tt.httpStubs(t, reg)
+
+		httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
+		baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
+		branch := func() (string, error) { return "", nil }
+		remotes := func() (context.Remotes, error) { return nil, nil }
+
+		tt.input.IO = io
+		tt.input.HttpClient = httpClient
+		tt.input.RetrieveCommentable = retrievePR(httpClient, baseRepo, branch, remotes, "123")
+
+		t.Run(tt.name, func(t *testing.T) {
+			err := shared.CommentableRun(tt.input)
+			assert.NoError(t, err)
+			assert.Equal(t, tt.stdout, stdout.String())
+			assert.Equal(t, tt.stderr, stderr.String())
+		})
+	}
+}
+
+func mockPullRequestFromNumber(_ *testing.T, reg *httpmock.Registry) {
+	reg.Register(
+		httpmock.GraphQL(`query PullRequestByNumber\b`),
+		httpmock.StringResponse(`
+			{ "data": { "repository": { "pullRequest": {
+				"number": 123,
+				"url": "https://github.com/OWNER/REPO/pull/123"
+			} } } }`),
+	)
+}
+
+func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
+	reg.Register(
+		httpmock.GraphQL(`mutation CommentCreate\b`),
+		httpmock.GraphQLMutation(`
+		{ "data": { "addComment": { "commentEdge": { "node": {
+			"url": "https://github.com/OWNER/REPO/pull/123#issuecomment-456"
+		} } } } }`,
+			func(inputs map[string]interface{}) {
+				assert.Equal(t, "comment body", inputs["body"])
+			}),
+	)
+}