From fd8c4633e334f7bb5788fbb73fa9e0c02ebad7c9 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:30:02 -0600 Subject: [PATCH 001/134] First pass at implementing `gh repo license list` --- pkg/cmd/repo/create/create.go | 4 +- pkg/cmd/repo/create/http.go | 11 -- pkg/cmd/repo/license/license.go | 18 ++ pkg/cmd/repo/license/list/list.go | 96 +++++++++ pkg/cmd/repo/license/list/list_test.go | 259 +++++++++++++++++++++++++ pkg/cmd/repo/repo.go | 2 + pkg/cmd/repo/shared/repo.go | 14 ++ 7 files changed, 391 insertions(+), 13 deletions(-) create mode 100644 pkg/cmd/repo/license/license.go create mode 100644 pkg/cmd/repo/license/list/list.go create mode 100644 pkg/cmd/repo/license/list/list_test.go 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 +} From 21f0d9466ecb23b3e76585f8b9e93452d35ff296 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 10:40:22 -0600 Subject: [PATCH 002/134] Divide shared repo package and add queries tests --- pkg/cmd/repo/create/create.go | 15 +- pkg/cmd/repo/fork/fork.go | 4 +- pkg/cmd/repo/license/list/list.go | 4 +- pkg/cmd/repo/shared/format/format.go | 14 ++ .../{repo_test.go => format/format_test.go} | 2 +- pkg/cmd/repo/shared/queries/queries.go | 19 +++ pkg/cmd/repo/shared/queries/queries_test.go | 136 ++++++++++++++++++ pkg/cmd/repo/shared/repo.go | 28 ---- 8 files changed, 182 insertions(+), 40 deletions(-) create mode 100644 pkg/cmd/repo/shared/format/format.go rename pkg/cmd/repo/shared/{repo_test.go => format/format_test.go} (98%) create mode 100644 pkg/cmd/repo/shared/queries/queries.go create mode 100644 pkg/cmd/repo/shared/queries/queries_test.go delete mode 100644 pkg/cmd/repo/shared/repo.go diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 4b8b18c70..a1ff22863 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -19,7 +19,8 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" + "github.com/cli/cli/v2/pkg/cmd/repo/shared/format" + "github.com/cli/cli/v2/pkg/cmd/repo/shared/queries" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -244,7 +245,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return nil, cobra.ShellCompDirectiveError } hostname, _ := cfg.Authentication().DefaultHost() - licenses, err := shared.ListLicenseTemplates(httpClient, hostname) + licenses, err := queries.ListLicenseTemplates(httpClient, hostname) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -317,9 +318,9 @@ func createFromScratch(opts *CreateOptions) error { return err } - targetRepo := shared.NormalizeRepoName(opts.Name) + targetRepo := format.NormalizeRepoName(opts.Name) if idx := strings.IndexRune(opts.Name, '/'); idx > 0 { - targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:]) + targetRepo = opts.Name[0:idx+1] + format.NormalizeRepoName(opts.Name[idx+1:]) } confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true) if err != nil { @@ -476,9 +477,9 @@ func createFromTemplate(opts *CreateOptions) error { } templateRepoMainBranch := templateRepo.DefaultBranchRef.Name - targetRepo := shared.NormalizeRepoName(opts.Name) + targetRepo := format.NormalizeRepoName(opts.Name) if idx := strings.IndexRune(opts.Name, '/'); idx > 0 { - targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:]) + targetRepo = opts.Name[0:idx+1] + format.NormalizeRepoName(opts.Name[idx+1:]) } confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true) if err != nil { @@ -830,7 +831,7 @@ func interactiveLicense(client *http.Client, hostname string, prompter iprompter return "", nil } - licenses, err := shared.ListLicenseTemplates(client, hostname) + licenses, err := queries.ListLicenseTemplates(client, hostname) if err != nil { return "", err } diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index a49f5d567..274af3083 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -16,7 +16,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" + "github.com/cli/cli/v2/pkg/cmd/repo/shared/format" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -225,7 +225,7 @@ func forkRun(opts *ForkOptions) error { } // Rename the new repo if necessary - if opts.ForkName != "" && !strings.EqualFold(forkedRepo.RepoName(), shared.NormalizeRepoName(opts.ForkName)) { + if opts.ForkName != "" && !strings.EqualFold(forkedRepo.RepoName(), format.NormalizeRepoName(opts.ForkName)) { forkedRepo, err = api.RenameRepo(apiClient, forkedRepo, opts.ForkName) if err != nil { return fmt.Errorf("could not rename fork: %w", err) diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index 48f8a74d9..4e52a5c6c 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -6,7 +6,7 @@ import ( "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/cmd/repo/shared/queries" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -67,7 +67,7 @@ func listRun(opts *ListOptions) error { } hostname, _ := cfg.Authentication().DefaultHost() - licenses, err := shared.ListLicenseTemplates(client, hostname) + licenses, err := queries.ListLicenseTemplates(client, hostname) if err != nil { return err } diff --git a/pkg/cmd/repo/shared/format/format.go b/pkg/cmd/repo/shared/format/format.go new file mode 100644 index 000000000..5d29201ec --- /dev/null +++ b/pkg/cmd/repo/shared/format/format.go @@ -0,0 +1,14 @@ +package format + +import ( + "regexp" + "strings" +) + +var invalidCharactersRE = regexp.MustCompile(`[^\w._-]+`) + +// NormalizeRepoName takes in the repo name the user inputted and normalizes it using the same logic as GitHub (GitHub.com/new) +func NormalizeRepoName(repoName string) string { + newName := invalidCharactersRE.ReplaceAllString(repoName, "-") + return strings.TrimSuffix(newName, ".git") +} diff --git a/pkg/cmd/repo/shared/repo_test.go b/pkg/cmd/repo/shared/format/format_test.go similarity index 98% rename from pkg/cmd/repo/shared/repo_test.go rename to pkg/cmd/repo/shared/format/format_test.go index 2e9251840..6a781ac02 100644 --- a/pkg/cmd/repo/shared/repo_test.go +++ b/pkg/cmd/repo/shared/format/format_test.go @@ -1,4 +1,4 @@ -package shared +package format import ( "testing" diff --git a/pkg/cmd/repo/shared/queries/queries.go b/pkg/cmd/repo/shared/queries/queries.go new file mode 100644 index 000000000..27616bc00 --- /dev/null +++ b/pkg/cmd/repo/shared/queries/queries.go @@ -0,0 +1,19 @@ +package queries + +import ( + "net/http" + + "github.com/cli/cli/v2/api" +) + +// ListLicenseTemplates fetches available repository templates. +// It uses API v3 because license template isn't supported by GraphQL. +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 +} diff --git a/pkg/cmd/repo/shared/queries/queries_test.go b/pkg/cmd/repo/shared/queries/queries_test.go new file mode 100644 index 000000000..7babbb264 --- /dev/null +++ b/pkg/cmd/repo/shared/queries/queries_test.go @@ -0,0 +1,136 @@ +package queries + +import ( + "net/http" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestListLicenseTemplates(t *testing.T) { + tests := []struct { + name string + httpStubs func(t *testing.T, reg *httpmock.Registry) + hostname string + wantLicenses []api.License + wantErr bool + wantErrMsg string + httpClient func() (*http.Client, error) + }{ + { + name: "happy path", + 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==" + } + ]`, + )) + }, + hostname: "api.github.com", + wantLicenses: []api.License{ + { + Key: "mit", + Name: "MIT License", + }, + { + Key: "lgpl-3.0", + Name: "GNU Lesser General Public License v3.0", + }, + { + Key: "mpl-2.0", + Name: "Mozilla Public License 2.0", + }, + { + Key: "agpl-3.0", + Name: "GNU Affero General Public License v3.0", + }, + { + Key: "unlicense", + Name: "The Unlicense", + }, + { + Key: "apache-2.0", + Name: "Apache License 2.0", + }, + { + Key: "gpl-3.0", + Name: "GNU General Public License v3.0", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + tt.httpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + client, _ := tt.httpClient() + t.Run(tt.name, func(t *testing.T) { + defer reg.Verify(t) + gotLicenses, err := ListLicenseTemplates(client, tt.hostname) + if !tt.wantErr { + assert.NoError(t, err, "Expected no error while fetching /licenses") + } + if tt.wantErr { + assert.Error(t, err, "Expected error while fetching /licenses") + } + assert.Equal(t, tt.wantLicenses, gotLicenses, "Licenses fetched is not as expected") + }) + } +} diff --git a/pkg/cmd/repo/shared/repo.go b/pkg/cmd/repo/shared/repo.go deleted file mode 100644 index b2633573e..000000000 --- a/pkg/cmd/repo/shared/repo.go +++ /dev/null @@ -1,28 +0,0 @@ -package shared - -import ( - "net/http" - "regexp" - "strings" - - "github.com/cli/cli/v2/api" -) - -var invalidCharactersRE = regexp.MustCompile(`[^\w._-]+`) - -// NormalizeRepoName takes in the repo name the user inputted and normalizes it using the same logic as GitHub (GitHub.com/new) -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 -} From 1aa2a824bac6b549ab6e07aff3ae87ec1cd686aa Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 10:42:54 -0600 Subject: [PATCH 003/134] Remove json output option Because the API query uses REST, the JSON exporter doesn't work as expected. --- pkg/cmd/repo/license/list/list.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index 4e52a5c6c..8d369c4ee 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -18,15 +18,9 @@ type LicenseRenderer interface { 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) } @@ -50,8 +44,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return listRun(opts) }, } - // cmdutil.AddFormatFlags(cmd, &opts.Exporter) - cmdutil.AddJSONFlags(cmd, &opts.Exporter, licenseFields) return cmd } @@ -76,10 +68,6 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no licenses found") } - if opts.Exporter != nil { - return opts.Exporter.Write(opts.IO, licenses) - } - r := &TableLicenseRenderer{} return r.Render(licenses, opts) } From 2f608e3772118d14c926a8778dd5acc1fc2b358f Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 11:46:37 -0600 Subject: [PATCH 004/134] Cleanup rendering and tests --- pkg/cmd/repo/license/list/list.go | 11 +---- pkg/cmd/repo/license/list/list_test.go | 67 +------------------------- 2 files changed, 4 insertions(+), 74 deletions(-) diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index 8d369c4ee..7b931811f 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -12,12 +12,6 @@ import ( "github.com/spf13/cobra" ) -type LicenseRenderer interface { - Render([]api.License, ListOptions) error -} - -type TableLicenseRenderer struct{} - type ListOptions struct { IO *iostreams.IOStreams HTTPClient func() (*http.Client, error) @@ -68,11 +62,10 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no licenses found") } - r := &TableLicenseRenderer{} - return r.Render(licenses, opts) + return renderLicenseTemplatesTable(licenses, opts) } -func (r *TableLicenseRenderer) Render(licenses []api.License, opts *ListOptions) error { +func renderLicenseTemplatesTable(licenses []api.License, opts *ListOptions) error { t := tableprinter.New(opts.IO, tableprinter.WithHeader("KEY", "NAME")) for _, l := range licenses { t.AddField(l.Key) diff --git a/pkg/cmd/repo/license/list/list_test.go b/pkg/cmd/repo/license/list/list_test.go index c84e3fff8..43d47c2df 100644 --- a/pkg/cmd/repo/license/list/list_test.go +++ b/pkg/cmd/repo/license/list/list_test.go @@ -6,7 +6,6 @@ import ( "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" @@ -24,14 +23,14 @@ func TestNewCmdList(t *testing.T) { tty bool }{ { - name: "no arguments", + name: "happy path no arguments", args: []string{}, wantErr: false, tty: false, }, { name: "too many arguments", - args: []string{"foo", "bar"}, + args: []string{"foo"}, wantErr: true, }, } @@ -63,68 +62,6 @@ func TestNewCmdList(t *testing.T) { } } -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 From ac779ba82a43df8ba601e7222417b84f675dc69b Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 11:48:48 -0600 Subject: [PATCH 005/134] fix output capitalization --- pkg/cmd/repo/license/list/list.go | 2 +- pkg/cmd/repo/license/list/list_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index 7b931811f..573bd4e29 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -59,7 +59,7 @@ func listRun(opts *ListOptions) error { } if len(licenses) == 0 { - return cmdutil.NewNoResultsError("no licenses found") + return cmdutil.NewNoResultsError("No licenses found") } return renderLicenseTemplatesTable(licenses, opts) diff --git a/pkg/cmd/repo/license/list/list_test.go b/pkg/cmd/repo/license/list/list_test.go index 43d47c2df..a740b0f83 100644 --- a/pkg/cmd/repo/license/list/list_test.go +++ b/pkg/cmd/repo/license/list/list_test.go @@ -157,7 +157,7 @@ func TestListRun(t *testing.T) { wantStdout: "", wantStderr: "", wantErr: true, - errMsg: "no licenses found", + errMsg: "No licenses found", opts: &ListOptions{}, }, } From 2b4464a3af1dc9d15fe4520d1cec89e5b70586b6 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 11:49:09 -0600 Subject: [PATCH 006/134] Fix ListLicenseTemplates doc --- pkg/cmd/repo/shared/queries/queries.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/repo/shared/queries/queries.go b/pkg/cmd/repo/shared/queries/queries.go index 27616bc00..fdb238fb4 100644 --- a/pkg/cmd/repo/shared/queries/queries.go +++ b/pkg/cmd/repo/shared/queries/queries.go @@ -6,7 +6,7 @@ import ( "github.com/cli/cli/v2/api" ) -// ListLicenseTemplates fetches available repository templates. +// ListLicenseTemplates fetches available repository license templates. // It uses API v3 because license template isn't supported by GraphQL. func ListLicenseTemplates(httpClient *http.Client, hostname string) ([]api.License, error) { var licenseTemplates []api.License From 25cdce2cecf7aef00f1d0e768d68b4aff1120094 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 11:59:15 -0600 Subject: [PATCH 007/134] Update license table headings and tests --- pkg/cmd/repo/license/list/list.go | 2 +- pkg/cmd/repo/license/list/list_test.go | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index 573bd4e29..b1d2b3578 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -66,7 +66,7 @@ func listRun(opts *ListOptions) error { } func renderLicenseTemplatesTable(licenses []api.License, opts *ListOptions) error { - t := tableprinter.New(opts.IO, tableprinter.WithHeader("KEY", "NAME")) + t := tableprinter.New(opts.IO, tableprinter.WithHeader("LICENSE KEY", "LICENSE NAME")) for _, l := range licenses { t.AddField(l.Key) t.AddField(l.Name) diff --git a/pkg/cmd/repo/license/list/list_test.go b/pkg/cmd/repo/license/list/list_test.go index a740b0f83..711a7e728 100644 --- a/pkg/cmd/repo/license/list/list_test.go +++ b/pkg/cmd/repo/license/list/list_test.go @@ -133,14 +133,14 @@ func TestListRun(t *testing.T) { )) }, 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 + LICENSE KEY LICENSE 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{}, @@ -189,8 +189,8 @@ func TestListRun(t *testing.T) { return } - assert.Equal(t, tt.wantStdout, stdout.String(), "Stdout differs from expected") - assert.Equal(t, tt.wantStderr, stderr.String(), "Stderr differs from expected") + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) }) } } From 038b57c866ba0f0599df7317d54a2ef4b0e171c3 Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 12:29:56 -0600 Subject: [PATCH 008/134] implement gitignore list --- pkg/cmd/repo/create/create.go | 4 +- pkg/cmd/repo/create/http.go | 11 -- pkg/cmd/repo/gitignore/gitignore.go | 18 ++ pkg/cmd/repo/gitignore/list/list.go | 75 ++++++++ pkg/cmd/repo/gitignore/list/list_test.go | 190 ++++++++++++++++++++ pkg/cmd/repo/license/license.go | 2 +- pkg/cmd/repo/license/list/list.go | 14 +- pkg/cmd/repo/license/list/list_test.go | 4 +- pkg/cmd/repo/repo.go | 2 + pkg/cmd/repo/shared/queries/queries.go | 12 ++ pkg/cmd/repo/shared/queries/queries_test.go | 97 ++++++++++ 11 files changed, 406 insertions(+), 23 deletions(-) create mode 100644 pkg/cmd/repo/gitignore/gitignore.go create mode 100644 pkg/cmd/repo/gitignore/list/list.go create mode 100644 pkg/cmd/repo/gitignore/list/list_test.go diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index a1ff22863..760757928 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -228,7 +228,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return nil, cobra.ShellCompDirectiveError } hostname, _ := cfg.Authentication().DefaultHost() - results, err := listGitIgnoreTemplates(httpClient, hostname) + results, err := queries.ListGitIgnoreTemplates(httpClient, hostname) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -812,7 +812,7 @@ func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompt return "", nil } - templates, err := listGitIgnoreTemplates(client, hostname) + templates, err := queries.ListGitIgnoreTemplates(client, hostname) if err != nil { return "", err } diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index ba99c3e4c..725fc48c5 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -339,17 +339,6 @@ func listTemplateRepositories(client *http.Client, hostname, owner string) ([]ap return templateRepositories, nil } -// listGitIgnoreTemplates uses API v3 here because gitignore template isn't supported by GraphQL yet. -func listGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) { - var gitIgnoreTemplates []string - client := api.NewClientFromHTTP(httpClient) - err := client.REST(hostname, "GET", "gitignore/templates", nil, &gitIgnoreTemplates) - if err != nil { - return []string{}, err - } - return gitIgnoreTemplates, 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/gitignore/gitignore.go b/pkg/cmd/repo/gitignore/gitignore.go new file mode 100644 index 000000000..260b506c5 --- /dev/null +++ b/pkg/cmd/repo/gitignore/gitignore.go @@ -0,0 +1,18 @@ +package gitignore + +import ( + cmdList "github.com/cli/cli/v2/pkg/cmd/repo/gitignore/list" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdGitIgnore(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "gitignore ", + Short: "View available repository .gitignore template", + } + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + + return cmd +} diff --git a/pkg/cmd/repo/gitignore/list/list.go b/pkg/cmd/repo/gitignore/list/list.go new file mode 100644 index 000000000..7de608566 --- /dev/null +++ b/pkg/cmd/repo/gitignore/list/list.go @@ -0,0 +1,75 @@ +package list + +import ( + "net/http" + + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/repo/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type ListOptions struct { + IO *iostreams.IOStreams + HTTPClient func() (*http.Client, error) + 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 .gitignore templates", + Aliases: []string{"ls"}, + Args: cmdutil.ExactArgs(0, "gh repo gitignore list takes no arguments"), + RunE: func(cmd *cobra.Command, args []string) error { + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + 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() + gitIgnoreTemplates, err := queries.ListGitIgnoreTemplates(client, hostname) + if err != nil { + return err + } + + if len(gitIgnoreTemplates) == 0 { + return cmdutil.NewNoResultsError("No .gitignore templates found") + } + + return renderGitIgnoreTemplatesTable(gitIgnoreTemplates, opts) +} + +func renderGitIgnoreTemplatesTable(gitIgnoreTemplates []string, opts *ListOptions) error { + t := tableprinter.New(opts.IO, tableprinter.WithHeader("GITIGNORE")) + for _, gt := range gitIgnoreTemplates { + t.AddField(gt) + t.EndRow() + } + + return t.Render() +} diff --git a/pkg/cmd/repo/gitignore/list/list_test.go b/pkg/cmd/repo/gitignore/list/list_test.go new file mode 100644 index 000000000..d7a0ef9e2 --- /dev/null +++ b/pkg/cmd/repo/gitignore/list/list_test.go @@ -0,0 +1,190 @@ +package list + +import ( + "bytes" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "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: "happy path no arguments", + args: []string{}, + wantErr: false, + tty: false, + }, + { + name: "too many arguments", + args: []string{"foo"}, + 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 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: "gitignore list tty", + isTTY: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "gitignore/templates"), + httpmock.StringResponse(`[ + "AL", + "Actionscript", + "Ada", + "Agda", + "Android", + "AppEngine", + "AppceleratorTitanium", + "ArchLinuxPackages", + "Autotools", + "Ballerina", + "C", + "C++", + "CFWheels", + "CMake", + "CUDA", + "CakePHP", + "ChefCookbook", + "Clojure", + "CodeIgniter", + "CommonLisp", + "Composer", + "Concrete5", + "Coq", + "CraftCMS", + "D" + ]`, + )) + }, + wantStdout: heredoc.Doc(` + GITIGNORE + AL + Actionscript + Ada + Agda + Android + AppEngine + AppceleratorTitanium + ArchLinuxPackages + Autotools + Ballerina + C + C++ + CFWheels + CMake + CUDA + CakePHP + ChefCookbook + Clojure + CodeIgniter + CommonLisp + Composer + Concrete5 + Coq + CraftCMS + D + `), + wantStderr: "", + opts: &ListOptions{}, + }, + { + name: "gitignore list no .gitignore templates tty", + isTTY: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "gitignore/templates"), + httpmock.StringResponse(`[]`), + ) + }, + wantStdout: "", + wantStderr: "", + wantErr: true, + errMsg: "No .gitignore templates 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()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/repo/license/license.go b/pkg/cmd/repo/license/license.go index 84896f2cd..a28b14f0b 100644 --- a/pkg/cmd/repo/license/license.go +++ b/pkg/cmd/repo/license/license.go @@ -9,7 +9,7 @@ import ( func NewCmdLicense(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "license ", - Short: "View available repository license options", + Short: "View available repository license templates", } cmd.AddCommand(cmdList.NewCmdList(f, nil)) diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index b1d2b3578..2ef9e7204 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -27,7 +27,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "list", - Short: "List available repository license options", + Short: "List available repository license templates", Aliases: []string{"ls"}, Args: cmdutil.ExactArgs(0, "gh repo license list takes no arguments"), RunE: func(cmd *cobra.Command, args []string) error { @@ -53,21 +53,21 @@ func listRun(opts *ListOptions) error { } hostname, _ := cfg.Authentication().DefaultHost() - licenses, err := queries.ListLicenseTemplates(client, hostname) + licenseTemplates, err := queries.ListLicenseTemplates(client, hostname) if err != nil { return err } - if len(licenses) == 0 { - return cmdutil.NewNoResultsError("No licenses found") + if len(licenseTemplates) == 0 { + return cmdutil.NewNoResultsError("No repository license templates found") } - return renderLicenseTemplatesTable(licenses, opts) + return renderLicenseTemplatesTable(licenseTemplates, opts) } -func renderLicenseTemplatesTable(licenses []api.License, opts *ListOptions) error { +func renderLicenseTemplatesTable(licenseTemplates []api.License, opts *ListOptions) error { t := tableprinter.New(opts.IO, tableprinter.WithHeader("LICENSE KEY", "LICENSE NAME")) - for _, l := range licenses { + for _, l := range licenseTemplates { t.AddField(l.Key) t.AddField(l.Name) t.EndRow() diff --git a/pkg/cmd/repo/license/list/list_test.go b/pkg/cmd/repo/license/list/list_test.go index 711a7e728..b01d64d9a 100644 --- a/pkg/cmd/repo/license/list/list_test.go +++ b/pkg/cmd/repo/license/list/list_test.go @@ -146,7 +146,7 @@ func TestListRun(t *testing.T) { opts: &ListOptions{}, }, { - name: "license list no licenses tty", + name: "license list no license templates tty", isTTY: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -157,7 +157,7 @@ func TestListRun(t *testing.T) { wantStdout: "", wantStderr: "", wantErr: true, - errMsg: "No licenses found", + errMsg: "No repository license templates found", opts: &ListOptions{}, }, } diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 611ef49ea..14a4bf49c 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" + gitIgnoreCmd "github.com/cli/cli/v2/pkg/cmd/repo/gitignore" 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" @@ -56,6 +57,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { repoEditCmd.NewCmdEdit(f, nil), deployKeyCmd.NewCmdDeployKey(f), licenseCmd.NewCmdLicense(f), + gitIgnoreCmd.NewCmdGitIgnore(f), repoRenameCmd.NewCmdRename(f, nil), repoArchiveCmd.NewCmdArchive(f, nil), repoUnarchiveCmd.NewCmdUnarchive(f, nil), diff --git a/pkg/cmd/repo/shared/queries/queries.go b/pkg/cmd/repo/shared/queries/queries.go index fdb238fb4..dea02de6c 100644 --- a/pkg/cmd/repo/shared/queries/queries.go +++ b/pkg/cmd/repo/shared/queries/queries.go @@ -17,3 +17,15 @@ func ListLicenseTemplates(httpClient *http.Client, hostname string) ([]api.Licen } return licenseTemplates, nil } + +// ListGitIgnoreTemplates fetches available repository gitignore templates. +// It uses API v3 here because gitignore template isn't supported by GraphQL. +func ListGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) { + var gitIgnoreTemplates []string + client := api.NewClientFromHTTP(httpClient) + err := client.REST(hostname, "GET", "gitignore/templates", nil, &gitIgnoreTemplates) + if err != nil { + return []string{}, err + } + return gitIgnoreTemplates, nil +} diff --git a/pkg/cmd/repo/shared/queries/queries_test.go b/pkg/cmd/repo/shared/queries/queries_test.go index 7babbb264..097467cfa 100644 --- a/pkg/cmd/repo/shared/queries/queries_test.go +++ b/pkg/cmd/repo/shared/queries/queries_test.go @@ -134,3 +134,100 @@ func TestListLicenseTemplates(t *testing.T) { }) } } + +func TestListGitIgnoreTemplates(t *testing.T) { + tests := []struct { + name string + httpStubs func(t *testing.T, reg *httpmock.Registry) + wantGitIgnoreTemplates []string + wantErr bool + wantErrMsg string + httpClient func() (*http.Client, error) + }{ + { + name: "happy path", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "gitignore/templates"), + httpmock.StringResponse(`[ + "AL", + "Actionscript", + "Ada", + "Agda", + "Android", + "AppEngine", + "AppceleratorTitanium", + "ArchLinuxPackages", + "Autotools", + "Ballerina", + "C", + "C++", + "CFWheels", + "CMake", + "CUDA", + "CakePHP", + "ChefCookbook", + "Clojure", + "CodeIgniter", + "CommonLisp", + "Composer", + "Concrete5", + "Coq", + "CraftCMS", + "D" + ]`, + )) + }, + wantGitIgnoreTemplates: []string{ + "AL", + "Actionscript", + "Ada", + "Agda", + "Android", + "AppEngine", + "AppceleratorTitanium", + "ArchLinuxPackages", + "Autotools", + "Ballerina", + "C", + "C++", + "CFWheels", + "CMake", + "CUDA", + "CakePHP", + "ChefCookbook", + "Clojure", + "CodeIgniter", + "CommonLisp", + "Composer", + "Concrete5", + "Coq", + "CraftCMS", + "D", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + tt.httpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + client, _ := tt.httpClient() + t.Run(tt.name, func(t *testing.T) { + defer reg.Verify(t) + gotGitIgnoreTemplates, err := ListGitIgnoreTemplates(client, "api.github.com") + if !tt.wantErr { + assert.NoError(t, err, "Expected no error while fetching /gitignore/templates") + } + if tt.wantErr { + assert.Error(t, err, "Expected error while fetching /gitignore/templates") + } + assert.Equal(t, tt.wantGitIgnoreTemplates, gotGitIgnoreTemplates, "GitIgnore templates fetched is not as expected") + }) + } +} From c76acb6aff9c100b73edfe5ca1b9c6c9f599e06b Mon Sep 17 00:00:00 2001 From: bagtoad <47394200+BagToad@users.noreply.github.com> Date: Sun, 29 Sep 2024 17:26:04 -0600 Subject: [PATCH 009/134] Implement gitignore view --- api/queries_repo.go | 5 + pkg/cmd/repo/gitignore/gitignore.go | 4 +- pkg/cmd/repo/gitignore/list/list.go | 11 +- pkg/cmd/repo/gitignore/list/list_test.go | 2 +- pkg/cmd/repo/gitignore/view/view.go | 102 +++++++++++ pkg/cmd/repo/gitignore/view/view_test.go | 186 ++++++++++++++++++++ pkg/cmd/repo/license/list/list.go | 7 + pkg/cmd/repo/shared/queries/queries.go | 13 ++ pkg/cmd/repo/shared/queries/queries_test.go | 54 ++++++ 9 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 pkg/cmd/repo/gitignore/view/view.go create mode 100644 pkg/cmd/repo/gitignore/view/view_test.go diff --git a/api/queries_repo.go b/api/queries_repo.go index 32cb5ff3b..2e2f711ca 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -211,6 +211,11 @@ type License struct { Name string `json:"name"` } +type GitIgnore struct { + Name string `json:"name"` + Source string `json:"source"` +} + // RepoOwner is the login name of the owner func (r Repository) RepoOwner() string { return r.Owner.Login diff --git a/pkg/cmd/repo/gitignore/gitignore.go b/pkg/cmd/repo/gitignore/gitignore.go index 260b506c5..8a9b8468e 100644 --- a/pkg/cmd/repo/gitignore/gitignore.go +++ b/pkg/cmd/repo/gitignore/gitignore.go @@ -2,6 +2,7 @@ package gitignore import ( cmdList "github.com/cli/cli/v2/pkg/cmd/repo/gitignore/list" + cmdView "github.com/cli/cli/v2/pkg/cmd/repo/gitignore/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -9,10 +10,11 @@ import ( func NewCmdGitIgnore(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "gitignore ", - Short: "View available repository .gitignore template", + Short: "List and view available repository gitignore templates", } cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) return cmd } diff --git a/pkg/cmd/repo/gitignore/list/list.go b/pkg/cmd/repo/gitignore/list/list.go index 7de608566..cddaeea83 100644 --- a/pkg/cmd/repo/gitignore/list/list.go +++ b/pkg/cmd/repo/gitignore/list/list.go @@ -1,6 +1,7 @@ package list import ( + "fmt" "net/http" "github.com/cli/cli/v2/internal/gh" @@ -26,7 +27,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "list", - Short: "List available repository .gitignore templates", + Short: "List available repository gitignore templates", Aliases: []string{"ls"}, Args: cmdutil.ExactArgs(0, "gh repo gitignore list takes no arguments"), RunE: func(cmd *cobra.Command, args []string) error { @@ -51,6 +52,12 @@ func listRun(opts *ListOptions) error { return err } + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) + } + hostname, _ := cfg.Authentication().DefaultHost() gitIgnoreTemplates, err := queries.ListGitIgnoreTemplates(client, hostname) if err != nil { @@ -58,7 +65,7 @@ func listRun(opts *ListOptions) error { } if len(gitIgnoreTemplates) == 0 { - return cmdutil.NewNoResultsError("No .gitignore templates found") + return cmdutil.NewNoResultsError("No gitignore templates found") } return renderGitIgnoreTemplatesTable(gitIgnoreTemplates, opts) diff --git a/pkg/cmd/repo/gitignore/list/list_test.go b/pkg/cmd/repo/gitignore/list/list_test.go index d7a0ef9e2..3a68ab511 100644 --- a/pkg/cmd/repo/gitignore/list/list_test.go +++ b/pkg/cmd/repo/gitignore/list/list_test.go @@ -151,7 +151,7 @@ func TestListRun(t *testing.T) { wantStdout: "", wantStderr: "", wantErr: true, - errMsg: "No .gitignore templates found", + errMsg: "No gitignore templates found", opts: &ListOptions{}, }, } diff --git a/pkg/cmd/repo/gitignore/view/view.go b/pkg/cmd/repo/gitignore/view/view.go new file mode 100644 index 000000000..917a66481 --- /dev/null +++ b/pkg/cmd/repo/gitignore/view/view.go @@ -0,0 +1,102 @@ +package view + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/pkg/cmd/repo/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + IO *iostreams.IOStreams + HTTPClient func() (*http.Client, error) + Config func() (gh.Config, error) + Template string +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HTTPClient: f.HttpClient, + Config: f.Config, + Template: "", + } + + cmd := &cobra.Command{ + Use: "view