[gh issue/pr comment] Create a comment if no comment already

This commit is contained in:
latzskim 2025-02-12 21:04:22 +01:00
parent cdb44f8298
commit e516e5ed5d
5 changed files with 297 additions and 50 deletions

View file

@ -11,12 +11,13 @@ import (
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(f.Prompter),
OpenInBrowser: f.Browser.Browse,
IO: f.IOStreams,
HttpClient: f.HttpClient,
EditSurvey: prShared.CommentableEditSurvey(f.Config, f.IOStreams),
InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter),
ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
OpenInBrowser: f.Browser.Browse,
}
var bodyFile string
@ -69,6 +70,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
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.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

@ -109,6 +109,29 @@ func TestNewCmdComment(t *testing.T) {
},
wantsErr: false,
},
{
name: "edit last flag",
input: "1 --edit-last",
output: shared.CommentableOptions{
Interactive: true,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
},
wantsErr: false,
},
{
name: "edit last flag with create if none",
input: "1 --edit-last --create-if-none",
output: shared.CommentableOptions{
Interactive: true,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
CreateIfNone: true,
},
wantsErr: false,
},
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
@ -139,6 +162,12 @@ func TestNewCmdComment(t *testing.T) {
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "create-if-none flag without edit-last",
input: "1 --create-if-none",
output: shared.CommentableOptions{},
wantsErr: true,
},
}
for _, tt := range tests {
@ -188,11 +217,12 @@ func TestNewCmdComment(t *testing.T) {
func Test_commentRun(t *testing.T) {
tests := []struct {
name string
input *shared.CommentableOptions
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
name string
input *shared.CommentableOptions
emptyComments bool
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
}{
{
name: "interactive editor",
@ -225,6 +255,24 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
{
name: "interactive editor with edit last and create if none",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
EditLast: true,
CreateIfNone: true,
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, 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{
@ -248,6 +296,39 @@ func Test_commentRun(t *testing.T) {
},
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none for empty comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-new")
return nil
},
},
emptyComments: true,
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-111")
return nil
},
},
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive editor",
input: &shared.CommentableOptions{
@ -277,6 +358,23 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
{
name: "non-interactive editor with edit last and create if none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
CreateIfNone: true,
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
emptyComments: true,
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 inline",
input: &shared.CommentableOptions{
@ -319,14 +417,21 @@ func Test_commentRun(t *testing.T) {
tt.input.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
comments := api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true},
{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"},
}}
if tt.emptyComments {
comments.Nodes = []api.Comment{}
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return &api.Issue{
ID: "ISSUE-ID",
URL: "https://github.com/OWNER/REPO/issues/123",
Comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true},
{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"},
}},
ID: "ISSUE-ID",
URL: "https://github.com/OWNER/REPO/issues/123",
Comments: comments,
}, ghrepo.New("OWNER", "REPO"), nil
}

View file

@ -10,12 +10,13 @@ import (
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(f.Prompter),
OpenInBrowser: f.Browser.Browse,
IO: f.IOStreams,
HttpClient: f.HttpClient,
EditSurvey: shared.CommentableEditSurvey(f.Config, f.IOStreams),
InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter),
ConfirmCreateIfNoneSurvey: shared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
OpenInBrowser: f.Browser.Browse,
}
var bodyFile string
@ -75,6 +76,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
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.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

@ -129,6 +129,29 @@ func TestNewCmdComment(t *testing.T) {
},
wantsErr: false,
},
{
name: "edit last flag",
input: "1 --edit-last",
output: shared.CommentableOptions{
Interactive: true,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
},
wantsErr: false,
},
{
name: "edit last flag with create if none",
input: "1 --edit-last --create-if-none",
output: shared.CommentableOptions{
Interactive: true,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
CreateIfNone: true,
},
wantsErr: false,
},
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
@ -159,6 +182,12 @@ func TestNewCmdComment(t *testing.T) {
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "create-if-none flag without edit-last",
input: "1 --create-if-none",
output: shared.CommentableOptions{},
wantsErr: true,
},
}
for _, tt := range tests {
@ -208,11 +237,12 @@ func TestNewCmdComment(t *testing.T) {
func Test_commentRun(t *testing.T) {
tests := []struct {
name string
input *shared.CommentableOptions
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
name string
input *shared.CommentableOptions
emptyComments bool
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
}{
{
name: "interactive editor",
@ -245,6 +275,24 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
{
name: "interactive editor with edit last and create if none",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
EditLast: true,
CreateIfNone: true,
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, 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{
@ -264,7 +312,43 @@ func Test_commentRun(t *testing.T) {
Body: "",
EditLast: true,
OpenInBrowser: func(string) error { return nil },
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-111")
return nil
},
},
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none for empty comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-new")
return nil
},
},
emptyComments: true,
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-111")
return nil
},
},
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
},
@ -297,6 +381,23 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
{
name: "non-interactive editor with edit last and create if none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
CreateIfNone: true,
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
emptyComments: true,
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 inline",
input: &shared.CommentableOptions{
@ -339,14 +440,20 @@ func Test_commentRun(t *testing.T) {
tt.input.IO = ios
tt.input.HttpClient = httpClient
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},
{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
}}
if tt.emptyComments {
comments.Nodes = []api.Comment{}
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return &api.PullRequest{
Number: 123,
URL: "https://github.com/OWNER/REPO/pull/123",
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},
{ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
}},
Number: 123,
URL: "https://github.com/OWNER/REPO/pull/123",
Comments: comments,
}, ghrepo.New("OWNER", "REPO"), nil
}

View file

@ -17,6 +17,8 @@ import (
"github.com/spf13/cobra"
)
var ErrNoUserComments = errors.New("no comments found for current user")
type InputType int
const (
@ -32,19 +34,21 @@ type Commentable interface {
}
type CommentableOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
RetrieveCommentable func() (Commentable, ghrepo.Interface, 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
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
RetrieveCommentable func() (Commentable, ghrepo.Interface, error)
EditSurvey func(string) (string, error)
InteractiveEditSurvey func(string) (string, error)
ConfirmSubmitSurvey func() (bool, error)
ConfirmCreateIfNoneSurvey func() (bool, error)
OpenInBrowser func(string) error
Interactive bool
InputType InputType
Body string
EditLast bool
CreateIfNone bool
Quiet bool
Host string
}
func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
@ -66,6 +70,10 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
inputFlags++
}
if opts.CreateIfNone && !opts.EditLast {
return cmdutil.FlagErrorf("`--create-if-none` can only be used with `--edit-last`")
}
if inputFlags == 0 {
if !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("flags required when not running interactively")
@ -85,7 +93,24 @@ func CommentableRun(opts *CommentableOptions) error {
}
opts.Host = repo.RepoHost()
if opts.EditLast {
return updateComment(commentable, opts)
err := updateComment(commentable, opts)
if !errors.Is(err, ErrNoUserComments) {
return err
}
if opts.Interactive {
if opts.CreateIfNone {
fmt.Fprintln(opts.IO.ErrOut, "No comments found. Creating a new comment.")
} else {
cont, err := opts.ConfirmCreateIfNoneSurvey()
if err != nil {
return err
}
if !cont {
return ErrNoUserComments
}
}
}
}
return createComment(commentable, opts)
}
@ -144,7 +169,7 @@ func createComment(commentable Commentable, opts *CommentableOptions) error {
func updateComment(commentable Commentable, opts *CommentableOptions) error {
comments := commentable.CurrentUserComments()
if len(comments) == 0 {
return fmt.Errorf("no comments found for current user")
return ErrNoUserComments
}
lastComment := &comments[len(comments)-1]
@ -219,6 +244,12 @@ func CommentableInteractiveEditSurvey(cf func() (gh.Config, error), io *iostream
}
}
func CommentableInteractiveCreateIfNoneSurvey(p Prompt) func() (bool, error) {
return func() (bool, error) {
return p.Confirm("No comments found. Create one?", true)
}
}
func CommentableEditSurvey(cf func() (gh.Config, error), io *iostreams.IOStreams) func(string) (string, error) {
return func(initialValue string) (string, error) {
editorCommand, err := cmdutil.DetermineEditor(cf)