diff --git a/api/queries_issue.go b/api/queries_issue.go index 17f32eabd..e561bf19c 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -123,6 +123,25 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } +func CommentCreate(client *Client, repoHost string, params map[string]interface{}) error { + query := ` + mutation CommentCreate($input: AddCommentInput!) { + addComment(input: $input) { clientMutationId } + }` + + variables := map[string]interface{}{ + "input": params, + } + + err := client.GraphQL(repoHost, query, variables, nil) + + if err != nil { + return err + } + + return nil +} + func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { type response struct { Repository struct { diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go new file mode 100644 index 000000000..9857802b8 --- /dev/null +++ b/pkg/cmd/issue/comment/comment.go @@ -0,0 +1,202 @@ +package comment + +import ( + "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" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/pkg/surveyext" + "github.com/spf13/cobra" +) + +type CommentOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Body string + SelectorArg string + Interactive bool + Action Action +} + +func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command { + opts := &CommentOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "comment { | }", + Short: "Create comments for the issue", + Example: heredoc.Doc(` + $ gh issue comment --body "I found a bug. Nothing works" + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + bodyProvided := cmd.Flags().Changed("body") + + opts.Interactive = !(bodyProvided) + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return commentRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body.") + + return cmd +} + +type Action int + +const ( + SubmitAction Action = iota + CancelAction +) + +func titleBodySurvey(editorCommand string, issueState *CommentOptions, apiClient *api.Client, repo ghrepo.Interface, providedBody string) error { + bodyQuestion := &survey.Question{ + Name: "body", + Prompt: &surveyext.GhEditor{ + BlankAllowed: false, + EditorCommand: editorCommand, + Editor: &survey.Editor{ + Message: "Body", + FileName: "*.md", + Default: issueState.Body, + }, + }, + } + + var qs []*survey.Question + + if providedBody == "" { + qs = append(qs, bodyQuestion) + } + + err := prompt.SurveyAsk(qs, issueState) + if err != nil { + panic(fmt.Sprintf("could not prompt: %w", err)) + } + + confirmA, err := confirmSubmission() + + if err != nil { + panic(fmt.Sprintf("unable to confirm: %w", err)) + } + + issueState.Action = confirmA + return nil +} + +func confirmSubmission() (Action, error) { + const ( + submitLabel = "Submit" + cancelLabel = "Cancel" + ) + + options := []string{submitLabel, cancelLabel} + + confirmAnswers := struct { + Confirmation int + }{} + confirmQs := []*survey.Question{ + { + Name: "confirmation", + Prompt: &survey.Select{ + Message: "What's next?", + Options: options, + }, + }, + } + + err := prompt.SurveyAsk(confirmQs, &confirmAnswers) + if err != nil { + return -1, fmt.Errorf("could not prompt: %w", err) + } + + switch options[confirmAnswers.Confirmation] { + case submitLabel: + return SubmitAction, nil + case cancelLabel: + return CancelAction, nil + default: + return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation) + } +} + +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 + } + + isTerminal := opts.IO.IsStdoutTTY() + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "\nMake a comment for %s in %s\n\n", issue.Title, ghrepo.FullName(baseRepo)) + } + + action := SubmitAction + body := opts.Body + + if opts.Interactive { + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + err = titleBodySurvey(editorCommand, opts, apiClient, baseRepo, body) + + if err != nil { + return err + } + + action = opts.Action + } + + if action == CancelAction { + return nil + } else if action == SubmitAction { + params := map[string]interface{}{ + "subjectId": issue.ID, + "body": opts.Body, + } + + err = api.CommentCreate(apiClient, baseRepo.RepoHost(), params) + if err != nil { + return err + } + + fmt.Fprintln(opts.IO.Out, issue.URL) + + return nil + } + return fmt.Errorf("unexpected action state: %v", action) +} diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go new file mode 100644 index 000000000..fe101a797 --- /dev/null +++ b/pkg/cmd/issue/comment/comment_test.go @@ -0,0 +1,117 @@ +package comment + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "reflect" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdComment(factory, func(opts *CommentOptions) error { + return commentRun(opts) + }) + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestCommentCreate(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 96, "title": "The title of the issue"} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"mutationId": "THE-ID"}`)) + + output, err := runCommand(http, true, `13 -b "cash rules everything around me"`) + if err != nil { + t.Errorf("error running command `issue comment`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + Body string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") + + r := regexp.MustCompile(`Make a comment for The title of the issue`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssue_Disabled(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "13") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Fatalf("got error: %v", err) + } +} diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go index 6f2cea6d9..12463b480 100644 --- a/pkg/cmd/issue/issue.go +++ b/pkg/cmd/issue/issue.go @@ -3,6 +3,7 @@ package issue import ( "github.com/MakeNowJust/heredoc" cmdClose "github.com/cli/cli/pkg/cmd/issue/close" + cmdComment "github.com/cli/cli/pkg/cmd/issue/comment" cmdCreate "github.com/cli/cli/pkg/cmd/issue/create" cmdList "github.com/cli/cli/pkg/cmd/issue/list" cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen" @@ -40,6 +41,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil)) cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdComment.NewCmdComment(f, nil)) return cmd }