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 { | }", 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/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.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 [ | | ]", + 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/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"]) + }), + ) +} 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) +}