diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 28ca0d1e8..4b8b18c70 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -244,7 +244,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return nil, cobra.ShellCompDirectiveError } hostname, _ := cfg.Authentication().DefaultHost() - licenses, err := listLicenseTemplates(httpClient, hostname) + licenses, err := shared.ListLicenseTemplates(httpClient, hostname) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -830,7 +830,7 @@ func interactiveLicense(client *http.Client, hostname string, prompter iprompter return "", nil } - licenses, err := listLicenseTemplates(client, hostname) + licenses, err := shared.ListLicenseTemplates(client, hostname) if err != nil { return "", err } diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index 120683c08..ba99c3e4c 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -350,17 +350,6 @@ func listGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, return gitIgnoreTemplates, nil } -// listLicenseTemplates uses API v3 here because license template isn't supported by GraphQL yet. -func listLicenseTemplates(httpClient *http.Client, hostname string) ([]api.License, error) { - var licenseTemplates []api.License - client := api.NewClientFromHTTP(httpClient) - err := client.REST(hostname, "GET", "licenses", nil, &licenseTemplates) - if err != nil { - return nil, err - } - return licenseTemplates, nil -} - // Returns the current username and any orgs that user is a member of. func userAndOrgs(httpClient *http.Client, hostname string) (string, []string, error) { client := api.NewClientFromHTTP(httpClient) diff --git a/pkg/cmd/repo/license/license.go b/pkg/cmd/repo/license/license.go new file mode 100644 index 000000000..84896f2cd --- /dev/null +++ b/pkg/cmd/repo/license/license.go @@ -0,0 +1,18 @@ +package license + +import ( + cmdList "github.com/cli/cli/v2/pkg/cmd/repo/license/list" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdLicense(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "license ", + Short: "View available repository license options", + } + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + + return cmd +} diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go new file mode 100644 index 000000000..48f8a74d9 --- /dev/null +++ b/pkg/cmd/repo/license/list/list.go @@ -0,0 +1,96 @@ +package list + +import ( + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/repo/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type LicenseRenderer interface { + Render([]api.License, ListOptions) error +} + +type TableLicenseRenderer struct{} + +var licenseFields = []string{ + "key", + "name", +} + +type ListOptions struct { + IO *iostreams.IOStreams + HTTPClient func() (*http.Client, error) + Exporter cmdutil.Exporter + Config func() (gh.Config, error) +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HTTPClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List available repository license options", + Aliases: []string{"ls"}, + Args: cmdutil.ExactArgs(0, "gh repo license list takes no arguments"), + RunE: func(cmd *cobra.Command, args []string) error { + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + // cmdutil.AddFormatFlags(cmd, &opts.Exporter) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, licenseFields) + return cmd +} + +func listRun(opts *ListOptions) error { + client, err := opts.HTTPClient() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + hostname, _ := cfg.Authentication().DefaultHost() + licenses, err := shared.ListLicenseTemplates(client, hostname) + if err != nil { + return err + } + + if len(licenses) == 0 { + return cmdutil.NewNoResultsError("no licenses found") + } + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, licenses) + } + + r := &TableLicenseRenderer{} + return r.Render(licenses, opts) +} + +func (r *TableLicenseRenderer) Render(licenses []api.License, opts *ListOptions) error { + t := tableprinter.New(opts.IO, tableprinter.WithHeader("KEY", "NAME")) + for _, l := range licenses { + t.AddField(l.Key) + t.AddField(l.Name) + t.EndRow() + } + + return t.Render() +} diff --git a/pkg/cmd/repo/license/list/list_test.go b/pkg/cmd/repo/license/list/list_test.go new file mode 100644 index 000000000..c84e3fff8 --- /dev/null +++ b/pkg/cmd/repo/license/list/list_test.go @@ -0,0 +1,259 @@ +package list + +import ( + "bytes" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + tty bool + }{ + { + name: "no arguments", + args: []string{}, + wantErr: false, + tty: false, + }, + { + name: "too many arguments", + args: []string{"foo", "bar"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: ios, + } + cmd := NewCmdList(f, func(*ListOptions) error { + return nil + }) + cmd.SetArgs(tt.args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestTableLicenseRenderer(t *testing.T) { + tests := []struct { + name string + opts ListOptions + isTTY bool + wantStdout string + wantErr bool + licenses []api.License + }{ + { + name: "licenses + tty", + opts: ListOptions{}, + isTTY: true, + wantStdout: heredoc.Doc(` + KEY NAME + mit MIT License + lgpl-3.0 GNU Lesser General Public License v3.0 + `), + wantErr: false, + licenses: []api.License{ + { + Key: "mit", + Name: "MIT License", + }, + { + Key: "lgpl-3.0", + Name: "GNU Lesser General Public License v3.0", + }, + }, + }, + { + name: "no licenses + tty", + opts: ListOptions{}, + isTTY: true, + wantStdout: heredoc.Doc(` + KEY NAME + `), + wantErr: false, + licenses: []api.License{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + tt.opts.IO = ios + + r := &TableLicenseRenderer{} + err := r.Render(tt.licenses, &tt.opts) + + if !tt.wantErr { + assert.NoError(t, err, "Expected no error while rendering table") + } + + assert.Equal(t, tt.wantStdout, stdout.String(), "Rendered table differs from expected") + + }) + } +} + +func TestListRun(t *testing.T) { + tests := []struct { + name string + opts *ListOptions + isTTY bool + httpStubs func(t *testing.T, reg *httpmock.Registry) + wantStdout string + wantStderr string + wantErr bool + errMsg string + }{ + { + name: "license list tty", + isTTY: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "licenses"), + httpmock.StringResponse(`[ + { + "key": "mit", + "name": "MIT License", + "spdx_id": "MIT", + "url": "https://api.github.com/licenses/mit", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "lgpl-3.0", + "name": "GNU Lesser General Public License v3.0", + "spdx_id": "LGPL-3.0", + "url": "https://api.github.com/licenses/lgpl-3.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "mpl-2.0", + "name": "Mozilla Public License 2.0", + "spdx_id": "MPL-2.0", + "url": "https://api.github.com/licenses/mpl-2.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "agpl-3.0", + "name": "GNU Affero General Public License v3.0", + "spdx_id": "AGPL-3.0", + "url": "https://api.github.com/licenses/agpl-3.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "unlicense", + "name": "The Unlicense", + "spdx_id": "Unlicense", + "url": "https://api.github.com/licenses/unlicense", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "gpl-3.0", + "name": "GNU General Public License v3.0", + "spdx_id": "GPL-3.0", + "url": "https://api.github.com/licenses/gpl-3.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + } + ]`, + )) + }, + wantStdout: heredoc.Doc(` + KEY NAME + mit MIT License + lgpl-3.0 GNU Lesser General Public License v3.0 + mpl-2.0 Mozilla Public License 2.0 + agpl-3.0 GNU Affero General Public License v3.0 + unlicense The Unlicense + apache-2.0 Apache License 2.0 + gpl-3.0 GNU General Public License v3.0 + `), + wantStderr: "", + opts: &ListOptions{}, + }, + { + name: "license list no licenses tty", + isTTY: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "licenses"), + httpmock.StringResponse(`[]`), + ) + }, + wantStdout: "", + wantStderr: "", + wantErr: true, + errMsg: "no licenses found", + opts: &ListOptions{}, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + tt.opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } + tt.opts.HTTPClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + tt.opts.IO = ios + + t.Run(tt.name, func(t *testing.T) { + defer reg.Verify(t) + err := listRun(tt.opts) + + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.errMsg, err.Error()) + return + } + + assert.Equal(t, tt.wantStdout, stdout.String(), "Stdout differs from expected") + assert.Equal(t, tt.wantStderr, stderr.String(), "Stderr differs from expected") + }) + } +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index baaa0e10f..611ef49ea 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -11,6 +11,7 @@ import ( repoEditCmd "github.com/cli/cli/v2/pkg/cmd/repo/edit" repoForkCmd "github.com/cli/cli/v2/pkg/cmd/repo/fork" gardenCmd "github.com/cli/cli/v2/pkg/cmd/repo/garden" + licenseCmd "github.com/cli/cli/v2/pkg/cmd/repo/license" repoListCmd "github.com/cli/cli/v2/pkg/cmd/repo/list" repoRenameCmd "github.com/cli/cli/v2/pkg/cmd/repo/rename" repoDefaultCmd "github.com/cli/cli/v2/pkg/cmd/repo/setdefault" @@ -54,6 +55,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { repoSyncCmd.NewCmdSync(f, nil), repoEditCmd.NewCmdEdit(f, nil), deployKeyCmd.NewCmdDeployKey(f), + licenseCmd.NewCmdLicense(f), repoRenameCmd.NewCmdRename(f, nil), repoArchiveCmd.NewCmdArchive(f, nil), repoUnarchiveCmd.NewCmdUnarchive(f, nil), diff --git a/pkg/cmd/repo/shared/repo.go b/pkg/cmd/repo/shared/repo.go index b980098b8..b2633573e 100644 --- a/pkg/cmd/repo/shared/repo.go +++ b/pkg/cmd/repo/shared/repo.go @@ -1,8 +1,11 @@ package shared import ( + "net/http" "regexp" "strings" + + "github.com/cli/cli/v2/api" ) var invalidCharactersRE = regexp.MustCompile(`[^\w._-]+`) @@ -12,3 +15,14 @@ func NormalizeRepoName(repoName string) string { newName := invalidCharactersRE.ReplaceAllString(repoName, "-") return strings.TrimSuffix(newName, ".git") } + +// ListLicenseTemplates uses API v3 here because license template isn't supported by GraphQL yet. +func ListLicenseTemplates(httpClient *http.Client, hostname string) ([]api.License, error) { + var licenseTemplates []api.License + client := api.NewClientFromHTTP(httpClient) + err := client.REST(hostname, "GET", "licenses", nil, &licenseTemplates) + if err != nil { + return nil, err + } + return licenseTemplates, nil +}