Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
137 changes: 137 additions & 0 deletions pkg/cmd/issue/argparsetest/argparsetest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package argparsetest

import (
"bytes"
"reflect"
"testing"

"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// newCmdFunc represents the typical function signature we use for creating commands e.g. `NewCmdView`.
//
// It is generic over `T` as each command construction has their own Options type e.g. `ViewOptions`
type newCmdFunc[T any] func(f *cmdutil.Factory, runF func(*T) error) *cobra.Command

// TestArgParsing is a test helper that verifies that issue commands correctly parse the `{issue number | url}`
// positional arg into an issue number and base repo.
//
// Looking through the existing tests, I noticed that the coverage was pretty smattered.
// Since nearly all issue commands only accept a single positional argument, we are able to reuse this test helper.
// Commands with no further flags or args can use this solely.
// Commands with extra flags use this and further table tests.
// Commands with extra required positional arguments (like `transfer`) cannot use this. They duplicate these cases inline.
func TestArgParsing[T any](t *testing.T, fn newCmdFunc[T]) {
tests := []struct {
name string
input string
expectedissueNumber int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/issue/Issue?

expectedBaseRepo ghrepo.Interface
expectErr bool
}{
{
name: "no argument",
input: "",
expectErr: true,
},
{
name: "issue number argument",
input: "23 --repo owner/repo",
expectedissueNumber: 23,
expectedBaseRepo: ghrepo.New("owner", "repo"),
},
{
name: "argument is hash prefixed number",
// Escaping is required here to avoid what I think is shellex treating it as a comment.
input: "\\#23 --repo owner/repo",
expectedissueNumber: 23,
expectedBaseRepo: ghrepo.New("owner", "repo"),
},
{
name: "argument is a URL",
input: "https://github.com/cli/cli/issues/23",
expectedissueNumber: 23,
expectedBaseRepo: ghrepo.New("cli", "cli"),
},
{
name: "argument cannot be parsed to an issue",
input: "unparseable",
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{}

argv, err := shlex.Split(tt.input)
assert.NoError(t, err)

var gotOpts T
cmd := fn(f, func(opts *T) error {
gotOpts = *opts
return nil
})

cmdutil.EnableRepoOverride(cmd, f)

// TODO: remember why we do this
cmd.Flags().BoolP("help", "x", false, "")
Comment on lines +82 to +83
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather see an explanation like: "this is to avoid collision with -h usage for anything other than --help; like -h for hostname". Also, what if the command under test had an x flag/option? Can't we use a safer shorthand instead of x, like _ or ?? I haven't tested these chars with Cobra but as I checked its code, seems like there's no restriction on the chars.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbh I just didn't remember why we were doing this, but I think you've remembered for me. I think we should probably extract something shared like avoidHelpCollision(cmd).


cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})

_, err = cmd.ExecuteC()

if tt.expectErr {
require.Error(t, err)
return
} else {
require.NoError(t, err)
}

actualIssueNumber := issueNumberFromOpts(t, gotOpts)
assert.Equal(t, tt.expectedissueNumber, actualIssueNumber)

actualBaseRepo := baseRepoFromOpts(t, gotOpts)
assert.True(
t,
ghrepo.IsSame(tt.expectedBaseRepo, actualBaseRepo),
"expected base repo %+v, got %+v", tt.expectedBaseRepo, actualBaseRepo,
)
})
}
}

func issueNumberFromOpts(t *testing.T, v any) int {
rv := reflect.ValueOf(v)
field := rv.FieldByName("IssueNumber")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this probably is the best way of doing this, so I'm just thinking out loud and these are just half-baked and over-engineered solutions. Feel free to ignore with no reply.

I'm thinking of two possible solutions to avoid binding to field names

1. Field tags

We can loop through the fields and pick the one with the right tag. We're still binding to a string literal, but the field name is free of any restriction.

type FooOption struct {
    BaseRepo    func() (ghrepo.Interface, error) `ghopt:"baseRepo"`
    IssueNumber int                              `ghopt:"issueNumber"`
}

2. Custom funcs

We can to add two func parameters to TestArgParsing to be responsible for extracting issue number and base repo:

  • extractIssueNumber: func(opt T) int
  • extractBaseRepo: func(opt T) ghrepo.Interface

If the passed argument is nil, we can use the current PR's implementation as a fallback. This way TestArgParsing is flexible enough for various situations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like 1 at all because it makes changes to production code to satisfy the test.

Re: 2, I don't really want commands to deviate from the pattern without good reason, so having configurability isn't an advantage to me.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note because Go generics are not very powerful, we can't constrain T to struct { IssueNumber int }, so there's no way to get type safety through this. Reflection is the only way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. Unless we introduce a getter function that we can abstract behind an interface (like IssueNumberRequired, or something).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally, and I don't want to do that either for the same reason as 1.

if !field.IsValid() || field.Kind() != reflect.Int {
t.Fatalf("Type %T does not have IssueNumber int field", v)
}
return int(field.Int())
}

func baseRepoFromOpts(t *testing.T, v any) ghrepo.Interface {
rv := reflect.ValueOf(v)
field := rv.FieldByName("BaseRepo")
// check whether the field is valid and of type func() (ghrepo.Interface, error)
if !field.IsValid() || field.Kind() != reflect.Func {
t.Fatalf("Type %T does not have BaseRepo func field", v)
}
// call the function and check the return value
results := field.Call([]reflect.Value{})
if len(results) != 2 {
t.Fatalf("%T.BaseRepo() does not return two values", v)
}
if !results[1].IsNil() {
t.Fatalf("%T.BaseRepo() returned an error: %v", v, results[1].Interface())
}
return results[0].Interface().(ghrepo.Interface)
}
27 changes: 21 additions & 6 deletions pkg/cmd/issue/close/close.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type CloseOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)

SelectorArg string
IssueNumber int
Comment string
Reason string

Expand All @@ -39,13 +39,23 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
Short: "Close issue",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
if err != nil {
return err
}

if len(args) > 0 {
opts.SelectorArg = args[0]
// If the args provided the base repo then use that directly.
if baseRepo, present := baseRepo.Value(); present {
opts.BaseRepo = func() (ghrepo.Interface, error) {
return baseRepo, nil
}
} else {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
}

opts.IssueNumber = issueNumber

if runF != nil {
return runF(opts)
}
Expand All @@ -67,7 +77,12 @@ func closeRun(opts *CloseOptions) error {
return err
}

issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"})
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}

issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "state"})
if err != nil {
return err
}
Expand Down
67 changes: 31 additions & 36 deletions pkg/cmd/issue/close/close_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,54 +7,40 @@ import (

fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewCmdClose(t *testing.T) {
// Test shared parsing of issue number / URL.
argparsetest.TestArgParsing(t, NewCmdClose)

tests := []struct {
name string
input string
output CloseOptions
wantErr bool
errMsg string
name string
input string
output CloseOptions
expectedBaseRepo ghrepo.Interface
wantErr bool
errMsg string
}{
{
name: "no argument",
input: "",
wantErr: true,
errMsg: "accepts 1 arg(s), received 0",
},
{
name: "issue number",
input: "123",
output: CloseOptions{
SelectorArg: "123",
},
},
{
name: "issue url",
input: "https://github.com/cli/cli/3",
output: CloseOptions{
SelectorArg: "https://github.com/cli/cli/3",
},
},
{
name: "comment",
input: "123 --comment 'closing comment'",
output: CloseOptions{
SelectorArg: "123",
IssueNumber: 123,
Comment: "closing comment",
},
},
{
name: "reason",
input: "123 --reason 'not planned'",
output: CloseOptions{
SelectorArg: "123",
IssueNumber: 123,
Reason: "not planned",
},
},
Expand All @@ -79,15 +65,24 @@ func TestNewCmdClose(t *testing.T) {

_, err = cmd.ExecuteC()
if tt.wantErr {
assert.Error(t, err)
require.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
return
}

assert.NoError(t, err)
assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
require.NoError(t, err)
assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber)
assert.Equal(t, tt.output.Comment, gotOpts.Comment)
assert.Equal(t, tt.output.Reason, gotOpts.Reason)
if tt.expectedBaseRepo != nil {
baseRepo, err := gotOpts.BaseRepo()
require.NoError(t, err)
require.True(
t,
ghrepo.IsSame(tt.expectedBaseRepo, baseRepo),
"expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo,
)
}
})
}
}
Expand All @@ -104,7 +99,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue by number",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
Expand All @@ -128,7 +123,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue with comment",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
Comment: "closing comment",
},
httpStubs: func(reg *httpmock.Registry) {
Expand Down Expand Up @@ -164,7 +159,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue with reason",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
Reason: "not planned",
Detector: &fd.EnabledDetectorMock{},
},
Expand Down Expand Up @@ -192,7 +187,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue with reason when reason is not supported",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
Reason: "not planned",
Detector: &fd.DisabledDetectorMock{},
},
Expand All @@ -219,7 +214,7 @@ func TestCloseRun(t *testing.T) {
{
name: "issue already closed",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
Expand All @@ -236,7 +231,7 @@ func TestCloseRun(t *testing.T) {
{
name: "issues disabled",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
Expand Down
29 changes: 28 additions & 1 deletion pkg/cmd/issue/comment/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package comment
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/issue/shared"
issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
Expand Down Expand Up @@ -37,15 +38,41 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
opts.RetrieveCommentable = func() (prShared.Commentable, ghrepo.Interface, error) {
// TODO wm: more testing
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is totally untested. Not sure how much I want to tackle this for the extra confidence. TBD.

issueNumber, parsedBaseRepo, err := shared.ParseIssueFromArg(args[0])
if err != nil {
return nil, nil, err
}

// If the args provided the base repo then use that directly.
var baseRepo ghrepo.Interface

if parsedBaseRepo, present := parsedBaseRepo.Value(); present {
baseRepo = parsedBaseRepo
} else {
// support `-R, --repo` override
baseRepo, err = f.BaseRepo()
if err != nil {
return nil, nil, err
}
}

httpClient, err := f.HttpClient()
if err != nil {
return nil, nil, err
}

fields := []string{"id", "url"}
if opts.EditLast {
fields = append(fields, "comments")
}
return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields)

issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, issueNumber, fields)
if err != nil {
return nil, nil, err
}

return issue, baseRepo, nil
}
return prShared.CommentablePreRun(cmd, opts)
},
Expand Down
Loading
Loading