diff --git a/api/queries_comments.go b/api/queries_comments.go index 8b39675f6..0e62351b1 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -15,7 +15,18 @@ type Comments struct { } } +func (cs Comments) CurrentUserComments() []Comment { + var comments []Comment + for _, c := range cs.Nodes { + if c.ViewerDidAuthor { + comments = append(comments, c) + } + } + return comments +} + type Comment struct { + ID string `json:"id"` Author Author `json:"author"` AuthorAssociation string `json:"authorAssociation"` Body string `json:"body"` @@ -24,6 +35,8 @@ type Comment struct { IsMinimized bool `json:"isMinimized"` MinimizedReason string `json:"minimizedReason"` ReactionGroups ReactionGroups `json:"reactionGroups"` + URL string `json:"url,omitempty"` + ViewerDidAuthor bool `json:"viewerDidAuthor"` } type CommentCreateInput struct { @@ -31,6 +44,11 @@ type CommentCreateInput struct { SubjectId string } +type CommentUpdateInput struct { + Body string + CommentId string +} + func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) { var mutation struct { AddComment struct { @@ -57,6 +75,34 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) ( return mutation.AddComment.CommentEdge.Node.URL, nil } +func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (string, error) { + var mutation struct { + UpdateIssueComment struct { + IssueComment struct { + URL string + } + } `graphql:"updateIssueComment(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.UpdateIssueCommentInput{ + Body: githubv4.String(params.Body), + ID: githubv4.ID(params.CommentId), + }, + } + + err := client.Mutate(repoHost, "CommentUpdate", &mutation, variables) + if err != nil { + return "", err + } + + return mutation.UpdateIssueComment.IssueComment.URL, nil +} + +func (c Comment) Identifier() string { + return c.ID +} + func (c Comment) AuthorLogin() string { return c.Author.Login } @@ -86,7 +132,7 @@ func (c Comment) IsHidden() bool { } func (c Comment) Link() string { - return "" + return c.URL } func (c Comment) Reactions() ReactionGroups { diff --git a/api/queries_issue.go b/api/queries_issue.go index ae409d729..93ef093b7 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -244,3 +244,7 @@ func (i Issue) Link() string { func (i Issue) Identifier() string { return i.ID } + +func (i Issue) CurrentUserComments() []Comment { + return i.Comments.CurrentUserComments() +} diff --git a/api/queries_pr.go b/api/queries_pr.go index e4f6013fa..af4e6bb0f 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -214,6 +214,10 @@ func (pr PullRequest) Identifier() string { return pr.ID } +func (pr PullRequest) CurrentUserComments() []Comment { + return pr.Comments.CurrentUserComments() +} + func (pr PullRequest) IsOpen() bool { return pr.State == "OPEN" } diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index edfda99dd..5fcb75e19 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -30,6 +30,7 @@ type PullRequestReviews struct { } type PullRequestReview struct { + ID string `json:"id"` Author Author `json:"author"` AuthorAssociation string `json:"authorAssociation"` Body string `json:"body"` @@ -67,6 +68,10 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables) } +func (prr PullRequestReview) Identifier() string { + return prr.ID +} + func (prr PullRequestReview) AuthorLogin() string { return prr.Author.Login } diff --git a/api/query_builder.go b/api/query_builder.go index 4d7d27d11..5ed9bcb74 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -23,6 +23,7 @@ func shortenQuery(q string) string { var issueComments = shortenQuery(` comments(first: 100) { nodes { + id, author{login}, authorAssociation, body, @@ -30,7 +31,9 @@ var issueComments = shortenQuery(` includesCreatedEdit, isMinimized, minimizedReason, - reactionGroups{content,users{totalCount}} + reactionGroups{content,users{totalCount}}, + url, + viewerDidAuthor }, pageInfo{hasNextPage,endCursor}, totalCount diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index a3f1b0b4d..3bd9df598 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -40,7 +40,11 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e if err != nil { return nil, nil, err } - return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], []string{"id", "url"}) + fields := []string{"id", "url"} + if opts.EditLast { + fields = append(fields, "comments") + } + return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields) } return prShared.CommentablePreRun(cmd, opts) }, @@ -64,6 +68,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") 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") return cmd } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index edf1900d3..4f747d882 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -200,7 +201,7 @@ func Test_commentRun(t *testing.T) { InputType: 0, Body: "", - InteractiveEditSurvey: func() (string, error) { return "comment body", nil }, + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -208,6 +209,22 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, + { + name: "interactive editor with edit last", + input: &shared.CommentableOptions{ + Interactive: true, + InputType: 0, + Body: "", + EditLast: true, + + InteractiveEditSurvey: func(string) (string, error) { return "comment body", 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{ @@ -219,6 +236,18 @@ func Test_commentRun(t *testing.T) { }, stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", }, + { + name: "non-interactive web with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + + OpenInBrowser: func(string) error { return nil }, + }, + stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", + }, { name: "non-interactive editor", input: &shared.CommentableOptions{ @@ -226,13 +255,28 @@ func Test_commentRun(t *testing.T) { InputType: shared.InputTypeEditor, Body: "", - EditSurvey: func() (string, error) { return "comment body", nil }, + EditSurvey: func(string) (string, error) { return "comment body", nil }, }, 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 editor with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + + EditSurvey: func(string) (string, error) { return "comment body", 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 inline", input: &shared.CommentableOptions{ @@ -245,6 +289,19 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, + { + name: "non-interactive inline with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeInline, + Body: "comment body", + EditLast: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n", + }, } for _, tt := range tests { ios, _, stdout, stderr := iostreams.Test() @@ -263,7 +320,14 @@ func Test_commentRun(t *testing.T) { return &http.Client{Transport: reg}, nil } tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { - return &mockCommentable{}, ghrepo.New("OWNER", "REPO"), nil + return &api.Issue{ + ID: "ISSUE-ID", + URL: "https://github.com/OWNER/REPO/issues/123", + Comments: api.Comments{Nodes: []api.Comment{ + {ID: "id1", Author: api.Author{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true}, + {ID: "id2", Author: api.Author{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"}, + }}, + }, ghrepo.New("OWNER", "REPO"), nil } t.Run(tt.name, func(t *testing.T) { @@ -275,15 +339,6 @@ func Test_commentRun(t *testing.T) { } } -type mockCommentable struct{} - -func (c mockCommentable) Identifier() string { - return "ISSUE-ID" -} -func (c mockCommentable) Link() string { - return "https://github.com/OWNER/REPO/issues/123" -} - func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation CommentCreate\b`), @@ -297,3 +352,17 @@ func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { }), ) } + +func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentUpdate\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueComment": { "issueComment": { + "url": "https://github.com/OWNER/REPO/issues/123#issuecomment-111" + } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "id1", inputs["id"]) + assert.Equal(t, "comment body", inputs["body"]) + }), + ) +} diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index 1ad79fd11..34644f62b 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -41,11 +41,15 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err if len(args) > 0 { selector = args[0] } + fields := []string{"id", "url"} + if opts.EditLast { + fields = append(fields, "comments") + } finder := shared.NewFinder(f) opts.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { return finder.Find(shared.FindOptions{ Selector: selector, - Fields: []string{"id", "url"}, + Fields: fields, }) } return shared.CommentablePreRun(cmd, opts) @@ -70,6 +74,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") 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") return cmd } diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index a5c0edfb1..f72701859 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -221,7 +221,7 @@ func Test_commentRun(t *testing.T) { InputType: 0, Body: "", - InteractiveEditSurvey: func() (string, error) { return "comment body", nil }, + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -229,6 +229,22 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", }, + { + name: "interactive editor with edit last", + input: &shared.CommentableOptions{ + Interactive: true, + InputType: 0, + Body: "", + EditLast: true, + + InteractiveEditSurvey: func(string) (string, error) { return "comment body", 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{ @@ -240,6 +256,18 @@ func Test_commentRun(t *testing.T) { }, stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n", }, + { + name: "non-interactive web with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + + OpenInBrowser: func(string) error { return nil }, + }, + stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n", + }, { name: "non-interactive editor", input: &shared.CommentableOptions{ @@ -247,13 +275,28 @@ func Test_commentRun(t *testing.T) { InputType: shared.InputTypeEditor, Body: "", - EditSurvey: func() (string, error) { return "comment body", nil }, + EditSurvey: func(string) (string, error) { return "comment body", nil }, }, 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 editor with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + + EditSurvey: func(string) (string, error) { return "comment body", 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 inline", input: &shared.CommentableOptions{ @@ -266,6 +309,19 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", }, + { + name: "non-interactive inline with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeInline, + Body: "comment body", + EditLast: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n", + }, } for _, tt := range tests { ios, _, stdout, stderr := iostreams.Test() @@ -287,6 +343,10 @@ func Test_commentRun(t *testing.T) { return &api.PullRequest{ Number: 123, URL: "https://github.com/OWNER/REPO/pull/123", + Comments: api.Comments{Nodes: []api.Comment{ + {ID: "id1", Author: api.Author{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true}, + {ID: "id2", Author: api.Author{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"}, + }}, }, ghrepo.New("OWNER", "REPO"), nil } @@ -311,3 +371,17 @@ func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { }), ) } + +func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentUpdate\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueComment": { "issueComment": { + "url": "https://github.com/OWNER/REPO/pull/123#issuecomment-111" + } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "id1", inputs["id"]) + assert.Equal(t, "comment body", inputs["body"]) + }), + ) +} diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index 5d1037fb7..8f1f89137 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -29,20 +29,23 @@ const ( type Commentable interface { Link() string Identifier() string + CurrentUserComments() []api.Comment } 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) + 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 } func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error { @@ -81,7 +84,14 @@ func CommentableRun(opts *CommentableOptions) error { if err != nil { return err } + opts.Host = repo.RepoHost() + if opts.EditLast { + return updateComment(commentable, opts) + } + return createComment(commentable, opts) +} +func createComment(commentable Commentable, opts *CommentableOptions) error { switch opts.InputType { case InputTypeWeb: openURL := commentable.Link() + "#issuecomment-new" @@ -91,10 +101,11 @@ func CommentableRun(opts *CommentableOptions) error { return opts.OpenInBrowser(openURL) case InputTypeEditor: var body string + var err error if opts.Interactive { - body, err = opts.InteractiveEditSurvey() + body, err = opts.InteractiveEditSurvey("") } else { - body, err = opts.EditSurvey() + body, err = opts.EditSurvey("") } if err != nil { return err @@ -116,15 +127,77 @@ func CommentableRun(opts *CommentableOptions) error { 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) + url, err := api.CommentCreate(apiClient, opts.Host, params) if err != nil { return err } + if !opts.Quiet { fmt.Fprintln(opts.IO.Out, url) } + + return nil +} + +func updateComment(commentable Commentable, opts *CommentableOptions) error { + comments := commentable.CurrentUserComments() + if len(comments) == 0 { + return fmt.Errorf("no comments found for current user") + } + + lastComment := &comments[len(comments)-1] + + switch opts.InputType { + case InputTypeWeb: + openURL := lastComment.Link() + if opts.IO.IsStdoutTTY() && !opts.Quiet { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL)) + } + return opts.OpenInBrowser(openURL) + case InputTypeEditor: + var body string + var err error + initialValue := lastComment.Content() + if opts.Interactive { + body, err = opts.InteractiveEditSurvey(initialValue) + } else { + body, err = opts.EditSurvey(initialValue) + } + 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.CommentUpdateInput{Body: opts.Body, CommentId: lastComment.Identifier()} + url, err := api.CommentUpdate(apiClient, opts.Host, params) + if err != nil { + return err + } + + if !opts.Quiet { + fmt.Fprintln(opts.IO.Out, url) + } + return nil } @@ -138,8 +211,8 @@ func CommentableConfirmSubmitSurvey() (bool, error) { return confirm, err } -func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) { - return func() (string, error) { +func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func(string) (string, error) { + return func(initialValue string) (string, error) { editorCommand, err := cmdutil.DetermineEditor(cf) if err != nil { return "", err @@ -147,17 +220,17 @@ func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iost cs := io.ColorScheme() fmt.Fprintf(io.Out, "- %s to draft your comment in %s... ", cs.Bold("Press Enter"), cs.Bold(surveyext.EditorName(editorCommand))) _ = waitForEnter(io.In) - return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut) + return surveyext.Edit(editorCommand, "*.md", initialValue, io.In, io.Out, io.ErrOut) } } -func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) { - return func() (string, error) { +func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func(string) (string, error) { + return func(initialValue string) (string, error) { editorCommand, err := cmdutil.DetermineEditor(cf) if err != nil { return "", err } - return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut) + return surveyext.Edit(editorCommand, "*.md", initialValue, io.In, io.Out, io.ErrOut) } } diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index e7d90a7e6..a05108d7b 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -13,6 +13,7 @@ import ( ) type Comment interface { + Identifier() string AuthorLogin() string Association() string Content() string