Add --delete-last option to pr comment and issue comment (#10596)

* deletion for issues with confirmation flag

* add handling for interaction case

* finish implementation for issues

* finish the implementation for issues

* finalize the implementation for PR

* fix missing --yes flag for PR

* address PR comments related to feedbacks

* improve CommentablePreRun for pre checks

* improve confirmation prompt and truncate long comment body

* address PR comments on tests

* Truncate comment for confirmation prompt

Signed-off-by: Babak K. Shandiz <babakks@github.com>

* Improve test case descriptions

Signed-off-by: Babak K. Shandiz <babakks@github.com>

* Fix mock comment body

Signed-off-by: Babak K. Shandiz <babakks@github.com>

* Remove irrelevant prompt stub

Signed-off-by: Babak K. Shandiz <babakks@github.com>

* Use `opts.Interactive` as TTY indicator

Signed-off-by: Babak K. Shandiz <babakks@github.com>

* Fix expected `Interactive` value

Signed-off-by: Babak K. Shandiz <babakks@github.com>

* Polish `TestNewCmdComment`

Signed-off-by: Babak K. Shandiz <babakks@github.com>

---------

Signed-off-by: Babak K. Shandiz <babakks@github.com>
Co-authored-by: Babak K. Shandiz <babakks@github.com>
This commit is contained in:
Sinan Sonmez (Chaush) 2025-05-01 15:12:55 +02:00 committed by GitHub
parent 913d13539d
commit 0a1e7a1fdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 542 additions and 10 deletions

View file

@ -44,6 +44,10 @@ type CommentCreateInput struct {
SubjectId string
}
type CommentDeleteInput struct {
CommentId string
}
type CommentUpdateInput struct {
Body string
CommentId string
@ -99,6 +103,27 @@ func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (
return mutation.UpdateIssueComment.IssueComment.URL, nil
}
func CommentDelete(client *Client, repoHost string, params CommentDeleteInput) error {
var mutation struct {
DeleteIssueComment struct {
ClientMutationID string
} `graphql:"deleteIssueComment(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.DeleteIssueCommentInput{
ID: githubv4.ID(params.CommentId),
},
}
err := client.Mutate(repoHost, "CommentDelete", &mutation, variables)
if err != nil {
return err
}
return nil
}
func (c Comment) Identifier() string {
return c.ID
}

View file

@ -18,6 +18,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter),
ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
ConfirmDeleteLastComment: prShared.CommentableConfirmDeleteLastComment(f.Prompter),
OpenInBrowser: f.Browser.Browse,
}
@ -63,7 +64,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
}
fields := []string{"id", "url"}
if opts.EditLast {
if opts.EditLast || opts.DeleteLast {
fields = append(fields, "comments")
}
@ -96,7 +97,9 @@ 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")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLast, "delete-last", false, "Delete the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided")
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

View file

@ -2,6 +2,7 @@ package comment
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@ -31,11 +32,13 @@ func TestNewCmdComment(t *testing.T) {
stdin string
output shared.CommentableOptions
wantsErr bool
isTTY bool
}{
{
name: "no arguments",
input: "",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
@ -46,6 +49,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -56,6 +60,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -66,6 +71,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "test",
},
isTTY: true,
wantsErr: false,
},
{
@ -77,6 +83,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "this is on standard input",
},
isTTY: true,
wantsErr: false,
},
{
@ -87,6 +94,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "a body from file",
},
isTTY: true,
wantsErr: false,
},
{
@ -97,6 +105,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeEditor,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -107,6 +116,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeWeb,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -118,6 +128,7 @@ func TestNewCmdComment(t *testing.T) {
Body: "",
EditLast: true,
},
isTTY: true,
wantsErr: false,
},
{
@ -130,42 +141,110 @@ func TestNewCmdComment(t *testing.T) {
EditLast: true,
CreateIfNone: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag non-interactive",
input: "1 --delete-last",
isTTY: false,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation non-interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: false,
wantsErr: false,
},
{
name: "delete last flag interactive",
input: "1 --delete-last",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation with web flag",
input: "1 --delete-last --yes --web",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with editor flag",
input: "1 --delete-last --yes --editor",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with body flag",
input: "1 --delete-last --yes --body",
isTTY: true,
wantsErr: true,
},
{
name: "delete pre-confirmation without delete last flag",
input: "1 --yes",
isTTY: true,
wantsErr: true,
},
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and web flags",
input: "1 --editor --web",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and body flags",
input: "1 --editor --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "web and body flags",
input: "1 --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor, web, and body flags",
input: "1 --editor --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "create-if-none flag without edit-last",
input: "1 --create-if-none",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
}
@ -173,9 +252,10 @@ func TestNewCmdComment(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, stdin, _, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
isTTY := tt.isTTY
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
ios.SetStderrTTY(isTTY)
if tt.stdin != "" {
_, _ = stdin.WriteString(tt.stdin)
@ -211,6 +291,8 @@ func TestNewCmdComment(t *testing.T) {
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.InputType, gotOpts.InputType)
assert.Equal(t, tt.output.Body, gotOpts.Body)
assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast)
assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed)
})
}
}
@ -220,6 +302,7 @@ func Test_commentRun(t *testing.T) {
name string
input *shared.CommentableOptions
emptyComments bool
comments api.Comments
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
@ -255,6 +338,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with interactive editor succeeds if there are comments",
@ -331,6 +415,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "creating new comment with non-interactive editor succeeds",
@ -358,6 +443,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with non-interactive editor succeeds if there are comments",
@ -433,6 +519,117 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "deleting last comment non-interactively without any comment",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment interactively without any comment",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment non-interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
DeleteLastConfirmed: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
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, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
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, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmation declined",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
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, Body: "comment body"},
}},
wantsErr: true,
stdout: "deletion not confirmed",
},
{
name: "deleting last comment interactively and confirmed with long comment body",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "Lorem ipsum dolor sit amet, consectet lo..." {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
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, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"},
}},
wantsErr: false,
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
}
for _, tt := range tests {
ios, _, stdout, stderr := iostreams.Test()
@ -458,6 +655,8 @@ func Test_commentRun(t *testing.T) {
if tt.emptyComments {
comments.Nodes = []api.Comment{}
} else if len(tt.comments.Nodes) > 0 {
comments = tt.comments
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
@ -472,6 +671,7 @@ func Test_commentRun(t *testing.T) {
err := shared.CommentableRun(tt.input)
if tt.wantsErr {
assert.Error(t, err)
assert.Equal(t, tt.stderr, stderr.String())
return
}
assert.NoError(t, err)
@ -508,3 +708,15 @@ func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
}),
)
}
func mockCommentDelete(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentDelete\b`),
httpmock.GraphQLMutation(`
{ "data": { "deleteIssueComment": {} } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "id1", inputs["id"])
},
),
)
}

View file

@ -16,6 +16,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter),
ConfirmCreateIfNoneSurvey: shared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
ConfirmDeleteLastComment: shared.CommentableConfirmDeleteLastComment(f.Prompter),
OpenInBrowser: f.Browser.Browse,
}
@ -43,7 +44,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
selector = args[0]
}
fields := []string{"id", "url"}
if opts.EditLast {
if opts.EditLast || opts.DeleteLast {
fields = append(fields, "comments")
}
finder := shared.NewFinder(f)
@ -75,7 +76,9 @@ 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")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLast, "delete-last", false, "Delete the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided")
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

View file

@ -2,6 +2,7 @@ package comment
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@ -31,6 +32,7 @@ func TestNewCmdComment(t *testing.T) {
stdin string
output shared.CommentableOptions
wantsErr bool
isTTY bool
}{
{
name: "no arguments",
@ -40,12 +42,14 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
name: "two arguments",
input: "1 2",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
@ -56,6 +60,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -66,6 +71,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -76,6 +82,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -86,6 +93,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "test",
},
isTTY: true,
wantsErr: false,
},
{
@ -97,6 +105,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "this is on standard input",
},
isTTY: true,
wantsErr: false,
},
{
@ -107,6 +116,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "a body from file",
},
isTTY: true,
wantsErr: false,
},
{
@ -117,6 +127,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeEditor,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -127,6 +138,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeWeb,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -138,6 +150,7 @@ func TestNewCmdComment(t *testing.T) {
Body: "",
EditLast: true,
},
isTTY: true,
wantsErr: false,
},
{
@ -150,42 +163,110 @@ func TestNewCmdComment(t *testing.T) {
EditLast: true,
CreateIfNone: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag non-interactive",
input: "1 --delete-last",
isTTY: false,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation non-interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: false,
wantsErr: false,
},
{
name: "delete last flag interactive",
input: "1 --delete-last",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation with web flag",
input: "1 --delete-last --yes --web",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with editor flag",
input: "1 --delete-last --yes --editor",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with body flag",
input: "1 --delete-last --yes --body",
isTTY: true,
wantsErr: true,
},
{
name: "delete pre-confirmation without delete last flag",
input: "1 --yes",
isTTY: true,
wantsErr: true,
},
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and web flags",
input: "1 --editor --web",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and body flags",
input: "1 --editor --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "web and body flags",
input: "1 --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor, web, and body flags",
input: "1 --editor --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "create-if-none flag without edit-last",
input: "1 --create-if-none",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
}
@ -193,9 +274,10 @@ func TestNewCmdComment(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, stdin, _, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
isTTY := tt.isTTY
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
ios.SetStderrTTY(isTTY)
if tt.stdin != "" {
_, _ = stdin.WriteString(tt.stdin)
@ -231,6 +313,8 @@ func TestNewCmdComment(t *testing.T) {
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.InputType, gotOpts.InputType)
assert.Equal(t, tt.output.Body, gotOpts.Body)
assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast)
assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed)
})
}
}
@ -240,6 +324,7 @@ func Test_commentRun(t *testing.T) {
name string
input *shared.CommentableOptions
emptyComments bool
comments api.Comments
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
@ -274,6 +359,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with interactive editor succeeds if there are comments",
@ -350,6 +436,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "creating new comment with non-interactive editor succeeds",
@ -377,6 +464,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with non-interactive editor succeeds if there are comments",
@ -451,6 +539,117 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "deleting last comment non-interactively without any comment",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment interactively without any comment",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment non-interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
DeleteLastConfirmed: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
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, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
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, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmation declined",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
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, Body: "comment body"},
}},
wantsErr: true,
stdout: "deletion not confirmed",
},
{
name: "deleting last comment interactively and confirmed with long comment body",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "Lorem ipsum dolor sit amet, consectet lo..." {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
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, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"},
}},
wantsErr: false,
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
}
for _, tt := range tests {
ios, _, stdout, stderr := iostreams.Test()
@ -475,6 +674,8 @@ func Test_commentRun(t *testing.T) {
}}
if tt.emptyComments {
comments.Nodes = []api.Comment{}
} else if len(tt.comments.Nodes) > 0 {
comments = tt.comments
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
@ -489,6 +690,7 @@ func Test_commentRun(t *testing.T) {
err := shared.CommentableRun(tt.input)
if tt.wantsErr {
assert.Error(t, err)
assert.Equal(t, tt.stderr, stderr.String())
return
}
assert.NoError(t, err)
@ -524,3 +726,15 @@ func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
}),
)
}
func mockCommentDelete(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentDelete\b`),
httpmock.GraphQLMutation(`
{ "data": { "deleteIssueComment": {} } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "id1", inputs["id"])
},
),
)
}

View file

@ -18,6 +18,7 @@ import (
)
var errNoUserComments = errors.New("no comments found for current user")
var errDeleteNotConfirmed = errors.New("deletion not confirmed")
type InputType int
@ -41,11 +42,14 @@ type CommentableOptions struct {
InteractiveEditSurvey func(string) (string, error)
ConfirmSubmitSurvey func() (bool, error)
ConfirmCreateIfNoneSurvey func() (bool, error)
ConfirmDeleteLastComment func(string) (bool, error)
OpenInBrowser func(string) error
Interactive bool
InputType InputType
Body string
EditLast bool
DeleteLast bool
DeleteLastConfirmed bool
CreateIfNone bool
Quiet bool
Host string
@ -74,6 +78,21 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
return cmdutil.FlagErrorf("`--create-if-none` can only be used with `--edit-last`")
}
if opts.DeleteLastConfirmed && !opts.DeleteLast {
return cmdutil.FlagErrorf("`--yes` should only be used with `--delete-last`")
}
if opts.DeleteLast {
if inputFlags > 0 {
return cmdutil.FlagErrorf("should not provide comment body when using `--delete-last`")
}
if opts.IO.CanPrompt() || opts.DeleteLastConfirmed {
opts.Interactive = opts.IO.CanPrompt()
return nil
}
return cmdutil.FlagErrorf("should provide `--yes` to confirm deletion in non-interactive mode")
}
if inputFlags == 0 {
if !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("flags required when not running interactively")
@ -92,6 +111,9 @@ func CommentableRun(opts *CommentableOptions) error {
return err
}
opts.Host = repo.RepoHost()
if opts.DeleteLast {
return deleteComment(commentable, opts)
}
// Create new comment, bail before complexities of updating the last comment
if !opts.EditLast {
@ -236,6 +258,53 @@ func updateComment(commentable Commentable, opts *CommentableOptions) error {
return nil
}
func deleteComment(commentable Commentable, opts *CommentableOptions) error {
comments := commentable.CurrentUserComments()
if len(comments) == 0 {
return errNoUserComments
}
lastComment := comments[len(comments)-1]
cs := opts.IO.ColorScheme()
if opts.Interactive && !opts.DeleteLastConfirmed {
// This is not an ideal way of truncating a random string that may
// contain emojis or other kind of wide chars.
truncated := lastComment.Body
if len(lastComment.Body) > 40 {
truncated = lastComment.Body[:40] + "..."
}
fmt.Fprintf(opts.IO.Out, "%s Deleted comments cannot be recovered.\n", cs.WarningIcon())
ok, err := opts.ConfirmDeleteLastComment(truncated)
if err != nil {
return err
}
if !ok {
return errDeleteNotConfirmed
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
params := api.CommentDeleteInput{CommentId: lastComment.Identifier()}
deletionErr := api.CommentDelete(apiClient, opts.Host, params)
if deletionErr != nil {
return deletionErr
}
if !opts.Quiet {
fmt.Fprintln(opts.IO.ErrOut, "Comment deleted")
}
return nil
}
func CommentableConfirmSubmitSurvey(p Prompt) func() (bool, error) {
return func() (bool, error) {
return p.Confirm("Submit?", true)
@ -271,6 +340,12 @@ func CommentableEditSurvey(cf func() (gh.Config, error), io *iostreams.IOStreams
}
}
func CommentableConfirmDeleteLastComment(p Prompt) func(string) (bool, error) {
return func(body string) (bool, error) {
return p.Confirm(fmt.Sprintf("Delete the comment: %q?", body), true)
}
}
func waitForEnter(r io.Reader) error {
scanner := bufio.NewScanner(r)
scanner.Scan()