From aa0f2de885b6902dbcedfc7f7c45c0f91a879099 Mon Sep 17 00:00:00 2001 From: Kousik Mitra Date: Fri, 14 Apr 2023 02:15:58 +0530 Subject: [PATCH 001/103] Return error on no-browser option if repo don't exists --- api/queries_repo.go | 20 ++++++++++ api/queries_repo_test.go | 70 +++++++++++++++++++++++++++++++++++ pkg/cmd/browse/browse.go | 14 ++++++- pkg/cmd/browse/browse_test.go | 11 ++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 06bea47bf..3b7e2e773 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -16,6 +16,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/go-gh/pkg/api" ghAPI "github.com/cli/go-gh/pkg/api" "github.com/shurcooL/githubv4" ) @@ -1370,3 +1371,22 @@ func GetRepoIDs(client *Client, host string, repositories []ghrepo.Interface) ([ } return result, nil } + +// RenameRepo renames the repository on GitHub and returns the renamed repository +func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) { + path := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName()) + + resp, err := client.HTTP().Head(path) + if err != nil { + return false, err + } + + switch resp.StatusCode { + case 200: + return true, nil + case 404: + return false, nil + default: + return false, api.HandleHTTPError(resp) + } +} diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 4ea14d0f5..1c790e3d8 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" ) func TestGitHubRepo_notFound(t *testing.T) { @@ -467,3 +468,72 @@ func TestDisplayName(t *testing.T) { } } } + +func TestRepoExists(t *testing.T) { + tests := []struct { + name string + httpStub func(*httpmock.Registry) + repo ghrepo.Interface + existCheck bool + wantErrMsg string + }{ + { + name: "repo exists", + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.REST("HEAD", "repos/OWNER/REPO"), + httpmock.StringResponse("{}"), + ) + }, + repo: ghrepo.New("OWNER", "REPO"), + existCheck: true, + wantErrMsg: "", + }, + { + name: "repo does not exists", + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.REST("HEAD", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(404, "Not Found"), + ) + }, + repo: ghrepo.New("OWNER", "REPO"), + existCheck: false, + wantErrMsg: "", + }, + { + name: "http error", + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.REST("HEAD", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(500, "Internal Server Error"), + ) + }, + repo: ghrepo.New("OWNER", "REPO"), + existCheck: false, + wantErrMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO)", + }, + } + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.httpStub != nil { + tt.httpStub(reg) + } + + client := newTestClient(reg) + + t.Run(tt.name, func(t *testing.T) { + exist, err := RepoExists(client, ghrepo.New("OWNER", "REPO")) + if tt.wantErrMsg != "" { + assert.Equal(t, tt.wantErrMsg, err.Error()) + } else { + assert.NoError(t, err) + } + + if exist != tt.existCheck { + t.Errorf("RepoExists() returns %v, expected %v", exist, tt.existCheck) + return + } + }) + } +} diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index 42664d77c..ae78c5155 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -180,7 +180,19 @@ func runBrowse(opts *BrowseOptions) error { url := ghrepo.GenerateRepoURL(baseRepo, "%s", section) if opts.NoBrowserFlag { - _, err := fmt.Fprintln(opts.IO.Out, url) + client, err := opts.HttpClient() + if err != nil { + return err + } + + exist, err := api.RepoExists(api.NewClientFromHTTP(client), baseRepo) + if err != nil { + return err + } + if !exist { + return fmt.Errorf("%s doesn't exists", text.DisplayURL(url)) + } + _, err = fmt.Fprintln(opts.IO.Out, url) return err } diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 4995e5cdf..9da60dcb1 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -232,6 +232,7 @@ func Test_runBrowse(t *testing.T) { tests := []struct { name string opts BrowseOptions + httpStub func(*httpmock.Registry) baseRepo ghrepo.Interface defaultBranch string expectedURL string @@ -432,6 +433,12 @@ func Test_runBrowse(t *testing.T) { SelectorArg: "init.rb:6", NoBrowserFlag: true, }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.REST("HEAD", "repos/mislav/will_paginate"), + httpmock.StringResponse("{}"), + ) + }, baseRepo: ghrepo.New("mislav", "will_paginate"), wantsErr: false, expectedURL: "https://github.com/mislav/will_paginate/blob/3-0-stable/init.rb?plain=1#L6", @@ -556,6 +563,10 @@ func Test_runBrowse(t *testing.T) { reg.StubRepoInfoResponse(tt.baseRepo.RepoOwner(), tt.baseRepo.RepoName(), tt.defaultBranch) } + if tt.httpStub != nil { + tt.httpStub(®) + } + opts := tt.opts opts.IO = ios opts.BaseRepo = func() (ghrepo.Interface, error) { From 213a59b8bddca6bf96cc63bd2fd817a6d3edf3c4 Mon Sep 17 00:00:00 2001 From: Josh Kraft Date: Fri, 28 Apr 2023 15:02:49 -0600 Subject: [PATCH 002/103] create cache commands --- pkg/cmd/cache/cache.go | 29 +++ pkg/cmd/cache/delete/delete.go | 155 +++++++++++++++ pkg/cmd/cache/delete/delete_test.go | 209 ++++++++++++++++++++ pkg/cmd/cache/list/list.go | 148 ++++++++++++++ pkg/cmd/cache/list/list_test.go | 296 ++++++++++++++++++++++++++++ pkg/cmd/cache/shared/shared.go | 73 +++++++ pkg/cmd/cache/shared/shared_test.go | 68 +++++++ pkg/cmd/root/root.go | 2 + 8 files changed, 980 insertions(+) create mode 100644 pkg/cmd/cache/cache.go create mode 100644 pkg/cmd/cache/delete/delete.go create mode 100644 pkg/cmd/cache/delete/delete_test.go create mode 100644 pkg/cmd/cache/list/list.go create mode 100644 pkg/cmd/cache/list/list_test.go create mode 100644 pkg/cmd/cache/shared/shared.go create mode 100644 pkg/cmd/cache/shared/shared_test.go diff --git a/pkg/cmd/cache/cache.go b/pkg/cmd/cache/cache.go new file mode 100644 index 000000000..db62c5e81 --- /dev/null +++ b/pkg/cmd/cache/cache.go @@ -0,0 +1,29 @@ +package cache + +import ( + "github.com/MakeNowJust/heredoc" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/cache/delete" + cmdList "github.com/cli/cli/v2/pkg/cmd/cache/list" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdCache(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "cache ", + Short: "Manage Github Actions caches", + Long: "Work with Github Actions caches.", + Example: heredoc.Doc(` + $ gh cache list + $ gh cache delete --all + `), + GroupID: "actions", + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + + return cmd +} diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go new file mode 100644 index 000000000..cd28fdab7 --- /dev/null +++ b/pkg/cmd/cache/delete/delete.go @@ -0,0 +1,155 @@ +package delete + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/cache/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + BaseRepo func() (ghrepo.Interface, error) + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + + DeleteAll bool + Identifier string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "delete [| | --all]", + Short: "Delete Github Actions caches", + Long: ` + Delete Github Actions caches. + + Deletion requires authorization with the "repo" scope. +`, + Example: heredoc.Doc(` + # Delete a cache by id + $ gh cache delete 1234 + + # Delete a cache by key + $ gh cache delete cache-key + + # Delete a cache by id in a specific repo + $ gh cache delete 1234 --repo cli/cli + + # Delete all caches + $ gh cache delete --all + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support -R/--repo flag + opts.BaseRepo = f.BaseRepo + + if err := cmdutil.MutuallyExclusive( + "specify only one of cache id, cache key, or --all", + opts.DeleteAll, len(args) > 0, + ); err != nil { + return err + } + + if !opts.DeleteAll && len(args) == 0 { + return cmdutil.FlagErrorf("must provide either cache id, cache key, or use --all") + } + + if len(args) == 1 { + opts.Identifier = args[0] + } + + if runF != nil { + return runF(opts) + } + + return deleteRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches") + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + client := api.NewClientFromHTTP(httpClient) + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repo: %w", err) + } + + var toDelete []string + if opts.DeleteAll { + caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1}) + if err != nil { + return err + } + if len(caches.ActionsCaches) == 0 { + return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon()) + } + for _, cache := range caches.ActionsCaches { + toDelete = append(toDelete, strconv.Itoa(cache.Id)) + } + } else { + toDelete = append(toDelete, opts.Identifier) + } + + return deleteCaches(opts, client, repo, toDelete) +} + +func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface, toDelete []string) error { + cs := opts.IO.ColorScheme() + repoName := ghrepo.FullName(repo) + opts.IO.StartProgressIndicator() + base := fmt.Sprintf("repos/%s/actions/caches", repoName) + + for _, cache := range toDelete { + path := "" + if id, err := strconv.Atoi(cache); err == nil { + path = fmt.Sprintf("%s/%d", base, id) + } else { + path = fmt.Sprintf("%s?key=%s", base, cache) + } + + err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil) + if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode == http.StatusNotFound { + err = fmt.Errorf("%s Could not find a cache matching %s in %s", cs.FailureIcon(), cache, repoName) + } else { + err = fmt.Errorf("%s Failed to delete cache: %w", cs.FailureIcon(), err) + } + } + opts.IO.StopProgressIndicator() + return err + } + } + + opts.IO.StopProgressIndicator() + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Deleted %s from %s\n", cs.SuccessIcon(), text.Pluralize(len(toDelete), "cache"), repoName) + } + + return nil +} diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go new file mode 100644 index 000000000..d6cd4ac56 --- /dev/null +++ b/pkg/cmd/cache/delete/delete_test.go @@ -0,0 +1,209 @@ +package delete + +import ( + "bytes" + "net/http" + "testing" + "time" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/cache/shared" + "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" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + cli string + wants DeleteOptions + wantsErr string + }{ + { + name: "no arguments", + cli: "", + wantsErr: "must provide either cache id, cache key, or use --all", + }, + { + name: "id argument", + cli: "123", + wants: DeleteOptions{Identifier: "123"}, + }, + { + name: "key argument", + cli: "A-Cache-Key", + wants: DeleteOptions{Identifier: "A-Cache-Key"}, + }, + { + name: "delete all flag", + cli: "--all", + wants: DeleteOptions{DeleteAll: true}, + }, + { + name: "id argument and delete all flag", + cli: "1 --all", + wantsErr: "specify only one of cache id, cache key, or --all", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + var gotOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr != "" { + assert.EqualError(t, err, tt.wantsErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wants.DeleteAll, gotOpts.DeleteAll) + assert.Equal(t, tt.wants.Identifier, gotOpts.Identifier) + }) + } +} + +func TestDeleteRun(t *testing.T) { + tests := []struct { + name string + opts DeleteOptions + stubs func(*httpmock.Registry) + tty bool + wantErr bool + wantErrMsg string + wantStderr string + wantStdout string + }{ + { + name: "deletes cache tty", + opts: DeleteOptions{Identifier: "123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"), + httpmock.StatusStringResponse(204, ""), + ) + }, + tty: true, + wantStdout: "✓ Deleted 1 cache from OWNER/REPO\n", + }, + { + name: "deletes cache notty", + opts: DeleteOptions{Identifier: "123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"), + httpmock.StatusStringResponse(204, ""), + ) + }, + tty: false, + wantStdout: "", + }, + { + name: "non-existent cache", + opts: DeleteOptions{Identifier: "123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"), + httpmock.StatusStringResponse(404, ""), + ) + }, + wantErr: true, + wantErrMsg: "X Could not find a cache matching 123 in OWNER/REPO", + }, + { + name: "deletes all caches", + opts: DeleteOptions{DeleteAll: true}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.JSONResponse(shared.CachePayload{ + ActionsCaches: []shared.Cache{ + { + Id: 123, + Key: "foo", + CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC), + LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC), + }, + { + Id: 456, + Key: "bar", + CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC), + LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC), + }, + }, + TotalCount: 2, + }), + ) + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"), + httpmock.StatusStringResponse(204, ""), + ) + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/456"), + httpmock.StatusStringResponse(204, ""), + ) + }, + tty: true, + wantStdout: "✓ Deleted 2 caches from OWNER/REPO\n", + }, + { + name: "displays delete error", + opts: DeleteOptions{Identifier: "123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"), + httpmock.StatusStringResponse(500, ""), + ) + }, + wantErr: true, + wantErrMsg: "X Failed to delete cache: HTTP 500 (https://api.github.com/repos/OWNER/REPO/actions/caches/123)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.stubs != nil { + tt.stubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStdinTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + defer reg.Verify(t) + + err := deleteRun(&tt.opts) + if tt.wantErr { + if tt.wantErrMsg != "" { + assert.EqualError(t, err, tt.wantErrMsg) + } else { + assert.Error(t, err) + } + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/cache/list/list.go b/pkg/cmd/cache/list/list.go new file mode 100644 index 000000000..9d16a9323 --- /dev/null +++ b/pkg/cmd/cache/list/list.go @@ -0,0 +1,148 @@ +package list + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/cache/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type ListOptions struct { + BaseRepo func() (ghrepo.Interface, error) + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Now time.Time + + Limit int + Order string + Sort string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List Github Actions caches", + Example: heredoc.Doc(` + # List caches for current repository + $ gh cache list + + # List caches for specific repository + $ gh cache list --repo cli/cli + + # List caches sorted by least recently accessed + $ gh cache list --sort last_accessed_at --order asc + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) + } + + if runF != nil { + return runF(&opts) + } + + return listRun(&opts) + }, + } + + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of caches to fetch") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "O", "desc", []string{"asc", "desc"}, "Order of caches returned") + cmdutil.StringEnumFlag(cmd, &opts.Sort, "sort", "S", "last_accessed_at", []string{"created_at", "last_accessed_at", "size_in_bytes"}, "Sort fetched caches") + + return cmd +} + +func listRun(opts *ListOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + client := api.NewClientFromHTTP(httpClient) + + opts.IO.StartProgressIndicator() + result, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: opts.Limit, Sort: opts.Sort, Order: opts.Order}) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("%s Failed to get caches: %w", opts.IO.ColorScheme().FailureIcon(), err) + } + + if len(result.ActionsCaches) == 0 { + return cmdutil.NewNoResultsError(fmt.Sprintf("No caches found in %s", ghrepo.FullName(repo))) + } + + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.Out, "Failed to start pager: %v\n", err) + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "\nShowing %d of %s in %s\n\n", len(result.ActionsCaches), text.Pluralize(result.TotalCount, "cache"), ghrepo.FullName(repo)) + } + + if opts.Now.IsZero() { + opts.Now = time.Now() + } + + tp := tableprinter.New(opts.IO) + tp.HeaderRow("ID", "KEY", "SIZE", "CREATED", "ACCESSED") + for _, cache := range result.ActionsCaches { + tp.AddField(opts.IO.ColorScheme().Cyan(fmt.Sprintf("%d", cache.Id))) + tp.AddField(cache.Key) + tp.AddField(humanFileSize(cache.SizeInBytes)) + tp.AddTimeField(time.Now(), cache.CreatedAt, opts.IO.ColorScheme().Gray) + tp.AddTimeField(time.Now(), cache.LastAccessedAt, opts.IO.ColorScheme().Gray) + tp.EndRow() + } + + return tp.Render() +} + +func humanFileSize(s int64) string { + if s < 1024 { + return fmt.Sprintf("%d B", s) + } + + kb := float64(s) / 1024 + if kb < 1024 { + return fmt.Sprintf("%s KiB", floatToString(kb, 2)) + } + + mb := kb / 1024 + if mb < 1024 { + return fmt.Sprintf("%s MiB", floatToString(mb, 2)) + } + + gb := mb / 1024 + return fmt.Sprintf("%s GiB", floatToString(gb, 2)) +} + +func floatToString(f float64, p uint8) string { + fs := fmt.Sprintf("%#f%0*s", f, p, "") + idx := strings.IndexRune(fs, '.') + return fs[:idx+int(p)+1] +} diff --git a/pkg/cmd/cache/list/list_test.go b/pkg/cmd/cache/list/list_test.go new file mode 100644 index 000000000..1129297ea --- /dev/null +++ b/pkg/cmd/cache/list/list_test.go @@ -0,0 +1,296 @@ +package list + +import ( + "bytes" + "net/http" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/cache/shared" + "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" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + input string + wants ListOptions + wantsErr string + }{ + { + name: "no arguments", + input: "", + wants: ListOptions{ + Limit: 30, + Order: "desc", + Sort: "last_accessed_at", + }, + }, + { + name: "with limit", + input: "--limit 100", + wants: ListOptions{ + Limit: 100, + Order: "desc", + Sort: "last_accessed_at", + }, + }, + { + name: "invalid limit", + input: "-L 0", + wantsErr: "invalid limit: 0", + }, + { + name: "with sort", + input: "--sort created_at", + wants: ListOptions{ + Limit: 30, + Order: "desc", + Sort: "created_at", + }, + }, + { + name: "with order", + input: "--order asc", + wants: ListOptions{ + Limit: 30, + Order: "asc", + Sort: "last_accessed_at", + }, + }, + } + + 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 *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr != "" { + assert.EqualError(t, err, tt.wantsErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wants.Limit, gotOpts.Limit) + assert.Equal(t, tt.wants.Sort, gotOpts.Sort) + assert.Equal(t, tt.wants.Order, gotOpts.Order) + }) + } +} + +func TestListRun(t *testing.T) { + var now = time.Date(2023, 1, 1, 1, 1, 1, 1, time.UTC) + tests := []struct { + name string + opts ListOptions + stubs func(*httpmock.Registry) + tty bool + wantErr bool + wantErrMsg string + wantStderr string + wantStdout string + }{ + { + name: "displays results tty", + tty: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.JSONResponse(shared.CachePayload{ + ActionsCaches: []shared.Cache{ + { + Id: 1, + Key: "foo", + CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC), + LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC), + SizeInBytes: 100, + }, + { + Id: 2, + Key: "bar", + CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC), + LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC), + SizeInBytes: 1024, + }, + }, + TotalCount: 2, + }), + ) + }, + wantStdout: heredoc.Doc(` + +Showing 2 of 2 caches in OWNER/REPO + +ID KEY SIZE CREATED ACCESSED +1 foo 100 B about 2 years ago about 1 year ago +2 bar 1.00 KiB about 2 years ago about 1 year ago +`), + }, + { + name: "displays results non-tty", + tty: false, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.JSONResponse(shared.CachePayload{ + ActionsCaches: []shared.Cache{ + { + Id: 1, + Key: "foo", + CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC), + LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC), + SizeInBytes: 100, + }, + { + Id: 2, + Key: "bar", + CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC), + LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC), + SizeInBytes: 1024, + }, + }, + TotalCount: 2, + }), + ) + }, + wantStdout: "1\tfoo\t100 B\t2021-01-01T01:01:01Z\t2022-01-01T01:01:01Z\n2\tbar\t1.00 KiB\t2021-01-01T01:01:01Z\t2022-01-01T01:01:01Z\n", + }, + { + name: "displays no results", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.JSONResponse(shared.CachePayload{ + ActionsCaches: []shared.Cache{}, + TotalCount: 0, + }), + ) + }, + wantErr: true, + wantErrMsg: "No caches found in OWNER/REPO", + }, + { + name: "displays list error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.StatusStringResponse(404, "Not Found"), + ) + }, + wantErr: true, + wantErrMsg: "X Failed to get caches: HTTP 404 (https://api.github.com/repos/OWNER/REPO/actions/caches?per_page=100)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.stubs != nil { + tt.stubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStdinTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + tt.opts.IO = ios + tt.opts.Now = now + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + defer reg.Verify(t) + + err := listRun(&tt.opts) + if tt.wantErr { + if tt.wantErrMsg != "" { + assert.EqualError(t, err, tt.wantErrMsg) + } else { + assert.Error(t, err) + } + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} + +func Test_humanFileSize(t *testing.T) { + tests := []struct { + name string + size int64 + want string + }{ + { + name: "min bytes", + size: 1, + want: "1 B", + }, + { + name: "max bytes", + size: 1023, + want: "1023 B", + }, + { + name: "min kibibytes", + size: 1024, + want: "1.00 KiB", + }, + { + name: "max kibibytes", + size: 1024*1024 - 1, + want: "1023.99 KiB", + }, + { + name: "min mibibytes", + size: 1024 * 1024, + want: "1.00 MiB", + }, + { + name: "fractional mibibytes", + size: 1024*1024*12 + 1024*350, + want: "12.34 MiB", + }, + { + name: "max mibibytes", + size: 1024*1024*1024 - 1, + want: "1023.99 MiB", + }, + { + name: "min gibibytes", + size: 1024 * 1024 * 1024, + want: "1.00 GiB", + }, + { + name: "fractional gibibytes", + size: 1024 * 1024 * 1024 * 1.5, + want: "1.50 GiB", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := humanFileSize(tt.size); got != tt.want { + t.Errorf("humanFileSize() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/cache/shared/shared.go b/pkg/cmd/cache/shared/shared.go new file mode 100644 index 000000000..271dee508 --- /dev/null +++ b/pkg/cmd/cache/shared/shared.go @@ -0,0 +1,73 @@ +package shared + +import ( + "fmt" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" +) + +type Cache struct { + CreatedAt time.Time `json:"created_at"` + Id int `json:"id"` + Key string `json:"key"` + LastAccessedAt time.Time `json:"last_accessed_at"` + Ref string `json:"ref"` + SizeInBytes int64 `json:"size_in_bytes"` + Version string `json:"version"` +} + +type CachePayload struct { + ActionsCaches []Cache `json:"actions_caches"` + TotalCount int `json:"total_count"` +} + +type GetCachesOptions struct { + Limit int + Order string + Sort string +} + +// Return a list of caches for a repository. Pass a negative limit to request +// all pages from the API until all caches have been fetched. +func GetCaches(client *api.Client, repo ghrepo.Interface, opts GetCachesOptions) (*CachePayload, error) { + path := fmt.Sprintf("repos/%s/actions/caches", ghrepo.FullName(repo)) + + perPage := 100 + if opts.Limit > 0 && opts.Limit < 100 { + perPage = opts.Limit + } + path += fmt.Sprintf("?per_page=%d", perPage) + + if opts.Sort != "" { + path += fmt.Sprintf("&sort=%s", opts.Sort) + } + if opts.Order != "" { + path += fmt.Sprintf("&direction=%s", opts.Order) + } + + var result *CachePayload +pagination: + for path != "" { + var response CachePayload + var err error + path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response) + if err != nil { + return nil, err + } + + if result == nil { + result = &response + } else { + result.ActionsCaches = append(result.ActionsCaches, response.ActionsCaches...) + } + + if opts.Limit > 0 && len(result.ActionsCaches) >= opts.Limit { + result.ActionsCaches = result.ActionsCaches[:opts.Limit] + break pagination + } + } + + return result, nil +} diff --git a/pkg/cmd/cache/shared/shared_test.go b/pkg/cmd/cache/shared/shared_test.go new file mode 100644 index 000000000..2b64fed28 --- /dev/null +++ b/pkg/cmd/cache/shared/shared_test.go @@ -0,0 +1,68 @@ +package shared + +import ( + "net/http" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestGetCaches(t *testing.T) { + tests := []struct { + name string + opts GetCachesOptions + stubs func(*httpmock.Registry) + wantsCount int + }{ + { + name: "no caches", + opts: GetCachesOptions{}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.StringResponse(`{"actions_caches": [], "total_count": 0}`), + ) + }, + wantsCount: 0, + }, + { + name: "limits cache count", + opts: GetCachesOptions{Limit: 1}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.StringResponse(`{"actions_caches": [{"id": 1}, {"id": 2}], "total_count": 2}`), + ) + }, + wantsCount: 1, + }, + { + name: "negative limit returns all caches", + opts: GetCachesOptions{Limit: -1}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.StringResponse(`{"actions_caches": [{"id": 1}, {"id": 2}], "total_count": 2}`), + ) + }, + wantsCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + tt.stubs(reg) + httpClient := &http.Client{Transport: reg} + client := api.NewClientFromHTTP(httpClient) + repo, err := ghrepo.FromFullName("OWNER/REPO") + assert.NoError(t, err) + result, err := GetCaches(client, repo, tt.opts) + assert.NoError(t, err) + assert.Equal(t, tt.wantsCount, len(result.ActionsCaches)) + }) + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 78b436fe1..fb014e8f3 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -13,6 +13,7 @@ import ( apiCmd "github.com/cli/cli/v2/pkg/cmd/api" authCmd "github.com/cli/cli/v2/pkg/cmd/auth" browseCmd "github.com/cli/cli/v2/pkg/cmd/browse" + cacheCmd "github.com/cli/cli/v2/pkg/cmd/cache" codespaceCmd "github.com/cli/cli/v2/pkg/cmd/codespace" completionCmd "github.com/cli/cli/v2/pkg/cmd/completion" configCmd "github.com/cli/cli/v2/pkg/cmd/config" @@ -123,6 +124,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(runCmd.NewCmdRun(&repoResolvingCmdFactory)) cmd.AddCommand(workflowCmd.NewCmdWorkflow(&repoResolvingCmdFactory)) cmd.AddCommand(labelCmd.NewCmdLabel(&repoResolvingCmdFactory)) + cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory)) // Help topics cmd.AddCommand(NewHelpTopic(f.IOStreams, "environment")) From 1dc54f85fb0eb43dad15ee7523535251ce99f98e Mon Sep 17 00:00:00 2001 From: Josh Kraft Date: Wed, 3 May 2023 11:08:32 -0600 Subject: [PATCH 003/103] typo --- pkg/cmd/cache/delete/delete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index cd28fdab7..c6d0672f8 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -32,7 +32,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "delete [| | --all]", + Use: "delete [| | --all]", Short: "Delete Github Actions caches", Long: ` Delete Github Actions caches. From a7fdc37b5d733f690d0fdd111e583d2fa834ca0a Mon Sep 17 00:00:00 2001 From: Kousik Mitra Date: Tue, 2 May 2023 22:41:25 +0530 Subject: [PATCH 004/103] Add fill-first option to create pr This will add an flag --fill-first to pr create sub-command. Which will allow users to create pr with details from first commit. --- pkg/cmd/pr/create/create.go | 16 ++++++++++------ pkg/cmd/pr/create/create_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 83421d88b..33a4f71fe 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -45,6 +45,7 @@ type CreateOptions struct { RepoOverride string Autofill bool + FillFirst bool WebMode bool RecoverFile string @@ -167,8 +168,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return errors.New("`--template` is not supported when using `--body` or `--body-file`") } - if !opts.IO.CanPrompt() && !opts.WebMode && !opts.Autofill && (!opts.TitleProvided || !opts.BodyProvided) { - return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill`) when not running interactively") + if !opts.IO.CanPrompt() && !opts.WebMode && !(opts.Autofill || opts.FillFirst) && (!opts.TitleProvided || !opts.BodyProvided) { + return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill` or `fill-first`) when not running interactively") } if runF != nil { @@ -187,6 +188,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default: current branch)") fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request") fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info") + fl.BoolVar(&opts.FillFirst, "fill-first", false, "Do not prompt for title/body and just use first commit info") fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`") fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.") fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") @@ -196,6 +198,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text") + cmd.MarkFlagsMutuallyExclusive("fill", "fill-first") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") _ = cmd.RegisterFlagCompletionFunc("reviewer", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -392,7 +396,7 @@ func createRun(opts *CreateOptions) (err error) { return } -func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error { +func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, opts *CreateOptions) error { baseRef := ctx.BaseTrackingBranch headRef := ctx.HeadBranch gitClient := ctx.GitClient @@ -402,7 +406,7 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) e return err } - if len(commits) == 1 { + if len(commits) == 1 || opts.FillFirst { state.Title = commits[0].Title body, err := gitClient.CommitBody(context.Background(), commits[0].Sha) if err != nil { @@ -485,8 +489,8 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata Draft: opts.IsDraft, } - if opts.Autofill || !opts.TitleProvided || !opts.BodyProvided { - err := initDefaultTitleBody(ctx, state) + if opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided { + err := initDefaultTitleBody(ctx, state, &opts) if err != nil && opts.Autofill { return nil, fmt.Errorf("could not compute title or body defaults: %w", err) } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 84b5cf6fa..b4282ad17 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -168,6 +168,32 @@ func TestNewCmdCreate(t *testing.T) { cli: "-t mytitle --template bug_fix.md --body-file body_file.md", wantsErr: true, }, + { + name: "with fill-first option", + tty: false, + cli: "--fill-first", + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "", + TitleProvided: false, + Body: "", + BodyProvided: false, + Autofill: false, + FillFirst: true, + RecoverFile: "", + WebMode: false, + IsDraft: false, + BaseBranch: "", + HeadBranch: "", + MaintainerCanModify: true, + }, + }, + { + name: "fill and fill-first is mutually exclusive", + tty: false, + cli: "--fill --fill-first", + wantsErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -210,6 +236,7 @@ func TestNewCmdCreate(t *testing.T) { assert.Equal(t, tt.wantsOpts.Title, opts.Title) assert.Equal(t, tt.wantsOpts.TitleProvided, opts.TitleProvided) assert.Equal(t, tt.wantsOpts.Autofill, opts.Autofill) + assert.Equal(t, tt.wantsOpts.FillFirst, opts.FillFirst) assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode) assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile) assert.Equal(t, tt.wantsOpts.IsDraft, opts.IsDraft) From 1b9906268f9a039bf6265f1e82182ce83d9b738e Mon Sep 17 00:00:00 2001 From: Kousik Mitra Date: Tue, 2 May 2023 23:22:53 +0530 Subject: [PATCH 005/103] Add test for fill first flag --- pkg/cmd/pr/create/create.go | 4 +-- pkg/cmd/pr/create/create_test.go | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 33a4f71fe..a758bf4a4 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -285,7 +285,7 @@ func createRun(opts *CreateOptions) (err error) { ghrepo.FullName(ctx.BaseRepo)) } - if opts.Autofill || (opts.TitleProvided && opts.BodyProvided) { + if opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided) { err = handlePush(*opts, *ctx) if err != nil { return @@ -491,7 +491,7 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata if opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided { err := initDefaultTitleBody(ctx, state, &opts) - if err != nil && opts.Autofill { + if err != nil && (opts.Autofill || opts.FillFirst) { return nil, fmt.Errorf("could not compute title or body defaults: %w", err) } } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index b4282ad17..33cb3f19b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -995,6 +995,49 @@ func Test_createRun(t *testing.T) { }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, + { + name: "fill-first flag provided", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = false + opts.FillFirst = true + opts.HeadBranch = "feature" + return func() {} + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register( + "git -c log.ShowSignature=false log --pretty=format:%H,%s --cherry origin/master...feature", + 0, + `56b6f8bb7c9e3a30093cd17e48934ce354148e80,first commit of pr + 343jdfe47c9e3a30093cd17e48934ce354148e80,second commit of pr + `, + ) + cs.Register( + "git -c log.ShowSignature=false show -s --pretty=format:%b 56b6f8bb7c9e3a30093cd17e48934ce354148e80", + 0, + "first commit description", + ) + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { + "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } + } + `, + func(input map[string]interface{}) { + assert.Equal(t, "first commit of pr", input["title"], "pr title should be first commit message") + assert.Equal(t, "first commit description", input["body"], "pr body should be first commit description") + }, + ), + ) + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 38e6287fe8501de40dcc01098858443fa20cc15a Mon Sep 17 00:00:00 2001 From: Kousik Mitra Date: Tue, 2 May 2023 23:42:17 +0530 Subject: [PATCH 006/103] Fix commit order --- pkg/cmd/pr/create/create.go | 12 ++++++++++-- pkg/cmd/pr/create/create_test.go | 7 +++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index a758bf4a4..218be6623 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -405,8 +405,16 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, o if err != nil { return err } - - if len(commits) == 1 || opts.FillFirst { + if opts.FillFirst { + firstCommitIndex := len(commits) - 1 + state.Title = commits[firstCommitIndex].Title + body, err := gitClient.CommitBody(context.Background(), commits[firstCommitIndex].Sha) + if err != nil { + return err + } + state.Body = body + fmt.Print(state.Title, state.Body) + } else if len(commits) == 1 { state.Title = commits[0].Title body, err := gitClient.CommitBody(context.Background(), commits[0].Sha) if err != nil { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 33cb3f19b..9c141ded6 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1008,12 +1008,11 @@ func Test_createRun(t *testing.T) { cs.Register( "git -c log.ShowSignature=false log --pretty=format:%H,%s --cherry origin/master...feature", 0, - `56b6f8bb7c9e3a30093cd17e48934ce354148e80,first commit of pr - 343jdfe47c9e3a30093cd17e48934ce354148e80,second commit of pr - `, + "56b6f8bb7c9e3a30093cd17e48934ce354148e80,second commit of pr\n"+ + "343jdfe47c9e3a30093cd17e48934ce354148e80,first commit of pr", ) cs.Register( - "git -c log.ShowSignature=false show -s --pretty=format:%b 56b6f8bb7c9e3a30093cd17e48934ce354148e80", + "git -c log.ShowSignature=false show -s --pretty=format:%b 343jdfe47c9e3a30093cd17e48934ce354148e80", 0, "first commit description", ) From b59f3dc29f70984ae75ef238cb3e7ba2a9afaabc Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 30 Jun 2023 19:48:06 +0900 Subject: [PATCH 007/103] Rewrite issue develop command to fix numerous issues --- api/queries_branch_issue_reference.go | 198 ++----- pkg/cmd/issue/develop/develop.go | 238 +++----- pkg/cmd/issue/develop/develop_test.go | 770 +++++++++++--------------- pkg/cmd/project/project.go | 2 +- pkg/cmd/run/cancel/cancel.go | 2 +- pkg/cmd/run/cancel/cancel_test.go | 4 +- 6 files changed, 466 insertions(+), 748 deletions(-) diff --git a/api/queries_branch_issue_reference.go b/api/queries_branch_issue_reference.go index b85f94654..29c3ab322 100644 --- a/api/queries_branch_issue_reference.go +++ b/api/queries_branch_issue_reference.go @@ -4,63 +4,16 @@ import ( "fmt" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/shurcooL/githubv4" ) type LinkedBranch struct { - ID string BranchName string - RepoUrl string + URL string } -// method to return url of linked branch, adds the branch name to the end of the repo url -func (b *LinkedBranch) Url() string { - return fmt.Sprintf("%s/tree/%s", b.RepoUrl, b.BranchName) -} - -func nameParam(params map[string]interface{}) string { - if params["name"] != "" { - return "name: $name," - } - return "" -} - -func nameArg(params map[string]interface{}) string { - if params["name"] != "" { - return "$name: String, " - } - return "" -} - -func CreateBranchIssueReference(client *Client, repo *Repository, params map[string]interface{}) (*LinkedBranch, error) { - query := fmt.Sprintf(` - mutation CreateLinkedBranch($issueId: ID!, $oid: GitObjectID!, %[1]s$repositoryId: ID) { - createLinkedBranch(input: { - issueId: $issueId, - %[2]s - oid: $oid, - repositoryId: $repositoryId - }) { - linkedBranch { - id - ref { - name - } - } - } - }`, nameArg(params), nameParam(params)) - - inputParams := map[string]interface{}{ - "repositoryId": repo.ID, - } - - for key, val := range params { - switch key { - case "issueId", "name", "oid": - inputParams[key] = val - } - } - - result := struct { +func CreateLinkedBranch(client *Client, host string, issueID, branchName, oid string) (string, error) { + var mutation struct { CreateLinkedBranch struct { LinkedBranch struct { ID string @@ -68,90 +21,72 @@ func CreateBranchIssueReference(client *Client, repo *Repository, params map[str Name string } } - } - }{} - - if err := client.GraphQL(repo.RepoHost(), query, inputParams, &result); err != nil { - return nil, err + } `graphql:"createLinkedBranch(input: $input)"` } - ref := LinkedBranch{ - ID: result.CreateLinkedBranch.LinkedBranch.ID, - BranchName: result.CreateLinkedBranch.LinkedBranch.Ref.Name, + input := githubv4.CreateLinkedBranchInput{ + IssueID: githubv4.ID(issueID), + Oid: githubv4.GitObjectID(oid), + } + if branchName != "" { + name := githubv4.String(branchName) + input.Name = &name + } + variables := map[string]interface{}{ + "input": input, } - return &ref, nil + err := client.Mutate(host, "CreateLinkedBranch", &mutation, variables) + if err != nil { + return "", err + } + + return mutation.CreateLinkedBranch.LinkedBranch.Ref.Name, nil } func ListLinkedBranches(client *Client, repo ghrepo.Interface, issueNumber int) ([]LinkedBranch, error) { - query := ` - query BranchIssueReferenceListLinkedBranches($repositoryName: String!, $repositoryOwner: String!, $issueNumber: Int!) { - repository(name: $repositoryName, owner: $repositoryOwner) { - issue(number: $issueNumber) { - linkedBranches(first: 30) { - edges { - node { - ref { - name - repository { - url - } - } - } - } - } - } - } - } - ` - - variables := map[string]interface{}{ - "repositoryName": repo.RepoName(), - "repositoryOwner": repo.RepoOwner(), - "issueNumber": issueNumber, - } - - result := struct { + var query struct { Repository struct { Issue struct { LinkedBranches struct { - Edges []struct { - Node struct { - Ref struct { - Name string - Repository struct { - NameWithOwner string - Url string - } + Nodes []struct { + Ref struct { + Name string + Repository struct { + Url string } } } - } - } - } - }{} + } `graphql:"linkedBranches(first: 30)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } - if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { + variables := map[string]interface{}{ + "number": githubv4.Int(issueNumber), + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + if err := client.Query(repo.RepoHost(), "ListLinkedBranches", &query, variables); err != nil { return []LinkedBranch{}, err } var branchNames []LinkedBranch - for _, edge := range result.Repository.Issue.LinkedBranches.Edges { + for _, node := range query.Repository.Issue.LinkedBranches.Nodes { branch := LinkedBranch{ - BranchName: edge.Node.Ref.Name, - RepoUrl: edge.Node.Ref.Repository.Url, + BranchName: node.Ref.Name, + URL: fmt.Sprintf("%s/tree/%s", node.Ref.Repository.Url, node.Ref.Name), } - branchNames = append(branchNames, branch) } return branchNames, nil } -// introspects the schema to see if we expose the LinkedBranch type -func CheckLinkedBranchFeature(client *Client, host string) (err error) { - var featureDetection struct { +func CheckLinkedBranchFeature(client *Client, host string) error { + var query struct { Name struct { Fields []struct { Name string @@ -159,42 +94,19 @@ func CheckLinkedBranchFeature(client *Client, host string) (err error) { } `graphql:"LinkedBranch: __type(name: \"LinkedBranch\")"` } - if err := client.Query(host, "LinkedBranch_fields", &featureDetection, nil); err != nil { + if err := client.Query(host, "LinkedBranchFeature", &query, nil); err != nil { return err } - if len(featureDetection.Name.Fields) == 0 { + if len(query.Name.Fields) == 0 { return fmt.Errorf("the `gh issue develop` command is not currently available") } return nil } -// This fetches the oids for the repo's default branch (`main`, etc) and the name the user might have provided in one shot. -func FindBaseOid(client *Client, repo *Repository, ref string) (string, string, error) { - query := ` - query BranchIssueReferenceFindBaseOid($repositoryName: String!, $repositoryOwner: String!, $ref: String!) { - repository(name: $repositoryName, owner: $repositoryOwner) { - defaultBranchRef { - target { - oid - } - } - ref(qualifiedName: $ref) { - target { - oid - } - } - } - }` - - variables := map[string]interface{}{ - "repositoryName": repo.Name, - "repositoryOwner": repo.RepoOwner(), - "ref": ref, - } - - result := struct { +func FindBaseOid(client *Client, repo ghrepo.Interface, ref string) (string, string, error) { + var query struct { Repository struct { DefaultBranchRef struct { Target struct { @@ -205,13 +117,19 @@ func FindBaseOid(client *Client, repo *Repository, ref string) (string, string, Target struct { Oid string } - } - } - }{} + } `graphql:"ref(qualifiedName: $ref)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } - if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { + variables := map[string]interface{}{ + "ref": githubv4.String(ref), + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + if err := client.Query(repo.RepoHost(), "FindBaseOid", &query, variables); err != nil { return "", "", err } - return result.Repository.Ref.Target.Oid, result.Repository.DefaultBranchRef.Target.Oid, nil + return query.Repository.Ref.Target.Oid, query.Repository.DefaultBranchRef.Target.Oid, nil } diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index a32b414b6..21a81aa6a 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -9,7 +9,6 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -21,17 +20,15 @@ import ( type DevelopOptions struct { HttpClient func() (*http.Client, error) GitClient *git.Client - Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Remotes func() (context.Remotes, error) - IssueRepoSelector string - IssueSelector string - Name string - BaseBranch string - Checkout bool - List bool + IssueSelector string + Name string + BaseBranch string + Checkout bool + List bool } func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.Command { @@ -39,200 +36,130 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra. IO: f.IOStreams, HttpClient: f.HttpClient, GitClient: f.GitClient, - Config: f.Config, BaseRepo: f.BaseRepo, Remotes: f.Remotes, } cmd := &cobra.Command{ - Use: "develop [flags] { | }", + Use: "develop { | }", Short: "Manage linked branches for an issue", Example: heredoc.Doc(` - $ gh issue develop --list 123 # list branches for issue 123 - $ gh issue develop --list --issue-repo "github/cli" 123 # list branches for issue 123 in repo "github/cli" - $ gh issue develop --list https://github.com/github/cli/issues/123 # list branches for issue 123 in repo "github/cli" - $ gh issue develop 123 --name "my-branch" --base my-feature # create a branch for issue 123 based on the my-feature branch - $ gh issue develop 123 --checkout # fetch and checkout the branch for issue 123 after creating it + # List branches for issue 123 + $ gh issue develop --list 123 + + # List branches for issue 123 in repo cli/cli + $ gh issue develop --list --repo cli/cli 123 + + # Create a branch for issue 123 based on the my-feature branch + $ gh issue develop 123 --base my-feature + + # Create a branch for issue 123 and checkout it out + $ gh issue develop 123 --checkout `), Args: cmdutil.ExactArgs(1, "issue number or url is required"), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // This is all a hack to not break the issue-repo flag. It will be removed + // in the near future and this hack can be removed at the same time. + flags := cmd.Flags() + if flags.Changed("issue-repo") && !flags.Changed("repo") { + repo, _ := flags.GetString("issue-repo") + _ = flags.Set("repo", repo) + } + if cmd.Parent() != nil { + return cmd.Parent().PersistentPreRunE(cmd, args) + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo + opts.IssueSelector = args[0] if runF != nil { return runF(opts) } - opts.IssueSelector = args[0] - if opts.List { - return developRunList(opts) - } - return developRunCreate(opts) + return developRun(opts) }, } + fl := cmd.Flags() fl.StringVarP(&opts.BaseBranch, "base", "b", "", "Name of the base branch you want to make your new branch from") fl.BoolVarP(&opts.Checkout, "checkout", "c", false, "Checkout the branch after creating it") - fl.StringVarP(&opts.IssueRepoSelector, "issue-repo", "i", "", "Name or URL of the issue's repository") fl.BoolVarP(&opts.List, "list", "l", false, "List linked branches for the issue") fl.StringVarP(&opts.Name, "name", "n", "", "Name of the branch to create") + + var issueRepoSelector string + fl.StringVarP(&issueRepoSelector, "issue-repo", "i", "", "Name or URL of the issue's repository") + _ = cmd.Flags().MarkDeprecated("issue-repo", "use `--repo` instead") + return cmd } -func developRunCreate(opts *DevelopOptions) (err error) { +func developRun(opts *DevelopOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err } - apiClient := api.NewClientFromHTTP(httpClient) - baseRepo, err := opts.BaseRepo() - if err != nil { - return err - } + + opts.IO.StartProgressIndicator() + issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicator() err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) + opts.IO.StopProgressIndicator() if err != nil { return err } - repo, err := api.GitHubRepo(apiClient, baseRepo) - if err != nil { - return err + if opts.List { + return developRunList(opts, apiClient, baseRepo, issue) } + return developRunCreate(opts, apiClient, baseRepo, issue) +} - issueNumber, issueRepo, err := issueMetadata(opts.IssueSelector, opts.IssueRepoSelector, baseRepo) - if err != nil { - return err - } - - // The mutation requires the issue id, not just its number - issue, _, err := shared.IssueFromArgWithFields(httpClient, func() (ghrepo.Interface, error) { return issueRepo, nil }, fmt.Sprint(issueNumber), []string{"id"}) - if err != nil { - return err - } - - // The mutation takes an oid instead of a branch name as it's a more stable reference - oid, default_branch_oid, err := api.FindBaseOid(apiClient, repo, opts.BaseBranch) +func developRunCreate(opts *DevelopOptions, apiClient *api.Client, baseRepo ghrepo.Interface, issue *api.Issue) error { + opts.IO.StartProgressIndicator() + oid, fallbackOID, err := api.FindBaseOid(apiClient, baseRepo, opts.BaseBranch) + opts.IO.StopProgressIndicator() if err != nil { return err } if oid == "" { - oid = default_branch_oid - } - - // get the oid of the branch from the base repo - params := map[string]interface{}{ - "issueId": issue.ID, - "name": opts.Name, - "oid": oid, - "repositoryId": repo.ID, - } - - ref, err := api.CreateBranchIssueReference(apiClient, repo, params) - opts.IO.StopProgressIndicator() - if err != nil { - return nil - } - - baseRepo.RepoHost() - fmt.Fprintf(opts.IO.Out, "%s/%s/%s/tree/%s\n", baseRepo.RepoHost(), baseRepo.RepoOwner(), baseRepo.RepoName(), ref.BranchName) - return checkoutBranch(opts, baseRepo, ref.BranchName) -} - -// If the issue is in the base repo, we can use the issue number directly. Otherwise, we need to use the issue's url or the IssueRepoSelector argument. -// If the repo from the URL doesn't match the IssueRepoSelector argument, we error. -func issueMetadata(issueSelector string, issueRepoSelector string, baseRepo ghrepo.Interface) (issueNumber int, issueFlagRepo ghrepo.Interface, err error) { - var targetRepo ghrepo.Interface - if issueRepoSelector != "" { - issueFlagRepo, err = ghrepo.FromFullNameWithHost(issueRepoSelector, baseRepo.RepoHost()) - if err != nil { - return 0, nil, err - } - } - - if issueFlagRepo != nil { - targetRepo = issueFlagRepo - } - - issueNumber, issueArgRepo, err := shared.IssueNumberAndRepoFromArg(issueSelector) - if err != nil { - return 0, nil, err - } - - if issueArgRepo != nil { - targetRepo = issueArgRepo - - if issueFlagRepo != nil { - differentOwner := (issueFlagRepo.RepoOwner() != issueArgRepo.RepoOwner()) - differentName := (issueFlagRepo.RepoName() != issueArgRepo.RepoName()) - if differentOwner || differentName { - return 0, nil, fmt.Errorf("issue repo in url %s/%s does not match the repo from --issue-repo %s/%s", issueArgRepo.RepoOwner(), issueArgRepo.RepoName(), issueFlagRepo.RepoOwner(), issueFlagRepo.RepoName()) - } - } - } - - if issueFlagRepo == nil && issueArgRepo == nil { - targetRepo = baseRepo - } - - if targetRepo == nil { - return 0, nil, fmt.Errorf("could not determine issue repo") - } - - return issueNumber, targetRepo, nil -} - -func printLinkedBranches(io *iostreams.IOStreams, branches []api.LinkedBranch) { - cs := io.ColorScheme() - table := tableprinter.New(io) - - for _, branch := range branches { - table.AddField(branch.BranchName, tableprinter.WithColor(cs.ColorFromString("cyan"))) - if io.CanPrompt() { - table.AddField(branch.Url()) - } - table.EndRow() - } - - _ = table.Render() -} - -func developRunList(opts *DevelopOptions) (err error) { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - - apiClient := api.NewClientFromHTTP(httpClient) - baseRepo, err := opts.BaseRepo() - if err != nil { - return err + oid = fallbackOID } opts.IO.StartProgressIndicator() - - err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) - if err != nil { - return err - } - issueNumber, issueRepo, err := issueMetadata(opts.IssueSelector, opts.IssueRepoSelector, baseRepo) - if err != nil { - return err - } - - branches, err := api.ListLinkedBranches(apiClient, issueRepo, issueNumber) - if err != nil { - return err - } - + branchName, err := api.CreateLinkedBranch(apiClient, baseRepo.RepoHost(), issue.ID, opts.Name, oid) opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.Out, "%s/%s/%s/tree/%s\n", baseRepo.RepoHost(), baseRepo.RepoOwner(), baseRepo.RepoName(), branchName) + + return checkoutBranch(opts, baseRepo, branchName) +} + +func developRunList(opts *DevelopOptions, apiClient *api.Client, baseRepo ghrepo.Interface, issue *api.Issue) error { + opts.IO.StartProgressIndicator() + branches, err := api.ListLinkedBranches(apiClient, baseRepo, issue.Number) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } if len(branches) == 0 { - return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s/%s#%d", issueRepo.RepoOwner(), issueRepo.RepoName(), issueNumber)) + return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s/%s#%d", baseRepo.RepoOwner(), baseRepo.RepoName(), issue.Number)) } if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "\nShowing linked branches for %s/%s#%d\n\n", issueRepo.RepoOwner(), issueRepo.RepoName(), issueNumber) + fmt.Fprintf(opts.IO.Out, "\nShowing linked branches for %s/%s#%d\n\n", baseRepo.RepoOwner(), baseRepo.RepoName(), issue.Number) } printLinkedBranches(opts.IO, branches) @@ -240,6 +167,17 @@ func developRunList(opts *DevelopOptions) (err error) { return nil } +func printLinkedBranches(io *iostreams.IOStreams, branches []api.LinkedBranch) { + cs := io.ColorScheme() + table := tableprinter.New(io) + for _, branch := range branches { + table.AddField(branch.BranchName, tableprinter.WithColor(cs.ColorFromString("cyan"))) + table.AddField(branch.URL) + table.EndRow() + } + _ = table.Render() +} + func checkoutBranch(opts *DevelopOptions, baseRepo ghrepo.Interface, checkoutBranch string) (err error) { remotes, err := opts.Remotes() if err != nil { diff --git a/pkg/cmd/issue/develop/develop_test.go b/pkg/cmd/issue/develop/develop_test.go index 1f39353b2..1d8962847 100644 --- a/pkg/cmd/issue/develop/develop_test.go +++ b/pkg/cmd/issue/develop/develop_test.go @@ -1,418 +1,274 @@ package develop import ( + "bytes" "errors" "net/http" "testing" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" - "github.com/cli/cli/v2/test" + "github.com/google/shlex" "github.com/stretchr/testify/assert" ) -func Test_developRun(t *testing.T) { - featureEnabledPayload := `{ - "data": { - "LinkedBranch": { - "fields": [ - { - "name": "id" - }, - { - "name": "ref" - } - ] - } - } - }` +func TestNewCmdDevelop(t *testing.T) { + tests := []struct { + name string + input string + output DevelopOptions + wantStdout string + wantStderr string + wantErr bool + errMsg string + }{ + { + name: "no argument", + input: "", + output: DevelopOptions{}, + wantErr: true, + errMsg: "issue number or url is required", + }, + { + name: "issue number", + input: "1", + output: DevelopOptions{ + IssueSelector: "1", + }, + }, + { + name: "issue url", + input: "https://github.com/cli/cli/issues/1", + output: DevelopOptions{ + IssueSelector: "https://github.com/cli/cli/issues/1", + }, + }, + { + name: "base flag", + input: "1 --base feature", + output: DevelopOptions{ + IssueSelector: "1", + BaseBranch: "feature", + }, + }, + { + name: "checkout flag", + input: "1 --checkout", + output: DevelopOptions{ + IssueSelector: "1", + Checkout: true, + }, + }, + { + name: "list flag", + input: "1 --list", + output: DevelopOptions{ + IssueSelector: "1", + List: true, + }, + }, + { + name: "name flag", + input: "1 --name feature", + output: DevelopOptions{ + IssueSelector: "1", + Name: "feature", + }, + }, + { + name: "issue-repo flag", + input: "1 --issue-repo cli/cli", + output: DevelopOptions{ + IssueSelector: "1", + }, + wantStdout: "Flag --issue-repo has been deprecated, use `--repo` instead\n", + }, + } - featureDisabledPayload := `{ "data": { "LinkedBranch": null } }` + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdOut, stdErr := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *DevelopOptions + cmd := NewCmdDevelop(f, func(opts *DevelopOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(stdOut) + cmd.SetErr(stdErr) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.output.IssueSelector, gotOpts.IssueSelector) + assert.Equal(t, tt.output.Name, gotOpts.Name) + assert.Equal(t, tt.output.BaseBranch, gotOpts.BaseBranch) + assert.Equal(t, tt.output.Checkout, gotOpts.Checkout) + assert.Equal(t, tt.output.List, gotOpts.List) + assert.Equal(t, tt.wantStdout, stdOut.String()) + assert.Equal(t, tt.wantStderr, stdErr.String()) + }) + } +} + +func TestDevelopRun(t *testing.T) { + featureEnabledPayload := `{"data":{"LinkedBranch":{"fields":[{"name":"id"},{"name":"ref"}]}}}` + featureDisabledPayload := `{"data":{"LinkedBranch":null}}` tests := []struct { name string - setup func(*DevelopOptions, *testing.T) func() + opts *DevelopOptions cmdStubs func(*run.CommandStubber) runStubs func(*run.CommandStubber) remotes map[string]string - askStubs func(*prompt.AskStubber) // TODO eventually migrate to PrompterMock httpStubs func(*httpmock.Registry, *testing.T) expectedOut string expectedErrOut string - expectedBrowse string wantErr string tty bool }{ { - name: "list branches for an issue", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "42" - opts.List = true - return func() {} + name: "returns an error when the feature is not supported by the API", + opts: &DevelopOptions{ + IssueSelector: "42", + List: true, }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), - httpmock.StringResponse(featureEnabledPayload), + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`), ) reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), - httpmock.GraphQLQuery(`{ - "data": { - "repository": { - "issue": { - "linkedBranches": { - "edges": [ - { - "node": { - "ref": { - "name": "foo" - } - } - }, - { - "node": { - "ref": { - "name": "bar" - } - } - } - ] - } - } - } - } - } - `, func(query string, inputs map[string]interface{}) { - assert.Equal(t, float64(42), inputs["issueNumber"]) - assert.Equal(t, "OWNER", inputs["repositoryOwner"]) - assert.Equal(t, "REPO", inputs["repositoryName"]) - })) - }, - expectedOut: "foo\nbar\n", - }, - { - name: "list branches for an issue in tty", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "42" - opts.List = true - return func() {} - }, - tty: true, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), - httpmock.StringResponse(featureEnabledPayload), - ) - reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), - httpmock.GraphQLQuery(`{ - "data": { - "repository": { - "issue": { - "linkedBranches": { - "edges": [ - { - "node": { - "ref": { - "name": "foo", - "repository": { - "url": "http://github.localhost/OWNER/REPO" - } - } - } - }, - { - "node": { - "ref": { - "name": "bar", - "repository": { - "url": "http://github.localhost/OWNER/OTHER-REPO" - } - } - } - } - ] - } - } - } - } - } - `, func(query string, inputs map[string]interface{}) { - assert.Equal(t, float64(42), inputs["issueNumber"]) - assert.Equal(t, "OWNER", inputs["repositoryOwner"]) - assert.Equal(t, "REPO", inputs["repositoryName"]) - })) - }, - expectedOut: "\nShowing linked branches for OWNER/REPO#42\n\nfoo http://github.localhost/OWNER/REPO/tree/foo\nbar http://github.localhost/OWNER/OTHER-REPO/tree/bar\n", - }, - { - name: "list branches for an issue providing an issue url", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" - opts.List = true - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), - httpmock.StringResponse(featureEnabledPayload), - ) - reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), - httpmock.GraphQLQuery(`{ - "data": { - "repository": { - "issue": { - "linkedBranches": { - "edges": [ - { - "node": { - "ref": { - "name": "foo" - } - } - }, - { - "node": { - "ref": { - "name": "bar" - } - } - } - ] - } - } - } - } - } - `, func(query string, inputs map[string]interface{}) { - assert.Equal(t, float64(42), inputs["issueNumber"]) - assert.Equal(t, "cli", inputs["repositoryOwner"]) - assert.Equal(t, "test-repo", inputs["repositoryName"]) - })) - }, - expectedOut: "foo\nbar\n", - }, - { - name: "list branches for an issue providing an issue repo", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "42" - opts.IssueRepoSelector = "cli/test-repo" - opts.List = true - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), - httpmock.StringResponse(featureEnabledPayload), - ) - reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), - httpmock.GraphQLQuery(`{ - "data": { - "repository": { - "issue": { - "linkedBranches": { - "edges": [ - { - "node": { - "ref": { - "name": "foo" - } - } - }, - { - "node": { - "ref": { - "name": "bar" - } - } - } - ] - } - } - } - } - } - `, func(query string, inputs map[string]interface{}) { - assert.Equal(t, float64(42), inputs["issueNumber"]) - assert.Equal(t, "cli", inputs["repositoryOwner"]) - assert.Equal(t, "test-repo", inputs["repositoryName"]) - })) - }, - expectedOut: "foo\nbar\n", - }, - { - name: "list branches for an issue providing an issue url and specifying the same repo works", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" - opts.IssueRepoSelector = "cli/test-repo" - opts.List = true - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), - httpmock.StringResponse(featureEnabledPayload), - ) - reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), - httpmock.GraphQLQuery(`{ - "data": { - "repository": { - "issue": { - "linkedBranches": { - "edges": [ - { - "node": { - "ref": { - "name": "foo" - } - } - }, - { - "node": { - "ref": { - "name": "bar" - } - } - } - ] - } - } - } - } - } - `, func(query string, inputs map[string]interface{}) { - assert.Equal(t, float64(42), inputs["issueNumber"]) - assert.Equal(t, "cli", inputs["repositoryOwner"]) - assert.Equal(t, "test-repo", inputs["repositoryName"]) - })) - }, - expectedOut: "foo\nbar\n", - }, - { - name: "list branches for an issue providing an issue url and specifying a different repo returns an error", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" - opts.IssueRepoSelector = "cli/other" - opts.List = true - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), - httpmock.StringResponse(featureEnabledPayload), - ) - }, - wantErr: "issue repo in url cli/test-repo does not match the repo from --issue-repo cli/other", - }, - { - name: "returns an error when the feature isn't enabled in the GraphQL API", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" - opts.List = true - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.GraphQL(`query LinkedBranchFeature\b`), httpmock.StringResponse(featureDisabledPayload), ) }, wantErr: "the `gh issue develop` command is not currently available", }, { - name: "develop new branch with name specified", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.Name = "my-branch" - opts.BaseBranch = "main" - opts.IssueSelector = "123" - return func() {} - }, - remotes: map[string]string{ - "origin": "OWNER/REPO", + name: "list branches for an issue", + opts: &DevelopOptions{ + IssueSelector: "42", + List: true, }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), httpmock.StringResponse(featureEnabledPayload), ) reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } }`), - ) - reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) - reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) - - reg.Register( - httpmock.GraphQL(`(?s)mutation CreateLinkedBranch\b.*issueId: \$issueId,\s+name: \$name,\s+oid: \$oid,`), - httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`, - func(query string, inputs map[string]interface{}) { - assert.Equal(t, "REPOID", inputs["repositoryId"]) - assert.Equal(t, "my-branch", inputs["name"]) - assert.Equal(t, "yar", inputs["issueId"]) - }), - ) + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + })) }, - runStubs: func(cs *run.CommandStubber) { - cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") - }, - expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + expectedOut: "foo\thttps://github.com/OWNER/REPO/tree/foo\nbar\thttps://github.com/OWNER/REPO/tree/bar\n", }, { - name: "develop new branch without a name provided omits the param from the mutation", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.Name = "" - opts.BaseBranch = "main" - opts.IssueSelector = "123" - return func() {} + name: "list branches for an issue in tty", + opts: &DevelopOptions{ + IssueSelector: "42", + List: true, + }, + tty: true, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/OTHER-REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + })) + }, + expectedOut: "\nShowing linked branches for OWNER/REPO#42\n\nfoo https://github.com/OWNER/REPO/tree/foo\nbar https://github.com/OWNER/OTHER-REPO/tree/bar\n", + }, + { + name: "list branches for an issue providing an issue url", + opts: &DevelopOptions{ + IssueSelector: "https://github.com/cli/cli/issues/42", + List: true, + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/OTHER-REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["number"]) + assert.Equal(t, "cli", inputs["owner"]) + assert.Equal(t, "cli", inputs["name"]) + })) + }, + expectedOut: "foo\thttps://github.com/OWNER/REPO/tree/foo\nbar\thttps://github.com/OWNER/OTHER-REPO/tree/bar\n", + }, + { + name: "develop new branch", + opts: &DevelopOptions{ + IssueSelector: "123", }, remotes: map[string]string{ "origin": "OWNER/REPO", }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.GraphQL(`query LinkedBranchFeature\b`), httpmock.StringResponse(featureEnabledPayload), ) reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } }`), + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) + httpmock.GraphQL(`query FindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`), + ) reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) - - reg.Register( - httpmock.GraphQL(`(?s)mutation CreateLinkedBranch\b.*\$oid: GitObjectID!, \$repositoryId:.*issueId: \$issueId,\s+oid: \$oid,`), - httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-issue-1"} } } } }`, - func(query string, inputs map[string]interface{}) { - assert.Equal(t, "REPOID", inputs["repositoryId"]) - assert.Equal(t, "", inputs["name"]) - assert.Equal(t, "yar", inputs["issueId"]) + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "DEFAULTOID", inputs["oid"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) }), ) }, @@ -422,123 +278,148 @@ func Test_developRun(t *testing.T) { expectedOut: "github.com/OWNER/REPO/tree/my-issue-1\n", }, { - name: "develop providing an issue url and specifying a different repo returns an error", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" - opts.IssueRepoSelector = "cli/other" - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), - httpmock.StringResponse(featureEnabledPayload), - ) - reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } }`), - ) - }, - wantErr: "issue repo in url cli/test-repo does not match the repo from --issue-repo cli/other", - }, - { - name: "develop new branch with checkout when the branch exists locally", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.Name = "my-branch" - opts.BaseBranch = "main" - opts.IssueSelector = "123" - opts.Checkout = true - return func() {} + name: "develop new branch with name and base specified", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "main", + IssueSelector: "123", }, remotes: map[string]string{ "origin": "OWNER/REPO", }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.GraphQL(`query LinkedBranchFeature\b`), httpmock.StringResponse(featureEnabledPayload), ) reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } }`), + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) - reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) - + httpmock.GraphQL(`query FindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"OID"}}}}}`)) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), - httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`, - func(query string, inputs map[string]interface{}) { - assert.Equal(t, "REPOID", inputs["repositoryId"]) + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, + func(inputs map[string]interface{}) { assert.Equal(t, "my-branch", inputs["name"]) - assert.Equal(t, "yar", inputs["issueId"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) }), ) }, runStubs: func(cs *run.CommandStubber) { - cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "") cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + }, + expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + }, + { + name: "develop new branch outside of local git repo", + opts: &DevelopOptions{ + IssueSelector: "https://github.com/cli/cli/issues/123", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query FindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`), + ) + reg.Register( + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "DEFAULTOID", inputs["oid"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) + }), + ) + }, + expectedOut: "github.com/cli/cli/tree/my-issue-1\n", + }, + { + name: "develop new branch with checkout when local branch exists", + opts: &DevelopOptions{ + Name: "my-branch", + IssueSelector: "123", + Checkout: true, + }, + remotes: map[string]string{ + "origin": "OWNER/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query FindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"OID"}}}}}`), + ) + reg.Register( + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "my-branch", inputs["name"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) + }), + ) + }, + runStubs: func(cs *run.CommandStubber) { + cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "") cs.Register(`git checkout my-branch`, 0, "") cs.Register(`git pull --ff-only origin my-branch`, 0, "") }, expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", }, { - name: "develop new branch with checkout when the branch does not exist locally", - setup: func(opts *DevelopOptions, t *testing.T) func() { - opts.Name = "my-branch" - opts.BaseBranch = "main" - opts.IssueSelector = "123" - opts.Checkout = true - return func() {} + name: "develop new branch with checkout when local branch does not exist", + opts: &DevelopOptions{ + Name: "my-branch", + IssueSelector: "123", + Checkout: true, }, remotes: map[string]string{ "origin": "OWNER/REPO", }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( - httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.GraphQL(`query LinkedBranchFeature\b`), httpmock.StringResponse(featureEnabledPayload), ) reg.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } }`), + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) - reg.Register( - httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) - + httpmock.GraphQL(`query FindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"OID"}}}}}`), + ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), - httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`, - func(query string, inputs map[string]interface{}) { - assert.Equal(t, "REPOID", inputs["repositoryId"]) + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, + func(inputs map[string]interface{}) { assert.Equal(t, "my-branch", inputs["name"]) - assert.Equal(t, "yar", inputs["issueId"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) }), ) }, runStubs: func(cs *run.CommandStubber) { - cs.Register(`git rev-parse --verify refs/heads/my-branch`, 1, "") cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/my-branch`, 1, "") cs.Register(`git checkout -b my-branch --track origin/my-branch`, 0, "") }, expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", @@ -546,16 +427,18 @@ func Test_developRun(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + opts := tt.opts + reg := &httpmock.Registry{} defer reg.Verify(t) if tt.httpStubs != nil { tt.httpStubs(reg, t) } - - opts := DevelopOptions{} + opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) ios.SetStderrTTY(tt.tty) @@ -564,12 +447,6 @@ func Test_developRun(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } - opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - opts.Config = func() (config.Config, error) { - return config.NewBlankConfig(), nil - } opts.Remotes = func() (context.Remotes, error) { if len(tt.remotes) == 0 { @@ -600,29 +477,14 @@ func Test_developRun(t *testing.T) { tt.runStubs(cmdStubs) } - cleanSetup := func() {} - if tt.setup != nil { - cleanSetup = tt.setup(&opts, t) - } - defer cleanSetup() - - var err error - if opts.List { - err = developRunList(&opts) - } else { - - err = developRunCreate(&opts) - } - output := &test.CmdOut{ - OutBuf: stdout, - ErrBuf: stderr, - } + err := developRun(opts) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) + return } else { assert.NoError(t, err) - assert.Equal(t, tt.expectedOut, output.String()) - assert.Equal(t, tt.expectedErrOut, output.Stderr()) + assert.Equal(t, tt.expectedOut, stdout.String()) + assert.Equal(t, tt.expectedErrOut, stderr.String()) } }) } diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go index 520463f64..dc3365696 100644 --- a/pkg/cmd/project/project.go +++ b/pkg/cmd/project/project.go @@ -24,7 +24,7 @@ import ( func NewCmdProject(f *cmdutil.Factory) *cobra.Command { var cmd = &cobra.Command{ - Use: "project [flags]", + Use: "project ", Short: "Work with GitHub Projects.", Long: "Work with GitHub Projects. Note that the token you are using must have 'project' scope, which is not set by default. You can verify your token scope by running 'gh auth status' and add the project scope by running 'gh auth refresh -s project'.", Example: heredoc.Doc(` diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index c29de543a..3e6cd9ffb 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -63,7 +63,7 @@ func runCancel(opts *CancelOptions) error { if opts.RunID != "" { _, err := strconv.Atoi(opts.RunID) if err != nil { - return fmt.Errorf("invalid run_id %#v", opts.RunID) + return fmt.Errorf("invalid run-id %#v", opts.RunID) } } httpClient, err := opts.HttpClient() diff --git a/pkg/cmd/run/cancel/cancel_test.go b/pkg/cmd/run/cancel/cancel_test.go index 78906ad2e..d3f580870 100644 --- a/pkg/cmd/run/cancel/cancel_test.go +++ b/pkg/cmd/run/cancel/cancel_test.go @@ -204,14 +204,14 @@ func TestRunCancel(t *testing.T) { wantOut: "✓ Request to cancel workflow 1234 submitted.\n", }, { - name: "invalid run_id", + name: "invalid run-id", opts: &CancelOptions{ RunID: "12\n34", }, httpStubs: func(reg *httpmock.Registry) { }, wantErr: true, - errMsg: "invalid run_id \"12\\n34\"", + errMsg: "invalid run-id \"12\\n34\"", }, } From 87fabebb68984c4e6d5ac9447a244191975d83c3 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 6 Feb 2023 15:31:36 -0800 Subject: [PATCH 008/103] start on gh rs --- pkg/cmd/ruleset/check/check.go | 65 ++++++++++++++++++++++++++++++++++ pkg/cmd/ruleset/ruleset.go | 26 ++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 pkg/cmd/ruleset/check/check.go create mode 100644 pkg/cmd/ruleset/ruleset.go diff --git a/pkg/cmd/ruleset/check/check.go b/pkg/cmd/ruleset/check/check.go new file mode 100644 index 000000000..a143981b1 --- /dev/null +++ b/pkg/cmd/ruleset/check/check.go @@ -0,0 +1,65 @@ +package check + +import ( + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type CheckOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + BaseRepo func() (ghrepo.Interface, error) + + Branch string +} + +func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command { + opts := &CheckOptions{ + IO: f.IOStreams, + Config: f.Config, + HttpClient: f.HttpClient, + } + cmd := &cobra.Command{ + Use: "check []", + Short: "Print rules that would apply to a given branch", + Long: heredoc.Doc(` + TODO + `), + Example: "TODO", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + // TODO flag to do a push + + if len(args) > 0 { + opts.Branch = args[0] + } + + if runF != nil { + return runF(opts) + } + + return checkRun(opts) + }, + } + + return cmd +} + +func checkRun(opts *CheckOptions) error { + // TODO sniff local branch if opts.Branch is empty + // TODO ask about pushing (if interactive) + // TODO error if not interactive and --push not specified + + // is the --push redundant? like, it needs to be specified every time for scripted use. can i tell if a branch is up to date with remote without a push? i could figure that out i think and then would know a push wasn't needed (but it will require a fetch per invocation. that seems fine?) + return nil +} diff --git a/pkg/cmd/ruleset/ruleset.go b/pkg/cmd/ruleset/ruleset.go new file mode 100644 index 000000000..2fa86684c --- /dev/null +++ b/pkg/cmd/ruleset/ruleset.go @@ -0,0 +1,26 @@ +package ruleset + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "ruleset ", + Short: "Manage repository and organization rulesets", + Long: heredoc.Doc(` + TODO + `), + Aliases: []string{"rs"}, + Example: "TODO", + } + + cmdutil.EnableRepoOverride(cmd, f) + // cmd.AddCommand(cmdList.NewCmdList(f, nil) + // cmd.AddCommand(cmdList.NewCmdView(f, nil) + // cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil) + + return cmd +} From bcb4194692020200cf89d8892918594a3905f7e5 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 8 Feb 2023 11:41:09 -0800 Subject: [PATCH 009/103] WIP compute branch, call API --- pkg/cmd/root/root.go | 2 ++ pkg/cmd/ruleset/check/check.go | 61 ++++++++++++++++++++++++++++++++-- pkg/cmd/ruleset/ruleset.go | 3 +- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 2929ca720..6957ff7b6 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -31,6 +31,7 @@ import ( releaseCmd "github.com/cli/cli/v2/pkg/cmd/release" repoCmd "github.com/cli/cli/v2/pkg/cmd/repo" creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits" + rulesetCmd "github.com/cli/cli/v2/pkg/cmd/ruleset" runCmd "github.com/cli/cli/v2/pkg/cmd/run" searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" @@ -152,6 +153,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory)) cmd.AddCommand(releaseCmd.NewCmdRelease(&repoResolvingCmdFactory)) cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) + cmd.AddCommand(rulesetCmd.NewCmdRuleset(&repoResolvingCmdFactory)) cmd.AddCommand(runCmd.NewCmdRun(&repoResolvingCmdFactory)) cmd.AddCommand(workflowCmd.NewCmdWorkflow(&repoResolvingCmdFactory)) cmd.AddCommand(labelCmd.NewCmdLabel(&repoResolvingCmdFactory)) diff --git a/pkg/cmd/ruleset/check/check.go b/pkg/cmd/ruleset/check/check.go index a143981b1..34c708c69 100644 --- a/pkg/cmd/ruleset/check/check.go +++ b/pkg/cmd/ruleset/check/check.go @@ -1,9 +1,14 @@ package check import ( + "context" + "fmt" "net/http" + "net/url" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" @@ -16,8 +21,10 @@ type CheckOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) + Git *git.Client - Branch string + Branch string + Default bool } func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command { @@ -25,6 +32,7 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + Git: f.GitClient, } cmd := &cobra.Command{ Use: "check []", @@ -48,18 +56,65 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm return runF(opts) } + if opts.Branch != "" && opts.Default { + return cmdutil.FlagErrorf( + "branch argument '%s' and --default mutually exclusive", opts.Branch) + } + return checkRun(opts) }, } + cmd.Flags().BoolVar(&opts.Default, "default", false, "Check rules on default branch") + return cmd } func checkRun(opts *CheckOptions) error { - // TODO sniff local branch if opts.Branch is empty // TODO ask about pushing (if interactive) // TODO error if not interactive and --push not specified + // TODO parsing for errors on push + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + client := api.NewClientFromHTTP(httpClient) + + repoI, err := opts.BaseRepo() + if err != nil { + return err + } + + git := opts.Git + + if opts.Default { + repo, err := api.GitHubRepo(client, repoI) + if err != nil { + return fmt.Errorf("could not get repository information: %w", err) + } + opts.Branch = repo.DefaultBranchRef.Name + } + + if opts.Branch == "" { + opts.Branch, err = git.CurrentBranch(context.Background()) + if err != nil { + return fmt.Errorf("could not determine current branch: %w", err) + } + } + + var lol interface{} + + endpoint := fmt.Sprintf("repos/%s/%s/rules/branches/%s", repoI.RepoOwner(), repoI.RepoName(), url.PathEscape(opts.Branch)) + + if err = client.REST(repoI.RepoHost(), "GET", endpoint, nil, &lol); err != nil { + return fmt.Errorf("GET %s failed: %w", endpoint, err) + } + + // TODO handle 404s gracefully + // TODO actually parse JSON + + fmt.Printf("DBG %#v\n", lol) - // is the --push redundant? like, it needs to be specified every time for scripted use. can i tell if a branch is up to date with remote without a push? i could figure that out i think and then would know a push wasn't needed (but it will require a fetch per invocation. that seems fine?) return nil } diff --git a/pkg/cmd/ruleset/ruleset.go b/pkg/cmd/ruleset/ruleset.go index 2fa86684c..266fbc93e 100644 --- a/pkg/cmd/ruleset/ruleset.go +++ b/pkg/cmd/ruleset/ruleset.go @@ -2,6 +2,7 @@ package ruleset import ( "github.com/MakeNowJust/heredoc" + cmdCheck "github.com/cli/cli/v2/pkg/cmd/ruleset/check" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -20,7 +21,7 @@ func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) // cmd.AddCommand(cmdList.NewCmdList(f, nil) // cmd.AddCommand(cmdList.NewCmdView(f, nil) - // cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil) + cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil)) return cmd } From 79113bd304eb9c8e14a4771011f4d3709eedc1be Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 8 Feb 2023 18:30:04 -0800 Subject: [PATCH 010/103] start writing up structs for rules API payloads --- pkg/cmd/ruleset/rules.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 pkg/cmd/ruleset/rules.go diff --git a/pkg/cmd/ruleset/rules.go b/pkg/cmd/ruleset/rules.go new file mode 100644 index 000000000..b3405d839 --- /dev/null +++ b/pkg/cmd/ruleset/rules.go @@ -0,0 +1,39 @@ +package ruleset + +type RuleType string +type Enforcement string +type MatchingOperator string + +const ( + RuleTypeCommitAuthorEmailPattern RuleType = "commit_author_email_pattern" + RuleTypePullRequest RuleType = "pull_request" + // TODO others + + EnforcementEnabled Enforcement = "enabled" + EnforcementDisabled Enforcement = "disabled" + + MatchingOperatorStartsWith MatchingOperator = "starts_with" + MatchingOperatorEndsWith MatchingOperator = "ends_with" + MatchingOperatorContains MatchingOperator = "contains" + MatchingOperatorRegex MatchingOperator = "regex" +) + +type ConfigurationPullRequest struct { + DissmissStaleReviewsOnPush bool + RequireCodeOwnerReview bool + RequestLastPushApproval bool + RequiredApprovingReviewCount int +} + +type BranchNamePattern struct { + Name string + Negate bool + Operator MatchingOperator +} + +type Rule struct { + ID string + Type RuleType + Enforcement Enforcement + Configuration interface{} +} From 24911ffa240796332424b743dd6495be5fbc08fa Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 14 Feb 2023 15:20:10 -0800 Subject: [PATCH 011/103] start on rs list --- pkg/cmd/ruleset/list/list.go | 56 ++++++++++++++++++++++++++++++++++++ pkg/cmd/ruleset/ruleset.go | 5 ++-- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/ruleset/list/list.go diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go new file mode 100644 index 000000000..ca0505f0b --- /dev/null +++ b/pkg/cmd/ruleset/list/list.go @@ -0,0 +1,56 @@ +package list + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + Organization string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + cmd := &cobra.Command{ + Use: "list", + Short: "List rulesets for a repository or organization", + Long: heredoc.Doc(` + TODO + `), + Example: "TODO", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + + return listRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rules") + + return cmd +} + +func listRun(opts *ListOptions) error { + fmt.Println(opts.Organization) + fmt.Println("LOL TODO") + return nil +} diff --git a/pkg/cmd/ruleset/ruleset.go b/pkg/cmd/ruleset/ruleset.go index 266fbc93e..f7d7799b6 100644 --- a/pkg/cmd/ruleset/ruleset.go +++ b/pkg/cmd/ruleset/ruleset.go @@ -3,6 +3,7 @@ package ruleset import ( "github.com/MakeNowJust/heredoc" cmdCheck "github.com/cli/cli/v2/pkg/cmd/ruleset/check" + cmdList "github.com/cli/cli/v2/pkg/cmd/ruleset/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -19,8 +20,8 @@ func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command { } cmdutil.EnableRepoOverride(cmd, f) - // cmd.AddCommand(cmdList.NewCmdList(f, nil) - // cmd.AddCommand(cmdList.NewCmdView(f, nil) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + // cmd.AddCommand(cmdList.NewCmdView(f, nil)) cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil)) return cmd From a742e9f8dfc05a1bec0faa55b42f4c0cf222019d Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 15 Feb 2023 12:51:06 -0800 Subject: [PATCH 012/103] wip --- pkg/cmd/ruleset/list/list.go | 45 ++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index ca0505f0b..251b887b6 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -5,6 +5,8 @@ import ( "net/http" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -14,6 +16,7 @@ import ( type ListOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams + Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) Organization string @@ -23,6 +26,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Config: f.Config, } cmd := &cobra.Command{ Use: "list", @@ -49,8 +53,45 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmd } +type Ruleset struct { + // TODO +} + func listRun(opts *ListOptions) error { - fmt.Println(opts.Organization) - fmt.Println("LOL TODO") + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + client := api.NewClientFromHTTP(httpClient) + + repoI, err := opts.BaseRepo() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + endpoint := fmt.Sprintf("repos/%s/%s/rulesets", + repoI.RepoOwner(), repoI.RepoName()) + hostname := repoI.RepoHost() + + if opts.Organization != "" { + endpoint = fmt.Sprintf("orgs/%s/rulesets", opts.Organization) + hostname, _ = cfg.DefaultHost() + } + + //var response []Ruleset + var response interface{} + + err = client.REST(hostname, "GET", endpoint, nil, &response) + if err != nil { + return fmt.Errorf("failed to call '%s': %w", endpoint, err) + } + + fmt.Printf("DBG %#v\n", response) + return nil } From bb9a9fa3c2c598145c64714cf03160486e21b360 Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 7 Apr 2023 17:14:03 -0400 Subject: [PATCH 013/103] GQL query works but it's messy --- api/client.go | 2 +- pkg/cmd/ruleset/list/http.go | 94 ++++++++++++++++++++++++++++++++++++ pkg/cmd/ruleset/list/list.go | 64 ++++++++++++++++-------- 3 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 pkg/cmd/ruleset/list/http.go diff --git a/api/client.go b/api/client.go index e32856554..0d2690378 100644 --- a/api/client.go +++ b/api/client.go @@ -19,7 +19,7 @@ const ( authorization = "Authorization" cacheTTL = "X-GH-CACHE-TTL" graphqlFeatures = "GraphQL-Features" - features = "merge_queue" + features = "merge_queue,push_policies" userAgent = "User-Agent" ) diff --git a/pkg/cmd/ruleset/list/http.go b/pkg/cmd/ruleset/list/http.go new file mode 100644 index 000000000..aa58a8a70 --- /dev/null +++ b/pkg/cmd/ruleset/list/http.go @@ -0,0 +1,94 @@ +package list + +import ( + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" +) + +type RepositoryRuleset struct { + Id string + Name string + Target string + // Enforcement string + Conditions struct { + RefName struct { + Include []string + Exclude []string + } + RepositoryName struct { + Include []string + Exclude []string + } + } +} + +type RepositoryRulesetList struct { + TotalCount int + Rulesets []RepositoryRuleset +} + +func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) (*RepositoryRulesetList, error) { + type response struct { + Repository struct { + Rulesets struct { + TotalCount int + Nodes []RepositoryRuleset + } + } + } + + query := ` + query RepositoryRulesetList( + $owner: String!, + $repo: String!, + $limit: Int! + ) { + repository(owner: $owner, name: $repo) { + rulesets(first: $limit) { + totalCount + nodes { + id + name + target + conditions { + refName { + include + exclude + } + repositoryName { + include + exclude + protected + } + }, + rules { + totalCount + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "limit": limit, + } + + client := api.NewClientFromHTTP(httpClient) + var res response + err := client.GraphQL(repo.RepoHost(), query, variables, &res) + if err != nil { + return nil, err + } + + list := RepositoryRulesetList{ + TotalCount: res.Repository.Rulesets.TotalCount, + Rulesets: res.Repository.Rulesets.Nodes, + } + + return &list, nil +} diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 251b887b6..c7bffd94b 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -3,11 +3,14 @@ package list import ( "fmt" "net/http" + "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -18,7 +21,10 @@ type ListOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + Limit int + WebMode bool Organization string } @@ -40,6 +46,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman // support `-R, --repo` override opts.BaseRepo = f.BaseRepo + // if opts.Limit < 1 { + // return cmdutil.FlagErrorf("invalid value for --limit: %v", opts.Limit) + // } + if runF != nil { return runF(opts) } @@ -48,13 +58,21 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of rules to list") cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rules") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List rules in the web browser") return cmd } type Ruleset struct { - // TODO + Id string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + SourceType string `json:"source_type"` + Source string `json:"source"` + Enforcement string `json:"enforcement"` + BypassMode string `json:"bypass_mode"` } func listRun(opts *ListOptions) error { @@ -62,36 +80,40 @@ func listRun(opts *ListOptions) error { if err != nil { return err } - client := api.NewClientFromHTTP(httpClient) repoI, err := opts.BaseRepo() if err != nil { return err } - cfg, err := opts.Config() + if opts.WebMode { + rulesetURL := ghrepo.GenerateRepoURL(repoI, "settings/rules") + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL)) + } + + return opts.Browser.Browse(rulesetURL) + } + + result, err := listRepoRulesets(httpClient, repoI, opts.Limit) if err != nil { return err } - endpoint := fmt.Sprintf("repos/%s/%s/rulesets", - repoI.RepoOwner(), repoI.RepoName()) - hostname := repoI.RepoHost() + cs := opts.IO.ColorScheme() - if opts.Organization != "" { - endpoint = fmt.Sprintf("orgs/%s/rulesets", opts.Organization) - hostname, _ = cfg.DefaultHost() + tp := tableprinter.New(opts.IO) + tp.HeaderRow("ID", "NAME" /* "STATUS",*/, "TARGET") + + for _, rs := range result.Rulesets { + tp.AddField(rs.Id) + tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) + // tp.AddField(strings.ToLower(rs.Enforcement)) + tp.AddField(strings.ToLower(rs.Target)) + tp.EndRow() } - //var response []Ruleset - var response interface{} - - err = client.REST(hostname, "GET", endpoint, nil, &response) - if err != nil { - return fmt.Errorf("failed to call '%s': %w", endpoint, err) - } - - fmt.Printf("DBG %#v\n", response) - - return nil + return tp.Render() } + +// func getRulesets() From 7b2c8aba8c5ec9add1f9e01c48ada235fae628d7 Mon Sep 17 00:00:00 2001 From: vaindil Date: Mon, 10 Apr 2023 17:13:50 -0400 Subject: [PATCH 014/103] refactor and support org rulesets --- pkg/cmd/ruleset/list/http.go | 174 +++++++++++++++++++++++------------ pkg/cmd/ruleset/list/list.go | 25 ++--- 2 files changed, 128 insertions(+), 71 deletions(-) diff --git a/pkg/cmd/ruleset/list/http.go b/pkg/cmd/ruleset/list/http.go index aa58a8a70..22e218974 100644 --- a/pkg/cmd/ruleset/list/http.go +++ b/pkg/cmd/ruleset/list/http.go @@ -7,7 +7,20 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" ) -type RepositoryRuleset struct { +type RulesetResponse struct { + Level struct { + Rulesets struct { + TotalCount int + Nodes []Ruleset + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + } +} + +type Ruleset struct { Id string Name string Target string @@ -24,71 +37,112 @@ type RepositoryRuleset struct { } } -type RepositoryRulesetList struct { +type RulesetList struct { TotalCount int - Rulesets []RepositoryRuleset + Rulesets []Ruleset } -func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) (*RepositoryRulesetList, error) { - type response struct { - Repository struct { - Rulesets struct { - TotalCount int - Nodes []RepositoryRuleset - } - } - } - - query := ` - query RepositoryRulesetList( - $owner: String!, - $repo: String!, - $limit: Int! - ) { - repository(owner: $owner, name: $repo) { - rulesets(first: $limit) { - totalCount - nodes { - id - name - target - conditions { - refName { - include - exclude - } - repositoryName { - include - exclude - protected - } - }, - rules { - totalCount - } - } - } - } - } - ` - +func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) (*RulesetList, error) { variables := map[string]interface{}{ "owner": repo.RepoOwner(), "repo": repo.RepoName(), - "limit": limit, } - client := api.NewClientFromHTTP(httpClient) - var res response - err := client.GraphQL(repo.RepoHost(), query, variables, &res) - if err != nil { - return nil, err - } - - list := RepositoryRulesetList{ - TotalCount: res.Repository.Rulesets.TotalCount, - Rulesets: res.Repository.Rulesets.Nodes, - } - - return &list, nil + return listRulesets(httpClient, rulesetQuery(false), variables, limit, repo.RepoHost()) +} + +func listOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host string) (*RulesetList, error) { + variables := map[string]interface{}{ + "login": orgLogin, + } + + return listRulesets(httpClient, rulesetQuery(true), variables, limit, host) +} + +func listRulesets(httpClient *http.Client, query string, variables map[string]interface{}, limit int, host string) (*RulesetList, error) { + pageLimit := min(limit, 100) + + res := RulesetList{ + Rulesets: []Ruleset{}, + } + client := api.NewClientFromHTTP(httpClient) + + for { + variables["limit"] = pageLimit + var data RulesetResponse + err := client.GraphQL(host, query, variables, &data) + if err != nil { + return nil, err + } + + res.TotalCount = data.Level.Rulesets.TotalCount + res.Rulesets = append(res.Rulesets, data.Level.Rulesets.Nodes...) + + if len(res.Rulesets) >= limit { + break + } + + if data.Level.Rulesets.PageInfo.HasNextPage { + variables["endCursor"] = data.Level.Rulesets.PageInfo.EndCursor + pageLimit = min(pageLimit, limit-len(res.Rulesets)) + } else { + break + } + } + + return &res, nil +} + +func rulesetQuery(org bool) string { + var args string + var level string + + if org { + args = "$login: String!" + level = "organization(login: $login)" + } else { + args = "$owner: String!, $repo: String!" + level = "repository(owner: $owner, name: $repo)" + } + + str := "query RulesetList($limit: Int!, $endCursor: String, " + args + ") { level: " + level + " {" + + str += ` + rulesets(first: $limit, after: $endCursor) { + totalCount + nodes { + id + #database_id + name + target + #enforcement + conditions { + refName { + include + exclude + } + repositoryName { + include + exclude + protected + } + } + rules { + totalCount + } + } + pageInfo { + hasNextPage + endCursor + } + }` + + return str + "}}" +} + +func min(a, b int) int { + if a < b { + return a + } + return b } diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index c7bffd94b..5d5f87ace 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -65,16 +65,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmd } -type Ruleset struct { - Id string `json:"id"` - Name string `json:"name"` - Target string `json:"target"` - SourceType string `json:"source_type"` - Source string `json:"source"` - Enforcement string `json:"enforcement"` - BypassMode string `json:"bypass_mode"` -} - func listRun(opts *ListOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -95,7 +85,20 @@ func listRun(opts *ListOptions) error { return opts.Browser.Browse(rulesetURL) } - result, err := listRepoRulesets(httpClient, repoI, opts.Limit) + var result *RulesetList + + if opts.Organization != "" { + var cfg config.Config + cfg, err = opts.Config() + if err != nil { + return err + } + hostname, _ := cfg.DefaultHost() + result, err = listOrgRulesets(httpClient, opts.Organization, opts.Limit, hostname) + } else { + result, err = listRepoRulesets(httpClient, repoI, opts.Limit) + } + if err != nil { return err } From 5155844d7f302d88359afa52961d1f46c5fb3303 Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 14 Apr 2023 17:07:02 -0400 Subject: [PATCH 015/103] updates, initial list test --- .../ruleset/list/fixtures/rulesetList.json | 69 ++++++ pkg/cmd/ruleset/list/http.go | 15 +- pkg/cmd/ruleset/list/list.go | 58 +++-- pkg/cmd/ruleset/list/list_test.go | 199 ++++++++++++++++++ 4 files changed, 320 insertions(+), 21 deletions(-) create mode 100644 pkg/cmd/ruleset/list/fixtures/rulesetList.json create mode 100644 pkg/cmd/ruleset/list/list_test.go diff --git a/pkg/cmd/ruleset/list/fixtures/rulesetList.json b/pkg/cmd/ruleset/list/fixtures/rulesetList.json new file mode 100644 index 000000000..560c9fb4c --- /dev/null +++ b/pkg/cmd/ruleset/list/fixtures/rulesetList.json @@ -0,0 +1,69 @@ +{ + "data": { + "level": { + "rulesets": { + "totalCount": 3, + "nodes": [ + { + "databaseId": 4, + "name": "test", + "target": "BRANCH", + "enforcement": "EVALUATE", + "conditions": { + "refName": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + }, + "repositoryName": null + }, + "rules": { + "totalCount": 1 + } + }, + { + "databaseId": 42, + "name": "asdf", + "target": "BRANCH", + "enforcement": "ACTIVE", + "conditions": { + "refName": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + }, + "repositoryName": null + }, + "rules": { + "totalCount": 2 + } + }, + { + "databaseId": 77, + "name": "foobar", + "target": "BRANCH", + "enforcement": "DISABLED", + "conditions": { + "refName": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + }, + "repositoryName": null + }, + "rules": { + "totalCount": 4 + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpHNA8E=" + } + } + } + } +} diff --git a/pkg/cmd/ruleset/list/http.go b/pkg/cmd/ruleset/list/http.go index 22e218974..10be1f57d 100644 --- a/pkg/cmd/ruleset/list/http.go +++ b/pkg/cmd/ruleset/list/http.go @@ -21,11 +21,11 @@ type RulesetResponse struct { } type Ruleset struct { - Id string - Name string - Target string - // Enforcement string - Conditions struct { + DatabaseId int + Name string + Target string + Enforcement string + Conditions struct { RefName struct { Include []string Exclude []string @@ -111,11 +111,10 @@ func rulesetQuery(org bool) string { rulesets(first: $limit, after: $endCursor) { totalCount nodes { - id - #database_id + databaseId name target - #enforcement + enforcement conditions { refName { include diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 5d5f87ace..4b30fabe2 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -3,11 +3,13 @@ package list import ( "fmt" "net/http" + "strconv" "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" @@ -32,6 +34,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Browser: f.Browser, Config: f.Config, } cmd := &cobra.Command{ @@ -46,9 +49,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - // if opts.Limit < 1 { - // return cmdutil.FlagErrorf("invalid value for --limit: %v", opts.Limit) - // } + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid value for --limit: %v", opts.Limit) + } if runF != nil { return runF(opts) @@ -76,8 +79,21 @@ func listRun(opts *ListOptions) error { return err } + cfg, err := opts.Config() + if err != nil { + return err + } + + hostname, _ := cfg.DefaultHost() + if opts.WebMode { - rulesetURL := ghrepo.GenerateRepoURL(repoI, "settings/rules") + var rulesetURL string + if opts.Organization != "" { + rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules", ghinstance.HostPrefix(hostname), opts.Organization) + } else { + rulesetURL = ghrepo.GenerateRepoURL(repoI, "settings/rules") + } + if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL)) } @@ -88,12 +104,6 @@ func listRun(opts *ListOptions) error { var result *RulesetList if opts.Organization != "" { - var cfg config.Config - cfg, err = opts.Config() - if err != nil { - return err - } - hostname, _ := cfg.DefaultHost() result, err = listOrgRulesets(httpClient, opts.Organization, opts.Limit, hostname) } else { result, err = listRepoRulesets(httpClient, repoI, opts.Limit) @@ -103,15 +113,37 @@ func listRun(opts *ListOptions) error { return err } + var entityName string + if opts.Organization != "" { + entityName = opts.Organization + } else { + entityName = ghrepo.FullName(repoI) + } + + if result.TotalCount == 0 { + msg := fmt.Sprintf("no rulesets found in %s", entityName) + return cmdutil.NewNoResultsError(msg) + } + + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) + } + cs := opts.IO.ColorScheme() + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "\nShowing %d of %d rulesets in %s\n\n", len(result.Rulesets), result.TotalCount, entityName) + } + tp := tableprinter.New(opts.IO) - tp.HeaderRow("ID", "NAME" /* "STATUS",*/, "TARGET") + tp.HeaderRow("ID", "NAME", "STATUS", "TARGET") for _, rs := range result.Rulesets { - tp.AddField(rs.Id) + tp.AddField(strconv.Itoa(rs.DatabaseId)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) - // tp.AddField(strings.ToLower(rs.Enforcement)) + tp.AddField(strings.ToLower(rs.Enforcement)) tp.AddField(strings.ToLower(rs.Target)) tp.EndRow() } diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go new file mode 100644 index 000000000..0a44b5438 --- /dev/null +++ b/pkg/cmd/ruleset/list/list_test.go @@ -0,0 +1,199 @@ +package list + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "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 Test_NewCmdList(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want ListOptions + wantErr string + }{ + { + name: "no arguments", + args: "", + isTTY: true, + want: ListOptions{ + Limit: 30, + WebMode: false, + Organization: "", + }, + }, + { + name: "limit", + args: "--limit 1", + isTTY: true, + want: ListOptions{ + Limit: 1, + WebMode: false, + Organization: "", + }, + }, + { + name: "org", + args: "--org \"my-org\"", + isTTY: true, + want: ListOptions{ + Limit: 30, + WebMode: false, + Organization: "my-org", + }, + }, + { + name: "web mode", + args: "--web", + isTTY: true, + want: ListOptions{ + Limit: 30, + WebMode: true, + Organization: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: ios, + } + + var opts *ListOptions + cmd := NewCmdList(f, func(o *ListOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.Limit, opts.Limit) + assert.Equal(t, tt.want.WebMode, opts.WebMode) + assert.Equal(t, tt.want.Organization, opts.Organization) + }) + } +} + +func Test_listRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts ListOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "list repo rulesets", + isTTY: true, + wantStdout: heredoc.Doc(` + + Showing 3 of 3 rulesets in OWNER/REPO + + ID NAME STATUS TARGET + 4 test evaluate branch + 42 asdf active branch + 77 foobar disabled branch + `), + wantStderr: "", + }, + { + name: "list org rulesets", + isTTY: true, + opts: ListOptions{ + Organization: "my-org", + }, + wantStdout: heredoc.Doc(` + + Showing 3 of 3 rulesets in my-org + + ID NAME STATUS TARGET + 4 test evaluate branch + 42 asdf active branch + 77 foobar disabled branch + `), + wantStderr: "", + }, + { + name: "machine-readable", + isTTY: false, + wantStdout: heredoc.Doc(` + 4 test evaluate branch + 42 asdf active branch + 77 foobar disabled branch + `), + wantStderr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.GraphQL(`query RulesetList\b`), httpmock.FileResponse("./fixtures/rulesetList.json")) + + tt.opts.IO = ios + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + tt.opts.Browser = &browser.Stub{} + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + err := listRun(&tt.opts) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} From 7f81645c785d7eaedd298fe5916b8a1ecb665542 Mon Sep 17 00:00:00 2001 From: vaindil Date: Tue, 25 Apr 2023 16:42:36 -0400 Subject: [PATCH 016/103] basic ruleset view works --- pkg/cmd/ruleset/list/http.go | 66 ++++----- pkg/cmd/ruleset/list/list.go | 9 +- pkg/cmd/ruleset/ruleset.go | 3 +- pkg/cmd/ruleset/shared/shared.go | 28 ++++ pkg/cmd/ruleset/view/http.go | 32 +++++ pkg/cmd/ruleset/view/view.go | 227 +++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 46 deletions(-) create mode 100644 pkg/cmd/ruleset/shared/shared.go create mode 100644 pkg/cmd/ruleset/view/http.go create mode 100644 pkg/cmd/ruleset/view/view.go diff --git a/pkg/cmd/ruleset/list/http.go b/pkg/cmd/ruleset/list/http.go index 10be1f57d..592bdeda5 100644 --- a/pkg/cmd/ruleset/list/http.go +++ b/pkg/cmd/ruleset/list/http.go @@ -1,17 +1,19 @@ package list import ( + "fmt" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" ) type RulesetResponse struct { Level struct { Rulesets struct { TotalCount int - Nodes []Ruleset + Nodes []shared.Ruleset PageInfo struct { HasNextPage bool EndCursor string @@ -20,26 +22,9 @@ type RulesetResponse struct { } } -type Ruleset struct { - DatabaseId int - Name string - Target string - Enforcement string - Conditions struct { - RefName struct { - Include []string - Exclude []string - } - RepositoryName struct { - Include []string - Exclude []string - } - } -} - type RulesetList struct { TotalCount int - Rulesets []Ruleset + Rulesets []shared.Ruleset } func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) (*RulesetList, error) { @@ -48,7 +33,7 @@ func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) "repo": repo.RepoName(), } - return listRulesets(httpClient, rulesetQuery(false), variables, limit, repo.RepoHost()) + return listRulesets(httpClient, rulesetsQuery(false), variables, limit, repo.RepoHost()) } func listOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host string) (*RulesetList, error) { @@ -56,14 +41,14 @@ func listOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host s "login": orgLogin, } - return listRulesets(httpClient, rulesetQuery(true), variables, limit, host) + return listRulesets(httpClient, rulesetsQuery(true), variables, limit, host) } func listRulesets(httpClient *http.Client, query string, variables map[string]interface{}, limit int, host string) (*RulesetList, error) { pageLimit := min(limit, 100) res := RulesetList{ - Rulesets: []Ruleset{}, + Rulesets: []shared.Ruleset{}, } client := api.NewClientFromHTTP(httpClient) @@ -93,7 +78,7 @@ func listRulesets(httpClient *http.Client, query string, variables map[string]in return &res, nil } -func rulesetQuery(org bool) string { +func rulesetsQuery(org bool) string { var args string var level string @@ -105,28 +90,28 @@ func rulesetQuery(org bool) string { level = "repository(owner: $owner, name: $repo)" } - str := "query RulesetList($limit: Int!, $endCursor: String, " + args + ") { level: " + level + " {" + str := fmt.Sprintf("query RulesetList($limit: Int!, $endCursor: String, %s) { level: %s {", args, level) - str += ` + return str + ` rulesets(first: $limit, after: $endCursor) { totalCount nodes { - databaseId + id: databaseId name target enforcement - conditions { - refName { - include - exclude - } - repositoryName { - include - exclude - protected - } - } - rules { + # conditions { + # refName { + # include + # exclude + # } + # repositoryName { + # include + # exclude + # protected + # } + # } + rulesGql: rules { totalCount } } @@ -134,9 +119,8 @@ func rulesetQuery(org bool) string { hasNextPage endCursor } - }` - - return str + "}}" + } + }}` } func min(a, b int) int { diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 4b30fabe2..2c9835124 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -61,9 +61,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of rules to list") - cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rules") - cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List rules in the web browser") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of rulesets to list") + cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rulesets") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List rulesets in the web browser") return cmd } @@ -125,6 +125,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError(msg) } + opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err == nil { defer opts.IO.StopPager() } else { @@ -141,7 +142,7 @@ func listRun(opts *ListOptions) error { tp.HeaderRow("ID", "NAME", "STATUS", "TARGET") for _, rs := range result.Rulesets { - tp.AddField(strconv.Itoa(rs.DatabaseId)) + tp.AddField(strconv.Itoa(rs.Id)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) tp.AddField(strings.ToLower(rs.Enforcement)) tp.AddField(strings.ToLower(rs.Target)) diff --git a/pkg/cmd/ruleset/ruleset.go b/pkg/cmd/ruleset/ruleset.go index f7d7799b6..64536e868 100644 --- a/pkg/cmd/ruleset/ruleset.go +++ b/pkg/cmd/ruleset/ruleset.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc" cmdCheck "github.com/cli/cli/v2/pkg/cmd/ruleset/check" cmdList "github.com/cli/cli/v2/pkg/cmd/ruleset/list" + cmdView "github.com/cli/cli/v2/pkg/cmd/ruleset/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -21,7 +22,7 @@ func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdList.NewCmdList(f, nil)) - // cmd.AddCommand(cmdList.NewCmdView(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil)) return cmd diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go new file mode 100644 index 000000000..d226ad18c --- /dev/null +++ b/pkg/cmd/ruleset/shared/shared.go @@ -0,0 +1,28 @@ +package shared + +type Ruleset struct { + Id int + Name string + Target string + Enforcement string + BypassMode string `json:"bypass_mode"` + BypassActors []struct { + ActorId int `json:"actor_id"` + ActorType string `json:"actor_type"` + } `json:"bypass_actors"` + Conditions map[string]map[string]interface { + // RefName struct { + // Include []string + // Exclude []string + // } `json:"ref_name"` + // RepositoryName struct { + // Include []string + // Exclude []string + // Protected bool + // } `json:"repository_name"` + } + RulesGql struct { + TotalCount int + } + Rules []interface{} +} diff --git a/pkg/cmd/ruleset/view/http.go b/pkg/cmd/ruleset/view/http.go new file mode 100644 index 000000000..bd56c3cf6 --- /dev/null +++ b/pkg/cmd/ruleset/view/http.go @@ -0,0 +1,32 @@ +package view + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" +) + +func viewRepoRuleset(httpClient *http.Client, repo ghrepo.Interface, databaseId string) (*shared.Ruleset, error) { + path := fmt.Sprintf("repos/%s/%s/rulesets/%s", repo.RepoOwner(), repo.RepoName(), databaseId) + return viewRuleset(httpClient, repo.RepoHost(), path) +} + +func viewOrgRuleset(httpClient *http.Client, orgLogin string, databaseId string, host string) (*shared.Ruleset, error) { + path := fmt.Sprintf("orgs/%s/rulesets/%s", orgLogin, databaseId) + return viewRuleset(httpClient, host, path) +} + +func viewRuleset(httpClient *http.Client, hostname string, path string) (*shared.Ruleset, error) { + apiClient := api.NewClientFromHTTP(httpClient) + result := shared.Ruleset{} + + err := apiClient.REST(hostname, "GET", path, nil, &result) + if err != nil { + return nil, err + } + + return &result, nil +} diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go new file mode 100644 index 000000000..49c74581c --- /dev/null +++ b/pkg/cmd/ruleset/view/view.go @@ -0,0 +1,227 @@ +package view + +import ( + "fmt" + "net/http" + "reflect" + "sort" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + + ID string + WebMode bool + Organization string +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Browser: f.Browser, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "view []", + Short: "View information about a ruleset", + Long: heredoc.Doc(` + View information about a GitHub ruleset. + + If no ID is provided, an interactive prompt will be used to choose + the ruleset to view. + `), + Example: heredoc.Doc(` + # View a ruleset in the current repository + $ gh ruleset view 43 + + # View a ruleset in a different repository + $ gh ruleset view 23 --repo owner/repo + + # View an organization-level ruleset + $ gh ruleset view 23 --org my-org + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + // a string is actually needed later on, so verify that it's numeric + // but use the string anyway + _, err := strconv.Atoi(args[0]) + if err != nil { + return cmdutil.FlagErrorf("invalid value for ruleset ID: %v is not an integer", args[0]) + } + opts.ID = args[0] + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the ruleset in the browser") + cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "Organization name if the ID ") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + repoI, err := opts.BaseRepo() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + hostname, _ := cfg.DefaultHost() + + if opts.WebMode { + // TODO need to validate ruleset's existence before opening + var rulesetURL string + if opts.Organization != "" { + rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules/%s", ghinstance.HostPrefix(hostname), opts.Organization, opts.ID) + } else { + rulesetURL = ghrepo.GenerateRepoURL(repoI, "settings/rules/%s", opts.ID) + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL)) + } + + return opts.Browser.Browse(rulesetURL) + } + + var rs *shared.Ruleset + if opts.Organization != "" { + rs, err = viewOrgRuleset(httpClient, opts.Organization, opts.ID, hostname) + } else { + rs, err = viewRepoRuleset(httpClient, repoI, opts.ID) + } + + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + w := opts.IO.Out + + fmt.Fprintf(w, "\n%s\n", cs.Bold(rs.Name)) + fmt.Fprintf(w, "ID: %d\n", rs.Id) + + switch rs.Enforcement { + case "disabled": + fmt.Fprintf(w, "%s\n", cs.Red("Disabled")) + case "evaluate": + fmt.Fprintf(w, "%s\n", cs.Yellow("Evaluate Mode (not being enforced)")) + case "active": + fmt.Fprintf(w, "%s\n", cs.Green("Active")) + default: + fmt.Fprintf(w, "Enforcement: %s\n", rs.Enforcement) + } + + fmt.Fprintf(w, "\n%s\n", cs.Bold("Bypassing")) + fmt.Fprintf(w, "Mode: %s\n", rs.BypassMode) + if len(rs.BypassActors) == 0 { + fmt.Fprintf(w, "No actors configured for bypass\n") + } else { + types := make(map[string]int) + for _, t := range rs.BypassActors { + val, exists := types[t.ActorType] + if exists { + types[t.ActorType] = val + 1 + } else { + types[t.ActorType] = 1 + } + } + + fmt.Fprintf(w, "Actor types allowed to bypass:\n") + for name, count := range types { + fmt.Fprintf(w, "- %s: %d actors\n", name, count) + } + } + + fmt.Fprintf(w, "\n%s\n", cs.Bold("Conditions")) + if len(rs.Conditions) == 0 { + fmt.Fprintf(w, "No conditions configured\n") + } else { + // sort keys for consistent responses, mismatched types don't allow this to be broken + // into a separate function + keys := make([]string, 0, len(rs.Conditions)) + for key := range rs.Conditions { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, name := range keys { + condition := rs.Conditions[name] + fmt.Fprintf(w, "- %s: ", name) + + // sort these keys too for consistency + subkeys := make([]string, 0, len(condition)) + for subkey := range condition { + subkeys = append(subkeys, subkey) + } + sort.Strings(subkeys) + + for _, n := range subkeys { + rawVal := condition[n] + + k := reflect.TypeOf(rawVal).Kind() + if rawVal == nil || + ((k == reflect.Slice || k == reflect.Map) && len(rawVal.([]interface{})) == 0) { + continue + } + + printVal := fmt.Sprint(rawVal) + + // fmt.Fprintf(w, "n: %s, type: %s\n", n, reflect.TypeOf(rawVal).String()) + + // switch val := rawVal.(type) { + // case []interface{}: + // // currently only string arrays are returned by the API at this level + // printVal = fmt.Sprint(val) + // default: + // printVal = fmt.Sprint(val) + // } + + fmt.Fprintf(w, "[%s: %s] ", n, printVal) + } + + fmt.Fprint(w, "\n") + } + } + + fmt.Fprintf(w, "\n%s\n", cs.Bold("Rules")) + fmt.Fprintf(w, "%d configured\n", reflect.ValueOf(rs.Rules).Len()) + + return nil +} From 3e6419237d67a6d70e508bd51b18fee5a3c60b2d Mon Sep 17 00:00:00 2001 From: vaindil Date: Mon, 1 May 2023 14:13:15 -0400 Subject: [PATCH 017/103] allow getting parents, move list to shared package --- pkg/cmd/ruleset/list/list.go | 39 +++++++++++++------- pkg/cmd/ruleset/{list => shared}/http.go | 29 ++++++++------- pkg/cmd/ruleset/shared/shared.go | 4 +++ pkg/cmd/ruleset/view/view.go | 45 +++++++++++++----------- 4 files changed, 72 insertions(+), 45 deletions(-) rename pkg/cmd/ruleset/{list => shared}/http.go (73%) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 2c9835124..e49710ba8 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -25,9 +26,10 @@ type ListOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser - Limit int - WebMode bool - Organization string + Limit int + IncludeParents bool + WebMode bool + Organization string } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -62,8 +64,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of rulesets to list") - cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rulesets") - cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List rulesets in the web browser") + cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rulesets for the provided organization") + cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", false, "Include rulesets configured at higher levels that also apply") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the list of rulesets in the web browser") return cmd } @@ -101,12 +104,12 @@ func listRun(opts *ListOptions) error { return opts.Browser.Browse(rulesetURL) } - var result *RulesetList + var result *shared.RulesetList if opts.Organization != "" { - result, err = listOrgRulesets(httpClient, opts.Organization, opts.Limit, hostname) + result, err = shared.ListOrgRulesets(httpClient, opts.Organization, opts.Limit, hostname, opts.IncludeParents) } else { - result, err = listRepoRulesets(httpClient, repoI, opts.Limit) + result, err = shared.ListRepoRulesets(httpClient, repoI, opts.Limit, opts.IncludeParents) } if err != nil { @@ -121,7 +124,11 @@ func listRun(opts *ListOptions) error { } if result.TotalCount == 0 { - msg := fmt.Sprintf("no rulesets found in %s", entityName) + parentsMsg := "" + if opts.IncludeParents { + parentsMsg = " or its parents" + } + msg := fmt.Sprintf("no rulesets found in %s%s", entityName, parentsMsg) return cmdutil.NewNoResultsError(msg) } @@ -139,17 +146,25 @@ func listRun(opts *ListOptions) error { } tp := tableprinter.New(opts.IO) - tp.HeaderRow("ID", "NAME", "STATUS", "TARGET") + tp.HeaderRow("ID", "NAME", "SOURCE", "STATUS", "TARGET", "RULES") for _, rs := range result.Rulesets { tp.AddField(strconv.Itoa(rs.Id)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) + var ownerString string + if rs.Source.RepoOwner != "" { + ownerString = fmt.Sprintf("%s (repo)", rs.Source.RepoOwner) + } else if rs.Source.OrgOwner != "" { + ownerString = fmt.Sprintf("%s (org)", rs.Source.OrgOwner) + } else { + ownerString = "(unknown)" + } + tp.AddField(ownerString) tp.AddField(strings.ToLower(rs.Enforcement)) tp.AddField(strings.ToLower(rs.Target)) + tp.AddField(strconv.Itoa(rs.RulesGql.TotalCount)) tp.EndRow() } return tp.Render() } - -// func getRulesets() diff --git a/pkg/cmd/ruleset/list/http.go b/pkg/cmd/ruleset/shared/http.go similarity index 73% rename from pkg/cmd/ruleset/list/http.go rename to pkg/cmd/ruleset/shared/http.go index 592bdeda5..9dae0d7e6 100644 --- a/pkg/cmd/ruleset/list/http.go +++ b/pkg/cmd/ruleset/shared/http.go @@ -1,4 +1,4 @@ -package list +package shared import ( "fmt" @@ -6,14 +6,13 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" ) type RulesetResponse struct { Level struct { Rulesets struct { TotalCount int - Nodes []shared.Ruleset + Nodes []Ruleset PageInfo struct { HasNextPage bool EndCursor string @@ -24,21 +23,23 @@ type RulesetResponse struct { type RulesetList struct { TotalCount int - Rulesets []shared.Ruleset + Rulesets []Ruleset } -func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) (*RulesetList, error) { +func ListRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int, includeParents bool) (*RulesetList, error) { variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), + "owner": repo.RepoOwner(), + "repo": repo.RepoName(), + "includeParents": includeParents, } return listRulesets(httpClient, rulesetsQuery(false), variables, limit, repo.RepoHost()) } -func listOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host string) (*RulesetList, error) { +func ListOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host string, includeParents bool) (*RulesetList, error) { variables := map[string]interface{}{ - "login": orgLogin, + "login": orgLogin, + "includeParents": includeParents, } return listRulesets(httpClient, rulesetsQuery(true), variables, limit, host) @@ -48,7 +49,7 @@ func listRulesets(httpClient *http.Client, query string, variables map[string]in pageLimit := min(limit, 100) res := RulesetList{ - Rulesets: []shared.Ruleset{}, + Rulesets: []Ruleset{}, } client := api.NewClientFromHTTP(httpClient) @@ -90,16 +91,20 @@ func rulesetsQuery(org bool) string { level = "repository(owner: $owner, name: $repo)" } - str := fmt.Sprintf("query RulesetList($limit: Int!, $endCursor: String, %s) { level: %s {", args, level) + str := fmt.Sprintf("query RulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, %s) { level: %s {", args, level) return str + ` - rulesets(first: $limit, after: $endCursor) { + rulesets(first: $limit, after: $endCursor, includeParents: $includeParents) { totalCount nodes { id: databaseId name target enforcement + source { + ... on Repository { repoOwner: nameWithOwner } + ... on Organization { orgOwner: login } + } # conditions { # refName { # include diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index d226ad18c..fa5f2e30f 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -21,6 +21,10 @@ type Ruleset struct { // Protected bool // } `json:"repository_name"` } + Source struct { + RepoOwner string + OrgOwner string + } RulesGql struct { TotalCount int } diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 49c74581c..6d0005909 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -81,7 +81,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the ruleset in the browser") - cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "Organization name if the ID ") + cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "Organization name if the provided ID is an organization-level ruleset") return cmd } @@ -104,22 +104,6 @@ func viewRun(opts *ViewOptions) error { hostname, _ := cfg.DefaultHost() - if opts.WebMode { - // TODO need to validate ruleset's existence before opening - var rulesetURL string - if opts.Organization != "" { - rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules/%s", ghinstance.HostPrefix(hostname), opts.Organization, opts.ID) - } else { - rulesetURL = ghrepo.GenerateRepoURL(repoI, "settings/rules/%s", opts.ID) - } - - if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL)) - } - - return opts.Browser.Browse(rulesetURL) - } - var rs *shared.Ruleset if opts.Organization != "" { rs, err = viewOrgRuleset(httpClient, opts.Organization, opts.ID, hostname) @@ -134,6 +118,25 @@ func viewRun(opts *ViewOptions) error { cs := opts.IO.ColorScheme() w := opts.IO.Out + if opts.WebMode { + if rs != nil { + var rulesetURL string + if opts.Organization != "" { + rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules/%s", ghinstance.HostPrefix(hostname), opts.Organization, opts.ID) + } else { + rulesetURL = ghrepo.GenerateRepoURL(repoI, "settings/rules/%s", opts.ID) + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL)) + } + + return opts.Browser.Browse(rulesetURL) + } else { + fmt.Fprintf(w, "ruleset not found\n") + } + } + fmt.Fprintf(w, "\n%s\n", cs.Bold(rs.Name)) fmt.Fprintf(w, "ID: %d\n", rs.Id) @@ -141,7 +144,7 @@ func viewRun(opts *ViewOptions) error { case "disabled": fmt.Fprintf(w, "%s\n", cs.Red("Disabled")) case "evaluate": - fmt.Fprintf(w, "%s\n", cs.Yellow("Evaluate Mode (not being enforced)")) + fmt.Fprintf(w, "%s\n", cs.Yellow("Evaluate Mode (not enforced)")) case "active": fmt.Fprintf(w, "%s\n", cs.Green("Active")) default: @@ -165,7 +168,7 @@ func viewRun(opts *ViewOptions) error { fmt.Fprintf(w, "Actor types allowed to bypass:\n") for name, count := range types { - fmt.Fprintf(w, "- %s: %d actors\n", name, count) + fmt.Fprintf(w, "- %s: %d configured\n", name, count) } } @@ -173,8 +176,8 @@ func viewRun(opts *ViewOptions) error { if len(rs.Conditions) == 0 { fmt.Fprintf(w, "No conditions configured\n") } else { - // sort keys for consistent responses, mismatched types don't allow this to be broken - // into a separate function + // sort keys for consistent responses, can't make a separate function due to + // mismatched types keys := make([]string, 0, len(rs.Conditions)) for key := range rs.Conditions { keys = append(keys, key) From e91670edcce73aa31396b4ac1071df291641270e Mon Sep 17 00:00:00 2001 From: vaindil Date: Tue, 2 May 2023 11:55:37 -0400 Subject: [PATCH 018/103] split ruleset types by API type --- api/client.go | 2 +- pkg/cmd/ruleset/list/list.go | 4 ++-- pkg/cmd/ruleset/shared/http.go | 21 ++++------------- pkg/cmd/ruleset/shared/shared.go | 40 ++++++++++++++++---------------- pkg/cmd/ruleset/view/http.go | 8 +++---- pkg/cmd/ruleset/view/view.go | 2 +- 6 files changed, 33 insertions(+), 44 deletions(-) diff --git a/api/client.go b/api/client.go index 0d2690378..e32856554 100644 --- a/api/client.go +++ b/api/client.go @@ -19,7 +19,7 @@ const ( authorization = "Authorization" cacheTTL = "X-GH-CACHE-TTL" graphqlFeatures = "GraphQL-Features" - features = "merge_queue,push_policies" + features = "merge_queue" userAgent = "User-Agent" ) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index e49710ba8..ee13afeef 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -149,7 +149,7 @@ func listRun(opts *ListOptions) error { tp.HeaderRow("ID", "NAME", "SOURCE", "STATUS", "TARGET", "RULES") for _, rs := range result.Rulesets { - tp.AddField(strconv.Itoa(rs.Id)) + tp.AddField(strconv.Itoa(rs.DatabaseId)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) var ownerString string if rs.Source.RepoOwner != "" { @@ -162,7 +162,7 @@ func listRun(opts *ListOptions) error { tp.AddField(ownerString) tp.AddField(strings.ToLower(rs.Enforcement)) tp.AddField(strings.ToLower(rs.Target)) - tp.AddField(strconv.Itoa(rs.RulesGql.TotalCount)) + tp.AddField(strconv.Itoa(rs.Rules.TotalCount)) tp.EndRow() } diff --git a/pkg/cmd/ruleset/shared/http.go b/pkg/cmd/ruleset/shared/http.go index 9dae0d7e6..2276f8989 100644 --- a/pkg/cmd/ruleset/shared/http.go +++ b/pkg/cmd/ruleset/shared/http.go @@ -12,7 +12,7 @@ type RulesetResponse struct { Level struct { Rulesets struct { TotalCount int - Nodes []Ruleset + Nodes []RulesetGraphQL PageInfo struct { HasNextPage bool EndCursor string @@ -23,7 +23,7 @@ type RulesetResponse struct { type RulesetList struct { TotalCount int - Rulesets []Ruleset + Rulesets []RulesetGraphQL } func ListRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int, includeParents bool) (*RulesetList, error) { @@ -49,7 +49,7 @@ func listRulesets(httpClient *http.Client, query string, variables map[string]in pageLimit := min(limit, 100) res := RulesetList{ - Rulesets: []Ruleset{}, + Rulesets: []RulesetGraphQL{}, } client := api.NewClientFromHTTP(httpClient) @@ -97,7 +97,7 @@ func rulesetsQuery(org bool) string { rulesets(first: $limit, after: $endCursor, includeParents: $includeParents) { totalCount nodes { - id: databaseId + databaseId name target enforcement @@ -105,18 +105,7 @@ func rulesetsQuery(org bool) string { ... on Repository { repoOwner: nameWithOwner } ... on Organization { orgOwner: login } } - # conditions { - # refName { - # include - # exclude - # } - # repositoryName { - # include - # exclude - # protected - # } - # } - rulesGql: rules { + rules { totalCount } } diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index fa5f2e30f..090d3d989 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -1,6 +1,20 @@ package shared -type Ruleset struct { +type RulesetGraphQL struct { + DatabaseId int + Name string + Target string + Enforcement string + Source struct { + RepoOwner string + OrgOwner string + } + Rules struct { + TotalCount int + } +} + +type RulesetREST struct { Id int Name string Target string @@ -10,23 +24,9 @@ type Ruleset struct { ActorId int `json:"actor_id"` ActorType string `json:"actor_type"` } `json:"bypass_actors"` - Conditions map[string]map[string]interface { - // RefName struct { - // Include []string - // Exclude []string - // } `json:"ref_name"` - // RepositoryName struct { - // Include []string - // Exclude []string - // Protected bool - // } `json:"repository_name"` - } - Source struct { - RepoOwner string - OrgOwner string - } - RulesGql struct { - TotalCount int - } - Rules []interface{} + Conditions map[string]map[string]interface{} + // TODO is this source field used? + SourceType string `json:"source_type"` + Source string + Rules []struct{} } diff --git a/pkg/cmd/ruleset/view/http.go b/pkg/cmd/ruleset/view/http.go index bd56c3cf6..d0b26f530 100644 --- a/pkg/cmd/ruleset/view/http.go +++ b/pkg/cmd/ruleset/view/http.go @@ -9,19 +9,19 @@ import ( "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" ) -func viewRepoRuleset(httpClient *http.Client, repo ghrepo.Interface, databaseId string) (*shared.Ruleset, error) { +func viewRepoRuleset(httpClient *http.Client, repo ghrepo.Interface, databaseId string) (*shared.RulesetREST, error) { path := fmt.Sprintf("repos/%s/%s/rulesets/%s", repo.RepoOwner(), repo.RepoName(), databaseId) return viewRuleset(httpClient, repo.RepoHost(), path) } -func viewOrgRuleset(httpClient *http.Client, orgLogin string, databaseId string, host string) (*shared.Ruleset, error) { +func viewOrgRuleset(httpClient *http.Client, orgLogin string, databaseId string, host string) (*shared.RulesetREST, error) { path := fmt.Sprintf("orgs/%s/rulesets/%s", orgLogin, databaseId) return viewRuleset(httpClient, host, path) } -func viewRuleset(httpClient *http.Client, hostname string, path string) (*shared.Ruleset, error) { +func viewRuleset(httpClient *http.Client, hostname string, path string) (*shared.RulesetREST, error) { apiClient := api.NewClientFromHTTP(httpClient) - result := shared.Ruleset{} + result := shared.RulesetREST{} err := apiClient.REST(hostname, "GET", path, nil, &result) if err != nil { diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 6d0005909..7569d3e62 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -104,7 +104,7 @@ func viewRun(opts *ViewOptions) error { hostname, _ := cfg.DefaultHost() - var rs *shared.Ruleset + var rs *shared.RulesetREST if opts.Organization != "" { rs, err = viewOrgRuleset(httpClient, opts.Organization, opts.ID, hostname) } else { From 8fa69c491507707c64faa9223bc7678e85dc37ac Mon Sep 17 00:00:00 2001 From: vaindil Date: Tue, 2 May 2023 12:21:57 -0400 Subject: [PATCH 019/103] don't allow both --repo and --org, add docs --- pkg/cmd/ruleset/list/list.go | 27 ++++++++++++++++++++++++--- pkg/cmd/ruleset/view/view.go | 4 ++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index ee13afeef..6183adb6a 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -43,11 +43,32 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Use: "list", Short: "List rulesets for a repository or organization", Long: heredoc.Doc(` - TODO + List GitHub rulesets for a repository or organization. + + If no options are provided, the current repository's rulesets are listed. You can query a different + repository's rulesets by using the --repo flag. You can also use the --org flag to list rulesets + configured for the provided organization. + + Use the --parents flag to include rulesets configured at higher levels that also apply to the current repository. + + Your access token must have the admin:org scope, which can be granted by running "gh auth refresh -s admin:org". `), - Example: "TODO", - Args: cobra.ExactArgs(0), + Example: heredoc.Doc(` + # List rulesets in the current repository + $ gh ruleset list + + # List rulesets in a different repository, including those configured at higher levels + $ gh ruleset list --repo owner/repo --parents + + # List rulesets in an organization + $ gh ruleset list --org org-name + `), + Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { + if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && opts.Organization != "" { + return cmdutil.FlagErrorf("only one of --repo and --org may be specified") + } + // support `-R, --repo` override opts.BaseRepo = f.BaseRepo diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 7569d3e62..fca7b17bf 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -60,6 +60,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && opts.Organization != "" { + return cmdutil.FlagErrorf("only one of --repo and --org may be specified") + } + // support `-R, --repo` override opts.BaseRepo = f.BaseRepo From ef30d875b503a295cd7d3e11678f858d49eb80d0 Mon Sep 17 00:00:00 2001 From: vaindil Date: Wed, 3 May 2023 11:28:04 -0400 Subject: [PATCH 020/103] gracefully handle missing admin:org scope --- pkg/cmd/ruleset/shared/http.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/cmd/ruleset/shared/http.go b/pkg/cmd/ruleset/shared/http.go index 2276f8989..4899369bf 100644 --- a/pkg/cmd/ruleset/shared/http.go +++ b/pkg/cmd/ruleset/shared/http.go @@ -1,8 +1,10 @@ package shared import ( + "errors" "fmt" "net/http" + "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" @@ -58,6 +60,10 @@ func listRulesets(httpClient *http.Client, query string, variables map[string]in var data RulesetResponse err := client.GraphQL(host, query, variables, &data) if err != nil { + if strings.Contains(err.Error(), "requires one of the following scopes: ['admin:org']") { + return nil, errors.New("the 'admin:org' scope is required to view organization rulesets, try running 'gh auth refresh -s admin:org'") + } + return nil, err } From f25b2af05389fdee0300ac09364a6acf9691fd38 Mon Sep 17 00:00:00 2001 From: vaindil Date: Thu, 4 May 2023 14:16:34 -0400 Subject: [PATCH 021/103] interactive ruleset selection, move shared logic --- pkg/cmd/ruleset/list/list.go | 32 ++++---------- pkg/cmd/ruleset/shared/http.go | 5 ++- pkg/cmd/ruleset/shared/shared.go | 41 +++++++++++++++++- pkg/cmd/ruleset/view/view.go | 74 ++++++++++++++++++++++++++++++-- 4 files changed, 122 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 6183adb6a..1b8a9cc00 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -137,20 +137,8 @@ func listRun(opts *ListOptions) error { return err } - var entityName string - if opts.Organization != "" { - entityName = opts.Organization - } else { - entityName = ghrepo.FullName(repoI) - } - if result.TotalCount == 0 { - parentsMsg := "" - if opts.IncludeParents { - parentsMsg = " or its parents" - } - msg := fmt.Sprintf("no rulesets found in %s%s", entityName, parentsMsg) - return cmdutil.NewNoResultsError(msg) + return shared.NoRulesetsFoundError(opts.Organization, repoI, opts.IncludeParents) } opts.IO.DetectTerminalTheme() @@ -163,7 +151,13 @@ func listRun(opts *ListOptions) error { cs := opts.IO.ColorScheme() if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "\nShowing %d of %d rulesets in %s\n\n", len(result.Rulesets), result.TotalCount, entityName) + parentsMsg := "" + if opts.IncludeParents { + parentsMsg = " and its parents" + } + + inMsg := fmt.Sprintf("%s%s", shared.EntityName(opts.Organization, repoI), parentsMsg) + fmt.Fprintf(opts.IO.Out, "\nShowing %d of %d rulesets in %s\n\n", len(result.Rulesets), result.TotalCount, inMsg) } tp := tableprinter.New(opts.IO) @@ -172,15 +166,7 @@ func listRun(opts *ListOptions) error { for _, rs := range result.Rulesets { tp.AddField(strconv.Itoa(rs.DatabaseId)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) - var ownerString string - if rs.Source.RepoOwner != "" { - ownerString = fmt.Sprintf("%s (repo)", rs.Source.RepoOwner) - } else if rs.Source.OrgOwner != "" { - ownerString = fmt.Sprintf("%s (org)", rs.Source.OrgOwner) - } else { - ownerString = "(unknown)" - } - tp.AddField(ownerString) + tp.AddField(shared.RulesetSource(rs)) tp.AddField(strings.ToLower(rs.Enforcement)) tp.AddField(strings.ToLower(rs.Target)) tp.AddField(strconv.Itoa(rs.Rules.TotalCount)) diff --git a/pkg/cmd/ruleset/shared/http.go b/pkg/cmd/ruleset/shared/http.go index 4899369bf..05a8e3cf7 100644 --- a/pkg/cmd/ruleset/shared/http.go +++ b/pkg/cmd/ruleset/shared/http.go @@ -108,8 +108,9 @@ func rulesetsQuery(org bool) string { target enforcement source { - ... on Repository { repoOwner: nameWithOwner } - ... on Organization { orgOwner: login } + __typename + ... on Repository { owner: nameWithOwner } + ... on Organization { owner: login } } rules { totalCount diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index 090d3d989..682eafb38 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -1,13 +1,20 @@ package shared +import ( + "fmt" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" +) + type RulesetGraphQL struct { DatabaseId int Name string Target string Enforcement string Source struct { - RepoOwner string - OrgOwner string + TypeName string `json:"__typename"` + Owner string } Rules struct { TotalCount int @@ -30,3 +37,33 @@ type RulesetREST struct { Source string Rules []struct{} } + +// Returns the source of the ruleset in the format "owner/name (repo)" or "owner (org)" +func RulesetSource(rs RulesetGraphQL) string { + var level string + if rs.Source.TypeName == "Repository" { + level = "repo" + } else if rs.Source.TypeName == "Organization" { + level = "org" + } else { + level = "unknown" + } + + return fmt.Sprintf("%s (%s)", rs.Source.Owner, level) +} + +func NoRulesetsFoundError(orgOption string, repoI ghrepo.Interface, includeParents bool) error { + entityName := EntityName(orgOption, repoI) + parentsMsg := "" + if includeParents { + parentsMsg = " or its parents" + } + return cmdutil.NewNoResultsError(fmt.Sprintf("no rulesets found in %s%s", entityName, parentsMsg)) +} + +func EntityName(orgOption string, repoI ghrepo.Interface) string { + if orgOption != "" { + return orgOption + } + return ghrepo.FullName(repoI) +} diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index fca7b17bf..0c642e838 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -6,12 +6,14 @@ import ( "reflect" "sort" "strconv" + "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,10 +27,13 @@ type ViewOptions struct { Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser + Prompter prompter.Prompter - ID string - WebMode bool - Organization string + ID string + WebMode bool + IncludeParents bool + InteractiveMode bool + Organization string } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -37,6 +42,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman HttpClient: f.HttpClient, Browser: f.Browser, Config: f.Config, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -49,6 +55,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman the ruleset to view. `), Example: heredoc.Doc(` + # Interactively choose a ruleset to view + $ gh ruleset view + # View a ruleset in the current repository $ gh ruleset view 43 @@ -75,6 +84,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmdutil.FlagErrorf("invalid value for ruleset ID: %v is not an integer", args[0]) } opts.ID = args[0] + } else if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("a ruleset ID must be provided when not running interactively") + } else { + opts.InteractiveMode = true } if runF != nil { @@ -86,6 +99,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the ruleset in the browser") cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "Organization name if the provided ID is an organization-level ruleset") + cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", false, "When choosing interactively, include rulesets configured at higher levels that also apply") return cmd } @@ -108,6 +122,38 @@ func viewRun(opts *ViewOptions) error { hostname, _ := cfg.DefaultHost() + if opts.InteractiveMode { + var rsList *shared.RulesetList + limit := 30 + if opts.Organization != "" { + rsList, err = shared.ListOrgRulesets(httpClient, opts.Organization, limit, hostname, opts.IncludeParents) + } else { + rsList, err = shared.ListRepoRulesets(httpClient, repoI, limit, opts.IncludeParents) + } + + if err != nil { + return err + } + + if rsList.TotalCount == 0 { + return shared.NoRulesetsFoundError(opts.Organization, repoI, opts.IncludeParents) + } + + rs, err := selectRulesetID(rsList, opts.Prompter) + if err != nil { + return err + } + + if rs != nil { + opts.ID = strconv.Itoa(rs.DatabaseId) + + // can't get a ruleset lower in the chain than what was queried, so no need to handle repos here + if rs.Source.TypeName == "Organization" { + opts.Organization = rs.Source.Owner + } + } + } + var rs *shared.RulesetREST if opts.Organization != "" { rs, err = viewOrgRuleset(httpClient, opts.Organization, opts.ID, hostname) @@ -232,3 +278,25 @@ func viewRun(opts *ViewOptions) error { return nil } + +func selectRulesetID(rsList *shared.RulesetList, p prompter.Prompter) (*shared.RulesetGraphQL, error) { + rulesets := make([]string, len(rsList.Rulesets)) + for i, rs := range rsList.Rulesets { + s := fmt.Sprintf( + "%d: %s | %s | contains %s | configured in %s", + rs.DatabaseId, + rs.Name, + strings.ToLower(rs.Enforcement), + text.Pluralize(rs.Rules.TotalCount, "rule"), + shared.RulesetSource(rs), + ) + rulesets[i] = s + } + + r, err := p.Select("Which ruleset would you like to view?", rulesets[0], rulesets) + if err != nil { + return nil, err + } + + return &rsList.Rulesets[r], nil +} From 8328a0abdcb380bc6669743206fc18bac10d8753 Mon Sep 17 00:00:00 2001 From: vaindil Date: Wed, 21 Jun 2023 14:09:44 -0400 Subject: [PATCH 022/103] update tests, add rules to view command --- .../ruleset/list/fixtures/rulesetList.json | 12 ++++ pkg/cmd/ruleset/list/list.go | 3 +- pkg/cmd/ruleset/list/list_test.go | 22 +++---- pkg/cmd/ruleset/shared/shared.go | 5 +- pkg/cmd/ruleset/view/view.go | 57 +++++++++++-------- 5 files changed, 61 insertions(+), 38 deletions(-) diff --git a/pkg/cmd/ruleset/list/fixtures/rulesetList.json b/pkg/cmd/ruleset/list/fixtures/rulesetList.json index 560c9fb4c..d7c631623 100644 --- a/pkg/cmd/ruleset/list/fixtures/rulesetList.json +++ b/pkg/cmd/ruleset/list/fixtures/rulesetList.json @@ -9,6 +9,10 @@ "name": "test", "target": "BRANCH", "enforcement": "EVALUATE", + "source": { + "__typename": "Repository", + "owner": "OWNER/REPO" + }, "conditions": { "refName": { "include": [ @@ -27,6 +31,10 @@ "name": "asdf", "target": "BRANCH", "enforcement": "ACTIVE", + "source": { + "__typename": "Repository", + "owner": "OWNER/REPO" + }, "conditions": { "refName": { "include": [ @@ -45,6 +53,10 @@ "name": "foobar", "target": "BRANCH", "enforcement": "DISABLED", + "source": { + "__typename": "Organization", + "owner": "Org-Name" + }, "conditions": { "refName": { "include": [ diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 1b8a9cc00..e49e573f0 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -161,14 +161,13 @@ func listRun(opts *ListOptions) error { } tp := tableprinter.New(opts.IO) - tp.HeaderRow("ID", "NAME", "SOURCE", "STATUS", "TARGET", "RULES") + tp.HeaderRow("ID", "NAME", "SOURCE", "STATUS", "RULES") for _, rs := range result.Rulesets { tp.AddField(strconv.Itoa(rs.DatabaseId)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) tp.AddField(shared.RulesetSource(rs)) tp.AddField(strings.ToLower(rs.Enforcement)) - tp.AddField(strings.ToLower(rs.Target)) tp.AddField(strconv.Itoa(rs.Rules.TotalCount)) tp.EndRow() } diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go index 0a44b5438..8e25a87fc 100644 --- a/pkg/cmd/ruleset/list/list_test.go +++ b/pkg/cmd/ruleset/list/list_test.go @@ -125,10 +125,10 @@ func Test_listRun(t *testing.T) { Showing 3 of 3 rulesets in OWNER/REPO - ID NAME STATUS TARGET - 4 test evaluate branch - 42 asdf active branch - 77 foobar disabled branch + ID NAME SOURCE STATUS RULES + 4 test OWNER/REPO (repo) evaluate 1 + 42 asdf OWNER/REPO (repo) active 2 + 77 foobar Org-Name (org) disabled 4 `), wantStderr: "", }, @@ -142,10 +142,10 @@ func Test_listRun(t *testing.T) { Showing 3 of 3 rulesets in my-org - ID NAME STATUS TARGET - 4 test evaluate branch - 42 asdf active branch - 77 foobar disabled branch + ID NAME SOURCE STATUS RULES + 4 test OWNER/REPO (repo) evaluate 1 + 42 asdf OWNER/REPO (repo) active 2 + 77 foobar Org-Name (org) disabled 4 `), wantStderr: "", }, @@ -153,9 +153,9 @@ func Test_listRun(t *testing.T) { name: "machine-readable", isTTY: false, wantStdout: heredoc.Doc(` - 4 test evaluate branch - 42 asdf active branch - 77 foobar disabled branch + 4 test OWNER/REPO (repo) evaluate 1 + 42 asdf OWNER/REPO (repo) active 2 + 77 foobar Org-Name (org) disabled 4 `), wantStderr: "", }, diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index 682eafb38..0299f056a 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -35,7 +35,10 @@ type RulesetREST struct { // TODO is this source field used? SourceType string `json:"source_type"` Source string - Rules []struct{} + Rules []struct { + Type string + Parameters map[string]interface{} + } } // Returns the source of the ruleset in the format "owner/name (repo)" or "owner (org)" diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 0c642e838..df4930de7 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -3,7 +3,6 @@ package view import ( "fmt" "net/http" - "reflect" "sort" "strconv" "strings" @@ -189,7 +188,9 @@ func viewRun(opts *ViewOptions) error { fmt.Fprintf(w, "\n%s\n", cs.Bold(rs.Name)) fmt.Fprintf(w, "ID: %d\n", rs.Id) + fmt.Fprintf(w, "Source: %s (%s)\n", rs.Source, rs.SourceType) + fmt.Fprint(w, "Enforceument: ") switch rs.Enforcement { case "disabled": fmt.Fprintf(w, "%s\n", cs.Red("Disabled")) @@ -198,7 +199,7 @@ func viewRun(opts *ViewOptions) error { case "active": fmt.Fprintf(w, "%s\n", cs.Green("Active")) default: - fmt.Fprintf(w, "Enforcement: %s\n", rs.Enforcement) + fmt.Fprintf(w, "%s\n", rs.Enforcement) } fmt.Fprintf(w, "\n%s\n", cs.Bold("Bypassing")) @@ -246,27 +247,7 @@ func viewRun(opts *ViewOptions) error { sort.Strings(subkeys) for _, n := range subkeys { - rawVal := condition[n] - - k := reflect.TypeOf(rawVal).Kind() - if rawVal == nil || - ((k == reflect.Slice || k == reflect.Map) && len(rawVal.([]interface{})) == 0) { - continue - } - - printVal := fmt.Sprint(rawVal) - - // fmt.Fprintf(w, "n: %s, type: %s\n", n, reflect.TypeOf(rawVal).String()) - - // switch val := rawVal.(type) { - // case []interface{}: - // // currently only string arrays are returned by the API at this level - // printVal = fmt.Sprint(val) - // default: - // printVal = fmt.Sprint(val) - // } - - fmt.Fprintf(w, "[%s: %s] ", n, printVal) + fmt.Fprintf(w, "[%s: %v] ", n, condition[n]) } fmt.Fprint(w, "\n") @@ -274,7 +255,35 @@ func viewRun(opts *ViewOptions) error { } fmt.Fprintf(w, "\n%s\n", cs.Bold("Rules")) - fmt.Fprintf(w, "%d configured\n", reflect.ValueOf(rs.Rules).Len()) + if len(rs.Rules) == 0 { + fmt.Fprintf(w, "No rules configured\n") + } else { + // sort keys for consistent responses + sort.SliceStable(rs.Rules, func(i, j int) bool { + return rs.Rules[i].Type < rs.Rules[j].Type + }) + + for _, rule := range rs.Rules { + fmt.Fprintf(w, "- %s", rule.Type) + + if rule.Parameters != nil && len(rule.Parameters) > 0 { + fmt.Fprintf(w, ": ") + + // sort these keys too for consistency + params := make([]string, 0, len(rule.Parameters)) + for p := range rule.Parameters { + params = append(params, p) + } + sort.Strings(params) + + for _, n := range params { + fmt.Fprintf(w, "[%s: %v] ", n, rule.Parameters[n]) + } + } + + fmt.Fprint(w, "\n") + } + } return nil } From 2f7caf8502415d89d365a261db2e5b5262c999a8 Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 30 Jun 2023 14:08:23 -0400 Subject: [PATCH 023/103] update bypass response and parents logic, tests --- pkg/cmd/ruleset/list/list.go | 9 +++--- pkg/cmd/ruleset/list/list_test.go | 12 +++++++ pkg/cmd/ruleset/shared/shared.go | 11 +++++-- pkg/cmd/ruleset/view/view.go | 52 +++++++++++++------------------ 4 files changed, 46 insertions(+), 38 deletions(-) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index e49e573f0..0091df00a 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -49,9 +49,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman repository's rulesets by using the --repo flag. You can also use the --org flag to list rulesets configured for the provided organization. - Use the --parents flag to include rulesets configured at higher levels that also apply to the current repository. + Use the --parents flag to control whether rulesets configured at higher levels that also apply to the provided + repository or organization should be returned. The default is true. - Your access token must have the admin:org scope, which can be granted by running "gh auth refresh -s admin:org". + Your access token must have the admin:org scope to use the --org flag, which can be granted by running "gh auth refresh -s admin:org". `), Example: heredoc.Doc(` # List rulesets in the current repository @@ -73,7 +74,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts.BaseRepo = f.BaseRepo if opts.Limit < 1 { - return cmdutil.FlagErrorf("invalid value for --limit: %v", opts.Limit) + return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) } if runF != nil { @@ -86,7 +87,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of rulesets to list") cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rulesets for the provided organization") - cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", false, "Include rulesets configured at higher levels that also apply") + cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", true, "Whether to include rulesets configured at higher levels that also apply") cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the list of rulesets in the web browser") return cmd diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go index 8e25a87fc..fd220a3be 100644 --- a/pkg/cmd/ruleset/list/list_test.go +++ b/pkg/cmd/ruleset/list/list_test.go @@ -66,6 +66,18 @@ func Test_NewCmdList(t *testing.T) { Organization: "", }, }, + { + name: "invalid limit", + args: "--limit 0", + isTTY: true, + wantErr: "invalid limit: 0", + }, + { + name: "repo and org specified", + args: "--org \"my-org\" -R \"owner/repo\"", + isTTY: true, + wantErr: "only one of --repo and --org may be specified", + }, } for _, tt := range tests { diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index 0299f056a..5631a24e7 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -26,10 +26,10 @@ type RulesetREST struct { Name string Target string Enforcement string - BypassMode string `json:"bypass_mode"` BypassActors []struct { - ActorId int `json:"actor_id"` - ActorType string `json:"actor_type"` + ActorId int `json:"actor_id"` + ActorType string `json:"actor_type"` + BypassMode string `json:"bypass_mode"` } `json:"bypass_actors"` Conditions map[string]map[string]interface{} // TODO is this source field used? @@ -39,6 +39,11 @@ type RulesetREST struct { Type string Parameters map[string]interface{} } + Links struct { + Html struct { + Href string + } + } `json:"_links"` } // Returns the source of the ruleset in the format "owner/name (repo)" or "owner (org)" diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index df4930de7..6b3604fa4 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -10,7 +10,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" @@ -52,15 +51,22 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman If no ID is provided, an interactive prompt will be used to choose the ruleset to view. + + Use the --parents flag to control whether rulesets configured at higher + levels that also apply to the provided repository or organization should + be returned. The default is true. `), Example: heredoc.Doc(` - # Interactively choose a ruleset to view + # Interactively choose a ruleset to view from all rulesets that apply to the current repository $ gh ruleset view - # View a ruleset in the current repository + # Interactively choose a ruleset to view from only rulesets configured in the current repository + $ gh ruleset view --no-parents + + # View a ruleset configured in the current repository or any of its parents $ gh ruleset view 43 - # View a ruleset in a different repository + # View a ruleset configured in a different repository or any of its parents $ gh ruleset view 23 --repo owner/repo # View an organization-level ruleset @@ -98,7 +104,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the ruleset in the browser") cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "Organization name if the provided ID is an organization-level ruleset") - cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", false, "When choosing interactively, include rulesets configured at higher levels that also apply") + cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", true, "Whether to include rulesets configured at higher levels that also apply") return cmd } @@ -169,18 +175,11 @@ func viewRun(opts *ViewOptions) error { if opts.WebMode { if rs != nil { - var rulesetURL string - if opts.Organization != "" { - rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules/%s", ghinstance.HostPrefix(hostname), opts.Organization, opts.ID) - } else { - rulesetURL = ghrepo.GenerateRepoURL(repoI, "settings/rules/%s", opts.ID) - } - if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL)) + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rs.Links.Html.Href)) } - return opts.Browser.Browse(rulesetURL) + return opts.Browser.Browse(rs.Links.Html.Href) } else { fmt.Fprintf(w, "ruleset not found\n") } @@ -202,24 +201,16 @@ func viewRun(opts *ViewOptions) error { fmt.Fprintf(w, "%s\n", rs.Enforcement) } - fmt.Fprintf(w, "\n%s\n", cs.Bold("Bypassing")) - fmt.Fprintf(w, "Mode: %s\n", rs.BypassMode) + fmt.Fprintf(w, "\n%s\n", cs.Bold("Bypass List")) if len(rs.BypassActors) == 0 { - fmt.Fprintf(w, "No actors configured for bypass\n") + fmt.Fprintf(w, "This ruleset cannot be bypassed\n") } else { - types := make(map[string]int) - for _, t := range rs.BypassActors { - val, exists := types[t.ActorType] - if exists { - types[t.ActorType] = val + 1 - } else { - types[t.ActorType] = 1 - } - } + sort.Slice(rs.BypassActors, func(i, j int) bool { + return rs.BypassActors[i].ActorId < rs.BypassActors[j].ActorId + }) - fmt.Fprintf(w, "Actor types allowed to bypass:\n") - for name, count := range types { - fmt.Fprintf(w, "- %s: %d configured\n", name, count) + for _, t := range rs.BypassActors { + fmt.Fprintf(w, "- %s (ID: %d), mode: %s\n", t.ActorType, t.ActorId, t.BypassMode) } } @@ -227,8 +218,7 @@ func viewRun(opts *ViewOptions) error { if len(rs.Conditions) == 0 { fmt.Fprintf(w, "No conditions configured\n") } else { - // sort keys for consistent responses, can't make a separate function due to - // mismatched types + // sort keys for consistent responses keys := make([]string, 0, len(rs.Conditions)) for key := range rs.Conditions { keys = append(keys, key) From 3add6721324b8e2f67ad3dfea998b8791537f4ea Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 30 Jun 2023 18:11:22 -0400 Subject: [PATCH 024/103] list test updates --- pkg/cmd/ruleset/list/list.go | 2 +- pkg/cmd/ruleset/list/list_test.go | 163 +++++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 0091df00a..6d2bc3cb1 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -116,7 +116,7 @@ func listRun(opts *ListOptions) error { if opts.Organization != "" { rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules", ghinstance.HostPrefix(hostname), opts.Organization) } else { - rulesetURL = ghrepo.GenerateRepoURL(repoI, "settings/rules") + rulesetURL = ghrepo.GenerateRepoURL(repoI, "rules") } if opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go index fd220a3be..2bb0e3cf7 100644 --- a/pkg/cmd/ruleset/list/list_test.go +++ b/pkg/cmd/ruleset/list/list_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -31,9 +32,10 @@ func Test_NewCmdList(t *testing.T) { args: "", isTTY: true, want: ListOptions{ - Limit: 30, - WebMode: false, - Organization: "", + Limit: 30, + IncludeParents: true, + WebMode: false, + Organization: "", }, }, { @@ -41,9 +43,21 @@ func Test_NewCmdList(t *testing.T) { args: "--limit 1", isTTY: true, want: ListOptions{ - Limit: 1, - WebMode: false, - Organization: "", + Limit: 1, + IncludeParents: true, + WebMode: false, + Organization: "", + }, + }, + { + name: "include parents", + args: "--parents=false", + isTTY: true, + want: ListOptions{ + Limit: 30, + IncludeParents: false, + WebMode: false, + Organization: "", }, }, { @@ -51,9 +65,10 @@ func Test_NewCmdList(t *testing.T) { args: "--org \"my-org\"", isTTY: true, want: ListOptions{ - Limit: 30, - WebMode: false, - Organization: "my-org", + Limit: 30, + IncludeParents: true, + WebMode: false, + Organization: "my-org", }, }, { @@ -61,9 +76,10 @@ func Test_NewCmdList(t *testing.T) { args: "--web", isTTY: true, want: ListOptions{ - Limit: 30, - WebMode: true, - Organization: "", + Limit: 30, + IncludeParents: true, + WebMode: true, + Organization: "", }, }, { @@ -121,6 +137,76 @@ func Test_NewCmdList(t *testing.T) { } } +func Test_RulesetList_Web(t *testing.T) { + tests := []struct { + name string + stdoutTTY bool + wantStdout string + wantBrowse string + }{ + { + name: "repo tty", + stdoutTTY: true, + wantStdout: "Opening github.com/OWNER/REPO in your browser.\n", + wantBrowse: "https://github.com/OWNER/REPO", + }, + { + name: "org tty", + stdoutTTY: true, + wantStdout: "Opening github.com/OWNER/REPO in your browser.\n", + wantBrowse: "https://github.com/OWNER/REPO", + }, + { + name: "repo non-tty", + stdoutTTY: false, + wantStdout: "", + wantBrowse: "https://github.com/OWNER/REPO", + }, + { + name: "org non-tty", + stdoutTTY: false, + wantStdout: "", + wantBrowse: "https://github.com/OWNER/REPO", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + + browser := &browser.Stub{} + opts := &ListOptions{ + WebMode: true, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Browser: browser, + } + + io, _, stdout, _ := iostreams.Test() + + opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + io.SetStdoutTTY(tt.stdoutTTY) + + _, teardown := run.Stub() + defer teardown(t) + + if err := listRun(opts); err != nil { + t.Errorf("listRun() error = %v", err) + } + assert.Equal(t, "", stdout.String()) + assert.Equal(t, tt.wantStdout, stdout.String()) + reg.Verify(t) + browser.Verify(t, tt.wantBrowse) + }) + } +} + func Test_listRun(t *testing.T) { tests := []struct { name string @@ -129,6 +215,7 @@ func Test_listRun(t *testing.T) { wantErr string wantStdout string wantStderr string + wantBrowse string }{ { name: "list repo rulesets", @@ -143,6 +230,7 @@ func Test_listRun(t *testing.T) { 77 foobar Org-Name (org) disabled 4 `), wantStderr: "", + wantBrowse: "", }, { name: "list org rulesets", @@ -160,6 +248,7 @@ func Test_listRun(t *testing.T) { 77 foobar Org-Name (org) disabled 4 `), wantStderr: "", + wantBrowse: "", }, { name: "machine-readable", @@ -170,6 +259,49 @@ func Test_listRun(t *testing.T) { 77 foobar Org-Name (org) disabled 4 `), wantStderr: "", + wantBrowse: "", + }, + { + name: "repo web mode, TTY", + isTTY: true, + opts: ListOptions{ + WebMode: true, + }, + wantStdout: "Opening github.com/OWNER/REPO/rules in your browser.\n", + wantStderr: "", + wantBrowse: "https://github.com/OWNER/REPO/rules", + }, + { + name: "org web mode, TTY", + isTTY: true, + opts: ListOptions{ + WebMode: true, + Organization: "my-org", + }, + wantStdout: "Opening github.com/organizations/my-org/settings/rules in your browser.\n", + wantStderr: "", + wantBrowse: "https://github.com/organizations/my-org/settings/rules", + }, + { + name: "repo web mode, non-TTY", + isTTY: false, + opts: ListOptions{ + WebMode: true, + }, + wantStdout: "", + wantStderr: "", + wantBrowse: "https://github.com/OWNER/REPO/rules", + }, + { + name: "org web mode, non-TTY", + isTTY: false, + opts: ListOptions{ + WebMode: true, + Organization: "my-org", + }, + wantStdout: "", + wantStderr: "", + wantBrowse: "https://github.com/organizations/my-org/settings/rules", }, } @@ -190,7 +322,8 @@ func Test_listRun(t *testing.T) { tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } - tt.opts.Browser = &browser.Stub{} + browser := &browser.Stub{} + tt.opts.Browser = browser tt.opts.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil } @@ -204,6 +337,10 @@ func Test_listRun(t *testing.T) { require.NoError(t, err) } + if tt.wantBrowse != "" { + browser.Verify(t, tt.wantBrowse) + } + assert.Equal(t, tt.wantStdout, stdout.String()) assert.Equal(t, tt.wantStderr, stderr.String()) }) From 5c5dcd2a802ce2dacf238b422ddbe8b0d04e191e Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 30 Jun 2023 18:34:41 -0400 Subject: [PATCH 025/103] initial view tests --- .../ruleset/view/fixtures/rulesetView.json | 62 +++++ pkg/cmd/ruleset/view/view_test.go | 257 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 pkg/cmd/ruleset/view/fixtures/rulesetView.json create mode 100644 pkg/cmd/ruleset/view/view_test.go diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetView.json b/pkg/cmd/ruleset/view/fixtures/rulesetView.json new file mode 100644 index 000000000..5824b341d --- /dev/null +++ b/pkg/cmd/ruleset/view/fixtures/rulesetView.json @@ -0,0 +1,62 @@ +{ + "id": 42, + "name": "Test Ruleset", + "target": "branch", + "source_type": "Repository", + "source": "my-owner/repo-name", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~ALL" + ] + } + }, + "rules": [ + { + "type": "commit_message_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "asdf", + "operator": "contains" + } + }, + { + "type": "commit_author_email_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "@example.com", + "operator": "ends_with" + } + }, + { + "type": "creation" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4dwx_uzSNG", + "_links": { + "self": { + "href": "https://api.github.com/repos/my-owner/repo-name/rulesets/42" + }, + "html": { + "href": "https://github.com/my-owner/repo-name/rules/42" + } + }, + "created_at": "2023-05-01T13:53:37.185-04:00", + "updated_at": "2023-06-29T17:38:03.722-04:00", + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + }, + { + "actor_id": 1, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ] +} diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go new file mode 100644 index 000000000..0548ff4fe --- /dev/null +++ b/pkg/cmd/ruleset/view/view_test.go @@ -0,0 +1,257 @@ +package view + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "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 Test_NewCmdView(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want ViewOptions + wantErr string + }{ + { + name: "no arguments", + args: "", + isTTY: true, + want: ViewOptions{ + ID: "", + WebMode: false, + IncludeParents: true, + InteractiveMode: true, + Organization: "", + }, + }, + { + name: "only ID", + args: "3", + isTTY: true, + want: ViewOptions{ + ID: "3", + WebMode: false, + IncludeParents: true, + InteractiveMode: false, + Organization: "", + }, + }, + { + name: "org", + args: "--org \"my-org\"", + isTTY: true, + want: ViewOptions{ + ID: "", + WebMode: false, + IncludeParents: true, + InteractiveMode: true, + Organization: "my-org", + }, + }, + { + name: "web mode", + args: "--web", + isTTY: true, + want: ViewOptions{ + ID: "", + WebMode: true, + IncludeParents: true, + InteractiveMode: true, + Organization: "", + }, + }, + { + name: "parents", + args: "--parents=false", + isTTY: true, + want: ViewOptions{ + ID: "", + WebMode: false, + IncludeParents: false, + InteractiveMode: true, + Organization: "", + }, + }, + { + name: "repo and org specified", + args: "--org \"my-org\" -R \"owner/repo\"", + isTTY: true, + wantErr: "only one of --repo and --org may be specified", + }, + { + name: "invalid ID", + args: "1.5", + isTTY: true, + wantErr: "invalid value for ruleset ID: 1.5 is not an integer", + }, + { + name: "ID not provided and not TTY", + args: "", + isTTY: false, + wantErr: "a ruleset ID must be provided when not running interactively", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: ios, + } + + var opts *ViewOptions + cmd := NewCmdView(f, func(o *ViewOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.ID, opts.ID) + assert.Equal(t, tt.want.WebMode, opts.WebMode) + assert.Equal(t, tt.want.IncludeParents, opts.IncludeParents) + assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode) + assert.Equal(t, tt.want.Organization, opts.Organization) + }) + } +} + +func Test_viewRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts ViewOptions + wantErr string + wantStdout string + wantStderr string + wantBrowse string + }{ + { + name: "view repo ruleset", + isTTY: true, + opts: ViewOptions{ + ID: "42", + }, + wantStdout: heredoc.Doc(` + + Test Ruleset + ID: 42 + Source: my-owner/repo-name (Repository) + Enforceument: Active + + Bypass List + - OrganizationAdmin (ID: 1), mode: always + - RepositoryRole (ID: 5), mode: always + + Conditions + - ref_name: [exclude: []] [include: [~ALL]] + + Rules + - commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] + - commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] + - creation + `), + wantStderr: "", + wantBrowse: "", + }, + { + name: "web mode, TTY", + isTTY: true, + opts: ViewOptions{ + ID: "42", + WebMode: true, + }, + wantStdout: "Opening github.com/my-owner/repo-name/rules/42 in your browser.\n", + wantStderr: "", + wantBrowse: "https://github.com/my-owner/repo-name/rules/42", + }, + { + name: "web mode, non-TTY", + isTTY: false, + opts: ViewOptions{ + ID: "42", + WebMode: true, + }, + wantStdout: "", + wantStderr: "", + wantBrowse: "https://github.com/my-owner/repo-name/rules/42", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register( + httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"), + httpmock.FileResponse("./fixtures/rulesetView.json"), + ) + + tt.opts.IO = ios + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("my-owner/repo-name") + } + browser := &browser.Stub{} + tt.opts.Browser = browser + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + err := viewRun(&tt.opts) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + if tt.wantBrowse != "" { + browser.Verify(t, tt.wantBrowse) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} From e4aa5ba84c06b7e48288e361d5aadfef63380446 Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 30 Jun 2023 19:25:28 -0400 Subject: [PATCH 026/103] add ruleset check command --- pkg/cmd/ruleset/check/check.go | 81 ++++++++++++++++++++++++-------- pkg/cmd/ruleset/shared/shared.go | 61 ++++++++++++++++++++++-- pkg/cmd/ruleset/view/view.go | 26 +--------- 3 files changed, 118 insertions(+), 50 deletions(-) diff --git a/pkg/cmd/ruleset/check/check.go b/pkg/cmd/ruleset/check/check.go index 34c708c69..53a73efb1 100644 --- a/pkg/cmd/ruleset/check/check.go +++ b/pkg/cmd/ruleset/check/check.go @@ -9,8 +9,11 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -22,9 +25,11 @@ type CheckOptions struct { Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) Git *git.Client + Browser browser.Browser Branch string Default bool + WebMode bool } func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command { @@ -32,33 +37,58 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + Browser: f.Browser, Git: f.GitClient, } cmd := &cobra.Command{ Use: "check []", - Short: "Print rules that would apply to a given branch", + Short: "View rules that would apply to a given branch", Long: heredoc.Doc(` - TODO + View information about GitHub rules that apply to a given branch. + + The provided branch name does not need to exist, rules will be displayed that would apply + to a branch with that name. All rules are returned regardless of where they are configured. + + If no branch name is provided, then the current branch will be used. + + The --default flag can be used to view rules that apply to the default branch of the current + repository. `), - Example: "TODO", - Args: cobra.MaximumNArgs(1), + Example: heredoc.Doc(` + # View all rules that apply to the current branch + $ gh ruleset check + + # View all rules that apply to a branch named "my-branch" in a different repository + $ gh ruleset check my-branch --repo owner/repo + + # View all rules that apply to the default branch in a different repository + $ gh ruleset check --default --repo owner/repo + + # View a ruleset configured in a different repository or any of its parents + $ gh ruleset view 23 --repo owner/repo + + # View an organization-level ruleset + $ gh ruleset view 23 --org my-org + `), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - // TODO flag to do a push - if len(args) > 0 { opts.Branch = args[0] } - if runF != nil { - return runF(opts) + if err := cmdutil.MutuallyExclusive( + "specify only one of `--default` or a branch name", + opts.Branch != "", + opts.Default, + ); err != nil { + return err } - if opts.Branch != "" && opts.Default { - return cmdutil.FlagErrorf( - "branch argument '%s' and --default mutually exclusive", opts.Branch) + if runF != nil { + return runF(opts) } return checkRun(opts) @@ -71,10 +101,6 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm } func checkRun(opts *CheckOptions) error { - // TODO ask about pushing (if interactive) - // TODO error if not interactive and --push not specified - // TODO parsing for errors on push - httpClient, err := opts.HttpClient() if err != nil { return err @@ -103,18 +129,33 @@ func checkRun(opts *CheckOptions) error { } } - var lol interface{} + rawPath := fmt.Sprintf("rules?ref=%s%s", url.QueryEscape("refs/heads/"), url.QueryEscape(opts.Branch)) + rulesURL := ghrepo.GenerateRepoURL(repoI, rawPath) + + if opts.WebMode { + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesURL)) + } + + return opts.Browser.Browse(rulesURL) + } + + var rules []shared.RulesetRule endpoint := fmt.Sprintf("repos/%s/%s/rules/branches/%s", repoI.RepoOwner(), repoI.RepoName(), url.PathEscape(opts.Branch)) - if err = client.REST(repoI.RepoHost(), "GET", endpoint, nil, &lol); err != nil { + if err = client.REST(repoI.RepoHost(), "GET", endpoint, nil, &rules); err != nil { return fmt.Errorf("GET %s failed: %w", endpoint, err) } - // TODO handle 404s gracefully - // TODO actually parse JSON + w := opts.IO.Out - fmt.Printf("DBG %#v\n", lol) + fmt.Fprintf(w, "%d rules apply to branch %s in repo %s/%s\n", len(rules), opts.Branch, repoI.RepoOwner(), repoI.RepoName()) + + if len(rules) > 0 { + fmt.Fprint(w, "\n") + fmt.Fprint(w, shared.ParseRulesForDisplay(rules)) + } return nil } diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index 5631a24e7..b365a006d 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -2,6 +2,8 @@ package shared import ( "fmt" + "sort" + "strings" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" @@ -35,17 +37,22 @@ type RulesetREST struct { // TODO is this source field used? SourceType string `json:"source_type"` Source string - Rules []struct { - Type string - Parameters map[string]interface{} - } - Links struct { + Rules []RulesetRule + Links struct { Html struct { Href string } } `json:"_links"` } +type RulesetRule struct { + Type string + Parameters map[string]interface{} + RulesetSourceType string `json:"ruleset_source_type"` + RulesetSource string `json:"ruleset_source"` + RulesetId int `json:"ruleset_id"` +} + // Returns the source of the ruleset in the format "owner/name (repo)" or "owner (org)" func RulesetSource(rs RulesetGraphQL) string { var level string @@ -60,6 +67,50 @@ func RulesetSource(rs RulesetGraphQL) string { return fmt.Sprintf("%s (%s)", rs.Source.Owner, level) } +func ParseRulesForDisplay(rules []RulesetRule) string { + var display strings.Builder + + // sort keys for consistent responses + sort.SliceStable(rules, func(i, j int) bool { + return rules[i].Type < rules[j].Type + }) + + for _, rule := range rules { + display.WriteString(fmt.Sprintf("- %s", rule.Type)) + + if rule.Parameters != nil && len(rule.Parameters) > 0 { + display.WriteString(": ") + + // sort these keys too for consistency + params := make([]string, 0, len(rule.Parameters)) + for p := range rule.Parameters { + params = append(params, p) + } + sort.Strings(params) + + for _, n := range params { + display.WriteString(fmt.Sprintf("[%s: %v] ", n, rule.Parameters[n])) + } + } + + // ruleset source info is only returned from the "get rules for a branch" endpoint + if rule.RulesetSource != "" { + display.WriteString( + fmt.Sprintf( + "\n (configured in ruleset %d from %s %s)\n", + rule.RulesetId, + strings.ToLower(rule.RulesetSourceType), + rule.RulesetSource, + ), + ) + } + + display.WriteString("\n") + } + + return display.String() +} + func NoRulesetsFoundError(orgOption string, repoI ghrepo.Interface, includeParents bool) error { entityName := EntityName(orgOption, repoI) parentsMsg := "" diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 6b3604fa4..b842e2e69 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -248,31 +248,7 @@ func viewRun(opts *ViewOptions) error { if len(rs.Rules) == 0 { fmt.Fprintf(w, "No rules configured\n") } else { - // sort keys for consistent responses - sort.SliceStable(rs.Rules, func(i, j int) bool { - return rs.Rules[i].Type < rs.Rules[j].Type - }) - - for _, rule := range rs.Rules { - fmt.Fprintf(w, "- %s", rule.Type) - - if rule.Parameters != nil && len(rule.Parameters) > 0 { - fmt.Fprintf(w, ": ") - - // sort these keys too for consistency - params := make([]string, 0, len(rule.Parameters)) - for p := range rule.Parameters { - params = append(params, p) - } - sort.Strings(params) - - for _, n := range params { - fmt.Fprintf(w, "[%s: %v] ", n, rule.Parameters[n]) - } - } - - fmt.Fprint(w, "\n") - } + fmt.Fprint(w, shared.ParseRulesForDisplay(rs.Rules)) } return nil From 77df68e11ac862e807ff720bd899b513175ed6e5 Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 30 Jun 2023 20:01:17 -0400 Subject: [PATCH 027/103] misc cleanup --- pkg/cmd/ruleset/list/list_test.go | 71 ------------------------------- pkg/cmd/ruleset/rules.go | 39 ----------------- pkg/cmd/ruleset/ruleset.go | 11 +++-- pkg/cmd/ruleset/shared/shared.go | 1 - 4 files changed, 8 insertions(+), 114 deletions(-) delete mode 100644 pkg/cmd/ruleset/rules.go diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go index 2bb0e3cf7..4a01b803a 100644 --- a/pkg/cmd/ruleset/list/list_test.go +++ b/pkg/cmd/ruleset/list/list_test.go @@ -10,7 +10,6 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -137,76 +136,6 @@ func Test_NewCmdList(t *testing.T) { } } -func Test_RulesetList_Web(t *testing.T) { - tests := []struct { - name string - stdoutTTY bool - wantStdout string - wantBrowse string - }{ - { - name: "repo tty", - stdoutTTY: true, - wantStdout: "Opening github.com/OWNER/REPO in your browser.\n", - wantBrowse: "https://github.com/OWNER/REPO", - }, - { - name: "org tty", - stdoutTTY: true, - wantStdout: "Opening github.com/OWNER/REPO in your browser.\n", - wantBrowse: "https://github.com/OWNER/REPO", - }, - { - name: "repo non-tty", - stdoutTTY: false, - wantStdout: "", - wantBrowse: "https://github.com/OWNER/REPO", - }, - { - name: "org non-tty", - stdoutTTY: false, - wantStdout: "", - wantBrowse: "https://github.com/OWNER/REPO", - }, - } - - for _, tt := range tests { - reg := &httpmock.Registry{} - reg.StubRepoInfoResponse("OWNER", "REPO", "main") - - browser := &browser.Stub{} - opts := &ListOptions{ - WebMode: true, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Browser: browser, - } - - io, _, stdout, _ := iostreams.Test() - - opts.IO = io - - t.Run(tt.name, func(t *testing.T) { - io.SetStdoutTTY(tt.stdoutTTY) - - _, teardown := run.Stub() - defer teardown(t) - - if err := listRun(opts); err != nil { - t.Errorf("listRun() error = %v", err) - } - assert.Equal(t, "", stdout.String()) - assert.Equal(t, tt.wantStdout, stdout.String()) - reg.Verify(t) - browser.Verify(t, tt.wantBrowse) - }) - } -} - func Test_listRun(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/ruleset/rules.go b/pkg/cmd/ruleset/rules.go deleted file mode 100644 index b3405d839..000000000 --- a/pkg/cmd/ruleset/rules.go +++ /dev/null @@ -1,39 +0,0 @@ -package ruleset - -type RuleType string -type Enforcement string -type MatchingOperator string - -const ( - RuleTypeCommitAuthorEmailPattern RuleType = "commit_author_email_pattern" - RuleTypePullRequest RuleType = "pull_request" - // TODO others - - EnforcementEnabled Enforcement = "enabled" - EnforcementDisabled Enforcement = "disabled" - - MatchingOperatorStartsWith MatchingOperator = "starts_with" - MatchingOperatorEndsWith MatchingOperator = "ends_with" - MatchingOperatorContains MatchingOperator = "contains" - MatchingOperatorRegex MatchingOperator = "regex" -) - -type ConfigurationPullRequest struct { - DissmissStaleReviewsOnPush bool - RequireCodeOwnerReview bool - RequestLastPushApproval bool - RequiredApprovingReviewCount int -} - -type BranchNamePattern struct { - Name string - Negate bool - Operator MatchingOperator -} - -type Rule struct { - ID string - Type RuleType - Enforcement Enforcement - Configuration interface{} -} diff --git a/pkg/cmd/ruleset/ruleset.go b/pkg/cmd/ruleset/ruleset.go index 64536e868..86411d717 100644 --- a/pkg/cmd/ruleset/ruleset.go +++ b/pkg/cmd/ruleset/ruleset.go @@ -12,12 +12,17 @@ import ( func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "ruleset ", - Short: "Manage repository and organization rulesets", + Short: "View info about repo rulesets", Long: heredoc.Doc(` - TODO + Repository rulesets are a way to define a set of rules that apply to a repository. + These commands allow you to view information about them. `), Aliases: []string{"rs"}, - Example: "TODO", + Example: heredoc.Doc(` + $ gh ruleset list + $ gh ruleset view --repo OWNER/REPO --web + $ gh ruleset check branch-name + `), } cmdutil.EnableRepoOverride(cmd, f) diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index b365a006d..a71b20522 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -34,7 +34,6 @@ type RulesetREST struct { BypassMode string `json:"bypass_mode"` } `json:"bypass_actors"` Conditions map[string]map[string]interface{} - // TODO is this source field used? SourceType string `json:"source_type"` Source string Rules []RulesetRule From 159ac8ba0ef1e8493803c1a6c44fb12132fd8aa5 Mon Sep 17 00:00:00 2001 From: vaindil Date: Mon, 3 Jul 2023 11:59:55 -0400 Subject: [PATCH 028/103] fix merge errors --- pkg/cmd/ruleset/list/list.go | 11 ++--------- pkg/cmd/ruleset/list/list_test.go | 4 ---- pkg/cmd/ruleset/view/view.go | 11 ++--------- pkg/cmd/ruleset/view/view_test.go | 4 ---- 4 files changed, 4 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 6d2bc3cb1..2b8a210ed 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -8,7 +8,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" @@ -16,13 +15,13 @@ import ( "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + ghAuth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) type ListOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams - Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -37,7 +36,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman IO: f.IOStreams, HttpClient: f.HttpClient, Browser: f.Browser, - Config: f.Config, } cmd := &cobra.Command{ Use: "list", @@ -104,12 +102,7 @@ func listRun(opts *ListOptions) error { return err } - cfg, err := opts.Config() - if err != nil { - return err - } - - hostname, _ := cfg.DefaultHost() + hostname, _ := ghAuth.DefaultHost() if opts.WebMode { var rulesetURL string diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go index 4a01b803a..8d1f0bb99 100644 --- a/pkg/cmd/ruleset/list/list_test.go +++ b/pkg/cmd/ruleset/list/list_test.go @@ -8,7 +8,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -253,9 +252,6 @@ func Test_listRun(t *testing.T) { } browser := &browser.Stub{} tt.opts.Browser = browser - tt.opts.Config = func() (config.Config, error) { - return config.NewBlankConfig(), nil - } err := listRun(&tt.opts) diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index b842e2e69..2b67060df 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -9,20 +9,19 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + ghAuth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) type ViewOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams - Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser Prompter prompter.Prompter @@ -39,7 +38,6 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman IO: f.IOStreams, HttpClient: f.HttpClient, Browser: f.Browser, - Config: f.Config, Prompter: f.Prompter, } @@ -120,12 +118,7 @@ func viewRun(opts *ViewOptions) error { return err } - cfg, err := opts.Config() - if err != nil { - return err - } - - hostname, _ := cfg.DefaultHost() + hostname, _ := ghAuth.DefaultHost() if opts.InteractiveMode { var rsList *shared.RulesetList diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go index 0548ff4fe..7e184ddc4 100644 --- a/pkg/cmd/ruleset/view/view_test.go +++ b/pkg/cmd/ruleset/view/view_test.go @@ -8,7 +8,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -233,9 +232,6 @@ func Test_viewRun(t *testing.T) { } browser := &browser.Stub{} tt.opts.Browser = browser - tt.opts.Config = func() (config.Config, error) { - return config.NewBlankConfig(), nil - } err := viewRun(&tt.opts) From dcefe340ada6fa887b90f581f5632853f616bd4b Mon Sep 17 00:00:00 2001 From: vaindil Date: Wed, 5 Jul 2023 13:17:50 -0400 Subject: [PATCH 029/103] add rs check tests --- pkg/cmd/ruleset/check/check.go | 18 +- pkg/cmd/ruleset/check/check_test.go | 233 ++++++++++++++++++ .../ruleset/check/fixtures/rulesetCheck.json | 62 +++++ 3 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 pkg/cmd/ruleset/check/check_test.go create mode 100644 pkg/cmd/ruleset/check/fixtures/rulesetCheck.json diff --git a/pkg/cmd/ruleset/check/check.go b/pkg/cmd/ruleset/check/check.go index 53a73efb1..3007fe36a 100644 --- a/pkg/cmd/ruleset/check/check.go +++ b/pkg/cmd/ruleset/check/check.go @@ -46,12 +46,12 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm Long: heredoc.Doc(` View information about GitHub rules that apply to a given branch. - The provided branch name does not need to exist, rules will be displayed that would apply + The provided branch name does not need to exist; rules will be displayed that would apply to a branch with that name. All rules are returned regardless of where they are configured. If no branch name is provided, then the current branch will be used. - The --default flag can be used to view rules that apply to the default branch of the current + The --default flag can be used to view rules that apply to the default branch of the repository. `), Example: heredoc.Doc(` @@ -96,6 +96,7 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm } cmd.Flags().BoolVar(&opts.Default, "default", false, "Check rules on default branch") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the branch rules page in a web browser") return cmd } @@ -109,7 +110,7 @@ func checkRun(opts *CheckOptions) error { repoI, err := opts.BaseRepo() if err != nil { - return err + return fmt.Errorf("could not determine repo to use: %w", err) } git := opts.Git @@ -129,15 +130,16 @@ func checkRun(opts *CheckOptions) error { } } - rawPath := fmt.Sprintf("rules?ref=%s%s", url.QueryEscape("refs/heads/"), url.QueryEscape(opts.Branch)) - rulesURL := ghrepo.GenerateRepoURL(repoI, rawPath) - if opts.WebMode { + // the query string parameter may have % signs in it, so it must be carefully used with Printf functions + queryString := fmt.Sprintf("?ref=%s", url.QueryEscape("refs/heads/"+opts.Branch)) + rawUrl := ghrepo.GenerateRepoURL(repoI, "rules") + if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesURL)) + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rawUrl)) } - return opts.Browser.Browse(rulesURL) + return opts.Browser.Browse(rawUrl + queryString) } var rules []shared.RulesetRule diff --git a/pkg/cmd/ruleset/check/check_test.go b/pkg/cmd/ruleset/check/check_test.go new file mode 100644 index 000000000..2d9dffae4 --- /dev/null +++ b/pkg/cmd/ruleset/check/check_test.go @@ -0,0 +1,233 @@ +package check + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "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 Test_NewCmdCheck(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want CheckOptions + wantErr string + }{ + { + name: "no arguments", + args: "", + isTTY: true, + want: CheckOptions{ + Branch: "", + Default: false, + WebMode: false, + }, + }, + { + name: "branch name", + args: "my-branch", + isTTY: true, + want: CheckOptions{ + Branch: "my-branch", + Default: false, + WebMode: false, + }, + }, + { + name: "default", + args: "--default=true", + isTTY: true, + want: CheckOptions{ + Branch: "", + Default: true, + WebMode: false, + }, + }, + { + name: "web mode", + args: "--web", + isTTY: true, + want: CheckOptions{ + Branch: "", + Default: false, + WebMode: true, + }, + }, + { + name: "both --default and branch name specified", + args: "--default asdf", + isTTY: true, + wantErr: "specify only one of `--default` or a branch name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: ios, + } + + var opts *CheckOptions + cmd := NewCmdCheck(f, func(o *CheckOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.Branch, opts.Branch) + assert.Equal(t, tt.want.Default, opts.Default) + assert.Equal(t, tt.want.WebMode, opts.WebMode) + }) + } +} + +func Test_checkRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts CheckOptions + wantErr string + wantStdout string + wantStderr string + wantBrowse string + }{ + { + name: "view rules for branch", + isTTY: true, + opts: CheckOptions{ + Branch: "my-branch", + }, + wantStdout: heredoc.Doc(` + 6 rules apply to branch my-branch in repo my-org/repo-name + + - commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] + (configured in ruleset 1234 from organization my-org) + + - commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] + (configured in ruleset 5678 from repository my-org/repo-name) + + - commit_message_pattern: [name: ] [negate: false] [operator: starts_with] [pattern: fff] + (configured in ruleset 1234 from organization my-org) + + - commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] + (configured in ruleset 5678 from repository my-org/repo-name) + + - creation + (configured in ruleset 5678 from repository my-org/repo-name) + + - required_signatures + (configured in ruleset 1234 from organization my-org) + + `), + wantStderr: "", + wantBrowse: "", + }, + { + name: "web mode, TTY", + isTTY: true, + opts: CheckOptions{ + Branch: "my-branch", + WebMode: true, + }, + wantStdout: "Opening github.com/my-org/repo-name/rules in your browser.\n", + wantStderr: "", + wantBrowse: "https://github.com/my-org/repo-name/rules?ref=refs%2Fheads%2Fmy-branch", + }, + { + name: "web mode, TTY, special character in branch name", + isTTY: true, + opts: CheckOptions{ + Branch: "my-feature/my-branch", + WebMode: true, + }, + wantStdout: "Opening github.com/my-org/repo-name/rules in your browser.\n", + wantStderr: "", + wantBrowse: "https://github.com/my-org/repo-name/rules?ref=refs%2Fheads%2Fmy-feature%2Fmy-branch", + }, + { + name: "web mode, non-TTY", + isTTY: false, + opts: CheckOptions{ + Branch: "my-branch", + WebMode: true, + }, + wantStdout: "", + wantStderr: "", + wantBrowse: "https://github.com/my-org/repo-name/rules?ref=refs%2Fheads%2Fmy-branch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register( + httpmock.REST("GET", "repos/my-org/repo-name/rules/branches/my-branch"), + httpmock.FileResponse("./fixtures/rulesetCheck.json"), + ) + + tt.opts.IO = ios + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("my-org/repo-name") + } + browser := &browser.Stub{} + tt.opts.Browser = browser + + err := checkRun(&tt.opts) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + if tt.wantBrowse != "" { + browser.Verify(t, tt.wantBrowse) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/ruleset/check/fixtures/rulesetCheck.json b/pkg/cmd/ruleset/check/fixtures/rulesetCheck.json new file mode 100644 index 000000000..c02fddef4 --- /dev/null +++ b/pkg/cmd/ruleset/check/fixtures/rulesetCheck.json @@ -0,0 +1,62 @@ +[ + { + "type": "commit_author_email_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "@example.com", + "operator": "ends_with" + }, + "ruleset_source_type": "Organization", + "ruleset_source": "my-org", + "ruleset_id": 1234 + }, + { + "type": "commit_message_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "fff", + "operator": "starts_with" + }, + "ruleset_source_type": "Organization", + "ruleset_source": "my-org", + "ruleset_id": 1234 + }, + { + "type": "required_signatures", + "ruleset_source_type": "Organization", + "ruleset_source": "my-org", + "ruleset_id": 1234 + }, + { + "type": "commit_message_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "asdf", + "operator": "contains" + }, + "ruleset_source_type": "Repository", + "ruleset_source": "my-org/repo-name", + "ruleset_id": 5678 + }, + { + "type": "commit_author_email_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "@example.com", + "operator": "ends_with" + }, + "ruleset_source_type": "Repository", + "ruleset_source": "my-org/repo-name", + "ruleset_id": 5678 + }, + { + "type": "creation", + "ruleset_source_type": "Repository", + "ruleset_source": "my-org/repo-name", + "ruleset_id": 5678 + } +] From 069faef86c4c63b7358050a2f832e1f64993b04f Mon Sep 17 00:00:00 2001 From: vaindil Date: Wed, 5 Jul 2023 14:29:52 -0400 Subject: [PATCH 030/103] refactor graphql query a bit --- pkg/cmd/ruleset/shared/http.go | 81 +++++++++++++++++----------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/pkg/cmd/ruleset/shared/http.go b/pkg/cmd/ruleset/shared/http.go index 05a8e3cf7..b9114aa85 100644 --- a/pkg/cmd/ruleset/shared/http.go +++ b/pkg/cmd/ruleset/shared/http.go @@ -2,7 +2,6 @@ package shared import ( "errors" - "fmt" "net/http" "strings" @@ -85,48 +84,50 @@ func listRulesets(httpClient *http.Client, query string, variables map[string]in return &res, nil } -func rulesetsQuery(org bool) string { - var args string - var level string - - if org { - args = "$login: String!" - level = "organization(login: $login)" - } else { - args = "$owner: String!, $repo: String!" - level = "repository(owner: $owner, name: $repo)" - } - - str := fmt.Sprintf("query RulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, %s) { level: %s {", args, level) - - return str + ` - rulesets(first: $limit, after: $endCursor, includeParents: $includeParents) { - totalCount - nodes { - databaseId - name - target - enforcement - source { - __typename - ... on Repository { owner: nameWithOwner } - ... on Organization { owner: login } - } - rules { - totalCount - } - } - pageInfo { - hasNextPage - endCursor - } - } - }}` -} - func min(a, b int) int { if a < b { return a } return b } + +func rulesetsQuery(org bool) string { + if org { + return orgGraphQLHeader + sharedGraphQLBody + } else { + return repoGraphQLHeader + sharedGraphQLBody + } +} + +const repoGraphQLHeader = ` +query RulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $owner: String!, $repo: String!) { + level: repository(owner: $owner, name: $repo) { +` + +const orgGraphQLHeader = ` +query RulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $login: String!) { + level: organization(login: $login) { +` + +const sharedGraphQLBody = ` +rulesets(first: $limit, after: $endCursor, includeParents: $includeParents) { + totalCount + nodes { + databaseId + name + target + enforcement + source { + __typename + ... on Repository { owner: nameWithOwner } + ... on Organization { owner: login } + } + rules { + totalCount + } + } + pageInfo { + hasNextPage + endCursor + } +}}}` From ab921f96e64aaa3d6b83ed279db93b3490801fbd Mon Sep 17 00:00:00 2001 From: vaindil Date: Wed, 5 Jul 2023 15:13:09 -0400 Subject: [PATCH 031/103] split org/repo graphql queries, better tests --- pkg/cmd/ruleset/list/list_test.go | 28 +++++- pkg/cmd/ruleset/shared/http.go | 4 +- .../ruleset/view/fixtures/rulesetViewOrg.json | 58 ++++++++++++ ...{rulesetView.json => rulesetViewRepo.json} | 0 pkg/cmd/ruleset/view/view.go | 2 +- pkg/cmd/ruleset/view/view_test.go | 90 +++++++++++++++++-- 6 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json rename pkg/cmd/ruleset/view/fixtures/{rulesetView.json => rulesetViewRepo.json} (100%) diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go index 8d1f0bb99..d075e46d6 100644 --- a/pkg/cmd/ruleset/list/list_test.go +++ b/pkg/cmd/ruleset/list/list_test.go @@ -140,6 +140,7 @@ func Test_listRun(t *testing.T) { name string isTTY bool opts ListOptions + httpStubs func(*httpmock.Registry) wantErr string wantStdout string wantStderr string @@ -157,6 +158,12 @@ func Test_listRun(t *testing.T) { 42 asdf OWNER/REPO (repo) active 2 77 foobar Org-Name (org) disabled 4 `), + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepoRulesetList\b`), + httpmock.FileResponse("./fixtures/rulesetList.json"), + ) + }, wantStderr: "", wantBrowse: "", }, @@ -175,6 +182,12 @@ func Test_listRun(t *testing.T) { 42 asdf OWNER/REPO (repo) active 2 77 foobar Org-Name (org) disabled 4 `), + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query OrgRulesetList\b`), + httpmock.FileResponse("./fixtures/rulesetList.json"), + ) + }, wantStderr: "", wantBrowse: "", }, @@ -186,6 +199,12 @@ func Test_listRun(t *testing.T) { 42 asdf OWNER/REPO (repo) active 2 77 foobar Org-Name (org) disabled 4 `), + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepoRulesetList\b`), + httpmock.FileResponse("./fixtures/rulesetList.json"), + ) + }, wantStderr: "", wantBrowse: "", }, @@ -240,12 +259,15 @@ func Test_listRun(t *testing.T) { ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) - fakeHTTP := &httpmock.Registry{} - fakeHTTP.Register(httpmock.GraphQL(`query RulesetList\b`), httpmock.FileResponse("./fixtures/rulesetList.json")) + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } tt.opts.IO = ios tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: fakeHTTP}, nil + return &http.Client{Transport: reg}, nil } tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") diff --git a/pkg/cmd/ruleset/shared/http.go b/pkg/cmd/ruleset/shared/http.go index b9114aa85..59480dadf 100644 --- a/pkg/cmd/ruleset/shared/http.go +++ b/pkg/cmd/ruleset/shared/http.go @@ -100,12 +100,12 @@ func rulesetsQuery(org bool) string { } const repoGraphQLHeader = ` -query RulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $owner: String!, $repo: String!) { +query RepoRulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $owner: String!, $repo: String!) { level: repository(owner: $owner, name: $repo) { ` const orgGraphQLHeader = ` -query RulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $login: String!) { +query OrgRulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $login: String!) { level: organization(login: $login) { ` diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json new file mode 100644 index 000000000..d18509bbf --- /dev/null +++ b/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json @@ -0,0 +1,58 @@ +{ + "id": 74, + "name": "My Org Ruleset", + "target": "branch", + "source_type": "Organization", + "source": "my-owner", + "enforcement": "disabled", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~ALL" + ] + }, + "repository_name": { + "exclude": [], + "include": [ + "~ALL" + ], + "protected": true + } + }, + "rules": [ + { + "type": "commit_message_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "asdf", + "operator": "contains" + } + }, + { + "type": "commit_author_email_pattern", + "parameters": { + "name": "", + "negate": false, + "pattern": "@example.com", + "operator": "ends_with" + } + }, + { + "type": "creation" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec4dwx_uzSNG", + "_links": { + "self": { + "href": "https://api.github.com/repos/my-owner/repo-name/rulesets/74" + }, + "html": { + "href": "https://github.com/organizations/my-owner/settings/rules/74" + } + }, + "created_at": "2023-05-01T13:53:37.185-04:00", + "updated_at": "2023-06-29T17:38:03.722-04:00", + "bypass_actors": [] +} diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetView.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json similarity index 100% rename from pkg/cmd/ruleset/view/fixtures/rulesetView.json rename to pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 2b67060df..615abdac5 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -182,7 +182,7 @@ func viewRun(opts *ViewOptions) error { fmt.Fprintf(w, "ID: %d\n", rs.Id) fmt.Fprintf(w, "Source: %s (%s)\n", rs.Source, rs.SourceType) - fmt.Fprint(w, "Enforceument: ") + fmt.Fprint(w, "Enforcement: ") switch rs.Enforcement { case "disabled": fmt.Fprintf(w, "%s\n", cs.Red("Disabled")) diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go index 7e184ddc4..78c32d076 100644 --- a/pkg/cmd/ruleset/view/view_test.go +++ b/pkg/cmd/ruleset/view/view_test.go @@ -153,6 +153,7 @@ func Test_viewRun(t *testing.T) { name string isTTY bool opts ViewOptions + httpStubs func(*httpmock.Registry) wantErr string wantStdout string wantStderr string @@ -169,7 +170,7 @@ func Test_viewRun(t *testing.T) { Test Ruleset ID: 42 Source: my-owner/repo-name (Repository) - Enforceument: Active + Enforcement: Active Bypass List - OrganizationAdmin (ID: 1), mode: always @@ -183,31 +184,102 @@ func Test_viewRun(t *testing.T) { - commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] - creation `), + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"), + httpmock.FileResponse("./fixtures/rulesetViewRepo.json"), + ) + }, wantStderr: "", wantBrowse: "", }, { - name: "web mode, TTY", + name: "view org ruleset", + isTTY: true, + opts: ViewOptions{ + ID: "74", + Organization: "my-owner", + }, + wantStdout: heredoc.Doc(` + + My Org Ruleset + ID: 74 + Source: my-owner (Organization) + Enforcement: Disabled + + Bypass List + This ruleset cannot be bypassed + + Conditions + - ref_name: [exclude: []] [include: [~ALL]] + - repository_name: [exclude: []] [include: [~ALL]] [protected: true] + + Rules + - commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] + - commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] + - creation + `), + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "orgs/my-owner/rulesets/74"), + httpmock.FileResponse("./fixtures/rulesetViewOrg.json"), + ) + }, + wantStderr: "", + wantBrowse: "", + }, + { + name: "web mode, TTY, repo", isTTY: true, opts: ViewOptions{ ID: "42", WebMode: true, }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"), + httpmock.FileResponse("./fixtures/rulesetViewRepo.json"), + ) + }, wantStdout: "Opening github.com/my-owner/repo-name/rules/42 in your browser.\n", wantStderr: "", wantBrowse: "https://github.com/my-owner/repo-name/rules/42", }, { - name: "web mode, non-TTY", + name: "web mode, non-TTY, repo", isTTY: false, opts: ViewOptions{ ID: "42", WebMode: true, }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"), + httpmock.FileResponse("./fixtures/rulesetViewRepo.json"), + ) + }, wantStdout: "", wantStderr: "", wantBrowse: "https://github.com/my-owner/repo-name/rules/42", }, + { + name: "web mode, TTY, org", + isTTY: true, + opts: ViewOptions{ + ID: "74", + Organization: "my-owner", + WebMode: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "orgs/my-owner/rulesets/74"), + httpmock.FileResponse("./fixtures/rulesetViewOrg.json"), + ) + }, + wantStdout: "Opening github.com/organizations/my-owner/settings/rules/74 in your browser.\n", + wantStderr: "", + wantBrowse: "https://github.com/organizations/my-owner/settings/rules/74", + }, } for _, tt := range tests { @@ -217,15 +289,15 @@ func Test_viewRun(t *testing.T) { ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) - fakeHTTP := &httpmock.Registry{} - fakeHTTP.Register( - httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"), - httpmock.FileResponse("./fixtures/rulesetView.json"), - ) + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } tt.opts.IO = ios tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: fakeHTTP}, nil + return &http.Client{Transport: reg}, nil } tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("my-owner/repo-name") From d82c1193b3ea3eb2ab537d3e52073a0b2137c779 Mon Sep 17 00:00:00 2001 From: vaindil Date: Thu, 6 Jul 2023 14:56:55 -0400 Subject: [PATCH 032/103] make IDs cyan, add prompter test --- pkg/cmd/ruleset/list/list.go | 2 +- .../view/fixtures/rulesetViewMultiple.json | 41 ++++++++ .../ruleset/view/fixtures/rulesetViewOrg.json | 2 +- pkg/cmd/ruleset/view/view.go | 12 +-- pkg/cmd/ruleset/view/view_test.go | 95 +++++++++++++------ 5 files changed, 116 insertions(+), 36 deletions(-) create mode 100644 pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 2b8a210ed..60afc69ab 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -158,7 +158,7 @@ func listRun(opts *ListOptions) error { tp.HeaderRow("ID", "NAME", "SOURCE", "STATUS", "RULES") for _, rs := range result.Rulesets { - tp.AddField(strconv.Itoa(rs.DatabaseId)) + tp.AddField(strconv.Itoa(rs.DatabaseId), tableprinter.WithColor(cs.Cyan)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) tp.AddField(shared.RulesetSource(rs)) tp.AddField(strings.ToLower(rs.Enforcement)) diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json new file mode 100644 index 000000000..1b0d9e2cf --- /dev/null +++ b/pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json @@ -0,0 +1,41 @@ +{ + "data": { + "level": { + "rulesets": { + "totalCount": 2, + "nodes": [ + { + "databaseId": 74, + "name": "My Org Ruleset", + "target": "BRANCH", + "enforcement": "EVALUATE", + "source": { + "__typename": "Organization", + "owner": "my-owner" + }, + "rules": { + "totalCount": 3 + } + }, + { + "databaseId": 42, + "name": "Test Ruleset", + "target": "BRANCH", + "enforcement": "ACTIVE", + "source": { + "__typename": "Repository", + "owner": "my-owner/repo-name" + }, + "rules": { + "totalCount": 3 + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "Mg" + } + } + } + } +} diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json index d18509bbf..88a2bd7ee 100644 --- a/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json +++ b/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json @@ -4,7 +4,7 @@ "target": "branch", "source_type": "Organization", "source": "my-owner", - "enforcement": "disabled", + "enforcement": "evaluate", "conditions": { "ref_name": { "exclude": [], diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 615abdac5..813f72c51 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -119,6 +119,7 @@ func viewRun(opts *ViewOptions) error { } hostname, _ := ghAuth.DefaultHost() + cs := opts.IO.ColorScheme() if opts.InteractiveMode { var rsList *shared.RulesetList @@ -137,7 +138,7 @@ func viewRun(opts *ViewOptions) error { return shared.NoRulesetsFoundError(opts.Organization, repoI, opts.IncludeParents) } - rs, err := selectRulesetID(rsList, opts.Prompter) + rs, err := selectRulesetID(rsList, opts.Prompter, cs) if err != nil { return err } @@ -163,7 +164,6 @@ func viewRun(opts *ViewOptions) error { return err } - cs := opts.IO.ColorScheme() w := opts.IO.Out if opts.WebMode { @@ -179,7 +179,7 @@ func viewRun(opts *ViewOptions) error { } fmt.Fprintf(w, "\n%s\n", cs.Bold(rs.Name)) - fmt.Fprintf(w, "ID: %d\n", rs.Id) + fmt.Fprintf(w, "ID: %s\n", cs.Cyan(strconv.Itoa(rs.Id))) fmt.Fprintf(w, "Source: %s (%s)\n", rs.Source, rs.SourceType) fmt.Fprint(w, "Enforcement: ") @@ -247,12 +247,12 @@ func viewRun(opts *ViewOptions) error { return nil } -func selectRulesetID(rsList *shared.RulesetList, p prompter.Prompter) (*shared.RulesetGraphQL, error) { +func selectRulesetID(rsList *shared.RulesetList, p prompter.Prompter, cs *iostreams.ColorScheme) (*shared.RulesetGraphQL, error) { rulesets := make([]string, len(rsList.Rulesets)) for i, rs := range rsList.Rulesets { s := fmt.Sprintf( - "%d: %s | %s | contains %s | configured in %s", - rs.DatabaseId, + "%s: %s | %s | contains %s | configured in %s", + cs.Cyan(strconv.Itoa(rs.DatabaseId)), rs.Name, strings.ToLower(rs.Enforcement), text.Pluralize(rs.Rules.TotalCount, "rule"), diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go index 78c32d076..ce9c21db4 100644 --- a/pkg/cmd/ruleset/view/view_test.go +++ b/pkg/cmd/ruleset/view/view_test.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -149,15 +150,36 @@ func Test_NewCmdView(t *testing.T) { } func Test_viewRun(t *testing.T) { + repoRulesetStdout := heredoc.Doc(` + + Test Ruleset + ID: 42 + Source: my-owner/repo-name (Repository) + Enforcement: Active + + Bypass List + - OrganizationAdmin (ID: 1), mode: always + - RepositoryRole (ID: 5), mode: always + + Conditions + - ref_name: [exclude: []] [include: [~ALL]] + + Rules + - commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] + - commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] + - creation + `) + tests := []struct { - name string - isTTY bool - opts ViewOptions - httpStubs func(*httpmock.Registry) - wantErr string - wantStdout string - wantStderr string - wantBrowse string + name string + isTTY bool + opts ViewOptions + httpStubs func(*httpmock.Registry) + prompterStubs func(*prompter.MockPrompter) + wantErr string + wantStdout string + wantStderr string + wantBrowse string }{ { name: "view repo ruleset", @@ -165,25 +187,7 @@ func Test_viewRun(t *testing.T) { opts: ViewOptions{ ID: "42", }, - wantStdout: heredoc.Doc(` - - Test Ruleset - ID: 42 - Source: my-owner/repo-name (Repository) - Enforcement: Active - - Bypass List - - OrganizationAdmin (ID: 1), mode: always - - RepositoryRole (ID: 5), mode: always - - Conditions - - ref_name: [exclude: []] [include: [~ALL]] - - Rules - - commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] - - commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] - - creation - `), + wantStdout: repoRulesetStdout, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"), @@ -205,7 +209,7 @@ func Test_viewRun(t *testing.T) { My Org Ruleset ID: 74 Source: my-owner (Organization) - Enforcement: Disabled + Enforcement: Evaluate Mode (not enforced) Bypass List This ruleset cannot be bypassed @@ -228,6 +232,35 @@ func Test_viewRun(t *testing.T) { wantStderr: "", wantBrowse: "", }, + { + name: "prompter", + isTTY: true, + opts: ViewOptions{ + InteractiveMode: true, + }, + wantStdout: repoRulesetStdout, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepoRulesetList\b`), + httpmock.FileResponse("./fixtures/rulesetViewMultiple.json"), + ) + reg.Register( + httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"), + httpmock.FileResponse("./fixtures/rulesetViewRepo.json"), + ) + }, + prompterStubs: func(pm *prompter.MockPrompter) { + const repoRuleset = "42: Test Ruleset | active | contains 3 rules | configured in my-owner/repo-name (repo)" + pm.RegisterSelect("Which ruleset would you like to view?", + []string{ + "74: My Org Ruleset | evaluate | contains 3 rules | configured in my-owner (org)", + repoRuleset, + }, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, repoRuleset) + }) + }, + }, { name: "web mode, TTY, repo", isTTY: true, @@ -289,6 +322,12 @@ func Test_viewRun(t *testing.T) { ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) + pm := prompter.NewMockPrompter(t) + if tt.prompterStubs != nil { + tt.prompterStubs(pm) + } + tt.opts.Prompter = pm + reg := &httpmock.Registry{} defer reg.Verify(t) if tt.httpStubs != nil { From 0d8c34bf59fee1f6485c87eeddf25a07e9374b3b Mon Sep 17 00:00:00 2001 From: Raj Hawaldar Date: Fri, 7 Jul 2023 19:53:39 +0530 Subject: [PATCH 033/103] Add --verify-tag to release edit command (#7646) --- .../release/delete-asset/delete_asset_test.go | 1 + pkg/cmd/release/download/download_test.go | 2 +- pkg/cmd/release/edit/edit.go | 13 +++++++++ pkg/cmd/release/edit/edit_test.go | 27 ++++++++++++++++++- pkg/cmd/release/edit/http.go | 20 ++++++++++++++ pkg/cmd/release/shared/fetch.go | 19 +++++++------ pkg/cmd/release/view/view_test.go | 1 + 7 files changed, 71 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/release/delete-asset/delete_asset_test.go b/pkg/cmd/release/delete-asset/delete_asset_test.go index f28f493d0..e302ca3d1 100644 --- a/pkg/cmd/release/delete-asset/delete_asset_test.go +++ b/pkg/cmd/release/delete-asset/delete_asset_test.go @@ -157,6 +157,7 @@ func Test_deleteAssetRun(t *testing.T) { ios.SetStderrTTY(tt.isTTY) fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) shared.StubFetchRelease(t, fakeHTTP, "OWNER", "REPO", tt.opts.TagName, `{ "tag_name": "v1.2.3", "draft": false, diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index c2260568a..782056314 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -516,7 +516,7 @@ func Test_downloadRun_cloberAndSkip(t *testing.T) { tt.opts.IO = ios reg := &httpmock.Registry{} - // defer reg.Verify(t) // FIXME: intermittetly fails due to StubFetchRelease internals + defer reg.Verify(t) shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ "assets": [ { "name": "windows-64bit.zip", "size": 34, diff --git a/pkg/cmd/release/edit/edit.go b/pkg/cmd/release/edit/edit.go index cac9c0e48..0360911bf 100644 --- a/pkg/cmd/release/edit/edit.go +++ b/pkg/cmd/release/edit/edit.go @@ -26,6 +26,7 @@ type EditOptions struct { Draft *bool Prerelease *bool IsLatest *bool + VerifyTag bool } func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command { @@ -81,6 +82,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or full commit SHA (default: main branch)") cmd.Flags().StringVar(&opts.TagName, "tag", "", "The name of the tag") cmd.Flags().StringVarP(¬esFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)") + cmd.Flags().BoolVar(&opts.VerifyTag, "verify-tag", false, "Abort in case the git tag doesn't already exist in the remote repository") _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target") @@ -110,6 +112,17 @@ func editRun(tag string, opts *EditOptions) error { params["tag_name"] = release.TagName } + if opts.VerifyTag && opts.TagName != "" { + remoteTagPresent, err := remoteTagExists(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + if !remoteTagPresent { + return fmt.Errorf("tag %s doesn't exist in the repo %s, aborting due to --verify-tag flag", + opts.TagName, ghrepo.FullName(baseRepo)) + } + } + editedRelease, err := editRelease(httpClient, baseRepo, release.DatabaseID, params) if err != nil { return err diff --git a/pkg/cmd/release/edit/edit_test.go b/pkg/cmd/release/edit/edit_test.go index 9c7085b11..180051d12 100644 --- a/pkg/cmd/release/edit/edit_test.go +++ b/pkg/cmd/release/edit/edit_test.go @@ -140,6 +140,15 @@ func Test_NewCmdEdit(t *testing.T) { Body: stringPtr("MY NOTES"), }, }, + { + name: "verify-tag", + args: "v1.2.0 --tag=v1.1.0 --verify-tag", + isTTY: false, + want: EditOptions{ + TagName: "v1.1.0", + VerifyTag: true, + }, + }, } for _, tt := range tests { @@ -189,6 +198,7 @@ func Test_NewCmdEdit(t *testing.T) { assert.Equal(t, tt.want.Draft, opts.Draft) assert.Equal(t, tt.want.Prerelease, opts.Prerelease) assert.Equal(t, tt.want.IsLatest, opts.IsLatest) + assert.Equal(t, tt.want.VerifyTag, opts.VerifyTag) }) } } @@ -406,6 +416,21 @@ func Test_editRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", wantStderr: "", }, + { + name: "error when remote tag does not exist and verify-tag flag is set", + isTTY: true, + opts: EditOptions{ + TagName: "v1.2.4", + VerifyTag: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register(httpmock.GraphQL("RepositoryFindRef"), + httpmock.StringResponse(`{"data":{"repository":{"ref": {"id": ""}}}}`)) + }, + wantErr: "tag v1.2.4 doesn't exist in the repo OWNER/REPO, aborting due to --verify-tag flag", + wantStdout: "", + wantStderr: "", + }, } for _, tt := range tests { @@ -416,7 +441,7 @@ func Test_editRun(t *testing.T) { ios.SetStderrTTY(tt.isTTY) fakeHTTP := &httpmock.Registry{} - // defer reg.Verify(t) // FIXME: intermittetly fails due to StubFetchRelease internals + defer fakeHTTP.Verify(t) shared.StubFetchRelease(t, fakeHTTP, "OWNER", "REPO", "v1.2.3", `{ "id": 12345, "tag_name": "v1.2.3" diff --git a/pkg/cmd/release/edit/http.go b/pkg/cmd/release/edit/http.go index 6016ad776..bf310da60 100644 --- a/pkg/cmd/release/edit/http.go +++ b/pkg/cmd/release/edit/http.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/shurcooL/githubv4" ) func editRelease(httpClient *http.Client, repo ghrepo.Interface, releaseID int64, params map[string]interface{}) (*shared.Release, error) { @@ -48,3 +49,22 @@ func editRelease(httpClient *http.Client, repo ghrepo.Interface, releaseID int64 err = json.Unmarshal(b, &newRelease) return &newRelease, err } + +func remoteTagExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) { + gql := api.NewClientFromHTTP(httpClient) + qualifiedTagName := fmt.Sprintf("refs/tags/%s", tagName) + var query struct { + Repository struct { + Ref struct { + ID string + } `graphql:"ref(qualifiedName: $tagName)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "tagName": githubv4.String(qualifiedTagName), + } + err := gql.Query(repo.RepoHost(), "RepositoryFindRef", &query, variables) + return query.Repository.Ref.ID != "", err +} diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index c8182cc83..84a9a369b 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -9,6 +9,7 @@ import ( "net/http" "reflect" "strings" + "testing" "time" "github.com/cli/cli/v2/api" @@ -224,11 +225,7 @@ func fetchReleasePath(ctx context.Context, httpClient *http.Client, host string, return &release, nil } -type testingT interface { - Errorf(format string, args ...interface{}) -} - -func StubFetchRelease(t testingT, reg *httpmock.Registry, owner, repoName, tagName, responseBody string) { +func StubFetchRelease(t *testing.T, reg *httpmock.Registry, owner, repoName, tagName, responseBody string) { path := "repos/OWNER/REPO/releases/tags/v1.2.3" if tagName == "" { path = "repos/OWNER/REPO/releases/latest" @@ -239,10 +236,12 @@ func StubFetchRelease(t testingT, reg *httpmock.Registry, owner, repoName, tagNa if tagName != "" { reg.Register( httpmock.GraphQL(`query RepositoryReleaseByTag\b`), - httpmock.GraphQLQuery(`{ "data": { "repository": { "release": null }}}`, func(q string, vars map[string]interface{}) { - assert.Equal(t, owner, vars["owner"]) - assert.Equal(t, repoName, vars["name"]) - assert.Equal(t, tagName, vars["tagName"]) - })) + httpmock.GraphQLQuery(`{ "data": { "repository": { "release": null }}}`, + func(q string, vars map[string]interface{}) { + assert.Equal(t, owner, vars["owner"]) + assert.Equal(t, repoName, vars["name"]) + assert.Equal(t, tagName, vars["tagName"]) + }), + ) } } diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go index 5d42ba373..c8b837923 100644 --- a/pkg/cmd/release/view/view_test.go +++ b/pkg/cmd/release/view/view_test.go @@ -215,6 +215,7 @@ func Test_viewRun(t *testing.T) { ios.SetStderrTTY(tt.isTTY) fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) shared.StubFetchRelease(t, fakeHTTP, "OWNER", "REPO", tt.opts.TagName, fmt.Sprintf(`{ "tag_name": "v1.2.3", "draft": false, From 47d94f8ce746ac9b437cba16880c7e8b63c76ae0 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 7 Jul 2023 11:51:58 +0900 Subject: [PATCH 034/103] Add --branch-repo flag --- api/queries_branch_issue_reference.go | 20 +++-- pkg/cmd/issue/develop/develop.go | 76 +++++++++++----- pkg/cmd/issue/develop/develop_test.go | 119 ++++++++++++++++++++++---- 3 files changed, 169 insertions(+), 46 deletions(-) diff --git a/api/queries_branch_issue_reference.go b/api/queries_branch_issue_reference.go index 29c3ab322..16e36831b 100644 --- a/api/queries_branch_issue_reference.go +++ b/api/queries_branch_issue_reference.go @@ -12,7 +12,7 @@ type LinkedBranch struct { URL string } -func CreateLinkedBranch(client *Client, host string, issueID, branchName, oid string) (string, error) { +func CreateLinkedBranch(client *Client, host string, repoID, issueID, branchID, branchName string) (string, error) { var mutation struct { CreateLinkedBranch struct { LinkedBranch struct { @@ -26,7 +26,11 @@ func CreateLinkedBranch(client *Client, host string, issueID, branchName, oid st input := githubv4.CreateLinkedBranchInput{ IssueID: githubv4.ID(issueID), - Oid: githubv4.GitObjectID(oid), + Oid: githubv4.GitObjectID(branchID), + } + if repoID != "" { + repo := githubv4.ID(repoID) + input.RepositoryID = &repo } if branchName != "" { name := githubv4.String(branchName) @@ -105,9 +109,10 @@ func CheckLinkedBranchFeature(client *Client, host string) error { return nil } -func FindBaseOid(client *Client, repo ghrepo.Interface, ref string) (string, string, error) { +func FindRepoBranchID(client *Client, repo ghrepo.Interface, ref string) (string, string, error) { var query struct { Repository struct { + Id string DefaultBranchRef struct { Target struct { Oid string @@ -127,9 +132,14 @@ func FindBaseOid(client *Client, repo ghrepo.Interface, ref string) (string, str "name": githubv4.String(repo.RepoName()), } - if err := client.Query(repo.RepoHost(), "FindBaseOid", &query, variables); err != nil { + if err := client.Query(repo.RepoHost(), "FindRepoBranchID", &query, variables); err != nil { return "", "", err } - return query.Repository.Ref.Target.Oid, query.Repository.DefaultBranchRef.Target.Oid, nil + branchID := query.Repository.Ref.Target.Oid + if branchID == "" { + branchID = query.Repository.DefaultBranchRef.Target.Oid + } + + return query.Repository.Id, branchID, nil } diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 21a81aa6a..1a100c9a1 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -26,6 +26,7 @@ type DevelopOptions struct { IssueSelector string Name string + BranchRepo string BaseBranch string Checkout bool List bool @@ -55,13 +56,23 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra. # Create a branch for issue 123 and checkout it out $ gh issue develop 123 --checkout - `), + + # Create a branch in repo monalisa/cli for issue 123 in repo cli/cli + $ gh issue develop 123 --repo cli/cli --branch-repo monalisa/cli + `), Args: cmdutil.ExactArgs(1, "issue number or url is required"), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // This is all a hack to not break the issue-repo flag. It will be removed - // in the near future and this hack can be removed at the same time. + // This is all a hack to not break the deprecated issue-repo flag. + // It will be removed in the near future and this hack can be removed at the same time. flags := cmd.Flags() - if flags.Changed("issue-repo") && !flags.Changed("repo") { + if flags.Changed("issue-repo") { + if flags.Changed("repo") { + if flags.Changed("branch-repo") { + return cmdutil.FlagErrorf("specify only `--repo` and `--branch-repo`") + } + branchRepo, _ := flags.GetString("repo") + _ = flags.Set("branch-repo", branchRepo) + } repo, _ := flags.GetString("issue-repo") _ = flags.Set("repo", repo) } @@ -74,6 +85,18 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra. // support `-R, --repo` override opts.BaseRepo = f.BaseRepo opts.IssueSelector = args[0] + if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--branch-repo`", opts.List, opts.BranchRepo != ""); err != nil { + return err + } + if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--base`", opts.List, opts.BaseBranch != ""); err != nil { + return err + } + if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--checkout`", opts.List, opts.Checkout); err != nil { + return err + } + if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--name`", opts.List, opts.Name != ""); err != nil { + return err + } if runF != nil { return runF(opts) } @@ -82,6 +105,7 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra. } fl := cmd.Flags() + fl.StringVar(&opts.BranchRepo, "branch-repo", "", "Name or URL of the repository where you want to create your new branch") fl.StringVarP(&opts.BaseBranch, "base", "b", "", "Name of the base branch you want to make your new branch from") fl.BoolVarP(&opts.Checkout, "checkout", "c", false, "Checkout the branch after creating it") fl.BoolVarP(&opts.List, "list", "l", false, "List linked branches for the issue") @@ -101,7 +125,7 @@ func developRun(opts *DevelopOptions) error { } opts.IO.StartProgressIndicator() - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}) + issue, issueRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}) opts.IO.StopProgressIndicator() if err != nil { return err @@ -110,56 +134,62 @@ func developRun(opts *DevelopOptions) error { apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicator() - err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) + err = api.CheckLinkedBranchFeature(apiClient, issueRepo.RepoHost()) opts.IO.StopProgressIndicator() if err != nil { return err } if opts.List { - return developRunList(opts, apiClient, baseRepo, issue) + return developRunList(opts, apiClient, issueRepo, issue) } - return developRunCreate(opts, apiClient, baseRepo, issue) + return developRunCreate(opts, apiClient, issueRepo, issue) } -func developRunCreate(opts *DevelopOptions, apiClient *api.Client, baseRepo ghrepo.Interface, issue *api.Issue) error { +func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { + branchRepo := issueRepo + var repoID string + if opts.BranchRepo != "" { + var err error + branchRepo, err = ghrepo.FromFullName(opts.BranchRepo) + if err != nil { + return err + } + } + opts.IO.StartProgressIndicator() - oid, fallbackOID, err := api.FindBaseOid(apiClient, baseRepo, opts.BaseBranch) + repoID, branchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) opts.IO.StopProgressIndicator() if err != nil { return err } - if oid == "" { - oid = fallbackOID - } - opts.IO.StartProgressIndicator() - branchName, err := api.CreateLinkedBranch(apiClient, baseRepo.RepoHost(), issue.ID, opts.Name, oid) + branchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) opts.IO.StopProgressIndicator() if err != nil { return err } - fmt.Fprintf(opts.IO.Out, "%s/%s/%s/tree/%s\n", baseRepo.RepoHost(), baseRepo.RepoOwner(), baseRepo.RepoName(), branchName) + fmt.Fprintf(opts.IO.Out, "%s/%s/%s/tree/%s\n", branchRepo.RepoHost(), branchRepo.RepoOwner(), branchRepo.RepoName(), branchName) - return checkoutBranch(opts, baseRepo, branchName) + return checkoutBranch(opts, branchRepo, branchName) } -func developRunList(opts *DevelopOptions, apiClient *api.Client, baseRepo ghrepo.Interface, issue *api.Issue) error { +func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { opts.IO.StartProgressIndicator() - branches, err := api.ListLinkedBranches(apiClient, baseRepo, issue.Number) + branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) opts.IO.StopProgressIndicator() if err != nil { return err } if len(branches) == 0 { - return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s/%s#%d", baseRepo.RepoOwner(), baseRepo.RepoName(), issue.Number)) + return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s/%s#%d", issueRepo.RepoOwner(), issueRepo.RepoName(), issue.Number)) } if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "\nShowing linked branches for %s/%s#%d\n\n", baseRepo.RepoOwner(), baseRepo.RepoName(), issue.Number) + fmt.Fprintf(opts.IO.Out, "\nShowing linked branches for %s/%s#%d\n\n", issueRepo.RepoOwner(), issueRepo.RepoName(), issue.Number) } printLinkedBranches(opts.IO, branches) @@ -178,7 +208,7 @@ func printLinkedBranches(io *iostreams.IOStreams, branches []api.LinkedBranch) { _ = table.Render() } -func checkoutBranch(opts *DevelopOptions, baseRepo ghrepo.Interface, checkoutBranch string) (err error) { +func checkoutBranch(opts *DevelopOptions, branchRepo ghrepo.Interface, checkoutBranch string) (err error) { remotes, err := opts.Remotes() if err != nil { // If the user specified the branch to be checked out and no remotes are found @@ -191,7 +221,7 @@ func checkoutBranch(opts *DevelopOptions, baseRepo ghrepo.Interface, checkoutBra } } - baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()) + baseRemote, err := remotes.FindByRepo(branchRepo.RepoOwner(), branchRepo.RepoName()) if err != nil { // If the user specified the branch to be checked out and no remote matches the // base repo, then display an error. Otherwise bail out silently. diff --git a/pkg/cmd/issue/develop/develop_test.go b/pkg/cmd/issue/develop/develop_test.go index 1d8962847..d4f8bce2d 100644 --- a/pkg/cmd/issue/develop/develop_test.go +++ b/pkg/cmd/issue/develop/develop_test.go @@ -48,6 +48,14 @@ func TestNewCmdDevelop(t *testing.T) { IssueSelector: "https://github.com/cli/cli/issues/1", }, }, + { + name: "branch-repo flag", + input: "1 --branch-repo owner/repo", + output: DevelopOptions{ + IssueSelector: "1", + BranchRepo: "owner/repo", + }, + }, { name: "base flag", input: "1 --base feature", @@ -88,6 +96,30 @@ func TestNewCmdDevelop(t *testing.T) { }, wantStdout: "Flag --issue-repo has been deprecated, use `--repo` instead\n", }, + { + name: "list and branch repo flags", + input: "1 --list --branch-repo owner/repo", + wantErr: true, + errMsg: "specify only one of `--list` or `--branch-repo`", + }, + { + name: "list and base flags", + input: "1 --list --base feature", + wantErr: true, + errMsg: "specify only one of `--list` or `--base`", + }, + { + name: "list and checkout flags", + input: "1 --list --checkout", + wantErr: true, + errMsg: "specify only one of `--list` or `--checkout`", + }, + { + name: "list and name flags", + input: "1 --list --name my-branch", + wantErr: true, + errMsg: "specify only one of `--list` or `--name`", + }, } for _, tt := range tests { @@ -260,15 +292,16 @@ func TestDevelopRun(t *testing.T) { httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query FindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`), + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`), ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`, func(inputs map[string]interface{}) { - assert.Equal(t, "DEFAULTOID", inputs["oid"]) + assert.Equal(t, "REPOID", inputs["repositoryId"]) assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "DEFAULTOID", inputs["oid"]) }), ) }, @@ -277,6 +310,52 @@ func TestDevelopRun(t *testing.T) { }, expectedOut: "github.com/OWNER/REPO/tree/my-issue-1\n", }, + { + name: "develop new branch in diffferent repo than issue", + opts: &DevelopOptions{ + IssueSelector: "123", + BranchRepo: "OWNER2/REPO", + }, + remotes: map[string]string{ + "origin": "OWNER2/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.GraphQLQuery(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`, + func(_ string, inputs map[string]interface{}) { + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["repo"]) + assert.Equal(t, float64(123), inputs["number"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.GraphQLQuery(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`, + func(_ string, inputs map[string]interface{}) { + assert.Equal(t, "OWNER2", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "REPOID", inputs["repositoryId"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "DEFAULTOID", inputs["oid"]) + }), + ) + }, + runStubs: func(cs *run.CommandStubber) { + cs.Register(`git fetch origin \+refs/heads/my-issue-1:refs/remotes/origin/my-issue-1`, 0, "") + }, + expectedOut: "github.com/OWNER2/REPO/tree/my-issue-1\n", + }, { name: "develop new branch with name and base specified", opts: &DevelopOptions{ @@ -297,15 +376,16 @@ func TestDevelopRun(t *testing.T) { httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query FindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"OID"}}}}}`)) + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, func(inputs map[string]interface{}) { - assert.Equal(t, "my-branch", inputs["name"]) - assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "REPOID", inputs["repositoryId"]) assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "my-branch", inputs["name"]) }), ) }, @@ -329,15 +409,16 @@ func TestDevelopRun(t *testing.T) { httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query FindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`), + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`), ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`, func(inputs map[string]interface{}) { - assert.Equal(t, "DEFAULTOID", inputs["oid"]) + assert.Equal(t, "REPOID", inputs["repositoryId"]) assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "DEFAULTOID", inputs["oid"]) }), ) }, @@ -363,16 +444,17 @@ func TestDevelopRun(t *testing.T) { httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query FindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"OID"}}}}}`), + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`), ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, func(inputs map[string]interface{}) { - assert.Equal(t, "my-branch", inputs["name"]) - assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "REPOID", inputs["repositoryId"]) assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "my-branch", inputs["name"]) }), ) }, @@ -404,16 +486,17 @@ func TestDevelopRun(t *testing.T) { httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`), ) reg.Register( - httpmock.GraphQL(`query FindBaseOid\b`), - httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"OID"}}}}}`), + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`), ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, func(inputs map[string]interface{}) { - assert.Equal(t, "my-branch", inputs["name"]) - assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "REPOID", inputs["repositoryId"]) assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "my-branch", inputs["name"]) }), ) }, From c5b642fc5f7f91905ecf81b7988c0dacbab98cc9 Mon Sep 17 00:00:00 2001 From: Michael Bianco Date: Mon, 10 Jul 2023 11:37:41 -0600 Subject: [PATCH 035/103] docs: example of setting multiple vars using stdin --- pkg/cmd/secret/set/set.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index b09295679..86dd35258 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -93,6 +93,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command # Set multiple secrets imported from the ".env" file $ gh secret set -f .env + + # Set multiple secrets from stdin + $ gh secret set -f - < myfile.txt `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { From 557127c7ca0ddb3adf2376002817d28418be1c93 Mon Sep 17 00:00:00 2001 From: nate smith Date: Mon, 10 Jul 2023 15:16:51 -0700 Subject: [PATCH 036/103] remove stray print --- pkg/cmd/pr/create/create.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 218be6623..6ddbf7c35 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -413,7 +413,6 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, o return err } state.Body = body - fmt.Print(state.Title, state.Body) } else if len(commits) == 1 { state.Title = commits[0].Title body, err := gitClient.CommitBody(context.Background(), commits[0].Sha) From 5785ccb4ec4b7e93043d091279fee07c2d12bfe5 Mon Sep 17 00:00:00 2001 From: nate smith Date: Mon, 10 Jul 2023 15:31:46 -0700 Subject: [PATCH 037/103] fix typo --- pkg/cmd/browse/browse.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index fd9397f79..b89434555 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -188,7 +188,7 @@ func runBrowse(opts *BrowseOptions) error { return err } if !exist { - return fmt.Errorf("%s doesn't exists", text.DisplayURL(url)) + return fmt.Errorf("%s doesn't exist", text.DisplayURL(url)) } _, err = fmt.Fprintln(opts.IO.Out, url) return err From 1b79e95311fdc56d05adaae032d79a82a5ccba2e Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 11 Jul 2023 13:34:22 +0900 Subject: [PATCH 038/103] Clean up style nits and simplify some logic --- pkg/cmd/pr/create/create.go | 38 ++++++++++++++------------------ pkg/cmd/pr/create/create_test.go | 5 ++--- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 009bcd03f..6809528b3 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -145,13 +145,19 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } if opts.IsDraft && opts.WebMode { - return errors.New("the `--draft` flag is not supported with `--web`") + return cmdutil.FlagErrorf("the `--draft` flag is not supported with `--web`") } + if len(opts.Reviewers) > 0 && opts.WebMode { - return errors.New("the `--reviewer` flag is not supported with `--web`") + return cmdutil.FlagErrorf("the `--reviewer` flag is not supported with `--web`") } + if cmd.Flags().Changed("no-maintainer-edit") && opts.WebMode { - return errors.New("the `--no-maintainer-edit` flag is not supported with `--web`") + return cmdutil.FlagErrorf("the `--no-maintainer-edit` flag is not supported with `--web`") + } + + if opts.Autofill && opts.FillFirst { + return cmdutil.FlagErrorf("`--fill` is not supported with `--fill-first`") } opts.BodyProvided = cmd.Flags().Changed("body") @@ -165,7 +171,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } if opts.Template != "" && opts.BodyProvided { - return errors.New("`--template` is not supported when using `--body` or `--body-file`") + return cmdutil.FlagErrorf("`--template` is not supported when using `--body` or `--body-file`") } if !opts.IO.CanPrompt() && !opts.WebMode && !(opts.Autofill || opts.FillFirst) && (!opts.TitleProvided || !opts.BodyProvided) { @@ -198,8 +204,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text") - cmd.MarkFlagsMutuallyExclusive("fill", "fill-first") - _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") _ = cmd.RegisterFlagCompletionFunc("reviewer", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -229,7 +233,7 @@ func createRun(opts *CreateOptions) (err error) { var openURL string if opts.WebMode { - if !opts.Autofill { + if !(opts.Autofill || opts.FillFirst) { state.Title = opts.Title state.Body = opts.Body } @@ -396,7 +400,7 @@ func createRun(opts *CreateOptions) (err error) { return } -func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, opts *CreateOptions) error { +func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool) error { baseRef := ctx.BaseTrackingBranch headRef := ctx.HeadBranch gitClient := ctx.GitClient @@ -405,24 +409,16 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, o if err != nil { return err } - if opts.FillFirst { - firstCommitIndex := len(commits) - 1 - state.Title = commits[firstCommitIndex].Title - body, err := gitClient.CommitBody(context.Background(), commits[firstCommitIndex].Sha) - if err != nil { - return err - } - state.Body = body - } else if len(commits) == 1 { - state.Title = commits[0].Title - body, err := gitClient.CommitBody(context.Background(), commits[0].Sha) + if len(commits) == 1 || useFirstCommit { + commitIndex := len(commits) - 1 + state.Title = commits[commitIndex].Title + body, err := gitClient.CommitBody(context.Background(), commits[commitIndex].Sha) if err != nil { return err } state.Body = body } else { state.Title = humanize(headRef) - var body strings.Builder for i := len(commits) - 1; i >= 0; i-- { fmt.Fprintf(&body, "- %s\n", commits[i].Title) @@ -497,7 +493,7 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata } if opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided { - err := initDefaultTitleBody(ctx, state, &opts) + err := initDefaultTitleBody(ctx, state, opts.FillFirst) if err != nil && (opts.Autofill || opts.FillFirst) { return nil, fmt.Errorf("could not compute title or body defaults: %w", err) } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index cb2acd03a..a8c10057a 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -169,7 +169,7 @@ func TestNewCmdCreate(t *testing.T) { wantsErr: true, }, { - name: "with fill-first option", + name: "fill-first", tty: false, cli: "--fill-first", wantsErr: false, @@ -189,7 +189,7 @@ func TestNewCmdCreate(t *testing.T) { }, }, { - name: "fill and fill-first is mutually exclusive", + name: "fill and fill-first", tty: false, cli: "--fill --fill-first", wantsErr: true, @@ -1000,7 +1000,6 @@ func Test_createRun(t *testing.T) { name: "fill-first flag provided", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { - opts.TitleProvided = false opts.FillFirst = true opts.HeadBranch = "feature" return func() {} From 528cb98481d76072056c92e823e13166ac4f6cca Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 11 Jul 2023 11:55:50 -0700 Subject: [PATCH 039/103] add gh cache to Actions explainer --- pkg/cmd/actions/actions.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index d24df6631..db072123a 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -26,6 +26,7 @@ func actionsExplainer(cs *iostreams.ColorScheme) string { header := cs.Bold("Welcome to GitHub Actions on the command line.") runHeader := cs.Bold("Interacting with workflow runs") workflowHeader := cs.Bold("Interacting with workflow files") + cacheHeader := cs.Bold("Interacting with the Actions cache") return heredoc.Docf(` %s @@ -49,5 +50,12 @@ func actionsExplainer(cs *iostreams.ColorScheme) string { gh workflow run: Trigger a workflow_dispatch run for a workflow file To see more help, run 'gh help workflow ' - `, header, runHeader, workflowHeader) + + %s + gh cache list: List all the caches saved in Actions for a repository + gh cache delete: Delete one or all saved caches in Actions for a repository + + To see more help, run 'gh help cache ' + + `, header, runHeader, workflowHeader, cacheHeader) } From 2a4160a3a38d3c05a1395b32cd422d5fe1a8e92d Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 14 Jul 2023 08:12:20 +0900 Subject: [PATCH 040/103] Do not add auth token to redirect requests which do not have the same host as the inital request. (#7692) --- api/http_client.go | 16 ++++++++++++---- api/http_client_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/api/http_client.go b/api/http_client.go index 691f99481..f25c2c922 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -91,9 +91,17 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { // If the header is already set in the request, don't overwrite it. if req.Header.Get(authorization) == "" { - hostname := ghinstance.NormalizeHostname(getHost(req)) - if token, _ := cfg.Token(hostname); token != "" { - req.Header.Set(authorization, fmt.Sprintf("token %s", token)) + var redirectHostnameChange bool + if req.Response != nil && req.Response.Request != nil { + redirectHostnameChange = getHost(req) != getHost(req.Response.Request) + } + // Only set header if an initial request or redirect request to the same host as the initial request. + // If the host has changed during a redirect do not add the authentication token header. + if !redirectHostnameChange { + hostname := ghinstance.NormalizeHostname(getHost(req)) + if token, _ := cfg.Token(hostname); token != "" { + req.Header.Set(authorization, fmt.Sprintf("token %s", token)) + } } } return rt.RoundTrip(req) @@ -128,5 +136,5 @@ func getHost(r *http.Request) string { if r.Host != "" { return r.Host } - return r.URL.Hostname() + return r.URL.Host } diff --git a/api/http_client_test.go b/api/http_client_test.go index ee59dbfc1..8cfe59706 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "regexp" + "strings" "testing" "github.com/MakeNowJust/heredoc" @@ -200,6 +201,40 @@ func TestNewHTTPClient(t *testing.T) { } } +func TestHTTPClientRedirectAuthenticationHeaderHandling(t *testing.T) { + var request *http.Request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request = r + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + var redirectRequest *http.Request + redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectRequest = r + http.Redirect(w, r, server.URL, http.StatusFound) + })) + defer redirectServer.Close() + + client, err := NewHTTPClient(HTTPClientOptions{ + Config: tinyConfig{ + fmt.Sprintf("%s:oauth_token", strings.TrimPrefix(redirectServer.URL, "http://")): "REDIRECT-TOKEN", + fmt.Sprintf("%s:oauth_token", strings.TrimPrefix(server.URL, "http://")): "TOKEN", + }, + }) + require.NoError(t, err) + + req, err := http.NewRequest("GET", redirectServer.URL, nil) + require.NoError(t, err) + + res, err := client.Do(req) + require.NoError(t, err) + + assert.Equal(t, "token REDIRECT-TOKEN", redirectRequest.Header.Get(authorization)) + assert.Equal(t, "", request.Header.Get(authorization)) + assert.Equal(t, 204, res.StatusCode) +} + type tinyConfig map[string]string func (c tinyConfig) Token(host string) (string, string) { From 5d82a9553c24de3ed0db0a3c119ddc8e334a73f6 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 18 Jul 2023 12:49:25 -0700 Subject: [PATCH 041/103] Sanitize file contents before displaying them (#7694) --- api/sanitize_ascii.go | 187 +----------------- internal/asciisanitizer/sanitizer.go | 220 ++++++++++++++++++++++ internal/asciisanitizer/sanitizer_test.go | 87 +++++++++ pkg/cmd/repo/view/http.go | 11 +- pkg/cmd/workflow/shared/shared.go | 11 +- 5 files changed, 329 insertions(+), 187 deletions(-) create mode 100644 internal/asciisanitizer/sanitizer.go create mode 100644 internal/asciisanitizer/sanitizer_test.go diff --git a/api/sanitize_ascii.go b/api/sanitize_ascii.go index 75cb3062b..d5d253452 100644 --- a/api/sanitize_ascii.go +++ b/api/sanitize_ascii.go @@ -1,12 +1,11 @@ package api import ( - "bytes" "io" "net/http" "regexp" - "strings" + "github.com/cli/cli/v2/internal/asciisanitizer" "golang.org/x/text/transform" ) @@ -14,8 +13,6 @@ var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) // GitHub servers do not sanitize their API output for terminal display // and leave in unescaped ASCII control characters. -// C0 control characters are represented in their unicode code point form ranging from \u0000 to \u001F. -// C1 control characters are represented in two bytes, the first being 0xC2 and the second ranging from 0x80 to 0x9F. // These control characters will be interpreted by the terminal, this behaviour can be // used maliciously as an attack vector, especially the control characters \u001B and \u009B. // This function wraps JSON response bodies in a ReadCloser that transforms C0 and C1 @@ -37,187 +34,7 @@ func sanitizedReadCloser(rc io.ReadCloser) io.ReadCloser { io.Reader io.Closer }{ - Reader: transform.NewReader(rc, &sanitizer{}), + Reader: transform.NewReader(rc, &asciisanitizer.Sanitizer{}), Closer: rc, } } - -// Sanitizer implements transform.Transformer interface. -type sanitizer struct { - addEscape bool -} - -// Transform uses a sliding window algorithm to detect C0 and C1 -// ASCII control sequences as they are read and replaces them -// with equivalent inert characters. Characters that are not part -// of a control sequence are not modified. -func (t *sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { - lSrc := len(src) - lDst := len(dst) - - for nSrc < lSrc-6 && nDst < lDst { - window := src[nSrc : nSrc+6] - - // Replace C1 Control Characters - if repl, found := mapC1ToCaret(window[:2]); found { - if len(repl)+nDst > lDst { - err = transform.ErrShortDst - return - } - for j := 0; j < len(repl); j++ { - dst[nDst] = repl[j] - nDst++ - } - nSrc += 2 - continue - } - - // Replace C0 Control Characters - if repl, found := mapC0ToCaret(window); found { - if t.addEscape { - repl = append([]byte{'\\'}, repl...) - } - if len(repl)+nDst > lDst { - err = transform.ErrShortDst - return - } - for j := 0; j < len(repl); j++ { - dst[nDst] = repl[j] - nDst++ - } - t.addEscape = false - nSrc += 6 - continue - } - - if window[0] == '\\' { - t.addEscape = !t.addEscape - } else { - t.addEscape = false - } - - dst[nDst] = src[nSrc] - nDst++ - nSrc++ - } - - if !atEOF { - err = transform.ErrShortSrc - return - } - - remaining := lSrc - nSrc - if remaining+nDst > lDst { - err = transform.ErrShortDst - return - } - - for j := 0; j < remaining; j++ { - dst[nDst] = src[nSrc] - nDst++ - nSrc++ - } - - return -} - -func (t *sanitizer) Reset() { - t.addEscape = false -} - -// mapC0ToCaret maps C0 control sequences to caret notation. -func mapC0ToCaret(b []byte) ([]byte, bool) { - if len(b) != 6 { - return b, false - } - if !bytes.HasPrefix(b, []byte(`\u00`)) { - return b, false - } - m := map[string]string{ - `\u0000`: `^@`, - `\u0001`: `^A`, - `\u0002`: `^B`, - `\u0003`: `^C`, - `\u0004`: `^D`, - `\u0005`: `^E`, - `\u0006`: `^F`, - `\u0007`: `^G`, - `\u0008`: `^H`, - `\u0009`: `^I`, - `\u000a`: `^J`, - `\u000b`: `^K`, - `\u000c`: `^L`, - `\u000d`: `^M`, - `\u000e`: `^N`, - `\u000f`: `^O`, - `\u0010`: `^P`, - `\u0011`: `^Q`, - `\u0012`: `^R`, - `\u0013`: `^S`, - `\u0014`: `^T`, - `\u0015`: `^U`, - `\u0016`: `^V`, - `\u0017`: `^W`, - `\u0018`: `^X`, - `\u0019`: `^Y`, - `\u001a`: `^Z`, - `\u001b`: `^[`, - `\u001c`: `^\\`, - `\u001d`: `^]`, - `\u001e`: `^^`, - `\u001f`: `^_`, - } - if c, ok := m[strings.ToLower(string(b))]; ok { - return []byte(c), true - } - return b, false -} - -// mapC1ToCaret maps C1 control sequences to caret notation. -// C1 control sequences are two bytes long where the first byte is 0xC2. -func mapC1ToCaret(b []byte) ([]byte, bool) { - if len(b) != 2 { - return b, false - } - if b[0] != 0xC2 { - return b, false - } - m := map[byte]string{ - 128: `^@`, - 129: `^A`, - 130: `^B`, - 131: `^C`, - 132: `^D`, - 133: `^E`, - 134: `^F`, - 135: `^G`, - 136: `^H`, - 137: `^I`, - 138: `^J`, - 139: `^K`, - 140: `^L`, - 141: `^M`, - 142: `^N`, - 143: `^O`, - 144: `^P`, - 145: `^Q`, - 146: `^R`, - 147: `^S`, - 148: `^T`, - 149: `^U`, - 150: `^V`, - 151: `^W`, - 152: `^X`, - 153: `^Y`, - 154: `^Z`, - 155: `^[`, - 156: `^\\`, - 157: `^]`, - 158: `^^`, - 159: `^_`, - } - if c, ok := m[b[1]]; ok { - return []byte(c), true - } - return b, false -} diff --git a/internal/asciisanitizer/sanitizer.go b/internal/asciisanitizer/sanitizer.go new file mode 100644 index 000000000..8c169f22b --- /dev/null +++ b/internal/asciisanitizer/sanitizer.go @@ -0,0 +1,220 @@ +// Package asciisanitizer implements an ASCII control character sanitizer for GitHub API responses so they can be +// safely displayed in the terminal. The GitHub API does not sanitize their responses for terminal display and will +// leave in unescaped ASCII control characters. These ASCII control characters will be interpreted by the terminal, +// this behaviour can be used maliciously as an attack vector, especially the ASCII control characters \u001B and \u009B. +package asciisanitizer + +import ( + "bytes" + "errors" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/transform" +) + +// Sanitizer implements transform.Transformer interface. +type Sanitizer struct { + addEscape bool +} + +// Transform uses a sliding window algorithm to detect C0 and C1 control characters as they are read and replaces +// them with equivalent inert characters. Bytes that are not part of a control character are not modified. +// C0 control characters can be either encoded or unencoded. C1 control characters are not encoded. +// Encoded C0 control characters are six byte strings, representing the unicode code point, ranging from \u0000 to \u001F. +func (t *Sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { + transfer := func(write, read []byte) error { + readLength := len(read) + writeLength := len(write) + if writeLength > len(dst) { + return transform.ErrShortDst + } + copy(dst, write) + nDst += writeLength + dst = dst[writeLength:] + nSrc += readLength + src = src[readLength:] + return nil + } + + for len(src) > 0 { + // Make sure we always have 6 bytes if there are 6 bytes available. + // This is necessary for encoded C0 control characters. + if len(src) < 6 && !atEOF { + err = transform.ErrShortSrc + return + } + // Only support UTF-8 encoded data. + r, size := utf8.DecodeRune(src) + if r == utf8.RuneError { + if !atEOF { + err = transform.ErrShortSrc + return + } else { + err = errors.New("invalid UTF-8 string") + return + } + } + // Replace C0 and C1 control characters. + if unicode.IsControl(r) { + if repl, found := mapControlToCaret(r); found { + err = transfer(repl, src[:size]) + if err != nil { + return + } + continue + } + } + // Replace encoded C0 control characters. + if len(src) >= 6 { + if repl, found := mapEncodedControlToCaret(src[:6]); found { + if t.addEscape { + // Add an escape character when necessary to prevent creating + // invalid JSON with our replacements. + repl = append([]byte{'\\'}, repl...) + } + err = transfer(repl, src[:6]) + if err != nil { + return + } + continue + } + } + err = transfer(src[:size], src[:size]) + if err != nil { + return + } + if r == '\\' { + t.addEscape = !t.addEscape + } else { + t.addEscape = false + } + } + return +} + +// Reset resets the state and allows the Sanitizer to be reused. +func (t *Sanitizer) Reset() { + t.addEscape = false +} + +// mapControlToCaret maps C0 and C1 control characters to their caret notation. +func mapControlToCaret(r rune) ([]byte, bool) { + //\t (09), \n (10), \v (11), \r (13) are valid C0 characters and are not sanitized. + m := map[rune]string{ + 0: `^@`, + 1: `^A`, + 2: `^B`, + 3: `^C`, + 4: `^D`, + 5: `^E`, + 6: `^F`, + 7: `^G`, + 8: `^H`, + 12: `^L`, + 14: `^N`, + 15: `^O`, + 16: `^P`, + 17: `^Q`, + 18: `^R`, + 19: `^S`, + 20: `^T`, + 21: `^U`, + 22: `^V`, + 23: `^W`, + 24: `^X`, + 25: `^Y`, + 26: `^Z`, + 27: `^[`, + 28: `^\\`, + 29: `^]`, + 30: `^^`, + 31: `^_`, + 128: `^@`, + 129: `^A`, + 130: `^B`, + 131: `^C`, + 132: `^D`, + 133: `^E`, + 134: `^F`, + 135: `^G`, + 136: `^H`, + 137: `^I`, + 138: `^J`, + 139: `^K`, + 140: `^L`, + 141: `^M`, + 142: `^N`, + 143: `^O`, + 144: `^P`, + 145: `^Q`, + 146: `^R`, + 147: `^S`, + 148: `^T`, + 149: `^U`, + 150: `^V`, + 151: `^W`, + 152: `^X`, + 153: `^Y`, + 154: `^Z`, + 155: `^[`, + 156: `^\\`, + 157: `^]`, + 158: `^^`, + 159: `^_`, + } + if c, ok := m[r]; ok { + return []byte(c), true + } + return nil, false +} + +// mapEncodedControlToCaret maps encoded C0 control characters to their caret notation. +// Encoded C0 control characters are six byte strings, representing the unicode code point, ranging from \u0000 to \u001F. +func mapEncodedControlToCaret(b []byte) ([]byte, bool) { + if len(b) != 6 { + return nil, false + } + if !bytes.HasPrefix(b, []byte(`\u00`)) { + return nil, false + } + m := map[string]string{ + `\u0000`: `^@`, + `\u0001`: `^A`, + `\u0002`: `^B`, + `\u0003`: `^C`, + `\u0004`: `^D`, + `\u0005`: `^E`, + `\u0006`: `^F`, + `\u0007`: `^G`, + `\u0008`: `^H`, + `\u0009`: `^I`, + `\u000a`: `^J`, + `\u000b`: `^K`, + `\u000c`: `^L`, + `\u000d`: `^M`, + `\u000e`: `^N`, + `\u000f`: `^O`, + `\u0010`: `^P`, + `\u0011`: `^Q`, + `\u0012`: `^R`, + `\u0013`: `^S`, + `\u0014`: `^T`, + `\u0015`: `^U`, + `\u0016`: `^V`, + `\u0017`: `^W`, + `\u0018`: `^X`, + `\u0019`: `^Y`, + `\u001a`: `^Z`, + `\u001b`: `^[`, + `\u001c`: `^\\`, + `\u001d`: `^]`, + `\u001e`: `^^`, + `\u001f`: `^_`, + } + if c, ok := m[strings.ToLower(string(b))]; ok { + return []byte(c), true + } + return nil, false +} diff --git a/internal/asciisanitizer/sanitizer_test.go b/internal/asciisanitizer/sanitizer_test.go new file mode 100644 index 000000000..80b251c5f --- /dev/null +++ b/internal/asciisanitizer/sanitizer_test.go @@ -0,0 +1,87 @@ +package asciisanitizer + +import ( + "bytes" + "testing" + "testing/iotest" + + "github.com/stretchr/testify/require" + "golang.org/x/text/transform" +) + +func TestSanitizerTransform(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "No control characters", + input: "The quick brown fox jumped over the lazy dog", + want: "The quick brown fox jumped over the lazy dog", + }, + { + name: "Encoded C0 control character", + input: `1\u0001`, + want: "1^A", + }, + { + name: "Encoded C0 control characters", + input: `1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t ` + + `A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f ` + + `10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 ` + + `1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f ` + + `\\u00\u001b ` + + `\u001B \\u001B \\\u001B \\\\u001B `, + want: `1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t ` + + `A\r\n B^K C^L D\r\n E^N F^O ` + + `10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y ` + + `1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_ ` + + `\\u00^[ ` + + `^[ \\^[ \\^[ \\\\^[ `, + }, + { + name: "C0 control character", + input: "1\x01", + want: "1^A", + }, + { + name: "C0 control characters", + input: "0\x00 1\x01 2\x02 3\x03 4\x04 5\x05 6\x06 7\x07 8\x08 9\x09 " + + "A\x0A B\x0B C\x0C D\x0D E\x0E F\x0F " + + "10\x10 11\x11 12\x12 13\x13 14\x14 15\x15 16\x16 17\x17 18\x18 19\x19 " + + "1A\x1A 1B\x1B 1C\x1C 1D\x1D 1E\x1E 1F\x1F ", + want: "0^@ 1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t " + + "A\n B\v C^L D\r E^N F^O " + + "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y " + + "1A^Z 1B^[ 1C^\\\\ 1D^] 1E^^ 1F^_ ", + }, + { + name: "C1 control character", + input: "80\xC2\x80", + want: "80^@", + }, + { + name: "C1 control characters", + input: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 " + + "8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F " + + "90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 " + + "9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F " + + "\xC2\xA1 ", + want: "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I " + + "8A^J 8B^K 8C^L 8D^M 8E^N 8F^O " + + "90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y " + + "9A^Z 9B^[ 9C^\\\\ 9D^] 9E^^ 9F^_ " + + "¡ ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sanitizer := &Sanitizer{} + reader := bytes.NewReader([]byte(tt.input)) + transformReader := transform.NewReader(reader, sanitizer) + err := iotest.TestReader(transformReader, []byte(tt.want)) + require.NoError(t, err) + }) + } +} diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index fe2584a90..da7196fef 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -1,13 +1,17 @@ package view import ( + "bytes" "encoding/base64" "errors" "fmt" + "io" "net/http" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/asciisanitizer" "github.com/cli/cli/v2/internal/ghrepo" + "golang.org/x/text/transform" ) var NotFoundError = errors.New("not found") @@ -40,9 +44,14 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) return nil, fmt.Errorf("failed to decode readme: %w", err) } + sanitized, err := io.ReadAll(transform.NewReader(bytes.NewReader(decoded), &asciisanitizer.Sanitizer{})) + if err != nil { + return nil, err + } + return &RepoReadme{ Filename: response.Name, - Content: string(decoded), + Content: string(sanitized), BaseURL: response.HTMLURL, }, nil } diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go index efd21529e..11b0bd03e 100644 --- a/pkg/cmd/workflow/shared/shared.go +++ b/pkg/cmd/workflow/shared/shared.go @@ -1,9 +1,11 @@ package shared import ( + "bytes" "encoding/base64" "errors" "fmt" + "io" "net/url" "path" "strconv" @@ -11,9 +13,11 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/asciisanitizer" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" + "golang.org/x/text/transform" ) const ( @@ -243,5 +247,10 @@ func GetWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow Work return nil, fmt.Errorf("failed to decode workflow file: %w", err) } - return decoded, nil + sanitized, err := io.ReadAll(transform.NewReader(bytes.NewReader(decoded), &asciisanitizer.Sanitizer{})) + if err != nil { + return nil, err + } + + return sanitized, nil } From 7f3196fcd4976a29ca6b4eba6229e5d3a02fe145 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 18 Jul 2023 12:49:55 -0700 Subject: [PATCH 042/103] Use filepath.Clean to sanitize path for archive downloads (#7720) --- pkg/cmd/release/download/download.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 2c7f4a18a..16e95db29 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -290,7 +290,7 @@ func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL, f return fmt.Errorf("unable to parse file name of archive: %w", err) } if serverFileName, ok := params["filename"]; ok { - fileName = serverFileName + fileName = filepath.Clean(serverFileName) } else { return errors.New("unable to determine file name of archive") } From ad283cff72863959d71f52d295f3969006bb5e73 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 19 Jul 2023 09:25:07 -0700 Subject: [PATCH 043/103] Fix pr checks command for GHES versions older than 3.9 (#7725) --- api/query_builder.go | 10 ++- .../featuredetection/feature_detection.go | 29 +++++++- .../feature_detection_test.go | 48 +++++++++++- pkg/cmd/pr/checks/checks.go | 23 ++++-- pkg/cmd/pr/checks/checks_test.go | 74 +++++++++++++++++-- pkg/cmd/pr/checks/fixtures/withEvents.json | 55 ++++++++++++++ pkg/cmd/pr/checks/fixtures/withoutEvents.json | 53 +++++++++++++ 7 files changed, 272 insertions(+), 20 deletions(-) create mode 100644 pkg/cmd/pr/checks/fixtures/withEvents.json create mode 100644 pkg/cmd/pr/checks/fixtures/withoutEvents.json diff --git a/api/query_builder.go b/api/query_builder.go index b74f9519b..bd2d2ece5 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -205,11 +205,15 @@ func StatusCheckRollupGraphQLWithoutCountByState(after string) string { }`), afterClause) } -func RequiredStatusCheckRollupGraphQL(prID, after string) string { +func RequiredStatusCheckRollupGraphQL(prID, after string, includeEvent bool) string { var afterClause string if after != "" { afterClause = ",after:" + after } + eventField := "event," + if !includeEvent { + eventField = "" + } return fmt.Sprintf(shortenQuery(` statusCheckRollup: commits(last: 1) { nodes { @@ -228,7 +232,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string { }, ...on CheckRun { name, - checkSuite{workflowRun{event,workflow{name}}}, + checkSuite{workflowRun{%[3]sworkflow{name}}}, status, conclusion, startedAt, @@ -242,7 +246,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string { } } } - }`), afterClause, prID) + }`), afterClause, prID, eventField) } var IssueFields = []string{ diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 983784804..d289d025f 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -5,6 +5,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "golang.org/x/sync/errgroup" ) type Detector interface { @@ -27,11 +28,13 @@ type PullRequestFeatures struct { // the checkRunCount, checkRunCountsByState, statusContextCount and stausContextCountsByState // fields on the StatusCheckRollupContextConnection CheckRunAndStatusContextCounts bool + CheckRunEvent bool } var allPullRequestFeatures = PullRequestFeatures{ MergeQueue: true, CheckRunAndStatusContextCounts: true, + CheckRunEvent: true, } type RepositoryFeatures struct { @@ -111,8 +114,26 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) { } `graphql:"StatusCheckRollupContextConnection: __type(name: \"StatusCheckRollupContextConnection\")"` } + // Break feature detection down into two separate queries because the platform + // only supports two `__type` expressions in one query. + var pullRequestFeatureDetection2 struct { + WorkflowRun struct { + Fields []struct { + Name string + } `graphql:"fields(includeDeprecated: true)"` + } `graphql:"WorkflowRun: __type(name: \"WorkflowRun\")"` + } + gql := api.NewClientFromHTTP(d.httpClient) - if err := gql.Query(d.host, "PullRequest_fields", &pullRequestFeatureDetection, nil); err != nil { + + var wg errgroup.Group + wg.Go(func() error { + return gql.Query(d.host, "PullRequest_fields", &pullRequestFeatureDetection, nil) + }) + wg.Go(func() error { + return gql.Query(d.host, "PullRequest_fields2", &pullRequestFeatureDetection2, nil) + }) + if err := wg.Wait(); err != nil { return PullRequestFeatures{}, err } @@ -132,6 +153,12 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) { } } + for _, field := range pullRequestFeatureDetection2.WorkflowRun.Fields { + if field.Name == "event" { + features.CheckRunEvent = true + } + } + return features, nil } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index dda6fe6a5..2a32e9a2d 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -82,7 +82,7 @@ func TestPullRequestFeatures(t *testing.T) { wantErr bool }{ { - name: "github.com with merge queue and status check counts by state", + name: "github.com with all features", hostname: "github.com", queryResponse: map[string]string{ `query PullRequest_fields\b`: heredoc.Doc(` @@ -104,10 +104,21 @@ func TestPullRequestFeatures(t *testing.T) { } } }`), + `query PullRequest_fields2\b`: heredoc.Doc(` + { + "data": { + "WorkflowRun": { + "fields": [ + {"name": "event"} + ] + } + } + }`), }, wantFeatures: PullRequestFeatures{ MergeQueue: true, CheckRunAndStatusContextCounts: true, + CheckRunEvent: true, }, wantErr: false, }, @@ -131,15 +142,26 @@ func TestPullRequestFeatures(t *testing.T) { } } }`), + `query PullRequest_fields2\b`: heredoc.Doc(` + { + "data": { + "WorkflowRun": { + "fields": [ + {"name": "event"} + ] + } + } + }`), }, wantFeatures: PullRequestFeatures{ MergeQueue: false, CheckRunAndStatusContextCounts: true, + CheckRunEvent: true, }, wantErr: false, }, { - name: "GHE with merge queue and status check counts by state", + name: "GHE with all features", hostname: "git.my.org", queryResponse: map[string]string{ `query PullRequest_fields\b`: heredoc.Doc(` @@ -161,15 +183,26 @@ func TestPullRequestFeatures(t *testing.T) { } } }`), + `query PullRequest_fields2\b`: heredoc.Doc(` + { + "data": { + "WorkflowRun": { + "fields": [ + {"name": "event"} + ] + } + } + }`), }, wantFeatures: PullRequestFeatures{ MergeQueue: true, CheckRunAndStatusContextCounts: true, + CheckRunEvent: true, }, wantErr: false, }, { - name: "GHE without merge queue and status check counts by state", + name: "GHE with no features", hostname: "git.my.org", queryResponse: map[string]string{ `query PullRequest_fields\b`: heredoc.Doc(` @@ -183,10 +216,19 @@ func TestPullRequestFeatures(t *testing.T) { } } }`), + `query PullRequest_fields2\b`: heredoc.Doc(` + { + "data": { + "WorkflowRun": { + "fields": [] + } + } + }`), }, wantFeatures: PullRequestFeatures{ MergeQueue: false, CheckRunAndStatusContextCounts: false, + CheckRunEvent: false, }, wantErr: false, }, diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index fb629d497..6cd47cc98 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -24,7 +25,8 @@ type ChecksOptions struct { IO *iostreams.IOStreams Browser browser.Browser - Finder shared.PRFinder + Finder shared.PRFinder + Detector fd.Detector SelectorArg string WebMode bool @@ -142,8 +144,19 @@ func checksRun(opts *ChecksOptions) error { var checks []check var counts checkCounts var err error + var includeEvent bool - checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required) + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(client, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) + } + if features, featuresErr := opts.Detector.PullRequestFeatures(); featuresErr != nil { + return featuresErr + } else { + includeEvent = features.CheckRunEvent + } + + checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent) if err != nil { return err } @@ -183,7 +196,7 @@ func checksRun(opts *ChecksOptions) error { time.Sleep(opts.Interval) - checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required) + checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent) if err != nil { break } @@ -210,7 +223,7 @@ func checksRun(opts *ChecksOptions) error { return nil } -func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool) ([]check, checkCounts, error) { +func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool, includeEvent bool) ([]check, checkCounts, error) { apiClient := api.NewClientFromHTTP(client) type response struct { @@ -224,7 +237,7 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu %s } } - }`, api.RequiredStatusCheckRollupGraphQL("$id", "$endCursor")) + }`, api.RequiredStatusCheckRollupGraphQL("$id", "$endCursor", includeEvent)) variables := map[string]interface{}{ "id": pr.ID, diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index a043176aa..e73e15731 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -123,14 +124,15 @@ func TestNewCmdChecks(t *testing.T) { func Test_checksRun(t *testing.T) { tests := []struct { - name string - tty bool - watch bool - failFast bool - required bool - httpStubs func(*httpmock.Registry) - wantOut string - wantErr string + name string + tty bool + watch bool + failFast bool + required bool + disableDetector bool + httpStubs func(*httpmock.Registry) + wantOut string + wantErr string }{ { name: "no commits tty", @@ -393,6 +395,54 @@ func Test_checksRun(t *testing.T) { wantOut: "awesome tests\tpass\t1m26s\tsweet link\tawesome description\ncool tests\tpass\t1m26s\tsweet link\tcool description\nrad tests\tpass\t1m26s\tsweet link\trad description\n", wantErr: "", }, + { + name: "events tty", + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestStatusChecks\b`), + httpmock.FileResponse("./fixtures/withEvents.json"), + ) + }, + wantOut: "All checks were successful\n0 failing, 2 successful, 0 skipped, and 0 pending checks\n\n✓ tests/cool tests (pull_request) cool description 1m26s sweet link\n✓ tests/cool tests (push) cool description 1m26s sweet link\n", + wantErr: "", + }, + { + name: "events not supported tty", + tty: true, + disableDetector: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestStatusChecks\b`), + httpmock.FileResponse("./fixtures/withoutEvents.json"), + ) + }, + wantOut: "All checks were successful\n0 failing, 1 successful, 0 skipped, and 0 pending checks\n\n✓ tests/cool tests cool description 1m26s sweet link\n", + wantErr: "", + }, + { + name: "events", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestStatusChecks\b`), + httpmock.FileResponse("./fixtures/withEvents.json"), + ) + }, + wantOut: "cool tests\tpass\t1m26s\tsweet link\tcool description\ncool tests\tpass\t1m26s\tsweet link\tcool description\n", + wantErr: "", + }, + { + name: "events not supported", + disableDetector: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestStatusChecks\b`), + httpmock.FileResponse("./fixtures/withoutEvents.json"), + ) + }, + wantOut: "cool tests\tpass\t1m26s\tsweet link\tcool description\n", + wantErr: "", + }, } for _, tt := range tests { @@ -407,7 +457,14 @@ func Test_checksRun(t *testing.T) { tt.httpStubs(reg) } + var detector fd.Detector + detector = &fd.EnabledDetectorMock{} + if tt.disableDetector { + detector = &fd.DisabledDetectorMock{} + } + response := &api.PullRequest{Number: 123, HeadRefName: "trunk"} + opts := &ChecksOptions{ HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil @@ -415,6 +472,7 @@ func Test_checksRun(t *testing.T) { IO: ios, SelectorArg: "123", Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")), + Detector: detector, Watch: tt.watch, FailFast: tt.failFast, Required: tt.required, diff --git a/pkg/cmd/pr/checks/fixtures/withEvents.json b/pkg/cmd/pr/checks/fixtures/withEvents.json new file mode 100644 index 000000000..b617e22d3 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/withEvents.json @@ -0,0 +1,55 @@ +{ + "data": { + "node": { + "statusCheckRollup": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "description": "cool description", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link", + "checkSuite": { + "workflowRun": { + "event": "pull_request", + "workflow": { + "name": "tests" + } + } + } + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "description": "cool description", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link", + "checkSuite": { + "workflowRun": { + "event": "push", + "workflow": { + "name": "tests" + } + } + } + } + ] + } + } + } + } + ] + } + } + } +} diff --git a/pkg/cmd/pr/checks/fixtures/withoutEvents.json b/pkg/cmd/pr/checks/fixtures/withoutEvents.json new file mode 100644 index 000000000..f12f82aa8 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/withoutEvents.json @@ -0,0 +1,53 @@ +{ + "data": { + "node": { + "statusCheckRollup": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "description": "cool description", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link", + "checkSuite": { + "workflowRun": { + "workflow": { + "name": "tests" + } + } + } + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "description": "cool description", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link", + "checkSuite": { + "workflowRun": { + "workflow": { + "name": "tests" + } + } + } + } + ] + } + } + } + } + ] + } + } + } +} From f5d581d3639bc8d30ad32d8e17a7c6a1fcfad463 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 24 Jul 2023 10:16:45 -0700 Subject: [PATCH 044/103] Do not make reviewer update request if there are no reviewer changes (#7730) --- pkg/cmd/pr/edit/edit.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 915eba657..bab7a26d4 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -260,6 +260,10 @@ func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, id if err != nil { return err } + if (userIds == nil || len(*userIds) == 0) && + (teamIds == nil || len(*teamIds) == 0) { + return nil + } union := githubv4.Boolean(false) reviewsRequestParams := githubv4.RequestReviewsInput{ PullRequestID: id, From 8079d18efd2e195456280d9b9742c13d33702e6a Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 24 Jul 2023 10:50:33 -0700 Subject: [PATCH 045/103] Use asciisanitization package from go-gh (#7745) --- api/http_client.go | 2 - api/http_client_test.go | 74 ++++++++ api/sanitize_ascii.go | 40 ---- api/sanitize_ascii_test.go | 93 --------- go.mod | 2 +- go.sum | 4 +- internal/asciisanitizer/sanitizer.go | 220 ---------------------- internal/asciisanitizer/sanitizer_test.go | 87 --------- pkg/cmd/repo/view/http.go | 2 +- pkg/cmd/workflow/shared/shared.go | 2 +- 10 files changed, 79 insertions(+), 447 deletions(-) delete mode 100644 api/sanitize_ascii.go delete mode 100644 api/sanitize_ascii_test.go delete mode 100644 internal/asciisanitizer/sanitizer.go delete mode 100644 internal/asciisanitizer/sanitizer_test.go diff --git a/api/http_client.go b/api/http_client.go index f25c2c922..53225b139 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -63,8 +63,6 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { client.Transport = AddAuthTokenHeader(client.Transport, opts.Config) } - client.Transport = AddASCIISanitizer(client.Transport) - return client, nil } diff --git a/api/http_client_test.go b/api/http_client_test.go index 8cfe59706..5bfe0aa05 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -235,6 +237,78 @@ func TestHTTPClientRedirectAuthenticationHeaderHandling(t *testing.T) { assert.Equal(t, 204, res.StatusCode) } +func TestHTTPClientSanitizeJSONControlCharactersC0(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issue := Issue{ + Title: "\u001B[31mRed Title\u001B[0m", + Body: "1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f", + Author: Author{ + ID: "1", + Name: "10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f", + Login: "monalisa \\u00\u001b", + }, + ActiveLockReason: "Escaped \u001B \\u001B \\\u001B \\\\u001B", + } + responseData, _ := json.Marshal(issue) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprint(w, string(responseData)) + })) + defer ts.Close() + + client, err := NewHTTPClient(HTTPClientOptions{}) + require.NoError(t, err) + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + res, err := client.Do(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + res.Body.Close() + require.NoError(t, err) + var issue Issue + err = json.Unmarshal(body, &issue) + require.NoError(t, err) + assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title) + assert.Equal(t, "1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t A\r\n B\v C^L D\r\n E^N F^O", issue.Body) + assert.Equal(t, "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y 1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_", issue.Author.Name) + assert.Equal(t, "monalisa \\u00^[", issue.Author.Login) + assert.Equal(t, "Escaped ^[ \\^[ \\^[ \\\\^[", issue.ActiveLockReason) +} + +func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issue := Issue{ + Title: "\xC2\x9B[31mRed Title\xC2\x9B[0m", + Body: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F", + Author: Author{ + ID: "1", + Name: "90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F", + Login: "monalisa\xC2\xA1", + }, + } + responseData, _ := json.Marshal(issue) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprint(w, string(responseData)) + })) + defer ts.Close() + + client, err := NewHTTPClient(HTTPClientOptions{}) + require.NoError(t, err) + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + res, err := client.Do(req) + require.NoError(t, err) + body, err := io.ReadAll(res.Body) + res.Body.Close() + require.NoError(t, err) + var issue Issue + err = json.Unmarshal(body, &issue) + require.NoError(t, err) + assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title) + assert.Equal(t, "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I 8A^J 8B^K 8C^L 8D^M 8E^N 8F^O", issue.Body) + assert.Equal(t, "90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y 9A^Z 9B^[ 9C^\\ 9D^] 9E^^ 9F^_", issue.Author.Name) + assert.Equal(t, "monalisa¡", issue.Author.Login) +} + type tinyConfig map[string]string func (c tinyConfig) Token(host string) (string, string) { diff --git a/api/sanitize_ascii.go b/api/sanitize_ascii.go deleted file mode 100644 index d5d253452..000000000 --- a/api/sanitize_ascii.go +++ /dev/null @@ -1,40 +0,0 @@ -package api - -import ( - "io" - "net/http" - "regexp" - - "github.com/cli/cli/v2/internal/asciisanitizer" - "golang.org/x/text/transform" -) - -var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) - -// GitHub servers do not sanitize their API output for terminal display -// and leave in unescaped ASCII control characters. -// These control characters will be interpreted by the terminal, this behaviour can be -// used maliciously as an attack vector, especially the control characters \u001B and \u009B. -// This function wraps JSON response bodies in a ReadCloser that transforms C0 and C1 -// control characters to their caret notations respectively so that the terminal will not -// interpret them. -func AddASCIISanitizer(rt http.RoundTripper) http.RoundTripper { - return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { - res, err := rt.RoundTrip(req) - if err != nil || !jsonTypeRE.MatchString(res.Header.Get("Content-Type")) { - return res, err - } - res.Body = sanitizedReadCloser(res.Body) - return res, err - }} -} - -func sanitizedReadCloser(rc io.ReadCloser) io.ReadCloser { - return struct { - io.Reader - io.Closer - }{ - Reader: transform.NewReader(rc, &asciisanitizer.Sanitizer{}), - Closer: rc, - } -} diff --git a/api/sanitize_ascii_test.go b/api/sanitize_ascii_test.go deleted file mode 100644 index 96fdfd073..000000000 --- a/api/sanitize_ascii_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "testing" - "testing/iotest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestHTTPClientSanitizeASCIIControlCharactersC0(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - issue := Issue{ - Title: "\u001B[31mRed Title\u001B[0m", - Body: "1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f", - Author: Author{ - ID: "1", - Name: "10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f", - Login: "monalisa \\u00\u001b", - }, - ActiveLockReason: "Escaped \u001B \\u001B \\\u001B \\\\u001B", - } - responseData, _ := json.Marshal(issue) - w.Header().Set("Content-Type", "application/json; charset=utf-8") - fmt.Fprint(w, string(responseData)) - })) - defer ts.Close() - - client, err := NewHTTPClient(HTTPClientOptions{}) - require.NoError(t, err) - req, err := http.NewRequest("GET", ts.URL, nil) - require.NoError(t, err) - res, err := client.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(res.Body) - res.Body.Close() - require.NoError(t, err) - var issue Issue - err = json.Unmarshal(body, &issue) - require.NoError(t, err) - assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title) - assert.Equal(t, "1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t A\r\n B^K C^L D\r\n E^N F^O", issue.Body) - assert.Equal(t, "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y 1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_", issue.Author.Name) - assert.Equal(t, "monalisa \\u00^[", issue.Author.Login) - assert.Equal(t, "Escaped ^[ \\^[ \\^[ \\\\^[", issue.ActiveLockReason) -} - -func TestHTTPClientSanitizeASCIIControlCharactersC1(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - issue := Issue{ - Title: "\xC2\x9B[31mRed Title\xC2\x9B[0m", - Body: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F", - Author: Author{ - ID: "1", - Name: "90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F", - Login: "monalisa\xC2\xA1", - }, - } - responseData, _ := json.Marshal(issue) - w.Header().Set("Content-Type", "application/json; charset=utf-8") - fmt.Fprint(w, string(responseData)) - })) - defer ts.Close() - - client, err := NewHTTPClient(HTTPClientOptions{}) - require.NoError(t, err) - req, err := http.NewRequest("GET", ts.URL, nil) - require.NoError(t, err) - res, err := client.Do(req) - require.NoError(t, err) - body, err := io.ReadAll(res.Body) - res.Body.Close() - require.NoError(t, err) - var issue Issue - err = json.Unmarshal(body, &issue) - require.NoError(t, err) - assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title) - assert.Equal(t, "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I 8A^J 8B^K 8C^L 8D^M 8E^N 8F^O", issue.Body) - assert.Equal(t, "90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y 9A^Z 9B^[ 9C^\\ 9D^] 9E^^ 9F^_", issue.Author.Name) - assert.Equal(t, "monalisa¡", issue.Author.Login) -} - -func TestSanitizedReadCloser(t *testing.T) { - data := []byte(`the quick brown fox\njumped over the lazy dog\t`) - rc := sanitizedReadCloser(io.NopCloser(bytes.NewReader(data))) - assert.NoError(t, iotest.TestReader(rc, data)) -} diff --git a/go.mod b/go.mod index 2c31b40a9..2602fc496 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/cenkalti/backoff/v4 v4.2.1 github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da github.com/charmbracelet/lipgloss v0.5.0 - github.com/cli/go-gh/v2 v2.0.1 + github.com/cli/go-gh/v2 v2.1.0 github.com/cli/oauth v1.0.1 github.com/cli/safeexec v1.0.1 github.com/cpuguy83/go-md2man/v2 v2.0.2 diff --git a/go.sum b/go.sum index f36c934c0..47e5159ae 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg= github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI= github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8= github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -github.com/cli/go-gh/v2 v2.0.1 h1:W4L7C5xT9CwkcqTsUzBhFhRKGpek9oRtdDLku2Hku+E= -github.com/cli/go-gh/v2 v2.0.1/go.mod h1:zWab1jRnJ0Ug8qRjsZHFk/Oq51ZWuhSxRL2FDUDgQWk= +github.com/cli/go-gh/v2 v2.1.0 h1:JzYEJyv3VOoU+O9prmoAb3q4JvCwx8dGgLjJF992+18= +github.com/cli/go-gh/v2 v2.1.0/go.mod h1:DwqlWB1TCBcKmDt/9/81EnlbGG7elLoevF4ypt5BnzE= github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA= github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= diff --git a/internal/asciisanitizer/sanitizer.go b/internal/asciisanitizer/sanitizer.go deleted file mode 100644 index 8c169f22b..000000000 --- a/internal/asciisanitizer/sanitizer.go +++ /dev/null @@ -1,220 +0,0 @@ -// Package asciisanitizer implements an ASCII control character sanitizer for GitHub API responses so they can be -// safely displayed in the terminal. The GitHub API does not sanitize their responses for terminal display and will -// leave in unescaped ASCII control characters. These ASCII control characters will be interpreted by the terminal, -// this behaviour can be used maliciously as an attack vector, especially the ASCII control characters \u001B and \u009B. -package asciisanitizer - -import ( - "bytes" - "errors" - "strings" - "unicode" - "unicode/utf8" - - "golang.org/x/text/transform" -) - -// Sanitizer implements transform.Transformer interface. -type Sanitizer struct { - addEscape bool -} - -// Transform uses a sliding window algorithm to detect C0 and C1 control characters as they are read and replaces -// them with equivalent inert characters. Bytes that are not part of a control character are not modified. -// C0 control characters can be either encoded or unencoded. C1 control characters are not encoded. -// Encoded C0 control characters are six byte strings, representing the unicode code point, ranging from \u0000 to \u001F. -func (t *Sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { - transfer := func(write, read []byte) error { - readLength := len(read) - writeLength := len(write) - if writeLength > len(dst) { - return transform.ErrShortDst - } - copy(dst, write) - nDst += writeLength - dst = dst[writeLength:] - nSrc += readLength - src = src[readLength:] - return nil - } - - for len(src) > 0 { - // Make sure we always have 6 bytes if there are 6 bytes available. - // This is necessary for encoded C0 control characters. - if len(src) < 6 && !atEOF { - err = transform.ErrShortSrc - return - } - // Only support UTF-8 encoded data. - r, size := utf8.DecodeRune(src) - if r == utf8.RuneError { - if !atEOF { - err = transform.ErrShortSrc - return - } else { - err = errors.New("invalid UTF-8 string") - return - } - } - // Replace C0 and C1 control characters. - if unicode.IsControl(r) { - if repl, found := mapControlToCaret(r); found { - err = transfer(repl, src[:size]) - if err != nil { - return - } - continue - } - } - // Replace encoded C0 control characters. - if len(src) >= 6 { - if repl, found := mapEncodedControlToCaret(src[:6]); found { - if t.addEscape { - // Add an escape character when necessary to prevent creating - // invalid JSON with our replacements. - repl = append([]byte{'\\'}, repl...) - } - err = transfer(repl, src[:6]) - if err != nil { - return - } - continue - } - } - err = transfer(src[:size], src[:size]) - if err != nil { - return - } - if r == '\\' { - t.addEscape = !t.addEscape - } else { - t.addEscape = false - } - } - return -} - -// Reset resets the state and allows the Sanitizer to be reused. -func (t *Sanitizer) Reset() { - t.addEscape = false -} - -// mapControlToCaret maps C0 and C1 control characters to their caret notation. -func mapControlToCaret(r rune) ([]byte, bool) { - //\t (09), \n (10), \v (11), \r (13) are valid C0 characters and are not sanitized. - m := map[rune]string{ - 0: `^@`, - 1: `^A`, - 2: `^B`, - 3: `^C`, - 4: `^D`, - 5: `^E`, - 6: `^F`, - 7: `^G`, - 8: `^H`, - 12: `^L`, - 14: `^N`, - 15: `^O`, - 16: `^P`, - 17: `^Q`, - 18: `^R`, - 19: `^S`, - 20: `^T`, - 21: `^U`, - 22: `^V`, - 23: `^W`, - 24: `^X`, - 25: `^Y`, - 26: `^Z`, - 27: `^[`, - 28: `^\\`, - 29: `^]`, - 30: `^^`, - 31: `^_`, - 128: `^@`, - 129: `^A`, - 130: `^B`, - 131: `^C`, - 132: `^D`, - 133: `^E`, - 134: `^F`, - 135: `^G`, - 136: `^H`, - 137: `^I`, - 138: `^J`, - 139: `^K`, - 140: `^L`, - 141: `^M`, - 142: `^N`, - 143: `^O`, - 144: `^P`, - 145: `^Q`, - 146: `^R`, - 147: `^S`, - 148: `^T`, - 149: `^U`, - 150: `^V`, - 151: `^W`, - 152: `^X`, - 153: `^Y`, - 154: `^Z`, - 155: `^[`, - 156: `^\\`, - 157: `^]`, - 158: `^^`, - 159: `^_`, - } - if c, ok := m[r]; ok { - return []byte(c), true - } - return nil, false -} - -// mapEncodedControlToCaret maps encoded C0 control characters to their caret notation. -// Encoded C0 control characters are six byte strings, representing the unicode code point, ranging from \u0000 to \u001F. -func mapEncodedControlToCaret(b []byte) ([]byte, bool) { - if len(b) != 6 { - return nil, false - } - if !bytes.HasPrefix(b, []byte(`\u00`)) { - return nil, false - } - m := map[string]string{ - `\u0000`: `^@`, - `\u0001`: `^A`, - `\u0002`: `^B`, - `\u0003`: `^C`, - `\u0004`: `^D`, - `\u0005`: `^E`, - `\u0006`: `^F`, - `\u0007`: `^G`, - `\u0008`: `^H`, - `\u0009`: `^I`, - `\u000a`: `^J`, - `\u000b`: `^K`, - `\u000c`: `^L`, - `\u000d`: `^M`, - `\u000e`: `^N`, - `\u000f`: `^O`, - `\u0010`: `^P`, - `\u0011`: `^Q`, - `\u0012`: `^R`, - `\u0013`: `^S`, - `\u0014`: `^T`, - `\u0015`: `^U`, - `\u0016`: `^V`, - `\u0017`: `^W`, - `\u0018`: `^X`, - `\u0019`: `^Y`, - `\u001a`: `^Z`, - `\u001b`: `^[`, - `\u001c`: `^\\`, - `\u001d`: `^]`, - `\u001e`: `^^`, - `\u001f`: `^_`, - } - if c, ok := m[strings.ToLower(string(b))]; ok { - return []byte(c), true - } - return nil, false -} diff --git a/internal/asciisanitizer/sanitizer_test.go b/internal/asciisanitizer/sanitizer_test.go deleted file mode 100644 index 80b251c5f..000000000 --- a/internal/asciisanitizer/sanitizer_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package asciisanitizer - -import ( - "bytes" - "testing" - "testing/iotest" - - "github.com/stretchr/testify/require" - "golang.org/x/text/transform" -) - -func TestSanitizerTransform(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "No control characters", - input: "The quick brown fox jumped over the lazy dog", - want: "The quick brown fox jumped over the lazy dog", - }, - { - name: "Encoded C0 control character", - input: `1\u0001`, - want: "1^A", - }, - { - name: "Encoded C0 control characters", - input: `1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t ` + - `A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f ` + - `10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 ` + - `1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f ` + - `\\u00\u001b ` + - `\u001B \\u001B \\\u001B \\\\u001B `, - want: `1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t ` + - `A\r\n B^K C^L D\r\n E^N F^O ` + - `10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y ` + - `1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_ ` + - `\\u00^[ ` + - `^[ \\^[ \\^[ \\\\^[ `, - }, - { - name: "C0 control character", - input: "1\x01", - want: "1^A", - }, - { - name: "C0 control characters", - input: "0\x00 1\x01 2\x02 3\x03 4\x04 5\x05 6\x06 7\x07 8\x08 9\x09 " + - "A\x0A B\x0B C\x0C D\x0D E\x0E F\x0F " + - "10\x10 11\x11 12\x12 13\x13 14\x14 15\x15 16\x16 17\x17 18\x18 19\x19 " + - "1A\x1A 1B\x1B 1C\x1C 1D\x1D 1E\x1E 1F\x1F ", - want: "0^@ 1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t " + - "A\n B\v C^L D\r E^N F^O " + - "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y " + - "1A^Z 1B^[ 1C^\\\\ 1D^] 1E^^ 1F^_ ", - }, - { - name: "C1 control character", - input: "80\xC2\x80", - want: "80^@", - }, - { - name: "C1 control characters", - input: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 " + - "8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F " + - "90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 " + - "9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F " + - "\xC2\xA1 ", - want: "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I " + - "8A^J 8B^K 8C^L 8D^M 8E^N 8F^O " + - "90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y " + - "9A^Z 9B^[ 9C^\\\\ 9D^] 9E^^ 9F^_ " + - "¡ ", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sanitizer := &Sanitizer{} - reader := bytes.NewReader([]byte(tt.input)) - transformReader := transform.NewReader(reader, sanitizer) - err := iotest.TestReader(transformReader, []byte(tt.want)) - require.NoError(t, err) - }) - } -} diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index da7196fef..5988580e5 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -9,8 +9,8 @@ import ( "net/http" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/asciisanitizer" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" "golang.org/x/text/transform" ) diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go index 11b0bd03e..cfb1ff86e 100644 --- a/pkg/cmd/workflow/shared/shared.go +++ b/pkg/cmd/workflow/shared/shared.go @@ -13,10 +13,10 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/asciisanitizer" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" "golang.org/x/text/transform" ) From fc685f92b62abdbc08b6995478ddb3d0aac0f82c Mon Sep 17 00:00:00 2001 From: Armand Grillet <2117580+armandgrillet@users.noreply.github.com> Date: Tue, 25 Jul 2023 18:03:33 +0200 Subject: [PATCH 046/103] Allow deleting of local branch after merging cross repo PRs (#7709) --- pkg/cmd/pr/merge/merge.go | 31 ++++++++----- pkg/cmd/pr/merge/merge_test.go | 82 ++++++++++++++++++++++++++++++---- 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index d49139011..a23dc7390 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -199,7 +199,6 @@ type mergeContext struct { autoMerge bool crossRepoPR bool deleteBranch bool - switchedToBranch string mergeQueueRequired bool } @@ -370,7 +369,7 @@ func (m *mergeContext) merge() error { // Delete local branch if requested and if allowed. func (m *mergeContext) deleteLocalBranch() error { - if m.crossRepoPR || m.autoMerge { + if m.autoMerge { return nil } @@ -395,6 +394,8 @@ func (m *mergeContext) deleteLocalBranch() error { return err } + switchedToBranch := "" + ctx := context.Background() // branch the command was run on is the same as the pull request branch @@ -424,14 +425,19 @@ func (m *mergeContext) deleteLocalBranch() error { _ = m.warnf(fmt.Sprintf("%s warning: not possible to fast-forward to: %q\n", m.cs.WarningIcon(), targetBranch)) } - m.switchedToBranch = targetBranch + switchedToBranch = targetBranch } if err := m.opts.GitClient.DeleteLocalBranch(ctx, m.pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete local branch %s: %w", m.cs.Cyan(m.pr.HeadRefName), err) } - return nil + switchedStatement := "" + if switchedToBranch != "" { + switchedStatement = fmt.Sprintf(" and switched to branch %s", m.cs.Cyan(switchedToBranch)) + } + + return m.infof("%s Deleted local branch %s%s\n", m.cs.SuccessIconWithColor(m.cs.Red), m.cs.Cyan(m.pr.HeadRefName), switchedStatement) } // Delete the remote branch if requested and if allowed. @@ -451,11 +457,7 @@ func (m *mergeContext) deleteRemoteBranch() error { } } - branch := "" - if m.switchedToBranch != "" { - branch = fmt.Sprintf(" and switched to branch %s", m.cs.Cyan(m.switchedToBranch)) - } - return m.infof("%s Deleted branch %s%s\n", m.cs.SuccessIconWithColor(m.cs.Red), m.cs.Cyan(m.pr.HeadRefName), branch) + return m.infof("%s Deleted remote branch %s\n", m.cs.SuccessIconWithColor(m.cs.Red), m.cs.Cyan(m.pr.HeadRefName)) } // Add the Pull Request to a merge queue @@ -577,11 +579,16 @@ func mergeMethodSurvey(p shared.Prompt, baseRepo *api.Repository) (PullRequestMe } func deleteBranchSurvey(opts *MergeOptions, crossRepoPR, localBranchExists bool) (bool, error) { - if !crossRepoPR && !opts.IsDeleteBranchIndicated { + if !opts.IsDeleteBranchIndicated { var message string + if opts.CanDeleteLocalBranch && localBranchExists { - message = "Delete the branch locally and on GitHub?" - } else { + if crossRepoPR { + message = "Delete the branch locally?" + } else { + message = "Delete the branch locally and on GitHub?" + } + } else if !crossRepoPR { message = "Delete the branch on GitHub?" } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index d2c8a4dcb..5055bf8f4 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -654,7 +654,8 @@ func TestPrMerge_deleteBranch(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, heredoc.Doc(` ✓ Merged pull request #10 (Blueberries are a good fruit) - ✓ Deleted branch blueberries and switched to branch main + ✓ Deleted local branch blueberries and switched to branch main + ✓ Deleted remote branch blueberries `), output.Stderr()) } @@ -704,7 +705,56 @@ func TestPrMerge_deleteBranch_nonDefault(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, heredoc.Doc(` ✓ Merged pull request #10 (Blueberries are a good fruit) - ✓ Deleted branch blueberries and switched to branch fruit + ✓ Deleted local branch blueberries and switched to branch fruit + ✓ Deleted remote branch blueberries + `), output.Stderr()) +} + +func TestPrMerge_deleteBranch_onlyLocally(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + shared.RunCommandFinder( + "", + &api.PullRequest{ + ID: "PR_10", + Number: 10, + State: "OPEN", + Title: "Blueberries are a good fruit", + HeadRefName: "blueberries", + BaseRefName: "main", + MergeStateStatus: "CLEAN", + HeadRepositoryOwner: api.Owner{Login: "HEAD"}, // Not the same owner as the base repo + }, + baseRepo("OWNER", "REPO", "main"), + ) + + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") + })) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git rev-parse --verify refs/heads/main`, 0, "") + cs.Register(`git checkout main`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") + cs.Register(`git branch -D blueberries`, 0, "") + cs.Register(`git pull --ff-only`, 0, "") + + output, err := runCommand(http, nil, "blueberries", true, `pr merge --merge --delete-branch`) + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + ✓ Merged pull request #10 (Blueberries are a good fruit) + ✓ Deleted local branch blueberries and switched to branch main `), output.Stderr()) } @@ -754,7 +804,8 @@ func TestPrMerge_deleteBranch_checkoutNewBranch(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, heredoc.Doc(` ✓ Merged pull request #10 (Blueberries are a good fruit) - ✓ Deleted branch blueberries and switched to branch fruit + ✓ Deleted local branch blueberries and switched to branch fruit + ✓ Deleted remote branch blueberries `), output.Stderr()) } @@ -800,7 +851,8 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, heredoc.Doc(` ✓ Merged pull request #10 (Blueberries are a good fruit) - ✓ Deleted branch blueberries + ✓ Deleted local branch blueberries + ✓ Deleted remote branch blueberries `), output.Stderr()) } @@ -1044,7 +1096,10 @@ func TestPrMerge_alreadyMerged(t *testing.T) { output, err := runCommand(http, pm, "blueberries", true, "pr merge 4") assert.NoError(t, err) assert.Equal(t, "", output.String()) - assert.Equal(t, "✓ Deleted branch blueberries and switched to branch main\n", output.Stderr()) + assert.Equal(t, heredoc.Doc(` + ✓ Deleted local branch blueberries and switched to branch main + ✓ Deleted remote branch blueberries + `), output.Stderr()) } func TestPrMerge_alreadyMerged_withMergeStrategy(t *testing.T) { @@ -1115,7 +1170,7 @@ func TestPrMerge_alreadyMerged_withMergeStrategy_TTY(t *testing.T) { } assert.Equal(t, "", output.String()) - assert.Equal(t, "✓ Deleted branch \n", output.Stderr()) + assert.Equal(t, "✓ Deleted local branch \n✓ Deleted remote branch \n", output.Stderr()) } func TestPrMerge_alreadyMerged_withMergeStrategy_crossRepo(t *testing.T) { @@ -1139,7 +1194,17 @@ func TestPrMerge_alreadyMerged_withMergeStrategy_crossRepo(t *testing.T) { cs.Register(`git rev-parse --verify refs/heads/`, 0, "") - output, err := runCommand(http, nil, "blueberries", true, "pr merge 4 --merge") + pm := &prompter.PrompterMock{ + ConfirmFunc: func(p string, d bool) (bool, error) { + if p == "Pull request #4 was already merged. Delete the branch locally?" { + return d, nil + } else { + return false, prompter.NoSuchPromptErr(p) + } + }, + } + + output, err := runCommand(http, pm, "blueberries", true, "pr merge 4 --merge") if err != nil { t.Fatalf("Got unexpected error running `pr merge` %s", err) } @@ -1282,7 +1347,8 @@ func TestPRMergeTTY_withDeleteBranch(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, heredoc.Doc(` ✓ Merged pull request #3 (It was the best of times) - ✓ Deleted branch blueberries and switched to branch main + ✓ Deleted local branch blueberries and switched to branch main + ✓ Deleted remote branch blueberries `), output.Stderr()) } From b9cacbc3474ffd6442a203fe2c5d11f6fe89050c Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 26 Jul 2023 11:50:37 -0700 Subject: [PATCH 047/103] Do not allow issue and pr templates to be symlinks (#7756) --- pkg/githubtemplate/github_template.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pkg/githubtemplate/github_template.go b/pkg/githubtemplate/github_template.go index 9cba70d5c..c52c3e4b4 100644 --- a/pkg/githubtemplate/github_template.go +++ b/pkg/githubtemplate/github_template.go @@ -2,6 +2,7 @@ package githubtemplate import ( "fmt" + "io/fs" "os" "path" "regexp" @@ -28,7 +29,6 @@ mainLoop: if err != nil { continue } - // detect multiple templates in a subdirectory for _, file := range files { if strings.EqualFold(file.Name(), name) && file.IsDir() { @@ -37,7 +37,8 @@ mainLoop: break } for _, tf := range templates { - if strings.HasSuffix(tf.Name(), ".md") { + if strings.HasSuffix(tf.Name(), ".md") && + file.Type() != fs.ModeSymlink { results = append(results, path.Join(dir, file.Name(), tf.Name())) } } @@ -48,6 +49,7 @@ mainLoop: } } } + sort.Strings(results) return results } @@ -62,19 +64,22 @@ func FindLegacy(rootDir string, name string) string { rootDir, path.Join(rootDir, "docs"), } + for _, dir := range candidateDirs { files, err := os.ReadDir(dir) if err != nil { continue } - // detect a single template file for _, file := range files { - if namePattern.MatchString(file.Name()) && !file.IsDir() { + if namePattern.MatchString(file.Name()) && + !file.IsDir() && + file.Type() != fs.ModeSymlink { return path.Join(dir, file.Name()) } } } + return "" } From f777bec798601d715c4f0d05638c0f941c790d5b Mon Sep 17 00:00:00 2001 From: Harvey Sanders Date: Thu, 27 Jul 2023 12:18:51 -0400 Subject: [PATCH 048/103] `release create`: Trim spaces on interactive tag name input (#7759) --- pkg/cmd/release/create/create.go | 1 + pkg/cmd/release/create/create_test.go | 55 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index dea14307f..a5a02fa31 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -223,6 +223,7 @@ func createRun(opts *CreateOptions) error { if err != nil { return fmt.Errorf("could not prompt: %w", err) } + opts.TagName = strings.TrimSpace(opts.TagName) } } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index e1b5f3341..69188f593 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -1028,6 +1028,61 @@ func Test_createRun_interactive(t *testing.T) { }, wantOut: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", }, + { + name: "create a release from new tag (with leading space)", + opts: &CreateOptions{}, + prompterStubs: func(t *testing.T, pm *prompter.MockPrompter) { + pm.RegisterSelect("Choose a tag", + []string{"v1.2.2", "v1.0.0", "v0.1.2", "Create a new tag"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "Create a new tag") + }) + pm.RegisterSelect("Release notes", + []string{"Write my own", "Write using generated notes as template", "Leave blank"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "Leave blank") + }) + pm.RegisterSelect("Submit?", + []string{"Publish release", "Save as draft", "Cancel"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "Publish release") + }) + pm.RegisterInput("Tag name", func(_, d string) (string, error) { + return " v1.2.3", nil + }) + pm.RegisterInput("Title (optional)", func(_, d string) (string, error) { + return d, nil + }) + pm.RegisterConfirm("Is this a prerelease?", func(_ string, _ bool) (bool, error) { + return false, nil + }) + }, + runStubs: func(rs *run.CommandStubber) { + rs.Register(`git tag --list`, 1, "") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/tags"), httpmock.StatusStringResponse(200, `[ + { "name": "v1.2.2" }, { "name": "v1.0.0" }, { "name": "v0.1.2" } + ]`)) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"), + httpmock.StatusStringResponse(200, `{ + "name": "generated name", + "body": "generated body" + }`)) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.StatusStringResponse(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`)) + }, + wantParams: map[string]interface{}{ + "draft": false, + "name": "generated name", + "prerelease": false, + "tag_name": "v1.2.3", + }, + wantOut: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + }, { name: "create a release using generated notes", opts: &CreateOptions{ From e0d2fc8eaabd62f06058df10ffbccd02a67d4c5c Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 7 Aug 2023 07:35:47 -0700 Subject: [PATCH 049/103] Use filepath.Base to sanitize path for archive downloads (#7805) --- pkg/cmd/release/download/download.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 16e95db29..bab6bd19b 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -290,7 +290,7 @@ func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL, f return fmt.Errorf("unable to parse file name of archive: %w", err) } if serverFileName, ok := params["filename"]; ok { - fileName = filepath.Clean(serverFileName) + fileName = filepath.Base(serverFileName) } else { return errors.New("unable to determine file name of archive") } From cd65409328e578e2249ce7f365ec121389c55b94 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 7 Aug 2023 17:12:46 -0700 Subject: [PATCH 050/103] switch to Prompter.MultiSelect --- pkg/cmd/repo/edit/edit.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index bbbd94dd5..64df48aa7 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -441,15 +441,17 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { if r.RebaseMergeAllowed { defaultMergeOptions = append(defaultMergeOptions, allowRebaseMerge) } - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.MultiSelect{ - Message: "Allowed merge strategies", - Default: defaultMergeOptions, - Options: []string{allowMergeCommits, allowSquashMerge, allowRebaseMerge}, - }, &selectedMergeOptions) + mergeOpts := []string{allowMergeCommits, allowSquashMerge, allowRebaseMerge} + selected, err := opts.Prompter.MultiSelect( + "Allowed merge strategies", + defaultMergeOptions, + mergeOpts) if err != nil { return err } + for _, i := range selected { + selectedMergeOptions = append(selectedMergeOptions, mergeOpts[i]) + } enableMergeCommit := isIncluded(allowMergeCommits, selectedMergeOptions) opts.Edits.EnableMergeCommit = &enableMergeCommit enableSquashMerge := isIncluded(allowSquashMerge, selectedMergeOptions) From 7d470c4df4321c4da37962ad511a165b61b9ed7e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 7 Aug 2023 17:59:12 -0700 Subject: [PATCH 051/103] name MultiSelect parameters in interface I wanted the parameters to show up in my autocomplete --- internal/prompter/prompter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index cd90fcd2d..df6819e1f 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -14,7 +14,7 @@ import ( //go:generate moq -rm -out prompter_mock.go . Prompter type Prompter interface { Select(string, string, []string) (int, error) - MultiSelect(string, []string, []string) ([]int, error) + MultiSelect(prompt string, defaults []string, options []string) ([]int, error) Input(string, string) (string, error) InputHostname() (string, error) Password(string) (string, error) From aed3a6774964eb23c3a90141104a8bc08b7da654 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 7 Aug 2023 17:59:56 -0700 Subject: [PATCH 052/103] WIP porting repo edit to opts.Prompter --- pkg/cmd/repo/edit/edit.go | 39 ++++++++++------------- pkg/cmd/repo/edit/edit_test.go | 58 +++++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 64df48aa7..4841362e7 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -279,7 +279,7 @@ func editRun(ctx context.Context, opts *EditOptions) error { return nil } -func interactiveChoice(r *api.Repository) ([]string, error) { +func interactiveChoice(opts EditOptions, r *api.Repository) ([]string, error) { options := []string{ optionDefaultBranchName, optionDescription, @@ -298,11 +298,14 @@ func interactiveChoice(r *api.Repository) ([]string, error) { options = append(options, optionAllowForking) } var answers []string - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err := prompt.SurveyAskOne(&survey.MultiSelect{ - Message: "What do you want to edit?", - Options: options, - }, &answers, survey.WithPageSize(11)) + selected, err := opts.Prompter.MultiSelect("What do you want to edit?", nil, options) + if err != nil { + return nil, err + } + for _, i := range selected { + answers = append(answers, options[i]) + } + return answers, err } @@ -310,7 +313,7 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { for _, v := range r.RepositoryTopics.Nodes { opts.topicsCache = append(opts.topicsCache, v.Topic.Name) } - choices, err := interactiveChoice(r) + choices, err := interactiveChoice(*opts, r) if err != nil { return err } @@ -318,14 +321,11 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { switch c { case optionDescription: opts.Edits.Description = &r.Description - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Input{ - Message: "Description of the repository", - Default: r.Description, - }, opts.Edits.Description) + answer, err := opts.Prompter.Input("Description of the repository", r.Description) if err != nil { return err } + opts.Edits.Description = &answer case optionHomePageURL: opts.Edits.Homepage = &r.HomepageURL //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter @@ -337,11 +337,7 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { return err } case optionTopics: - var addTopics string - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Input{ - Message: "Add topics?(csv format)", - }, &addTopics) + addTopics, err := opts.Prompter.Input("Add topics?(csv format)", "") if err != nil { return err } @@ -350,14 +346,13 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } if len(opts.topicsCache) > 0 { - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.MultiSelect{ - Message: "Remove Topics", - Options: opts.topicsCache, - }, &opts.RemoveTopics) + selected, err := opts.Prompter.MultiSelect("Remove Topics", nil, opts.topicsCache) if err != nil { return err } + for _, i := range selected { + opts.RemoveTopics = append(opts.RemoveTopics, opts.topicsCache[i]) + } } case optionDefaultBranchName: opts.Edits.DefaultBranch = &r.DefaultBranchRef.Name diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index 7302b5169..98f1cb8cc 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -174,11 +175,26 @@ func Test_editRun(t *testing.T) { } } +// TODO consider unit test for interactiveRepoEdit that exercises every prompt type + func Test_editRun_interactive(t *testing.T) { + editList := []string{ + "Default Branch Name", + "Description", + "Home Page URL", + "Issues", + "Merge Options", + "Projects", + "Template Repository", + "Topics", + "Visibility", + "Wikis"} + tests := []struct { name string opts EditOptions askStubs func(*prompt.AskStubber) + promptStubs func(*prompter.MockPrompter) httpStubs func(*testing.T, *httpmock.Registry) wantsStderr string wantsErr string @@ -189,9 +205,16 @@ func Test_editRun_interactive(t *testing.T) { Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), InteractiveMode: true, }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("What do you want to edit?").AnswerWith([]string{"Description"}) - as.StubPrompt("Description of the repository").AnswerWith("awesome repo description") + askStubs: func(as *prompt.AskStubber) {}, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterMultiSelect("What do you want to edit?", nil, editList, + func(_ string, _, opts []string) ([]int, error) { + return []int{1}, nil + }) + pm.RegisterInput("Description of the repository", + func(_, _ string) (string, error) { + return "awesome repo description", nil + }) }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -229,11 +252,24 @@ func Test_editRun_interactive(t *testing.T) { Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), InteractiveMode: true, }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("What do you want to edit?").AnswerWith([]string{"Description", "Topics"}) - as.StubPrompt("Description of the repository").AnswerWith("awesome repo description") - as.StubPrompt("Add topics?(csv format)").AnswerWith("a, b,c,d ") - as.StubPrompt("Remove Topics").AnswerWith([]string{"x"}) + askStubs: func(as *prompt.AskStubber) {}, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterMultiSelect("What do you want to edit?", nil, editList, + func(_ string, _, opts []string) ([]int, error) { + return []int{1, 7}, nil + }) + pm.RegisterInput("Description of the repository", + func(_, _ string) (string, error) { + return "awesome repo description", nil + }) + pm.RegisterInput("Add topics?(csv format)", + func(_, _ string) (string, error) { + return "a, b,c,d ", nil + }) + pm.RegisterMultiSelect("Remove Topics", nil, []string{"x"}, + func(_ string, _, opts []string) ([]int, error) { + return []int{0}, nil + }) }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -333,6 +369,12 @@ func Test_editRun_interactive(t *testing.T) { tt.httpStubs(t, httpReg) } + pm := prompter.NewMockPrompter(t) + tt.opts.Prompter = pm + if tt.promptStubs != nil { + tt.promptStubs(pm) + } + opts := &tt.opts opts.HTTPClient = &http.Client{Transport: httpReg} opts.IO = ios From 444ca57e173f8559ad6c9697aa55d76f08c62888 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 8 Aug 2023 09:50:45 -0400 Subject: [PATCH 053/103] Update CONTRIBUTING.md Bringing the contribution documentation in line with the release documentation as Go 1.19+ is required. --- .github/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3b2584063..eed468389 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -23,7 +23,7 @@ Please avoid: ## Building the project Prerequisites: -- Go 1.16+ +- Go 1.19+ Build with: * Unix-like systems: `make` From 93e1511bae2923f617d99526281c6b8918a7c3ab Mon Sep 17 00:00:00 2001 From: John Keech Date: Tue, 8 Aug 2023 09:32:06 -0700 Subject: [PATCH 054/103] Codespaces: Use the host name from the logged in server for commands (#7795) * Use the host name from the logged in server for codespace commands * Fix existing tests * Add tests for server url configuration * Rename defaultApiUrl to defaultAPIURL and comment cleanup * Switch to t.Setenv in codespaces api tests * Switch to t.Setenv in other tests * Support custom server in web flows for list and create * Rename GetServerURL() to ServerURL() --- internal/codespaces/api/api.go | 64 ++++-- internal/codespaces/api/api_test.go | 190 +++++++++++++++++- pkg/cmd/codespace/common.go | 1 + pkg/cmd/codespace/create.go | 4 +- pkg/cmd/codespace/create_test.go | 30 +++ pkg/cmd/codespace/list.go | 4 +- pkg/cmd/codespace/list_test.go | 24 +++ pkg/cmd/codespace/mock_api.go | 151 ++++++++------ pkg/cmd/codespace/root.go | 19 +- pkg/cmd/project/close/close_test.go | 4 +- pkg/cmd/project/copy/copy_test.go | 4 +- pkg/cmd/project/create/create_test.go | 4 +- pkg/cmd/project/delete/delete_test.go | 4 +- pkg/cmd/project/edit/edit_test.go | 4 +- .../project/field-create/field_create_test.go | 4 +- .../project/field-delete/field_delete_test.go | 4 +- pkg/cmd/project/field-list/field_list_test.go | 4 +- pkg/cmd/project/item-add/item_add_test.go | 4 +- .../project/item-archive/item_archive_test.go | 4 +- .../project/item-create/item_create_test.go | 4 +- .../project/item-delete/item_delete_test.go | 4 +- pkg/cmd/project/item-edit/item_edit_test.go | 4 +- pkg/cmd/project/item-list/item_list_test.go | 4 +- pkg/cmd/project/list/list_test.go | 4 +- pkg/cmd/project/view/view_test.go | 4 +- pkg/cmd/root/root.go | 52 +---- 26 files changed, 409 insertions(+), 194 deletions(-) diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index d66aff065..c55b92400 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -1,13 +1,12 @@ package api // For descriptions of service interfaces, see: -// - https://online.visualstudio.com/api/swagger (for visualstudio.com) // - https://docs.github.com/en/rest/reference/repos (for api.github.com) // - https://github.com/github/github/blob/master/app/api/codespaces.rb (for vscs_internal) // TODO(adonovan): replace the last link with a public doc URL when available. // TODO(adonovan): a possible reorganization would be to split this -// file into three internal packages, one per backend service, and to +// file into two internal packages, one per backend service, and to // rename api.API to github.Client: // // - github.GetUser(github.Client) @@ -20,7 +19,6 @@ package api // - codespaces.GetToken(Client, login, name) // - codespaces.List(Client, user) // - codespaces.Start(Client, token, codespace) -// - visualstudio.GetRegionLocation(http.Client) // no dependency on github // // This would make the meaning of each operation clearer. @@ -34,6 +32,7 @@ import ( "io" "net/http" "net/url" + "os" "reflect" "regexp" "strconv" @@ -42,13 +41,14 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/opentracing/opentracing-go" ) const ( - githubServer = "https://github.com" - githubAPI = "https://api.github.com" - vscsAPI = "https://online.visualstudio.com" + defaultAPIURL = "https://api.github.com" + defaultServerURL = "https://github.com" ) const ( @@ -60,31 +60,40 @@ const ( // API is the interface to the codespace service. type API struct { - client httpClient - vscsAPI string + client func() (*http.Client, error) githubAPI string githubServer string retryBackoff time.Duration } -type httpClient interface { - Do(req *http.Request) (*http.Response, error) -} - // New creates a new API client connecting to the configured endpoints with the HTTP client. -func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API { - if serverURL == "" { - serverURL = githubServer - } +func New(f *cmdutil.Factory) *API { + apiURL := os.Getenv("GITHUB_API_URL") if apiURL == "" { - apiURL = githubAPI + cfg, err := f.Config() + if err != nil { + // fallback to the default api endpoint + apiURL = defaultAPIURL + } else { + host, _ := cfg.Authentication().DefaultHost() + apiURL = ghinstance.RESTPrefix(host) + } } - if vscsURL == "" { - vscsURL = vscsAPI + + serverURL := os.Getenv("GITHUB_SERVER_URL") + if serverURL == "" { + cfg, err := f.Config() + if err != nil { + // fallback to the default server endpoint + serverURL = defaultServerURL + } else { + host, _ := cfg.Authentication().DefaultHost() + serverURL = ghinstance.HostPrefix(host) + } } + return &API{ - client: httpClient, - vscsAPI: strings.TrimSuffix(vscsURL, "/"), + client: f.HttpClient, githubAPI: strings.TrimSuffix(apiURL, "/"), githubServer: strings.TrimSuffix(serverURL, "/"), retryBackoff: 100 * time.Millisecond, @@ -97,6 +106,11 @@ type User struct { Type string `json:"type"` } +// ServerURL returns the server url (not the API url), such as https://github.com +func (a *API) ServerURL() string { + return a.githubServer +} + // GetUser returns the user associated with the given token. func (a *API) GetUser(ctx context.Context) (*User, error) { req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil) @@ -1119,7 +1133,13 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http span, ctx := opentracing.StartSpanFromContext(ctx, spanName) defer span.Finish() req = req.WithContext(ctx) - return a.client.Do(req) + + httpClient, err := a.client() + if err != nil { + return nil, err + } + + return httpClient.Do(req) } // setHeaders sets the required headers for the API. diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go index 5e9316fc1..75f82c39e 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -3,12 +3,16 @@ package api import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" "reflect" "strconv" "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmdutil" ) func generateCodespaceList(start int, end int) []*Codespace { @@ -126,13 +130,181 @@ func createFakeCreateEndpointServer(t *testing.T, wantStatus int) *httptest.Serv })) } +func createHttpClient() (*http.Client, error) { + return &http.Client{}, nil +} + +func TestNew_APIURL_dotcomConfig(t *testing.T) { + t.Setenv("GITHUB_API_URL", "") + cfg := &config.ConfigMock{ + AuthenticationFunc: func() *config.AuthConfig { + return &config.AuthConfig{} + }, + } + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return cfg, nil + }, + } + api := New(f) + + if api.githubAPI != "https://api.github.com" { + t.Fatalf("expected https://api.github.com, got %s", api.githubAPI) + } + if len(cfg.AuthenticationCalls()) != 1 { + t.Fatalf("API url was not pulled from the config") + } +} + +func TestNew_APIURL_customConfig(t *testing.T) { + t.Setenv("GITHUB_API_URL", "") + cfg := &config.ConfigMock{ + AuthenticationFunc: func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST") + return authCfg + }, + } + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return cfg, nil + }, + } + api := New(f) + + if api.githubAPI != "https://github.mycompany.com/api/v3" { + t.Fatalf("expected https://github.mycompany.com/api/v3, got %s", api.githubAPI) + } + if len(cfg.AuthenticationCalls()) != 1 { + t.Fatalf("API url was not pulled from the config") + } +} + +func TestNew_APIURL_env(t *testing.T) { + t.Setenv("GITHUB_API_URL", "https://api.mycompany.com") + cfg := &config.ConfigMock{ + AuthenticationFunc: func() *config.AuthConfig { + return &config.AuthConfig{} + }, + } + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return cfg, nil + }, + } + api := New(f) + + if api.githubAPI != "https://api.mycompany.com" { + t.Fatalf("expected https://api.mycompany.com, got %s", api.githubAPI) + } + if len(cfg.AuthenticationCalls()) != 0 { + t.Fatalf("Configuration was checked instead of using the GITHUB_API_URL environment variable") + } +} + +func TestNew_APIURL_dotcomFallback(t *testing.T) { + t.Setenv("GITHUB_API_URL", "") + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return nil, errors.New("Failed to load") + }, + } + api := New(f) + + if api.githubAPI != "https://api.github.com" { + t.Fatalf("expected https://api.github.com, got %s", api.githubAPI) + } +} + +func TestNew_ServerURL_dotcomConfig(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "") + cfg := &config.ConfigMock{ + AuthenticationFunc: func() *config.AuthConfig { + return &config.AuthConfig{} + }, + } + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return cfg, nil + }, + } + api := New(f) + + if api.githubServer != "https://github.com" { + t.Fatalf("expected https://github.com, got %s", api.githubServer) + } + if len(cfg.AuthenticationCalls()) != 1 { + t.Fatalf("Server url was not pulled from the config") + } +} + +func TestNew_ServerURL_customConfig(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "") + cfg := &config.ConfigMock{ + AuthenticationFunc: func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST") + return authCfg + }, + } + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return cfg, nil + }, + } + api := New(f) + + if api.githubServer != "https://github.mycompany.com" { + t.Fatalf("expected https://github.mycompany.com, got %s", api.githubServer) + } + if len(cfg.AuthenticationCalls()) != 1 { + t.Fatalf("Server url was not pulled from the config") + } +} + +func TestNew_ServerURL_env(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com") + cfg := &config.ConfigMock{ + AuthenticationFunc: func() *config.AuthConfig { + return &config.AuthConfig{} + }, + } + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return cfg, nil + }, + } + api := New(f) + + if api.githubServer != "https://mycompany.com" { + t.Fatalf("expected https://mycompany.com, got %s", api.githubServer) + } + if len(cfg.AuthenticationCalls()) != 0 { + t.Fatalf("Configuration was checked instead of using the GITHUB_SERVER_URL environment variable") + } +} + +func TestNew_ServerURL_dotcomFallback(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "") + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return nil, errors.New("Failed to load") + }, + } + api := New(f) + + if api.githubServer != "https://github.com" { + t.Fatalf("expected https://github.com, got %s", api.githubServer) + } +} + func TestCreateCodespaces(t *testing.T) { svr := createFakeCreateEndpointServer(t, http.StatusCreated) defer svr.Close() api := API{ githubAPI: svr.URL, - client: &http.Client{}, + client: createHttpClient, } ctx := context.TODO() @@ -161,7 +333,7 @@ func TestCreateCodespaces_displayName(t *testing.T) { api := API{ githubAPI: svr.URL, - client: &http.Client{}, + client: createHttpClient, } retentionPeriod := 0 @@ -186,7 +358,7 @@ func TestCreateCodespaces_Pending(t *testing.T) { api := API{ githubAPI: svr.URL, - client: &http.Client{}, + client: createHttpClient, retryBackoff: 0, } @@ -213,7 +385,7 @@ func TestListCodespaces_limited(t *testing.T) { api := API{ githubAPI: svr.URL, - client: &http.Client{}, + client: createHttpClient, } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{Limit: 200}) @@ -238,7 +410,7 @@ func TestListCodespaces_unlimited(t *testing.T) { api := API{ githubAPI: svr.URL, - client: &http.Client{}, + client: createHttpClient, } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{}) @@ -329,7 +501,7 @@ func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMa api := API{ githubAPI: svr.URL, - client: &http.Client{}, + client: createHttpClient, } ctx := context.Background() @@ -374,7 +546,7 @@ func TestRetries(t *testing.T) { t.Cleanup(srv.Close) a := &API{ githubAPI: srv.URL, - client: &http.Client{}, + client: createHttpClient, } cs, err := a.GetCodespace(context.Background(), "test", false) if err != nil { @@ -562,7 +734,7 @@ func TestAPI_EditCodespace(t *testing.T) { defer svr.Close() a := &API{ - client: &http.Client{}, + client: createHttpClient, githubAPI: svr.URL, } got, err := a.EditCodespace(tt.args.ctx, tt.args.codespaceName, tt.args.params) @@ -602,7 +774,7 @@ func TestAPI_EditCodespacePendingOperation(t *testing.T) { defer svr.Close() a := &API{ - client: &http.Client{}, + client: createHttpClient, githubAPI: svr.URL, } diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index f6f6fb937..006669151 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -88,6 +88,7 @@ func startLiveShareSession(ctx context.Context, codespace *api.Codespace, a *App //go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient type apiClient interface { + ServerURL() string GetUser(ctx context.Context) (*api.User, error) GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index 6f973d3cb..889f0b33b 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -129,7 +129,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } if opts.useWeb && userInputs.Repository == "" { - return a.browser.Browse("https://github.com/codespaces/new") + return a.browser.Browse(fmt.Sprintf("%s/codespaces/new", a.apiClient.ServerURL())) } promptForRepoAndBranch := userInputs.Repository == "" && !opts.useWeb @@ -293,7 +293,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } if opts.useWeb { - return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", createParams.RepositoryID, createParams.Branch, createParams.Machine, createParams.Location)) + return a.browser.Browse(fmt.Sprintf("%s/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", a.apiClient.ServerURL(), createParams.RepositoryID, createParams.Branch, createParams.Machine, createParams.Location)) } var codespace *api.Codespace diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index 7b74c206d..caf6df271 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -453,11 +453,32 @@ Alternatively, you can run "create" with the "--default-permissions" option to c }, { name: "return default url when using web flag without other flags", + fields: fields{ + apiClient: apiCreateDefaults(&apiClientMock{ + ServerURLFunc: func() string { + return "https://github.com" + }, + }), + }, opts: createOptions{ useWeb: true, }, wantURL: "https://github.com/codespaces/new", }, + { + name: "return custom server url when using web flag", + fields: fields{ + apiClient: apiCreateDefaults(&apiClientMock{ + ServerURLFunc: func() string { + return "https://github.mycompany.com" + }, + }), + }, + opts: createOptions{ + useWeb: true, + }, + wantURL: "https://github.mycompany.com/codespaces/new", + }, { name: "skip machine check when using web flag and no machine provided", fields: fields{ @@ -473,6 +494,9 @@ Alternatively, you can run "create" with the "--default-permissions" option to c Name: "monalisa-dotfiles-abcd1234", }, nil }, + ServerURLFunc: func() string { + return "https://github.com" + }, }), }, opts: createOptions{ @@ -499,6 +523,9 @@ Alternatively, you can run "create" with the "--default-permissions" option to c Name: "monalisa-dotfiles-abcd1234", }, nil }, + ServerURLFunc: func() string { + return "https://github.com" + }, }), }, opts: createOptions{ @@ -524,6 +551,9 @@ Alternatively, you can run "create" with the "--default-permissions" option to c Machine: api.CodespaceMachine{Name: "GIGA"}, }, nil }, + ServerURLFunc: func() string { + return "https://github.com" + }, }), }, opts: createOptions{ diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index bba34ff45..4775e3db5 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -79,7 +79,7 @@ func newListCmd(app *App) *cobra.Command { func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Exporter) error { if opts.useWeb && opts.repo == "" { - return a.browser.Browse("https://github.com/codespaces") + return a.browser.Browse(fmt.Sprintf("%s/codespaces", a.apiClient.ServerURL())) } var codespaces []*api.Codespace @@ -113,7 +113,7 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo } if opts.useWeb && codespaces[0].Repository.ID > 0 { - return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces?repository_id=%d", codespaces[0].Repository.ID)) + return a.browser.Browse(fmt.Sprintf("%s/codespaces?repository_id=%d", a.apiClient.ServerURL(), codespaces[0].Repository.ID)) } //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter diff --git a/pkg/cmd/codespace/list_test.go b/pkg/cmd/codespace/list_test.go index d99b8b97e..35f30581b 100644 --- a/pkg/cmd/codespace/list_test.go +++ b/pkg/cmd/codespace/list_test.go @@ -170,11 +170,32 @@ func TestApp_List(t *testing.T) { }, { name: "list codespaces,--web", + fields: fields{ + apiClient: &apiClientMock{ + ServerURLFunc: func() string { + return "https://github.com" + }, + }, + }, opts: &listOptions{ useWeb: true, }, wantURL: "https://github.com/codespaces", }, + { + name: "list codespaces,--web with custom server url", + fields: fields{ + apiClient: &apiClientMock{ + ServerURLFunc: func() string { + return "https://github.mycompany.com" + }, + }, + }, + opts: &listOptions{ + useWeb: true, + }, + wantURL: "https://github.mycompany.com/codespaces", + }, { name: "list codespaces,--web, --repo flag", fields: fields{ @@ -199,6 +220,9 @@ func TestApp_List(t *testing.T) { }, }, nil }, + ServerURLFunc: func() string { + return "https://github.com" + }, }, }, opts: &listOptions{ diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go index 0796679fc..7e211fd7c 100644 --- a/pkg/cmd/codespace/mock_api.go +++ b/pkg/cmd/codespace/mock_api.go @@ -7,7 +7,7 @@ import ( "context" "sync" - "github.com/cli/cli/v2/internal/codespaces/api" + codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api" ) // apiClientMock is a mock implementation of apiClient. @@ -16,43 +16,46 @@ import ( // // // make and configure a mocked apiClient // mockedapiClient := &apiClientMock{ -// CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { +// CreateCodespaceFunc: func(ctx context.Context, params *codespacesAPI.CreateCodespaceParams) (*codespacesAPI.Codespace, error) { // panic("mock out the CreateCodespace method") // }, // DeleteCodespaceFunc: func(ctx context.Context, name string, orgName string, userName string) error { // panic("mock out the DeleteCodespace method") // }, -// EditCodespaceFunc: func(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error) { +// EditCodespaceFunc: func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) { // panic("mock out the EditCodespace method") // }, -// GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) { +// GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) { // panic("mock out the GetCodespace method") // }, -// GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) { +// GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*codespacesAPI.User, error) { // panic("mock out the GetCodespaceBillableOwner method") // }, -// GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) { +// GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params codespacesAPI.RepoSearchParameters) ([]string, error) { // panic("mock out the GetCodespaceRepoSuggestions method") // }, -// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { +// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *codespacesAPI.Codespace, path string) ([]byte, error) { // panic("mock out the GetCodespaceRepositoryContents method") // }, -// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) { +// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*codespacesAPI.Machine, error) { // panic("mock out the GetCodespacesMachines method") // }, -// GetOrgMemberCodespaceFunc: func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) { +// GetOrgMemberCodespaceFunc: func(ctx context.Context, orgName string, userName string, codespaceName string) (*codespacesAPI.Codespace, error) { // panic("mock out the GetOrgMemberCodespace method") // }, -// GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) { +// GetRepositoryFunc: func(ctx context.Context, nwo string) (*codespacesAPI.Repository, error) { // panic("mock out the GetRepository method") // }, -// GetUserFunc: func(ctx context.Context) (*api.User, error) { +// ServerURLFunc: func() string { +// panic("mock out the ServerURL method") +// }, +// GetUserFunc: func(ctx context.Context) (*codespacesAPI.User, error) { // panic("mock out the GetUser method") // }, -// ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) { +// ListCodespacesFunc: func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) { // panic("mock out the ListCodespaces method") // }, -// ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { +// ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]codespacesAPI.DevContainerEntry, error) { // panic("mock out the ListDevContainers method") // }, // StartCodespaceFunc: func(ctx context.Context, name string) error { @@ -69,43 +72,46 @@ import ( // } type apiClientMock struct { // CreateCodespaceFunc mocks the CreateCodespace method. - CreateCodespaceFunc func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) + CreateCodespaceFunc func(ctx context.Context, params *codespacesAPI.CreateCodespaceParams) (*codespacesAPI.Codespace, error) // DeleteCodespaceFunc mocks the DeleteCodespace method. DeleteCodespaceFunc func(ctx context.Context, name string, orgName string, userName string) error // EditCodespaceFunc mocks the EditCodespace method. - EditCodespaceFunc func(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error) + EditCodespaceFunc func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) // GetCodespaceFunc mocks the GetCodespace method. - GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) + GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) // GetCodespaceBillableOwnerFunc mocks the GetCodespaceBillableOwner method. - GetCodespaceBillableOwnerFunc func(ctx context.Context, nwo string) (*api.User, error) + GetCodespaceBillableOwnerFunc func(ctx context.Context, nwo string) (*codespacesAPI.User, error) // GetCodespaceRepoSuggestionsFunc mocks the GetCodespaceRepoSuggestions method. - GetCodespaceRepoSuggestionsFunc func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) + GetCodespaceRepoSuggestionsFunc func(ctx context.Context, partialSearch string, params codespacesAPI.RepoSearchParameters) ([]string, error) // GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method. - GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) + GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *codespacesAPI.Codespace, path string) ([]byte, error) // GetCodespacesMachinesFunc mocks the GetCodespacesMachines method. - GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) + GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*codespacesAPI.Machine, error) // GetOrgMemberCodespaceFunc mocks the GetOrgMemberCodespace method. - GetOrgMemberCodespaceFunc func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) + GetOrgMemberCodespaceFunc func(ctx context.Context, orgName string, userName string, codespaceName string) (*codespacesAPI.Codespace, error) // GetRepositoryFunc mocks the GetRepository method. - GetRepositoryFunc func(ctx context.Context, nwo string) (*api.Repository, error) + GetRepositoryFunc func(ctx context.Context, nwo string) (*codespacesAPI.Repository, error) + + // ServerURLFunc mocks the ServerURL method. + ServerURLFunc func() string // GetUserFunc mocks the GetUser method. - GetUserFunc func(ctx context.Context) (*api.User, error) + GetUserFunc func(ctx context.Context) (*codespacesAPI.User, error) // ListCodespacesFunc mocks the ListCodespaces method. - ListCodespacesFunc func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) + ListCodespacesFunc func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) // ListDevContainersFunc mocks the ListDevContainers method. - ListDevContainersFunc func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) + ListDevContainersFunc func(ctx context.Context, repoID int, branch string, limit int) ([]codespacesAPI.DevContainerEntry, error) // StartCodespaceFunc mocks the StartCodespace method. StartCodespaceFunc func(ctx context.Context, name string) error @@ -120,7 +126,7 @@ type apiClientMock struct { // Ctx is the ctx argument value. Ctx context.Context // Params is the params argument value. - Params *api.CreateCodespaceParams + Params *codespacesAPI.CreateCodespaceParams } // DeleteCodespace holds details about calls to the DeleteCodespace method. DeleteCodespace []struct { @@ -140,7 +146,7 @@ type apiClientMock struct { // CodespaceName is the codespaceName argument value. CodespaceName string // Params is the params argument value. - Params *api.EditCodespaceParams + Params *codespacesAPI.EditCodespaceParams } // GetCodespace holds details about calls to the GetCodespace method. GetCodespace []struct { @@ -165,14 +171,14 @@ type apiClientMock struct { // PartialSearch is the partialSearch argument value. PartialSearch string // Params is the params argument value. - Params api.RepoSearchParameters + Params codespacesAPI.RepoSearchParameters } // GetCodespaceRepositoryContents holds details about calls to the GetCodespaceRepositoryContents method. GetCodespaceRepositoryContents []struct { // Ctx is the ctx argument value. Ctx context.Context // Codespace is the codespace argument value. - Codespace *api.Codespace + Codespace *codespacesAPI.Codespace // Path is the path argument value. Path string } @@ -207,6 +213,9 @@ type apiClientMock struct { // Nwo is the nwo argument value. Nwo string } + // ServerURL holds details about calls to the ServerURL method. + ServerURL []struct { + } // GetUser holds details about calls to the GetUser method. GetUser []struct { // Ctx is the ctx argument value. @@ -217,7 +226,7 @@ type apiClientMock struct { // Ctx is the ctx argument value. Ctx context.Context // Opts is the opts argument value. - Opts api.ListCodespacesOptions + Opts codespacesAPI.ListCodespacesOptions } // ListDevContainers holds details about calls to the ListDevContainers method. ListDevContainers []struct { @@ -259,6 +268,7 @@ type apiClientMock struct { lockGetCodespacesMachines sync.RWMutex lockGetOrgMemberCodespace sync.RWMutex lockGetRepository sync.RWMutex + lockServerURL sync.RWMutex lockGetUser sync.RWMutex lockListCodespaces sync.RWMutex lockListDevContainers sync.RWMutex @@ -267,13 +277,13 @@ type apiClientMock struct { } // CreateCodespace calls CreateCodespaceFunc. -func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { +func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *codespacesAPI.CreateCodespaceParams) (*codespacesAPI.Codespace, error) { if mock.CreateCodespaceFunc == nil { panic("apiClientMock.CreateCodespaceFunc: method is nil but apiClient.CreateCodespace was just called") } callInfo := struct { Ctx context.Context - Params *api.CreateCodespaceParams + Params *codespacesAPI.CreateCodespaceParams }{ Ctx: ctx, Params: params, @@ -290,11 +300,11 @@ func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *api.Crea // len(mockedapiClient.CreateCodespaceCalls()) func (mock *apiClientMock) CreateCodespaceCalls() []struct { Ctx context.Context - Params *api.CreateCodespaceParams + Params *codespacesAPI.CreateCodespaceParams } { var calls []struct { Ctx context.Context - Params *api.CreateCodespaceParams + Params *codespacesAPI.CreateCodespaceParams } mock.lockCreateCodespace.RLock() calls = mock.calls.CreateCodespace @@ -347,14 +357,14 @@ func (mock *apiClientMock) DeleteCodespaceCalls() []struct { } // EditCodespace calls EditCodespaceFunc. -func (mock *apiClientMock) EditCodespace(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error) { +func (mock *apiClientMock) EditCodespace(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) { if mock.EditCodespaceFunc == nil { panic("apiClientMock.EditCodespaceFunc: method is nil but apiClient.EditCodespace was just called") } callInfo := struct { Ctx context.Context CodespaceName string - Params *api.EditCodespaceParams + Params *codespacesAPI.EditCodespaceParams }{ Ctx: ctx, CodespaceName: codespaceName, @@ -373,12 +383,12 @@ func (mock *apiClientMock) EditCodespace(ctx context.Context, codespaceName stri func (mock *apiClientMock) EditCodespaceCalls() []struct { Ctx context.Context CodespaceName string - Params *api.EditCodespaceParams + Params *codespacesAPI.EditCodespaceParams } { var calls []struct { Ctx context.Context CodespaceName string - Params *api.EditCodespaceParams + Params *codespacesAPI.EditCodespaceParams } mock.lockEditCodespace.RLock() calls = mock.calls.EditCodespace @@ -387,7 +397,7 @@ func (mock *apiClientMock) EditCodespaceCalls() []struct { } // GetCodespace calls GetCodespaceFunc. -func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) { +func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) { if mock.GetCodespaceFunc == nil { panic("apiClientMock.GetCodespaceFunc: method is nil but apiClient.GetCodespace was just called") } @@ -427,7 +437,7 @@ func (mock *apiClientMock) GetCodespaceCalls() []struct { } // GetCodespaceBillableOwner calls GetCodespaceBillableOwnerFunc. -func (mock *apiClientMock) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*api.User, error) { +func (mock *apiClientMock) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*codespacesAPI.User, error) { if mock.GetCodespaceBillableOwnerFunc == nil { panic("apiClientMock.GetCodespaceBillableOwnerFunc: method is nil but apiClient.GetCodespaceBillableOwner was just called") } @@ -463,14 +473,14 @@ func (mock *apiClientMock) GetCodespaceBillableOwnerCalls() []struct { } // GetCodespaceRepoSuggestions calls GetCodespaceRepoSuggestionsFunc. -func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) { +func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params codespacesAPI.RepoSearchParameters) ([]string, error) { if mock.GetCodespaceRepoSuggestionsFunc == nil { panic("apiClientMock.GetCodespaceRepoSuggestionsFunc: method is nil but apiClient.GetCodespaceRepoSuggestions was just called") } callInfo := struct { Ctx context.Context PartialSearch string - Params api.RepoSearchParameters + Params codespacesAPI.RepoSearchParameters }{ Ctx: ctx, PartialSearch: partialSearch, @@ -489,12 +499,12 @@ func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, part func (mock *apiClientMock) GetCodespaceRepoSuggestionsCalls() []struct { Ctx context.Context PartialSearch string - Params api.RepoSearchParameters + Params codespacesAPI.RepoSearchParameters } { var calls []struct { Ctx context.Context PartialSearch string - Params api.RepoSearchParameters + Params codespacesAPI.RepoSearchParameters } mock.lockGetCodespaceRepoSuggestions.RLock() calls = mock.calls.GetCodespaceRepoSuggestions @@ -503,13 +513,13 @@ func (mock *apiClientMock) GetCodespaceRepoSuggestionsCalls() []struct { } // GetCodespaceRepositoryContents calls GetCodespaceRepositoryContentsFunc. -func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { +func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *codespacesAPI.Codespace, path string) ([]byte, error) { if mock.GetCodespaceRepositoryContentsFunc == nil { panic("apiClientMock.GetCodespaceRepositoryContentsFunc: method is nil but apiClient.GetCodespaceRepositoryContents was just called") } callInfo := struct { Ctx context.Context - Codespace *api.Codespace + Codespace *codespacesAPI.Codespace Path string }{ Ctx: ctx, @@ -528,12 +538,12 @@ func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, c // len(mockedapiClient.GetCodespaceRepositoryContentsCalls()) func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct { Ctx context.Context - Codespace *api.Codespace + Codespace *codespacesAPI.Codespace Path string } { var calls []struct { Ctx context.Context - Codespace *api.Codespace + Codespace *codespacesAPI.Codespace Path string } mock.lockGetCodespaceRepositoryContents.RLock() @@ -543,7 +553,7 @@ func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct { } // GetCodespacesMachines calls GetCodespacesMachinesFunc. -func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) { +func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*codespacesAPI.Machine, error) { if mock.GetCodespacesMachinesFunc == nil { panic("apiClientMock.GetCodespacesMachinesFunc: method is nil but apiClient.GetCodespacesMachines was just called") } @@ -591,7 +601,7 @@ func (mock *apiClientMock) GetCodespacesMachinesCalls() []struct { } // GetOrgMemberCodespace calls GetOrgMemberCodespaceFunc. -func (mock *apiClientMock) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) { +func (mock *apiClientMock) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*codespacesAPI.Codespace, error) { if mock.GetOrgMemberCodespaceFunc == nil { panic("apiClientMock.GetOrgMemberCodespaceFunc: method is nil but apiClient.GetOrgMemberCodespace was just called") } @@ -635,7 +645,7 @@ func (mock *apiClientMock) GetOrgMemberCodespaceCalls() []struct { } // GetRepository calls GetRepositoryFunc. -func (mock *apiClientMock) GetRepository(ctx context.Context, nwo string) (*api.Repository, error) { +func (mock *apiClientMock) GetRepository(ctx context.Context, nwo string) (*codespacesAPI.Repository, error) { if mock.GetRepositoryFunc == nil { panic("apiClientMock.GetRepositoryFunc: method is nil but apiClient.GetRepository was just called") } @@ -670,8 +680,35 @@ func (mock *apiClientMock) GetRepositoryCalls() []struct { return calls } +// ServerURL calls ServerURLFunc. +func (mock *apiClientMock) ServerURL() string { + if mock.ServerURLFunc == nil { + panic("apiClientMock.ServerURLFunc: method is nil but apiClient.ServerURL was just called") + } + callInfo := struct { + }{} + mock.lockServerURL.Lock() + mock.calls.ServerURL = append(mock.calls.ServerURL, callInfo) + mock.lockServerURL.Unlock() + return mock.ServerURLFunc() +} + +// ServerURLCalls gets all the calls that were made to ServerURL. +// Check the length with: +// +// len(mockedapiClient.ServerURLCalls()) +func (mock *apiClientMock) ServerURLCalls() []struct { +} { + var calls []struct { + } + mock.lockServerURL.RLock() + calls = mock.calls.ServerURL + mock.lockServerURL.RUnlock() + return calls +} + // GetUser calls GetUserFunc. -func (mock *apiClientMock) GetUser(ctx context.Context) (*api.User, error) { +func (mock *apiClientMock) GetUser(ctx context.Context) (*codespacesAPI.User, error) { if mock.GetUserFunc == nil { panic("apiClientMock.GetUserFunc: method is nil but apiClient.GetUser was just called") } @@ -703,13 +740,13 @@ func (mock *apiClientMock) GetUserCalls() []struct { } // ListCodespaces calls ListCodespacesFunc. -func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) { +func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) { if mock.ListCodespacesFunc == nil { panic("apiClientMock.ListCodespacesFunc: method is nil but apiClient.ListCodespaces was just called") } callInfo := struct { Ctx context.Context - Opts api.ListCodespacesOptions + Opts codespacesAPI.ListCodespacesOptions }{ Ctx: ctx, Opts: opts, @@ -726,11 +763,11 @@ func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts api.ListCode // len(mockedapiClient.ListCodespacesCalls()) func (mock *apiClientMock) ListCodespacesCalls() []struct { Ctx context.Context - Opts api.ListCodespacesOptions + Opts codespacesAPI.ListCodespacesOptions } { var calls []struct { Ctx context.Context - Opts api.ListCodespacesOptions + Opts codespacesAPI.ListCodespacesOptions } mock.lockListCodespaces.RLock() calls = mock.calls.ListCodespaces @@ -739,7 +776,7 @@ func (mock *apiClientMock) ListCodespacesCalls() []struct { } // ListDevContainers calls ListDevContainersFunc. -func (mock *apiClientMock) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { +func (mock *apiClientMock) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) ([]codespacesAPI.DevContainerEntry, error) { if mock.ListDevContainersFunc == nil { panic("apiClientMock.ListDevContainersFunc: method is nil but apiClient.ListDevContainers was just called") } diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index 28a80edae..d1675a8f7 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -1,15 +1,28 @@ package codespace import ( + codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api" + + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) -func NewRootCmd(app *App) *cobra.Command { +func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { root := &cobra.Command{ - Use: "codespace", - Short: "Connect to and manage codespaces", + Use: "codespace", + Short: "Connect to and manage codespaces", + Aliases: []string{"cs"}, + GroupID: "core", } + app := NewApp( + f.IOStreams, + f, + codespacesAPI.New(f), + f.Browser, + f.Remotes, + ) + root.AddCommand(newCodeCmd(app)) root.AddCommand(newCreateCmd(app)) root.AddCommand(newEditCmd(app)) diff --git a/pkg/cmd/project/close/close_test.go b/pkg/cmd/project/close/close_test.go index a19dcdabd..96ec6ac50 100644 --- a/pkg/cmd/project/close/close_test.go +++ b/pkg/cmd/project/close/close_test.go @@ -1,7 +1,6 @@ package close import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -56,8 +55,7 @@ func TestNewCmdClose(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/copy/copy_test.go b/pkg/cmd/project/copy/copy_test.go index d8abf5ec9..3caec592d 100644 --- a/pkg/cmd/project/copy/copy_test.go +++ b/pkg/cmd/project/copy/copy_test.go @@ -1,7 +1,6 @@ package copy import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -75,8 +74,7 @@ func TestNewCmdCopy(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/create/create_test.go b/pkg/cmd/project/create/create_test.go index 3ef773cb9..74d1747b7 100644 --- a/pkg/cmd/project/create/create_test.go +++ b/pkg/cmd/project/create/create_test.go @@ -1,7 +1,6 @@ package create import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -45,8 +44,7 @@ func TestNewCmdCreate(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/delete/delete_test.go b/pkg/cmd/project/delete/delete_test.go index a27180266..589ef72bc 100644 --- a/pkg/cmd/project/delete/delete_test.go +++ b/pkg/cmd/project/delete/delete_test.go @@ -1,7 +1,6 @@ package delete import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -49,8 +48,7 @@ func TestNewCmdDelete(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/edit/edit_test.go b/pkg/cmd/project/edit/edit_test.go index 605c5f5cc..da30b0935 100644 --- a/pkg/cmd/project/edit/edit_test.go +++ b/pkg/cmd/project/edit/edit_test.go @@ -1,7 +1,6 @@ package edit import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -92,8 +91,7 @@ func TestNewCmdEdit(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/field-create/field_create_test.go b/pkg/cmd/project/field-create/field_create_test.go index 31328a04f..01904d9e9 100644 --- a/pkg/cmd/project/field-create/field_create_test.go +++ b/pkg/cmd/project/field-create/field_create_test.go @@ -1,7 +1,6 @@ package fieldcreate import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -79,8 +78,7 @@ func TestNewCmdCreateField(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/field-delete/field_delete_test.go b/pkg/cmd/project/field-delete/field_delete_test.go index 61783d44d..6b1e6c763 100644 --- a/pkg/cmd/project/field-delete/field_delete_test.go +++ b/pkg/cmd/project/field-delete/field_delete_test.go @@ -1,7 +1,6 @@ package fielddelete import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -43,8 +42,7 @@ func TestNewCmdDeleteField(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/field-list/field_list_test.go b/pkg/cmd/project/field-list/field_list_test.go index 60e61188d..c6c2f97c6 100644 --- a/pkg/cmd/project/field-list/field_list_test.go +++ b/pkg/cmd/project/field-list/field_list_test.go @@ -1,7 +1,6 @@ package fieldlist import ( - "os" "testing" "github.com/cli/cli/v2/internal/tableprinter" @@ -53,8 +52,7 @@ func TestNewCmdList(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/item-add/item_add_test.go b/pkg/cmd/project/item-add/item_add_test.go index 4f1ebd4f6..0beb1fed6 100644 --- a/pkg/cmd/project/item-add/item_add_test.go +++ b/pkg/cmd/project/item-add/item_add_test.go @@ -1,7 +1,6 @@ package itemadd import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -65,8 +64,7 @@ func TestNewCmdaddItem(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/item-archive/item_archive_test.go b/pkg/cmd/project/item-archive/item_archive_test.go index 85a34af22..6eb8336f2 100644 --- a/pkg/cmd/project/item-archive/item_archive_test.go +++ b/pkg/cmd/project/item-archive/item_archive_test.go @@ -1,7 +1,6 @@ package itemarchive import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -73,8 +72,7 @@ func TestNewCmdarchiveItem(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/item-create/item_create_test.go b/pkg/cmd/project/item-create/item_create_test.go index d49144f3c..1482fe268 100644 --- a/pkg/cmd/project/item-create/item_create_test.go +++ b/pkg/cmd/project/item-create/item_create_test.go @@ -1,7 +1,6 @@ package itemcreate import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -73,8 +72,7 @@ func TestNewCmdCreateItem(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/item-delete/item_delete_test.go b/pkg/cmd/project/item-delete/item_delete_test.go index c5a3f01fd..4d3e97059 100644 --- a/pkg/cmd/project/item-delete/item_delete_test.go +++ b/pkg/cmd/project/item-delete/item_delete_test.go @@ -1,7 +1,6 @@ package itemdelete import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -65,8 +64,7 @@ func TestNewCmdDeleteItem(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go index 156871f32..5cdc69b87 100644 --- a/pkg/cmd/project/item-edit/item_edit_test.go +++ b/pkg/cmd/project/item-edit/item_edit_test.go @@ -1,7 +1,6 @@ package itemedit import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -105,8 +104,7 @@ func TestNewCmdeditItem(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/item-list/item_list_test.go b/pkg/cmd/project/item-list/item_list_test.go index d618451b1..490473691 100644 --- a/pkg/cmd/project/item-list/item_list_test.go +++ b/pkg/cmd/project/item-list/item_list_test.go @@ -1,7 +1,6 @@ package itemlist import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -54,8 +53,7 @@ func TestNewCmdList(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/list/list_test.go b/pkg/cmd/project/list/list_test.go index 48fb689ed..492d224b0 100644 --- a/pkg/cmd/project/list/list_test.go +++ b/pkg/cmd/project/list/list_test.go @@ -2,7 +2,6 @@ package list import ( "bytes" - "os" "testing" "github.com/cli/cli/v2/internal/tableprinter" @@ -56,8 +55,7 @@ func TestNewCmdlist(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/project/view/view_test.go b/pkg/cmd/project/view/view_test.go index 5c76c1413..35f6dfc9b 100644 --- a/pkg/cmd/project/view/view_test.go +++ b/pkg/cmd/project/view/view_test.go @@ -2,7 +2,6 @@ package view import ( "bytes" - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -57,8 +56,7 @@ func TestNewCmdview(t *testing.T) { }, } - os.Setenv("GH_TOKEN", "auth-token") - defer os.Unsetenv("GH_TOKEN") + t.Setenv("GH_TOKEN", "auth-token") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index fd6378216..8c8420bee 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -5,11 +5,9 @@ import ( "net/http" "os" "strings" - "sync" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias" "github.com/cli/cli/v2/pkg/cmd/alias/shared" @@ -134,7 +132,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(variableCmd.NewCmdVariable(f)) cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f)) cmd.AddCommand(statusCmd.NewCmdStatus(f, nil)) - cmd.AddCommand(newCodespaceCmd(f)) + cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) // below here at the commands that require the "intelligent" BaseRepo resolver @@ -241,51 +239,3 @@ func bareHTTPClient(f *cmdutil.Factory, version string) func() (*http.Client, er return api.NewHTTPClient(opts) } } - -func newCodespaceCmd(f *cmdutil.Factory) *cobra.Command { - serverURL := os.Getenv("GITHUB_SERVER_URL") - apiURL := os.Getenv("GITHUB_API_URL") - vscsURL := os.Getenv("INTERNAL_VSCS_TARGET_URL") - app := codespaceCmd.NewApp( - f.IOStreams, - f, - codespacesAPI.New( - serverURL, - apiURL, - vscsURL, - &lazyLoadedHTTPClient{factory: f}, - ), - f.Browser, - f.Remotes, - ) - cmd := codespaceCmd.NewRootCmd(app) - cmd.Use = "codespace" - cmd.Aliases = []string{"cs"} - cmd.GroupID = "core" - return cmd -} - -type lazyLoadedHTTPClient struct { - factory *cmdutil.Factory - - httpClientMu sync.RWMutex // guards httpClient - httpClient *http.Client -} - -func (l *lazyLoadedHTTPClient) Do(req *http.Request) (*http.Response, error) { - l.httpClientMu.RLock() - httpClient := l.httpClient - l.httpClientMu.RUnlock() - - if httpClient == nil { - var err error - l.httpClientMu.Lock() - l.httpClient, err = l.factory.HttpClient() - l.httpClientMu.Unlock() - if err != nil { - return nil, err - } - } - - return l.httpClient.Do(req) -} From bd12522cb2d932b5b869bf815b8c57a975dd8541 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 8 Aug 2023 15:37:58 -0700 Subject: [PATCH 055/103] finish porting existing tests --- pkg/cmd/repo/edit/edit.go | 15 +++++---------- pkg/cmd/repo/edit/edit_test.go | 31 ++++++++++++++++--------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 4841362e7..58e32a262 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -458,24 +458,19 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } opts.Edits.EnableAutoMerge = &r.AutoMergeAllowed - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Enable Auto Merge?", - Default: r.AutoMergeAllowed, - }, opts.Edits.EnableAutoMerge) + c, err := opts.Prompter.Confirm("Enable Auto Merge?", r.AutoMergeAllowed) if err != nil { return err } + opts.Edits.EnableAutoMerge = &c opts.Edits.DeleteBranchOnMerge = &r.DeleteBranchOnMerge - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Automatically delete head branches after merging?", - Default: r.DeleteBranchOnMerge, - }, opts.Edits.DeleteBranchOnMerge) + c, err = opts.Prompter.Confirm( + "Automatically delete head branches after merging?", r.DeleteBranchOnMerge) if err != nil { return err } + opts.Edits.DeleteBranchOnMerge = &c case optionTemplateRepo: opts.Edits.IsTemplate = &r.IsTemplate //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index 98f1cb8cc..198b2fabd 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -7,8 +7,6 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/pkg/prompt" - "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" @@ -193,7 +191,6 @@ func Test_editRun_interactive(t *testing.T) { tests := []struct { name string opts EditOptions - askStubs func(*prompt.AskStubber) promptStubs func(*prompter.MockPrompter) httpStubs func(*testing.T, *httpmock.Registry) wantsStderr string @@ -205,7 +202,6 @@ func Test_editRun_interactive(t *testing.T) { Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), InteractiveMode: true, }, - askStubs: func(as *prompt.AskStubber) {}, promptStubs: func(pm *prompter.MockPrompter) { pm.RegisterMultiSelect("What do you want to edit?", nil, editList, func(_ string, _, opts []string) ([]int, error) { @@ -252,7 +248,6 @@ func Test_editRun_interactive(t *testing.T) { Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), InteractiveMode: true, }, - askStubs: func(as *prompt.AskStubber) {}, promptStubs: func(pm *prompter.MockPrompter) { pm.RegisterMultiSelect("What do you want to edit?", nil, editList, func(_ string, _, opts []string) ([]int, error) { @@ -312,11 +307,22 @@ func Test_editRun_interactive(t *testing.T) { Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), InteractiveMode: true, }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("What do you want to edit?").AnswerWith([]string{"Merge Options"}) - as.StubPrompt("Allowed merge strategies").AnswerWith([]string{allowMergeCommits, allowRebaseMerge}) - as.StubPrompt("Enable Auto Merge?").AnswerWith(false) - as.StubPrompt("Automatically delete head branches after merging?").AnswerWith(false) + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterMultiSelect("What do you want to edit?", nil, editList, + func(_ string, _, opts []string) ([]int, error) { + return []int{4}, nil + }) + pm.RegisterMultiSelect("Allowed merge strategies", nil, + []string{allowMergeCommits, allowSquashMerge, allowRebaseMerge}, + func(_ string, _, opts []string) ([]int, error) { + return []int{0, 2}, nil + }) + pm.RegisterConfirm("Enable Auto Merge?", func(_ string, _ bool) (bool, error) { + return false, nil + }) + pm.RegisterConfirm("Automatically delete head branches after merging?", func(_ string, _ bool) (bool, error) { + return false, nil + }) }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( @@ -378,11 +384,6 @@ func Test_editRun_interactive(t *testing.T) { opts := &tt.opts opts.HTTPClient = &http.Client{Transport: httpReg} opts.IO = ios - //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock - as := prompt.NewAskStubber(t) - if tt.askStubs != nil { - tt.askStubs(as) - } err := editRun(context.Background(), opts) if tt.wantsErr == "" { From 8927040028c1aba8daa3c294bf39f58d008bcd54 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 8 Aug 2023 15:58:16 -0700 Subject: [PATCH 056/103] remove dead code --- pkg/cmd/repo/edit/edit.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 58e32a262..5731302c8 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -394,16 +394,6 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { if err != nil { return err } - case optionDiscussions: - opts.Edits.EnableDiscussions = &r.HasDiscussionsEnabled - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Enable Discussions?", - Default: r.HasDiscussionsEnabled, - }, opts.Edits.EnableDiscussions) - if err != nil { - return err - } case optionVisibility: opts.Edits.Visibility = &r.Visibility visibilityOptions := []string{"public", "private", "internal"} From c1296194ee5c921da7d2d462e0b2faefe12cff0c Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 8 Aug 2023 17:28:06 -0700 Subject: [PATCH 057/103] port more prompts and cover with new tests --- pkg/cmd/repo/edit/edit.go | 43 ++++++------------- pkg/cmd/repo/edit/edit_test.go | 78 +++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 5731302c8..26d1ce380 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -319,6 +319,7 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } for _, c := range choices { switch c { + // TODO these initial assignments are no-ops; delete them case optionDescription: opts.Edits.Description = &r.Description answer, err := opts.Prompter.Input("Description of the repository", r.Description) @@ -328,14 +329,11 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { opts.Edits.Description = &answer case optionHomePageURL: opts.Edits.Homepage = &r.HomepageURL - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Input{ - Message: "Repository home page URL", - Default: r.HomepageURL, - }, opts.Edits.Homepage) + a, err := opts.Prompter.Input("Repository home page URL", r.HomepageURL) if err != nil { return err } + opts.Edits.Homepage = &a case optionTopics: addTopics, err := opts.Prompter.Input("Add topics?(csv format)", "") if err != nil { @@ -356,44 +354,32 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } case optionDefaultBranchName: opts.Edits.DefaultBranch = &r.DefaultBranchRef.Name - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Input{ - Message: "Default branch name", - Default: r.DefaultBranchRef.Name, - }, opts.Edits.DefaultBranch) + name, err := opts.Prompter.Input("Default branch name", r.DefaultBranchRef.Name) if err != nil { return err } + opts.Edits.DefaultBranch = &name case optionWikis: opts.Edits.EnableWiki = &r.HasWikiEnabled - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Enable Wikis?", - Default: r.HasWikiEnabled, - }, opts.Edits.EnableWiki) + c, err := opts.Prompter.Confirm("Enable Wikis?", r.HasWikiEnabled) if err != nil { return err } + opts.Edits.EnableWiki = &c case optionIssues: opts.Edits.EnableIssues = &r.HasIssuesEnabled - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Enable Issues?", - Default: r.HasIssuesEnabled, - }, opts.Edits.EnableIssues) + a, err := opts.Prompter.Confirm("Enable Issues?", r.HasIssuesEnabled) if err != nil { return err } + opts.Edits.EnableIssues = &a case optionProjects: opts.Edits.EnableProjects = &r.HasProjectsEnabled - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Enable Projects?", - Default: r.HasProjectsEnabled, - }, opts.Edits.EnableProjects) + a, err := opts.Prompter.Confirm("Enable Projects?", r.HasProjectsEnabled) if err != nil { return err } + opts.Edits.EnableProjects = &a case optionVisibility: opts.Edits.Visibility = &r.Visibility visibilityOptions := []string{"public", "private", "internal"} @@ -463,14 +449,11 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { opts.Edits.DeleteBranchOnMerge = &c case optionTemplateRepo: opts.Edits.IsTemplate = &r.IsTemplate - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Convert into a template repository?", - Default: r.IsTemplate, - }, opts.Edits.IsTemplate) + c, err := opts.Prompter.Confirm("Convert into a template repository?", r.IsTemplate) if err != nil { return err } + opts.Edits.IsTemplate = &c case optionAllowForking: opts.Edits.AllowForking = &r.ForkingAllowed //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index 198b2fabd..0c6a6d2f6 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -173,8 +173,6 @@ func Test_editRun(t *testing.T) { } } -// TODO consider unit test for interactiveRepoEdit that exercises every prompt type - func Test_editRun_interactive(t *testing.T) { editList := []string{ "Default Branch Name", @@ -196,6 +194,82 @@ func Test_editRun_interactive(t *testing.T) { wantsStderr string wantsErr string }{ + // TODO forking of an org repo + { + name: "the rest", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + InteractiveMode: true, + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterMultiSelect("What do you want to edit?", nil, editList, + func(_ string, _, opts []string) ([]int, error) { + return []int{0, 2, 3, 5, 6, 8, 9}, nil + }) + pm.RegisterInput("Default branch name", func(_, _ string) (string, error) { + return "trunk", nil + }) + pm.RegisterInput("Repository home page URL", func(_, _ string) (string, error) { + return "https://zombo.com", nil + }) + pm.RegisterConfirm("Enable Issues?", func(_ string, _ bool) (bool, error) { + return true, nil + }) + pm.RegisterConfirm("Enable Projects?", func(_ string, _ bool) (bool, error) { + return true, nil + }) + pm.RegisterConfirm("Convert into a template repository?", func(_ string, _ bool) (bool, error) { + return true, nil + }) + pm.RegisterSelect("Visibility", []string{"public", "private", "internal"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "private") + }) + pm.RegisterConfirm("Do you want to change visibility to private?", func(_ string, _ bool) (bool, error) { + return true, nil + }) + pm.RegisterConfirm("Enable Wikis?", func(_ string, _ bool) (bool, error) { + return true, nil + }) + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { + "data": { + "repository": { + "visibility": "public", + "description": "description", + "homePageUrl": "https://url.com", + "defaultBranchRef": { + "name": "main" + }, + "stargazerCount": 10, + "isInOrganization": false, + "repositoryTopics": { + "nodes": [{ + "topic": { + "name": "x" + } + }] + } + } + } + }`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, "trunk", payload["default_branch"]) + assert.Equal(t, "https://zombo.com", payload["homepage"]) + assert.Equal(t, true, payload["has_issues"]) + assert.Equal(t, true, payload["has_projects"]) + assert.Equal(t, "private", payload["visibility"]) + assert.Equal(t, true, payload["is_template"]) + assert.Equal(t, true, payload["has_wiki"]) + })) + }, + }, { name: "updates repo description", opts: EditOptions{ From 3e2f09d97046ea808d7fc05df1a164973db4996f Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 8 Aug 2023 17:35:35 -0700 Subject: [PATCH 058/103] strip more dead code --- pkg/cmd/repo/edit/edit.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 26d1ce380..fb3d87bea 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -319,16 +319,13 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } for _, c := range choices { switch c { - // TODO these initial assignments are no-ops; delete them case optionDescription: - opts.Edits.Description = &r.Description answer, err := opts.Prompter.Input("Description of the repository", r.Description) if err != nil { return err } opts.Edits.Description = &answer case optionHomePageURL: - opts.Edits.Homepage = &r.HomepageURL a, err := opts.Prompter.Input("Repository home page URL", r.HomepageURL) if err != nil { return err @@ -353,35 +350,30 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } } case optionDefaultBranchName: - opts.Edits.DefaultBranch = &r.DefaultBranchRef.Name name, err := opts.Prompter.Input("Default branch name", r.DefaultBranchRef.Name) if err != nil { return err } opts.Edits.DefaultBranch = &name case optionWikis: - opts.Edits.EnableWiki = &r.HasWikiEnabled c, err := opts.Prompter.Confirm("Enable Wikis?", r.HasWikiEnabled) if err != nil { return err } opts.Edits.EnableWiki = &c case optionIssues: - opts.Edits.EnableIssues = &r.HasIssuesEnabled a, err := opts.Prompter.Confirm("Enable Issues?", r.HasIssuesEnabled) if err != nil { return err } opts.Edits.EnableIssues = &a case optionProjects: - opts.Edits.EnableProjects = &r.HasProjectsEnabled a, err := opts.Prompter.Confirm("Enable Projects?", r.HasProjectsEnabled) if err != nil { return err } opts.Edits.EnableProjects = &a case optionVisibility: - opts.Edits.Visibility = &r.Visibility visibilityOptions := []string{"public", "private", "internal"} selected, err := opts.Prompter.Select("Visibility", strings.ToLower(r.Visibility), visibilityOptions) if err != nil { @@ -448,7 +440,6 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } opts.Edits.DeleteBranchOnMerge = &c case optionTemplateRepo: - opts.Edits.IsTemplate = &r.IsTemplate c, err := opts.Prompter.Confirm("Convert into a template repository?", r.IsTemplate) if err != nil { return err From 480505511ce7d1775f7ce23ea23db28514461561 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 8 Aug 2023 17:53:21 -0700 Subject: [PATCH 059/103] test for forking in org --- pkg/cmd/repo/edit/edit.go | 12 +++------ pkg/cmd/repo/edit/edit_test.go | 48 +++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index fb3d87bea..cb11e2622 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" @@ -19,7 +18,6 @@ import ( "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/cli/v2/pkg/set" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" @@ -446,15 +444,13 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } opts.Edits.IsTemplate = &c case optionAllowForking: - opts.Edits.AllowForking = &r.ForkingAllowed - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne(&survey.Confirm{ - Message: "Allow forking (of an organization repository)?", - Default: r.ForkingAllowed, - }, opts.Edits.AllowForking) + c, err := opts.Prompter.Confirm( + "Allow forking (of an organization repository)?", + r.ForkingAllowed) if err != nil { return err } + opts.Edits.AllowForking = &c } } return nil diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index 0c6a6d2f6..07f7d4d55 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -194,7 +194,53 @@ func Test_editRun_interactive(t *testing.T) { wantsStderr string wantsErr string }{ - // TODO forking of an org repo + { + name: "forking of org repo", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + InteractiveMode: true, + }, + promptStubs: func(pm *prompter.MockPrompter) { + el := append(editList, optionAllowForking) + pm.RegisterMultiSelect("What do you want to edit?", nil, el, + func(_ string, _, opts []string) ([]int, error) { + return []int{10}, nil + }) + pm.RegisterConfirm("Allow forking (of an organization repository)?", func(_ string, _ bool) (bool, error) { + return true, nil + }) + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { + "data": { + "repository": { + "visibility": "public", + "description": "description", + "homePageUrl": "https://url.com", + "defaultBranchRef": { + "name": "main" + }, + "isInOrganization": true, + "repositoryTopics": { + "nodes": [{ + "topic": { + "name": "x" + } + }] + } + } + } + }`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, true, payload["allow_forking"]) + })) + }, + }, { name: "the rest", opts: EditOptions{ From 8ed632afb9ea1164b75d42c3b5754f7ea0a7e65e Mon Sep 17 00:00:00 2001 From: vaindil Date: Thu, 10 Aug 2023 11:51:48 -0400 Subject: [PATCH 060/103] Allow --org parameter in lieu of a repo context for rulesets, add current_user_can_bypass to rs view (#7747) --- pkg/cmd/ruleset/list/list.go | 12 +++- pkg/cmd/ruleset/list/list_test.go | 55 +++++++++++++++++-- pkg/cmd/ruleset/shared/shared.go | 11 ++-- .../view/fixtures/rulesetViewRepo.json | 1 + pkg/cmd/ruleset/view/view.go | 16 +++++- pkg/cmd/ruleset/view/view_test.go | 55 ++++++++++++++++++- 6 files changed, 132 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 60afc69ab..6d13441ea 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -97,9 +97,15 @@ func listRun(opts *ListOptions) error { return err } - repoI, err := opts.BaseRepo() - if err != nil { - return err + var repoI ghrepo.Interface + + // only one of the repo or org context is necessary + if opts.Organization == "" { + var repoErr error + repoI, repoErr = opts.BaseRepo() + if repoErr != nil { + return repoErr + } } hostname, _ := ghAuth.DefaultHost() diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go index d075e46d6..1c370fcfb 100644 --- a/pkg/cmd/ruleset/list/list_test.go +++ b/pkg/cmd/ruleset/list/list_test.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -171,11 +172,12 @@ func Test_listRun(t *testing.T) { name: "list org rulesets", isTTY: true, opts: ListOptions{ - Organization: "my-org", + IncludeParents: true, + Organization: "my-org", }, wantStdout: heredoc.Doc(` - Showing 3 of 3 rulesets in my-org + Showing 3 of 3 rulesets in my-org and its parents ID NAME SOURCE STATUS RULES 4 test OWNER/REPO (repo) evaluate 1 @@ -191,6 +193,45 @@ func Test_listRun(t *testing.T) { wantStderr: "", wantBrowse: "", }, + { + name: "list repo rulesets, no rulesets", + isTTY: true, + opts: ListOptions{ + IncludeParents: true, + }, + wantStdout: "", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepoRulesetList\b`), + httpmock.JSONResponse(shared.RulesetList{ + TotalCount: 0, + Rulesets: []shared.RulesetGraphQL{}, + }), + ) + }, + wantErr: "no rulesets found in OWNER/REPO or its parents", + wantStderr: "", + wantBrowse: "", + }, + { + name: "list org rulesets, no rulesets", + isTTY: true, + opts: ListOptions{ + Organization: "my-org", + }, + wantStdout: "", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query OrgRulesetList\b`), + httpmock.JSONResponse(shared.RulesetList{ + TotalCount: 0, + Rulesets: []shared.RulesetGraphQL{}, + }), + ) + }, + wantErr: "no rulesets found in my-org", + wantBrowse: "", + }, { name: "machine-readable", isTTY: false, @@ -269,9 +310,15 @@ func Test_listRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("OWNER/REPO") + + // only set this if org is not set, because the repo isn't needed if --org is provided and + // leaving it undefined will catch potential errors + if tt.opts.Organization == "" { + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } } + browser := &browser.Stub{} tt.opts.Browser = browser diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index a71b20522..3ec1c83ee 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -24,11 +24,12 @@ type RulesetGraphQL struct { } type RulesetREST struct { - Id int - Name string - Target string - Enforcement string - BypassActors []struct { + Id int + Name string + Target string + Enforcement string + CurrentUserCanBypass string `json:"current_user_can_bypass"` + BypassActors []struct { ActorId int `json:"actor_id"` ActorType string `json:"actor_type"` BypassMode string `json:"bypass_mode"` diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json index 5824b341d..53e6489a5 100644 --- a/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json +++ b/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json @@ -5,6 +5,7 @@ "source_type": "Repository", "source": "my-owner/repo-name", "enforcement": "active", + "current_user_can_bypass": "pull_requests_only", "conditions": { "ref_name": { "exclude": [], diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 813f72c51..0b79302e5 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -113,9 +113,15 @@ func viewRun(opts *ViewOptions) error { return err } - repoI, err := opts.BaseRepo() - if err != nil { - return err + var repoI ghrepo.Interface + + // only one of the repo or org context is necessary + if opts.Organization == "" { + var repoErr error + repoI, repoErr = opts.BaseRepo() + if repoErr != nil { + return repoErr + } } hostname, _ := ghAuth.DefaultHost() @@ -194,6 +200,10 @@ func viewRun(opts *ViewOptions) error { fmt.Fprintf(w, "%s\n", rs.Enforcement) } + if rs.CurrentUserCanBypass != "" { + fmt.Fprintf(w, "You can bypass: %s\n", strings.ReplaceAll(rs.CurrentUserCanBypass, "_", " ")) + } + fmt.Fprintf(w, "\n%s\n", cs.Bold("Bypass List")) if len(rs.BypassActors) == 0 { fmt.Fprintf(w, "This ruleset cannot be bypassed\n") diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go index ce9c21db4..5e2846441 100644 --- a/pkg/cmd/ruleset/view/view_test.go +++ b/pkg/cmd/ruleset/view/view_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -156,6 +157,7 @@ func Test_viewRun(t *testing.T) { ID: 42 Source: my-owner/repo-name (Repository) Enforcement: Active + You can bypass: pull requests only Bypass List - OrganizationAdmin (ID: 1), mode: always @@ -233,7 +235,48 @@ func Test_viewRun(t *testing.T) { wantBrowse: "", }, { - name: "prompter", + name: "interactive mode, repo, no rulesets found", + isTTY: true, + opts: ViewOptions{ + InteractiveMode: true, + }, + wantStdout: "", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepoRulesetList\b`), + httpmock.JSONResponse(shared.RulesetList{ + TotalCount: 0, + Rulesets: []shared.RulesetGraphQL{}, + }), + ) + }, + wantErr: "no rulesets found in my-owner/repo-name", + wantStderr: "", + wantBrowse: "", + }, + { + name: "interactive mode, org, no rulesets found", + isTTY: true, + opts: ViewOptions{ + InteractiveMode: true, + Organization: "my-owner", + }, + wantStdout: "", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query OrgRulesetList\b`), + httpmock.JSONResponse(shared.RulesetList{ + TotalCount: 0, + Rulesets: []shared.RulesetGraphQL{}, + }), + ) + }, + wantErr: "no rulesets found in my-owner", + wantStderr: "", + wantBrowse: "", + }, + { + name: "interactive mode, prompter", isTTY: true, opts: ViewOptions{ InteractiveMode: true, @@ -338,9 +381,15 @@ func Test_viewRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("my-owner/repo-name") + + // only set this if org is not set, because the repo isn't needed if --org is provided and + // leaving it undefined will catch potential errors + if tt.opts.Organization == "" { + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("my-owner/repo-name") + } } + browser := &browser.Stub{} tt.opts.Browser = browser From aa231319ca7b06b0c4cabe3015e5e75af21b658a Mon Sep 17 00:00:00 2001 From: cawfeecake <48775802+cawfeecake@users.noreply.github.com> Date: Thu, 10 Aug 2023 09:08:15 -0700 Subject: [PATCH 061/103] Add missing "ls" aliases to list commands (#7818) --- pkg/cmd/cache/list/list.go | 3 ++- pkg/cmd/org/list/list.go | 1 + pkg/cmd/project/list/list.go | 3 ++- pkg/cmd/ruleset/list/list.go | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/cache/list/list.go b/pkg/cmd/cache/list/list.go index 9d16a9323..d2f4aa10d 100644 --- a/pkg/cmd/cache/list/list.go +++ b/pkg/cmd/cache/list/list.go @@ -47,7 +47,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman # List caches sorted by least recently accessed $ gh cache list --sort last_accessed_at --order asc `), - Args: cobra.NoArgs, + Aliases: []string{"ls"}, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo diff --git a/pkg/cmd/org/list/list.go b/pkg/cmd/org/list/list.go index c30dc8b1a..54e5daf5a 100644 --- a/pkg/cmd/org/list/list.go +++ b/pkg/cmd/org/list/list.go @@ -39,6 +39,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman # List more organizations $ gh org list --limit 100 `), + Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { if opts.Limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index 661cbcd10..c92434522 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -33,8 +33,8 @@ type listConfig struct { func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.Command { opts := listOpts{} listCmd := &cobra.Command{ - Short: "List the projects for an owner", Use: "list", + Short: "List the projects for an owner", Example: heredoc.Doc(` # list the current user's projects gh project list @@ -42,6 +42,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C # list the projects for org github including closed projects gh project list --owner github --closed `), + Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { client, err := client.New(f) if err != nil { diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 6d13441ea..c88f2d1e0 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -62,7 +62,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman # List rulesets in an organization $ gh ruleset list --org org-name `), - Args: cobra.ExactArgs(0), + Aliases: []string{"ls"}, + Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && opts.Organization != "" { return cmdutil.FlagErrorf("only one of --repo and --org may be specified") From f5477d931b746d60709e2097d5e96c5308a61168 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 10 Aug 2023 17:34:17 -0700 Subject: [PATCH 062/103] pass prompter around instead of opts --- pkg/cmd/repo/edit/edit.go | 47 ++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index cb11e2622..112aa5131 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -15,7 +15,6 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/set" @@ -23,6 +22,13 @@ import ( "golang.org/x/sync/errgroup" ) +type iprompter interface { + MultiSelect(prompt string, defaults []string, options []string) ([]int, error) + Input(string, string) (string, error) + Confirm(string, bool) (bool, error) + Select(string, string, []string) (int, error) +} + const ( allowMergeCommits = "Allow Merge Commits" allowSquashMerge = "Allow Squash Merging" @@ -51,7 +57,7 @@ type EditOptions struct { RemoveTopics []string InteractiveMode bool Detector fd.Detector - Prompter prompter.Prompter + Prompter iprompter // Cache of current repo topics to avoid retrieving them // in multiple flows. topicsCache []string @@ -277,7 +283,7 @@ func editRun(ctx context.Context, opts *EditOptions) error { return nil } -func interactiveChoice(opts EditOptions, r *api.Repository) ([]string, error) { +func interactiveChoice(p iprompter, r *api.Repository) ([]string, error) { options := []string{ optionDefaultBranchName, optionDescription, @@ -296,7 +302,7 @@ func interactiveChoice(opts EditOptions, r *api.Repository) ([]string, error) { options = append(options, optionAllowForking) } var answers []string - selected, err := opts.Prompter.MultiSelect("What do you want to edit?", nil, options) + selected, err := p.MultiSelect("What do you want to edit?", nil, options) if err != nil { return nil, err } @@ -311,26 +317,27 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { for _, v := range r.RepositoryTopics.Nodes { opts.topicsCache = append(opts.topicsCache, v.Topic.Name) } - choices, err := interactiveChoice(*opts, r) + p := opts.Prompter + choices, err := interactiveChoice(p, r) if err != nil { return err } for _, c := range choices { switch c { case optionDescription: - answer, err := opts.Prompter.Input("Description of the repository", r.Description) + answer, err := p.Input("Description of the repository", r.Description) if err != nil { return err } opts.Edits.Description = &answer case optionHomePageURL: - a, err := opts.Prompter.Input("Repository home page URL", r.HomepageURL) + a, err := p.Input("Repository home page URL", r.HomepageURL) if err != nil { return err } opts.Edits.Homepage = &a case optionTopics: - addTopics, err := opts.Prompter.Input("Add topics?(csv format)", "") + addTopics, err := p.Input("Add topics?(csv format)", "") if err != nil { return err } @@ -339,7 +346,7 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } if len(opts.topicsCache) > 0 { - selected, err := opts.Prompter.MultiSelect("Remove Topics", nil, opts.topicsCache) + selected, err := p.MultiSelect("Remove Topics", nil, opts.topicsCache) if err != nil { return err } @@ -348,32 +355,32 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } } case optionDefaultBranchName: - name, err := opts.Prompter.Input("Default branch name", r.DefaultBranchRef.Name) + name, err := p.Input("Default branch name", r.DefaultBranchRef.Name) if err != nil { return err } opts.Edits.DefaultBranch = &name case optionWikis: - c, err := opts.Prompter.Confirm("Enable Wikis?", r.HasWikiEnabled) + c, err := p.Confirm("Enable Wikis?", r.HasWikiEnabled) if err != nil { return err } opts.Edits.EnableWiki = &c case optionIssues: - a, err := opts.Prompter.Confirm("Enable Issues?", r.HasIssuesEnabled) + a, err := p.Confirm("Enable Issues?", r.HasIssuesEnabled) if err != nil { return err } opts.Edits.EnableIssues = &a case optionProjects: - a, err := opts.Prompter.Confirm("Enable Projects?", r.HasProjectsEnabled) + a, err := p.Confirm("Enable Projects?", r.HasProjectsEnabled) if err != nil { return err } opts.Edits.EnableProjects = &a case optionVisibility: visibilityOptions := []string{"public", "private", "internal"} - selected, err := opts.Prompter.Select("Visibility", strings.ToLower(r.Visibility), visibilityOptions) + selected, err := p.Select("Visibility", strings.ToLower(r.Visibility), visibilityOptions) if err != nil { return err } @@ -382,7 +389,7 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { (r.StargazerCount > 0 || r.Watchers.TotalCount > 0) { cs := opts.IO.ColorScheme() fmt.Fprintf(opts.IO.ErrOut, "%s Changing the repository visibility to private will cause permanent loss of stars and watchers.\n", cs.WarningIcon()) - confirmed, err = opts.Prompter.Confirm("Do you want to change visibility to private?", false) + confirmed, err = p.Confirm("Do you want to change visibility to private?", false) if err != nil { return err } @@ -403,7 +410,7 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { defaultMergeOptions = append(defaultMergeOptions, allowRebaseMerge) } mergeOpts := []string{allowMergeCommits, allowSquashMerge, allowRebaseMerge} - selected, err := opts.Prompter.MultiSelect( + selected, err := p.MultiSelect( "Allowed merge strategies", defaultMergeOptions, mergeOpts) @@ -424,27 +431,27 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } opts.Edits.EnableAutoMerge = &r.AutoMergeAllowed - c, err := opts.Prompter.Confirm("Enable Auto Merge?", r.AutoMergeAllowed) + c, err := p.Confirm("Enable Auto Merge?", r.AutoMergeAllowed) if err != nil { return err } opts.Edits.EnableAutoMerge = &c opts.Edits.DeleteBranchOnMerge = &r.DeleteBranchOnMerge - c, err = opts.Prompter.Confirm( + c, err = p.Confirm( "Automatically delete head branches after merging?", r.DeleteBranchOnMerge) if err != nil { return err } opts.Edits.DeleteBranchOnMerge = &c case optionTemplateRepo: - c, err := opts.Prompter.Confirm("Convert into a template repository?", r.IsTemplate) + c, err := p.Confirm("Convert into a template repository?", r.IsTemplate) if err != nil { return err } opts.Edits.IsTemplate = &c case optionAllowForking: - c, err := opts.Prompter.Confirm( + c, err := p.Confirm( "Allow forking (of an organization repository)?", r.ForkingAllowed) if err != nil { From c5eb188698a9a7234dca03ba91bb98b5674d46ad Mon Sep 17 00:00:00 2001 From: Jun Nishimura Date: Tue, 15 Aug 2023 23:58:45 +0900 Subject: [PATCH 063/103] Add clobber flag to `alias set` (#7787) --- pkg/cmd/alias/set/set.go | 57 ++-- pkg/cmd/alias/set/set_test.go | 561 +++++++++++++++++----------------- 2 files changed, 317 insertions(+), 301 deletions(-) diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go index 20c86c473..77de9ca5d 100644 --- a/pkg/cmd/alias/set/set.go +++ b/pkg/cmd/alias/set/set.go @@ -17,9 +17,10 @@ type SetOptions struct { Config func() (config.Config, error) IO *iostreams.IOStreams - Name string - Expansion string - IsShell bool + Name string + Expansion string + IsShell bool + OverwriteExisting bool validAliasName func(string) bool validAliasExpansion func(string) bool @@ -86,6 +87,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command } cmd.Flags().BoolVarP(&opts.IsShell, "shell", "s", false, "Declare an alias to be passed through a shell interpreter") + cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing aliases of the same name") return cmd } @@ -104,31 +106,39 @@ func setRun(opts *SetOptions) error { return fmt.Errorf("did not understand expansion: %w", err) } - isTerminal := opts.IO.IsStdoutTTY() - if isTerminal { - fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion)) - } - if opts.IsShell && !strings.HasPrefix(expansion, "!") { expansion = "!" + expansion } + isTerminal := opts.IO.IsStdoutTTY() + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "- Creating alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion)) + } + + var existingAlias bool + if _, err := aliasCfg.Get(opts.Name); err == nil { + existingAlias = true + } + if !opts.validAliasName(opts.Name) { - return fmt.Errorf("could not create alias: %q is already a gh command, extension, or alias", opts.Name) + if !existingAlias { + return fmt.Errorf("%s Could not create alias %s: already a gh command or extension", + cs.FailureIcon(), + cs.Bold(opts.Name)) + } + + if existingAlias && !opts.OverwriteExisting { + return fmt.Errorf("%s Could not create alias %s: name already taken, use the --clobber flag to overwrite it", + cs.FailureIcon(), + cs.Bold(opts.Name), + ) + } } if !opts.validAliasExpansion(expansion) { - return fmt.Errorf("could not create alias: %s does not correspond to a gh command, extension, or alias", expansion) - } - - successMsg := fmt.Sprintf("%s Added alias.", cs.SuccessIcon()) - if oldExpansion, err := aliasCfg.Get(opts.Name); err == nil { - successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s", - cs.SuccessIcon(), - cs.Bold(opts.Name), - cs.Bold(oldExpansion), - cs.Bold(expansion), - ) + return fmt.Errorf("%s Could not create alias %s: expansion does not correspond to a gh command, extension, or alias", + cs.FailureIcon(), + cs.Bold(opts.Name)) } aliasCfg.Add(opts.Name, expansion) @@ -138,6 +148,13 @@ func setRun(opts *SetOptions) error { return err } + successMsg := fmt.Sprintf("%s Added alias %s", cs.SuccessIcon(), cs.Bold(opts.Name)) + if existingAlias && opts.OverwriteExisting { + successMsg = fmt.Sprintf("%s Changed alias %s", + cs.WarningIcon(), + cs.Bold(opts.Name)) + } + if isTerminal { fmt.Fprintln(opts.IO.ErrOut, successMsg) } diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go index 54fcabf5b..4acb8b8b0 100644 --- a/pkg/cmd/alias/set/set_test.go +++ b/pkg/cmd/alias/set/set_test.go @@ -2,314 +2,313 @@ package set import ( "bytes" - "io" + "fmt" "testing" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmd/alias/shared" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func runCommand(cfg config.Config, isTTY bool, cli string, in string) (*test.CmdOut, error) { - ios, stdin, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(isTTY) - ios.SetStdinTTY(isTTY) - ios.SetStderrTTY(isTTY) - stdin.WriteString(in) - - factory := &cmdutil.Factory{ - IOStreams: ios, - Config: func() (config.Config, error) { - return cfg, nil +func TestNewCmdSet(t *testing.T) { + tests := []struct { + name string + input string + output SetOptions + wantErr bool + errMsg string + }{ + { + name: "no arguments", + input: "", + wantErr: true, + errMsg: "accepts 2 arg(s), received 0", }, - ExtensionManager: &extensions.ExtensionManagerMock{ - ListFunc: func() []extensions.Extension { - return []extensions.Extension{} + { + name: "only one argument", + input: "name", + wantErr: true, + errMsg: "accepts 2 arg(s), received 1", + }, + { + name: "name and expansion", + input: "alias-name alias-expansion", + output: SetOptions{ + Name: "alias-name", + Expansion: "alias-expansion", + }, + }, + { + name: "shell flag", + input: "alias-name alias-expansion --shell", + output: SetOptions{ + Name: "alias-name", + Expansion: "alias-expansion", + IsShell: true, + }, + }, + { + name: "clobber flag", + input: "alias-name alias-expansion --clobber", + output: SetOptions{ + Name: "alias-name", + Expansion: "alias-expansion", + OverwriteExisting: true, }, }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *SetOptions + cmd := NewCmdSet(f, func(opts *SetOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) - cmd := NewCmdSet(factory, nil) - - // Create fake command structure for testing. - rootCmd := &cobra.Command{} - rootCmd.AddCommand(cmd) - prCmd := &cobra.Command{Use: "pr"} - prCmd.AddCommand(&cobra.Command{Use: "checkout"}) - prCmd.AddCommand(&cobra.Command{Use: "status"}) - rootCmd.AddCommand(prCmd) - issueCmd := &cobra.Command{Use: "issue"} - issueCmd.AddCommand(&cobra.Command{Use: "list"}) - rootCmd.AddCommand(issueCmd) - apiCmd := &cobra.Command{Use: "api"} - apiCmd.AddCommand(&cobra.Command{Use: "graphql"}) - rootCmd.AddCommand(apiCmd) - - argv, err := shlex.Split("set " + cli) - if err != nil { - return nil, err - } - rootCmd.SetArgs(argv) - - rootCmd.SetIn(stdin) - rootCmd.SetOut(io.Discard) - rootCmd.SetErr(io.Discard) - - _, err = rootCmd.ExecuteC() - return &test.CmdOut{ - OutBuf: stdout, - ErrBuf: stderr, - }, err -} - -func TestAliasSet_gh_command(t *testing.T) { - cfg := config.NewFromString(``) - - _, err := runCommand(cfg, true, "pr 'pr status'", "") - assert.EqualError(t, err, `could not create alias: "pr" is already a gh command, extension, or alias`) -} - -func TestAliasSet_empty_aliases(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewFromString(heredoc.Doc(` - aliases: - editor: vim - `)) - - output, err := runCommand(cfg, true, "co 'pr checkout'", "") - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Added alias") - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.String(), "") - - expected := `aliases: - co: pr checkout -editor: vim -` - assert.Equal(t, expected, mainBuf.String()) -} - -func TestAliasSet_existing_alias(t *testing.T) { - _ = config.StubWriteConfig(t) - - cfg := config.NewFromString(heredoc.Doc(` - aliases: - co: pr checkout - `)) - - output, err := runCommand(cfg, true, "co 'pr checkout -Rcool/repo'", "") - require.NoError(t, err) - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo") -} - -func TestAliasSet_space_args(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewFromString(``) - - output, err := runCommand(cfg, true, `il 'issue list -l "cool story"'`, "") - require.NoError(t, err) - - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), `Adding alias for.*il.*issue list -l "cool story"`) - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`) -} - -func TestAliasSet_arg_processing(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cases := []struct { - Cmd string - ExpectedOutputLine string - ExpectedConfigLine string - }{ - {`il "issue list"`, "- Adding alias for.*il.*issue list", "il: issue list"}, - - {`iz 'issue list'`, "- Adding alias for.*iz.*issue list", "iz: issue list"}, - - {`ii 'issue list --author="$1" --label="$2"'`, - `- Adding alias for.*ii.*issue list --author="\$1" --label="\$2"`, - `ii: issue list --author="\$1" --label="\$2"`}, - - {`ix "issue list --author='\$1' --label='\$2'"`, - `- Adding alias for.*ix.*issue list --author='\$1' --label='\$2'`, - `ix: issue list --author='\$1' --label='\$2'`}, - } - - for _, c := range cases { - t.Run(c.Cmd, func(t *testing.T) { - cfg := config.NewFromString(``) - - output, err := runCommand(cfg, true, c.Cmd, "") - if err != nil { - t.Fatalf("got unexpected error running %s: %s", c.Cmd, err) + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return } - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine) - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine) + assert.NoError(t, err) + assert.Equal(t, tt.output.Name, gotOpts.Name) + assert.Equal(t, tt.output.Expansion, gotOpts.Expansion) + assert.Equal(t, tt.output.IsShell, gotOpts.IsShell) + assert.Equal(t, tt.output.OverwriteExisting, gotOpts.OverwriteExisting) }) } } -func TestAliasSet_init_alias_cfg(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewFromString(heredoc.Doc(` - editor: vim - `)) - - output, err := runCommand(cfg, true, "diff 'pr diff'", "") - require.NoError(t, err) - - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) - - expected := `editor: vim -aliases: - diff: pr diff -` - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.") - assert.Equal(t, expected, mainBuf.String()) -} - -func TestAliasSet_existing_aliases(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewFromString(heredoc.Doc(` - aliases: - foo: bar - `)) - - output, err := runCommand(cfg, true, "view 'pr view'", "") - require.NoError(t, err) - - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) - - expected := `aliases: - foo: bar - view: pr view -` - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.") - assert.Equal(t, expected, mainBuf.String()) - -} - -func TestAliasSet_invalid_command(t *testing.T) { - cfg := config.NewFromString(``) - - _, err := runCommand(cfg, true, "co 'pe checkout'", "") - assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command, extension, or alias") -} - -func TestShellAlias_flag(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewFromString(``) - - output, err := runCommand(cfg, true, "--shell igrep 'gh issue list | grep'", "") - if err != nil { - t.Fatalf("unexpected error: %s", err) +func TestSetRun(t *testing.T) { + tests := []struct { + name string + tty bool + opts *SetOptions + stdin string + wantExpansion string + wantStdout string + wantStderr string + wantErrMsg string + }{ + { + name: "creates alias tty", + tty: true, + opts: &SetOptions{ + Name: "foo", + Expansion: "bar", + }, + wantExpansion: "bar", + wantStderr: "- Creating alias for foo: bar\n✓ Added alias foo\n", + }, + { + name: "creates alias", + opts: &SetOptions{ + Name: "foo", + Expansion: "bar", + }, + wantExpansion: "bar", + }, + { + name: "creates shell alias tty", + tty: true, + opts: &SetOptions{ + Name: "igrep", + Expansion: "!gh issue list | grep", + }, + wantExpansion: "!gh issue list | grep", + wantStderr: "- Creating alias for igrep: !gh issue list | grep\n✓ Added alias igrep\n", + }, + { + name: "creates shell alias", + opts: &SetOptions{ + Name: "igrep", + Expansion: "!gh issue list | grep", + }, + wantExpansion: "!gh issue list | grep", + }, + { + name: "creates shell alias using flag tty", + tty: true, + opts: &SetOptions{ + Name: "igrep", + Expansion: "gh issue list | grep", + IsShell: true, + }, + wantExpansion: "!gh issue list | grep", + wantStderr: "- Creating alias for igrep: !gh issue list | grep\n✓ Added alias igrep\n", + }, + { + name: "creates shell alias using flag", + opts: &SetOptions{ + Name: "igrep", + Expansion: "gh issue list | grep", + IsShell: true, + }, + wantExpansion: "!gh issue list | grep", + }, + { + name: "creates alias where expansion has args tty", + tty: true, + opts: &SetOptions{ + Name: "foo", + Expansion: "bar baz --author='$1' --label='$2'", + }, + wantExpansion: "bar baz --author='$1' --label='$2'", + wantStderr: "- Creating alias for foo: bar baz --author='$1' --label='$2'\n✓ Added alias foo\n", + }, + { + name: "creates alias where expansion has args", + opts: &SetOptions{ + Name: "foo", + Expansion: "bar baz --author='$1' --label='$2'", + }, + wantExpansion: "bar baz --author='$1' --label='$2'", + }, + { + name: "creates alias from stdin tty", + tty: true, + opts: &SetOptions{ + Name: "foo", + Expansion: "-", + }, + stdin: `bar baz --author="$1" --label="$2"`, + wantExpansion: `bar baz --author="$1" --label="$2"`, + wantStderr: "- Creating alias for foo: bar baz --author=\"$1\" --label=\"$2\"\n✓ Added alias foo\n", + }, + { + name: "creates alias from stdin", + opts: &SetOptions{ + Name: "foo", + Expansion: "-", + }, + stdin: `bar baz --author="$1" --label="$2"`, + wantExpansion: `bar baz --author="$1" --label="$2"`, + }, + { + name: "overwrites existing alias tty", + tty: true, + opts: &SetOptions{ + Name: "co", + Expansion: "bar", + OverwriteExisting: true, + }, + wantExpansion: "bar", + wantStderr: "- Creating alias for co: bar\n! Changed alias co\n", + }, + { + name: "overwrites existing alias", + opts: &SetOptions{ + Name: "co", + Expansion: "bar", + OverwriteExisting: true, + }, + wantExpansion: "bar", + }, + { + name: "fails when alias name is an existing alias tty", + tty: true, + opts: &SetOptions{ + Name: "co", + Expansion: "bar", + }, + wantExpansion: "pr checkout", + wantErrMsg: "X Could not create alias co: name already taken, use the --clobber flag to overwrite it", + wantStderr: "- Creating alias for co: bar\n", + }, + { + name: "fails when alias name is an existing alias", + opts: &SetOptions{ + Name: "co", + Expansion: "bar", + }, + wantExpansion: "pr checkout", + wantErrMsg: "X Could not create alias co: name already taken, use the --clobber flag to overwrite it", + }, + { + name: "fails when alias expansion is not an existing command tty", + tty: true, + opts: &SetOptions{ + Name: "foo", + Expansion: "baz", + }, + wantErrMsg: "X Could not create alias foo: expansion does not correspond to a gh command, extension, or alias", + wantStderr: "- Creating alias for foo: baz\n", + }, + { + name: "fails when alias expansion is not an existing command", + opts: &SetOptions{ + Name: "foo", + Expansion: "baz", + }, + wantErrMsg: "X Could not create alias foo: expansion does not correspond to a gh command, extension, or alias", + }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCmd := &cobra.Command{} + barCmd := &cobra.Command{Use: "bar"} + barCmd.AddCommand(&cobra.Command{Use: "baz"}) + rootCmd.AddCommand(barCmd) + coCmd := &cobra.Command{Use: "co"} + rootCmd.AddCommand(coCmd) - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) + tt.opts.validAliasName = shared.ValidAliasNameFunc(rootCmd) + tt.opts.validAliasExpansion = shared.ValidAliasExpansionFunc(rootCmd) - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") + ios, stdin, stdout, stderr := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + tt.opts.IO = ios - expected := `aliases: - igrep: '!gh issue list | grep' -` - assert.Equal(t, expected, mainBuf.String()) + if tt.stdin != "" { + fmt.Fprint(stdin, tt.stdin) + } + + cfg := config.NewBlankConfig() + cfg.WriteFunc = func() error { + return nil + } + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + err := setRun(tt.opts) + if tt.wantErrMsg != "" { + assert.EqualError(t, err, tt.wantErrMsg) + writeCalls := cfg.WriteCalls() + assert.Equal(t, 0, len(writeCalls)) + } else { + assert.NoError(t, err) + writeCalls := cfg.WriteCalls() + assert.Equal(t, 1, len(writeCalls)) + } + + ac := cfg.Aliases() + expansion, _ := ac.Get(tt.opts.Name) + assert.Equal(t, tt.wantExpansion, expansion) + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } } -func TestShellAlias_bang(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewFromString(``) - - output, err := runCommand(cfg, true, "igrep '!gh issue list | grep'", "") - require.NoError(t, err) - - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") - - expected := `aliases: - igrep: '!gh issue list | grep' -` - assert.Equal(t, expected, mainBuf.String()) -} - -func TestShellAlias_from_stdin(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewFromString(``) - - output, err := runCommand(cfg, true, "users -", `api graphql -F name="$1" -f query=' - query ($name: String!) { - user(login: $name) { - name - } - }'`) - - require.NoError(t, err) - - mainBuf := bytes.Buffer{} - readConfigs(&mainBuf, io.Discard) - - //nolint:staticcheck // prefer exact matchers over ExpectLines - test.ExpectLines(t, output.Stderr(), "Adding alias for.*users") - - expected := `aliases: - users: |- - api graphql -F name="$1" -f query=' - query ($name: String!) { - user(login: $name) { - name - } - }' -` - - assert.Equal(t, expected, mainBuf.String()) -} - -func TestShellAlias_getExpansion(t *testing.T) { +func TestGetExpansion(t *testing.T) { tests := []struct { name string want string From e4cddb562edc1436258dc1a70cf8c8c2ca08eb29 Mon Sep 17 00:00:00 2001 From: Jamie Tanna Date: Tue, 15 Aug 2023 18:40:09 +0100 Subject: [PATCH 064/103] Remove GHE handling for `workflow` scope (#7841) According to [0] we appear to no longer support GHE 2.x, as the latest release was deprecated in early 2022. We can therefore remove this custom handling. [0]: https://docs.github.com/en/enterprise-server@3.7/admin/all-releases --- pkg/cmd/auth/shared/login_flow.go | 8 ++------ pkg/cmd/auth/shared/login_flow_test.go | 24 ++++++------------------ 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 7c2ff1639..48cccd3b2 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -162,7 +162,7 @@ func Login(opts *LoginOptions) error { fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` Tip: you can generate a Personal Access Token here https://%s/settings/tokens The minimum required scopes are %s. - `, hostname, scopesSentence(minimumScopes, ghinstance.IsEnterprise(hostname)))) + `, hostname, scopesSentence(minimumScopes))) var err error authToken, err = opts.Prompter.AuthToken() @@ -216,14 +216,10 @@ func Login(opts *LoginOptions) error { return nil } -func scopesSentence(scopes []string, isEnterprise bool) string { +func scopesSentence(scopes []string) string { quoted := make([]string, len(scopes)) for i, s := range scopes { quoted[i] = fmt.Sprintf("'%s'", s) - if s == "workflow" && isEnterprise { - // remove when GHE 2.x reaches EOL - quoted[i] += " (GHE 3.0+)" - } } return strings.Join(quoted, ", ") } diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index 92ae75915..15cc3d2d6 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -117,8 +117,7 @@ func TestLogin_ssh(t *testing.T) { func Test_scopesSentence(t *testing.T) { type args struct { - scopes []string - isEnterprise bool + scopes []string } tests := []struct { name string @@ -128,39 +127,28 @@ func Test_scopesSentence(t *testing.T) { { name: "basic scopes", args: args{ - scopes: []string{"repo", "read:org"}, - isEnterprise: false, + scopes: []string{"repo", "read:org"}, }, want: "'repo', 'read:org'", }, { name: "empty", args: args{ - scopes: []string(nil), - isEnterprise: false, + scopes: []string(nil), }, want: "", }, { - name: "workflow scope for dotcom", + name: "workflow scope", args: args{ - scopes: []string{"repo", "workflow"}, - isEnterprise: false, + scopes: []string{"repo", "workflow"}, }, want: "'repo', 'workflow'", }, - { - name: "workflow scope for GHE", - args: args{ - scopes: []string{"repo", "workflow"}, - isEnterprise: true, - }, - want: "'repo', 'workflow' (GHE 3.0+)", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := scopesSentence(tt.args.scopes, tt.args.isEnterprise); got != tt.want { + if got := scopesSentence(tt.args.scopes); got != tt.want { t.Errorf("scopesSentence() = %q, want %q", got, tt.want) } }) From 4a57a812f5db9db72812030f19b214c93b18b297 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 16 Aug 2023 10:37:58 -0700 Subject: [PATCH 065/103] Upgrade to Go 1.21 (#7843) --- .github/CONTRIBUTING.md | 2 +- .github/workflows/deployment.yml | 8 ++++---- .github/workflows/go.yml | 4 ++-- .github/workflows/lint.yml | 6 +++--- docs/source.md | 2 +- go.mod | 2 +- go.sum | 4 ++++ internal/codespaces/api/api_test.go | 6 ++++++ pkg/cmd/label/create.go | 4 ++-- pkg/cmd/repo/credits/credits.go | 12 ++++++------ pkg/cmd/repo/garden/garden.go | 12 ++++++------ 11 files changed, 36 insertions(+), 26 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index eed468389..10f17a2fc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -23,7 +23,7 @@ Please avoid: ## Building the project Prerequisites: -- Go 1.19+ +- Go 1.21+ Build with: * Unix-like systems: `make` diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 3974737aa..b15b23806 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -1,6 +1,6 @@ name: Deployment -concurrency: +concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: true @@ -17,7 +17,7 @@ on: default: production type: environment go_version: - default: "1.19" + default: "1.21" type: string platforms: default: "linux,macos,windows" @@ -61,7 +61,7 @@ jobs: dist/*.tar.gz dist/*.rpm dist/*.deb - + macos: runs-on: macos-latest environment: ${{ inputs.environment }} @@ -118,7 +118,7 @@ jobs: path: | dist/*.tar.gz dist/*.zip - + windows: runs-on: windows-latest environment: ${{ inputs.environment }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1a3a33318..a375e2d8f 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - name: Set up Go 1.19 + - name: Set up Go 1.21 uses: actions/setup-go@v4 with: - go-version: 1.19 + go-version: 1.21 - name: Check out code uses: actions/checkout@v3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dd1fede0d..c8defbf3a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - - name: Set up Go 1.19 + - name: Set up Go 1.21 uses: actions/setup-go@v4 with: - go-version: 1.19 + go-version: 1.21 - name: Check out code uses: actions/checkout@v3 @@ -40,7 +40,7 @@ jobs: go mod verify go mod download - LINT_VERSION=1.50.1 + LINT_VERSION=1.54.1 curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \ tar xz --strip-components 1 --wildcards \*/golangci-lint mkdir -p bin && mv golangci-lint bin/ diff --git a/docs/source.md b/docs/source.md index 9034e9de5..cc1605b8c 100644 --- a/docs/source.md +++ b/docs/source.md @@ -1,6 +1,6 @@ # Installation from source -1. Verify that you have Go 1.19+ installed +1. Verify that you have Go 1.21+ installed ```sh $ go version diff --git a/go.mod b/go.mod index 2602fc496..48632a1e3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cli/cli/v2 -go 1.19 +go 1.21 require ( github.com/AlecAivazis/survey/v2 v2.3.7 diff --git a/go.sum b/go.sum index 47e5159ae..14af85480 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -182,6 +183,7 @@ golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfS golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M= +golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -221,11 +223,13 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w= google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go index 75f82c39e..65852cc79 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -136,6 +136,7 @@ func createHttpClient() (*http.Client, error) { func TestNew_APIURL_dotcomConfig(t *testing.T) { t.Setenv("GITHUB_API_URL", "") + t.Setenv("GITHUB_SERVER_URL", "https://github.com") cfg := &config.ConfigMock{ AuthenticationFunc: func() *config.AuthConfig { return &config.AuthConfig{} @@ -158,6 +159,7 @@ func TestNew_APIURL_dotcomConfig(t *testing.T) { func TestNew_APIURL_customConfig(t *testing.T) { t.Setenv("GITHUB_API_URL", "") + t.Setenv("GITHUB_SERVER_URL", "https://github.mycompany.com") cfg := &config.ConfigMock{ AuthenticationFunc: func() *config.AuthConfig { authCfg := &config.AuthConfig{} @@ -182,6 +184,7 @@ func TestNew_APIURL_customConfig(t *testing.T) { func TestNew_APIURL_env(t *testing.T) { t.Setenv("GITHUB_API_URL", "https://api.mycompany.com") + t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com") cfg := &config.ConfigMock{ AuthenticationFunc: func() *config.AuthConfig { return &config.AuthConfig{} @@ -218,6 +221,7 @@ func TestNew_APIURL_dotcomFallback(t *testing.T) { func TestNew_ServerURL_dotcomConfig(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_API_URL", "https://api.github.com") cfg := &config.ConfigMock{ AuthenticationFunc: func() *config.AuthConfig { return &config.AuthConfig{} @@ -240,6 +244,7 @@ func TestNew_ServerURL_dotcomConfig(t *testing.T) { func TestNew_ServerURL_customConfig(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_API_URL", "https://github.mycompany.com/api/v3") cfg := &config.ConfigMock{ AuthenticationFunc: func() *config.AuthConfig { authCfg := &config.AuthConfig{} @@ -264,6 +269,7 @@ func TestNew_ServerURL_customConfig(t *testing.T) { func TestNew_ServerURL_env(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com") + t.Setenv("GITHUB_API_URL", "https://api.mycompany.com") cfg := &config.ConfigMock{ AuthenticationFunc: func() *config.AuthConfig { return &config.AuthConfig{} diff --git a/pkg/cmd/label/create.go b/pkg/cmd/label/create.go index f9c63f6e6..0a88fbcab 100644 --- a/pkg/cmd/label/create.go +++ b/pkg/cmd/label/create.go @@ -102,8 +102,8 @@ func createRun(opts *createOptions) error { } if opts.Color == "" { - rand.Seed(time.Now().UnixNano()) - opts.Color = randomColors[rand.Intn(len(randomColors)-1)] + r := rand.New(rand.NewSource(time.Now().UnixNano())) + opts.Color = randomColors[r.Intn(len(randomColors)-1)] } opts.IO.StartProgressIndicator() diff --git a/pkg/cmd/repo/credits/credits.go b/pkg/cmd/repo/credits/credits.go index 37390bcb1..4b7b9fb28 100644 --- a/pkg/cmd/repo/credits/credits.go +++ b/pkg/cmd/repo/credits/credits.go @@ -178,7 +178,7 @@ func creditsRun(opts *CreditsOptions) error { return nil } - rand.Seed(time.Now().UnixNano()) + r := rand.New(rand.NewSource(time.Now().UnixNano())) lines := []string{} @@ -199,13 +199,13 @@ func creditsRun(opts *CreditsOptions) error { starLinesLeft := []string{} for x := 0; x < len(lines); x++ { - starLinesLeft = append(starLinesLeft, starLine(margin)) + starLinesLeft = append(starLinesLeft, starLine(r, margin)) } starLinesRight := []string{} for x := 0; x < len(lines); x++ { lineWidth := termWidth - (margin + len(lines[x])) - starLinesRight = append(starLinesRight, starLine(lineWidth)) + starLinesRight = append(starLinesRight, starLine(r, lineWidth)) } loop := true @@ -245,13 +245,13 @@ func creditsRun(opts *CreditsOptions) error { return nil } -func starLine(width int) string { +func starLine(r *rand.Rand, width int) string { line := "" starChance := 0.1 for y := 0; y < width; y++ { - chance := rand.Float64() + chance := r.Float64() if chance <= starChance { - charRoll := rand.Float64() + charRoll := r.Float64() switch { case charRoll < 0.3: line += "." diff --git a/pkg/cmd/repo/garden/garden.go b/pkg/cmd/repo/garden/garden.go index c9c59d420..840d5adca 100644 --- a/pkg/cmd/repo/garden/garden.go +++ b/pkg/cmd/repo/garden/garden.go @@ -169,7 +169,7 @@ func gardenRun(opts *GardenOptions) error { } seed := computeSeed(ghrepo.FullName(toView)) - rand.Seed(seed) + r := rand.New(rand.NewSource(seed)) termWidth, termHeight, err := utils.TerminalSize(out) if err != nil { @@ -198,7 +198,7 @@ func gardenRun(opts *GardenOptions) error { } player := &Player{0, 0, cs.Bold("@"), geo, 0} - garden := plantGarden(commits, geo) + garden := plantGarden(r, commits, geo) if len(garden) < geo.Height { geo.Height = len(garden) } @@ -334,11 +334,11 @@ func isQuit(b []byte) bool { return rune(b[0]) == 'q' || bytes.Equal(b, ctrlC) } -func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell { +func plantGarden(r *rand.Rand, commits []*Commit, geo *Geometry) [][]*Cell { cellIx := 0 grassCell := &Cell{RGB(0, 200, 0, ","), "You're standing on a patch of grass in a field of wildflowers."} garden := [][]*Cell{} - streamIx := rand.Intn(geo.Width - 1) + streamIx := r.Intn(geo.Width - 1) if streamIx == geo.Width/2 { streamIx-- } @@ -363,7 +363,7 @@ func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell { }) tint += 15 streamIx-- - if rand.Float64() < 0.5 { + if r.Float64() < 0.5 { streamIx++ } if streamIx < 0 { @@ -393,7 +393,7 @@ func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell { continue } - chance := rand.Float64() + chance := r.Float64() if chance <= geo.Density { commit := commits[cellIx] garden[y] = append(garden[y], &Cell{ From 719c9579ba835e52b65e0f9d73104462e2db6b94 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 10 Aug 2023 18:03:46 -0700 Subject: [PATCH 066/103] switch to prompter in workflow commands --- pkg/cmd/run/list/list.go | 10 +++- pkg/cmd/workflow/disable/disable.go | 8 ++- pkg/cmd/workflow/disable/disable_test.go | 38 ++++++------ pkg/cmd/workflow/enable/enable.go | 8 ++- pkg/cmd/workflow/enable/enable_test.go | 38 ++++++------ pkg/cmd/workflow/run/run.go | 74 +++++++++++++---------- pkg/cmd/workflow/run/run_test.go | 76 ++++++++++++++---------- pkg/cmd/workflow/shared/shared.go | 23 +++---- pkg/cmd/workflow/view/view.go | 8 ++- 9 files changed, 169 insertions(+), 114 deletions(-) diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index af84b089e..394af5dcb 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -24,6 +24,7 @@ type ListOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) BaseRepo func() (ghrepo.Interface, error) + Prompter iprompter Exporter cmdutil.Exporter @@ -38,10 +39,15 @@ type ListOptions struct { now time.Time } +type iprompter interface { + Select(string, string, []string) (int, error) +} + func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Prompter: f.Prompter, now: time.Now(), } @@ -103,7 +109,9 @@ func listRun(opts *ListOptions) error { opts.IO.StartProgressIndicator() if opts.WorkflowSelector != "" { states := []workflowShared.WorkflowState{workflowShared.Active} - if workflow, err := workflowShared.ResolveWorkflow(opts.IO, client, baseRepo, false, opts.WorkflowSelector, states); err == nil { + if workflow, err := workflowShared.ResolveWorkflow( + opts.Prompter, opts.IO, client, baseRepo, false, opts.WorkflowSelector, + states); err == nil { filters.WorkflowID = workflow.ID filters.WorkflowName = workflow.Name } else { diff --git a/pkg/cmd/workflow/disable/disable.go b/pkg/cmd/workflow/disable/disable.go index ed3155fbd..8b2fb62d3 100644 --- a/pkg/cmd/workflow/disable/disable.go +++ b/pkg/cmd/workflow/disable/disable.go @@ -17,15 +17,21 @@ type DisableOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Prompter iprompter Selector string Prompt bool } +type iprompter interface { + Select(string, string, []string) (int, error) +} + func NewCmdDisable(f *cmdutil.Factory, runF func(*DisableOptions) error) *cobra.Command { opts := &DisableOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -69,7 +75,7 @@ func runDisable(opts *DisableOptions) error { states := []shared.WorkflowState{shared.Active} workflow, err := shared.ResolveWorkflow( - opts.IO, client, repo, opts.Prompt, opts.Selector, states) + opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) if err != nil { var fae shared.FilteredAllError if errors.As(err, &fae) { diff --git a/pkg/cmd/workflow/disable/disable_test.go b/pkg/cmd/workflow/disable/disable_test.go index 4bbf766e7..d8ba3fcd9 100644 --- a/pkg/cmd/workflow/disable/disable_test.go +++ b/pkg/cmd/workflow/disable/disable_test.go @@ -7,11 +7,11 @@ import ( "testing" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -91,14 +91,14 @@ func TestNewCmdDisable(t *testing.T) { func TestDisableRun(t *testing.T) { tests := []struct { - name string - opts *DisableOptions - httpStubs func(*httpmock.Registry) - askStubs func(*prompt.AskStubber) - tty bool - wantOut string - wantErrOut string - wantErr bool + name string + opts *DisableOptions + httpStubs func(*httpmock.Registry) + promptStubs func(*prompter.MockPrompter) + tty bool + wantOut string + wantErrOut string + wantErr bool }{ { name: "tty no arg", @@ -120,8 +120,10 @@ func TestDisableRun(t *testing.T) { httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/789/disable"), httpmock.StatusStringResponse(204, "{}")) }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("Select a workflow").AnswerWith("another workflow (another.yml)") + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"a workflow (flow.yml)", "another workflow (another.yml)"}, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "another workflow (another.yml)") + }) }, wantOut: "✓ Disabled another workflow\n", }, @@ -169,8 +171,10 @@ func TestDisableRun(t *testing.T) { httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/1011/disable"), httpmock.StatusStringResponse(204, "{}")) }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("Which workflow do you mean?").AnswerWith("another workflow (yetanother.yml)") + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Which workflow do you mean?", []string{"another workflow (another.yml)", "another workflow (yetanother.yml)"}, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "another workflow (yetanother.yml)") + }) }, wantOut: "✓ Disabled another workflow\n", }, @@ -269,10 +273,10 @@ func TestDisableRun(t *testing.T) { } t.Run(tt.name, func(t *testing.T) { - //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock - as := prompt.NewAskStubber(t) - if tt.askStubs != nil { - tt.askStubs(as) + pm := prompter.NewMockPrompter(t) + tt.opts.Prompter = pm + if tt.promptStubs != nil { + tt.promptStubs(pm) } err := runDisable(tt.opts) diff --git a/pkg/cmd/workflow/enable/enable.go b/pkg/cmd/workflow/enable/enable.go index 06922a46f..93e8ac007 100644 --- a/pkg/cmd/workflow/enable/enable.go +++ b/pkg/cmd/workflow/enable/enable.go @@ -17,15 +17,21 @@ type EnableOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Prompter iprompter Selector string Prompt bool } +type iprompter interface { + Select(string, string, []string) (int, error) +} + func NewCmdEnable(f *cmdutil.Factory, runF func(*EnableOptions) error) *cobra.Command { opts := &EnableOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -68,7 +74,7 @@ func runEnable(opts *EnableOptions) error { } states := []shared.WorkflowState{shared.DisabledManually, shared.DisabledInactivity} - workflow, err := shared.ResolveWorkflow( + workflow, err := shared.ResolveWorkflow(opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) if err != nil { var fae shared.FilteredAllError diff --git a/pkg/cmd/workflow/enable/enable_test.go b/pkg/cmd/workflow/enable/enable_test.go index b626f794d..905965087 100644 --- a/pkg/cmd/workflow/enable/enable_test.go +++ b/pkg/cmd/workflow/enable/enable_test.go @@ -7,11 +7,11 @@ import ( "testing" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -91,14 +91,14 @@ func TestNewCmdEnable(t *testing.T) { func TestEnableRun(t *testing.T) { tests := []struct { - name string - opts *EnableOptions - httpStubs func(*httpmock.Registry) - askStubs func(*prompt.AskStubber) - tty bool - wantOut string - wantErrOut string - wantErr bool + name string + opts *EnableOptions + httpStubs func(*httpmock.Registry) + promptStubs func(*prompter.MockPrompter) + tty bool + wantOut string + wantErrOut string + wantErr bool }{ { name: "tty no arg", @@ -120,8 +120,10 @@ func TestEnableRun(t *testing.T) { httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/456/enable"), httpmock.StatusStringResponse(204, "{}")) }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("Select a workflow").AnswerWith("a disabled workflow (disabled.yml)") + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"a disabled workflow (disabled.yml)"}, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "a disabled workflow (disabled.yml)") + }) }, wantOut: "✓ Enabled a disabled workflow\n", }, @@ -169,8 +171,10 @@ func TestEnableRun(t *testing.T) { httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/1213/enable"), httpmock.StatusStringResponse(204, "{}")) }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("Which workflow do you mean?").AnswerWith("a disabled workflow (anotherDisabled.yml)") + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Which workflow do you mean?", []string{"a disabled workflow (disabled.yml)", "a disabled workflow (anotherDisabled.yml)"}, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "a disabled workflow (anotherDisabled.yml)") + }) }, wantOut: "✓ Enabled a disabled workflow\n", }, @@ -309,10 +313,10 @@ func TestEnableRun(t *testing.T) { } t.Run(tt.name, func(t *testing.T) { - //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock - as := prompt.NewAskStubber(t) - if tt.askStubs != nil { - tt.askStubs(as) + pm := prompter.NewMockPrompter(t) + tt.opts.Prompter = pm + if tt.promptStubs != nil { + tt.promptStubs(pm) } err := runEnable(tt.opts) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 564b41985..4ba0a2ab4 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -11,14 +11,12 @@ import ( "sort" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -27,6 +25,7 @@ type RunOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Prompter iprompter Selector string Ref string @@ -39,10 +38,16 @@ type RunOptions struct { Prompt bool } +type iprompter interface { + Input(string, string) (string, error) + Select(string, string, []string) (int, error) +} + func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command { opts := &RunOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -198,7 +203,7 @@ func (ia *InputAnswer) WriteAnswer(name string, value interface{}) error { return fmt.Errorf("unexpected value type: %v", value) } -func collectInputs(yamlContent []byte) (map[string]string, error) { +func collectInputs(p iprompter, yamlContent []byte) (map[string]string, error) { inputs, err := findInputs(yamlContent) if err != nil { return nil, err @@ -210,32 +215,24 @@ func collectInputs(yamlContent []byte) (map[string]string, error) { return providedInputs, nil } - qs := []*survey.Question{} - for inputName, input := range inputs { - q := &survey.Question{ - Name: inputName, - Prompt: &survey.Input{ - Message: inputName, - Default: input.Default, - }, - } + for _, input := range inputs { + var answer string if input.Required { - q.Validate = survey.Required + for answer == "" { + answer, err = p.Input(input.Name+" (required)", input.Default) + if err != nil { + break + } + } + } else { + answer, err = p.Input(input.Name, input.Default) } - qs = append(qs, q) - } - sort.Slice(qs, func(i, j int) bool { - return qs[i].Name < qs[j].Name - }) + if err != nil { + return nil, err + } - inputAnswer := InputAnswer{ - providedInputs: providedInputs, - } - //nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter - err = prompt.SurveyAsk(qs, &inputAnswer) - if err != nil { - return nil, err + providedInputs[input.Name] = answer } return providedInputs, nil @@ -263,7 +260,7 @@ func runRun(opts *RunOptions) error { } states := []shared.WorkflowState{shared.Active} - workflow, err := shared.ResolveWorkflow( + workflow, err := shared.ResolveWorkflow(opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) if err != nil { var fae shared.FilteredAllError @@ -290,7 +287,7 @@ func runRun(opts *RunOptions) error { if err != nil { return fmt.Errorf("unable to fetch workflow file content: %w", err) } - providedInputs, err = collectInputs(yamlContent) + providedInputs, err = collectInputs(opts.Prompter, yamlContent) if err != nil { return err } @@ -330,12 +327,13 @@ func runRun(opts *RunOptions) error { } type WorkflowInput struct { + Name string Required bool Default string Description string } -func findInputs(yamlContent []byte) (map[string]WorkflowInput, error) { +func findInputs(yamlContent []byte) ([]WorkflowInput, error) { var rootNode yaml.Node err := yaml.Unmarshal(yamlContent, &rootNode) if err != nil { @@ -400,16 +398,32 @@ func findInputs(yamlContent []byte) (map[string]WorkflowInput, error) { return nil, errors.New("unable to manually run a workflow without a workflow_dispatch event") } - out := map[string]WorkflowInput{} + out := []WorkflowInput{} + + m := map[string]WorkflowInput{} if inputsKeyNode == nil || inputsMapNode == nil { return out, nil } - err = inputsMapNode.Decode(&out) + err = inputsMapNode.Decode(&m) if err != nil { return nil, fmt.Errorf("could not decode workflow inputs: %w", err) } + for name, input := range m { + input.Name = name + out = append(out, WorkflowInput{ + Name: name, + Default: input.Default, + Description: input.Description, + Required: input.Required, + }) + } + + sort.Slice(out, func(i, j int) bool { + return out[i].Name < out[j].Name + }) + return out, nil } diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index c90c9a09e..70fea8aa7 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -12,11 +12,11 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -215,7 +215,7 @@ func Test_findInputs(t *testing.T) { YAML []byte wantErr bool errMsg string - wantOut map[string]WorkflowInput + wantOut []WorkflowInput }{ { name: "blank", @@ -244,12 +244,12 @@ func Test_findInputs(t *testing.T) { { name: "short syntax", YAML: []byte("name: workflow\non: workflow_dispatch"), - wantOut: map[string]WorkflowInput{}, + wantOut: []WorkflowInput{}, }, { name: "array of events", YAML: []byte("name: workflow\non: [pull_request, workflow_dispatch]\n"), - wantOut: map[string]WorkflowInput{}, + wantOut: []WorkflowInput{}, }, { name: "inputs", @@ -274,18 +274,22 @@ jobs: - name: echo run: | echo "echo"`), - wantOut: map[string]WorkflowInput{ - "foo": { + wantOut: []WorkflowInput{ + { + Name: "bar", + Default: "boo", + }, + { + Name: "baz", + Description: "it's baz", + }, + { + Name: "foo", Required: true, Description: "good foo", }, - "bar": { - Default: "boo", - }, - "baz": { - Description: "it's baz", - }, - "quux": { + { + Name: "quux", Required: true, Default: "cool", }, @@ -359,15 +363,15 @@ jobs: } tests := []struct { - name string - opts *RunOptions - tty bool - wantErr bool - errOut string - wantOut string - wantBody map[string]interface{} - httpStubs func(*httpmock.Registry) - askStubs func(*prompt.AskStubber) + name string + opts *RunOptions + tty bool + wantErr bool + errOut string + wantOut string + wantBody map[string]interface{} + httpStubs func(*httpmock.Registry) + promptStubs func(*prompter.MockPrompter) }{ { name: "bad JSON", @@ -577,8 +581,10 @@ jobs: httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/1/dispatches"), httpmock.StatusStringResponse(204, "cool")) }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("Select a workflow").AnswerDefault() + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"minimal workflow (minimal.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{}, @@ -614,10 +620,16 @@ jobs: httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), httpmock.StatusStringResponse(204, "cool")) }, - askStubs: func(as *prompt.AskStubber) { - as.StubPrompt("Select a workflow").AnswerDefault() - as.StubPrompt("greeting").AnswerWith("hi") - as.StubPrompt("name").AnswerWith("scully") + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"a workflow (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterInput("greeting", func(_, _ string) (string, error) { + return "hi", nil + }) + pm.RegisterInput("name (required)", func(_, _ string) (string, error) { + return "scully", nil + }) }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -652,10 +664,10 @@ jobs: } t.Run(tt.name, func(t *testing.T) { - //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock - as := prompt.NewAskStubber(t) - if tt.askStubs != nil { - tt.askStubs(as) + pm := prompter.NewMockPrompter(t) + tt.opts.Prompter = pm + if tt.promptStubs != nil { + tt.promptStubs(pm) } err := runRun(tt.opts) diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go index cfb1ff86e..6d887f927 100644 --- a/pkg/cmd/workflow/shared/shared.go +++ b/pkg/cmd/workflow/shared/shared.go @@ -11,11 +11,9 @@ import ( "strconv" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/go-gh/v2/pkg/asciisanitizer" "golang.org/x/text/transform" ) @@ -26,6 +24,10 @@ const ( DisabledInactivity WorkflowState = "disabled_inactivity" ) +type iprompter interface { + Select(string, string, []string) (int, error) +} + type WorkflowState string type Workflow struct { @@ -90,7 +92,7 @@ type FilteredAllError struct { error } -func SelectWorkflow(workflows []Workflow, promptMsg string, states []WorkflowState) (*Workflow, error) { +func selectWorkflow(p iprompter, workflows []Workflow, promptMsg string, states []WorkflowState) (*Workflow, error) { filtered := []Workflow{} candidates := []string{} for _, workflow := range workflows { @@ -107,14 +109,7 @@ func SelectWorkflow(workflows []Workflow, promptMsg string, states []WorkflowSta return nil, FilteredAllError{errors.New("")} } - var selected int - - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err := prompt.SurveyAskOne(&survey.Select{ - Message: promptMsg, - Options: candidates, - PageSize: 15, - }, &selected) + selected, err := p.Select(promptMsg, "", candidates) if err != nil { return nil, err } @@ -182,7 +177,7 @@ func getWorkflowsByName(client *api.Client, repo ghrepo.Interface, name string, return filtered, nil } -func ResolveWorkflow(io *iostreams.IOStreams, client *api.Client, repo ghrepo.Interface, prompt bool, workflowSelector string, states []WorkflowState) (*Workflow, error) { +func ResolveWorkflow(p iprompter, io *iostreams.IOStreams, client *api.Client, repo ghrepo.Interface, prompt bool, workflowSelector string, states []WorkflowState) (*Workflow, error) { if prompt { workflows, err := GetWorkflows(client, repo, 0) if len(workflows) == 0 { @@ -198,7 +193,7 @@ func ResolveWorkflow(io *iostreams.IOStreams, client *api.Client, repo ghrepo.In return nil, fmt.Errorf("could not fetch workflows for %s: %w", ghrepo.FullName(repo), err) } - return SelectWorkflow(workflows, "Select a workflow", states) + return selectWorkflow(p, workflows, "Select a workflow", states) } workflows, err := FindWorkflow(client, repo, workflowSelector, states) @@ -222,7 +217,7 @@ func ResolveWorkflow(io *iostreams.IOStreams, client *api.Client, repo ghrepo.In return nil, errors.New(errMsg) } - return SelectWorkflow(workflows, "Which workflow do you mean?", states) + return selectWorkflow(p, workflows, "Which workflow do you mean?", states) } func GetWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow Workflow, ref string) ([]byte, error) { diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index f45024828..23b148942 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -26,6 +26,7 @@ type ViewOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser + Prompter iprompter Selector string Ref string @@ -37,11 +38,16 @@ type ViewOptions struct { now time.Time } +type iprompter interface { + Select(string, string, []string) (int, error) +} + func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { opts := &ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Browser: f.Browser, + Prompter: f.Prompter, now: time.Now(), } @@ -104,7 +110,7 @@ func runView(opts *ViewOptions) error { var workflow *shared.Workflow states := []shared.WorkflowState{shared.Active} - workflow, err = shared.ResolveWorkflow(opts.IO, client, repo, opts.Prompt, opts.Selector, states) + workflow, err = shared.ResolveWorkflow(opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) if err != nil { return err } From 2ddfc3827def5430a21ff38a47870ddb6861568d Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 16 Aug 2023 16:02:14 -0500 Subject: [PATCH 067/103] use new prompter in repo fork --- pkg/cmd/repo/fork/fork.go | 17 +++++---- pkg/cmd/repo/fork/fork_test.go | 70 ++++++++++++++++++---------------- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 087d7219e..0d2d1da69 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -19,13 +19,16 @@ import ( "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/cli/cli/v2/pkg/prompt" "github.com/spf13/cobra" "github.com/spf13/pflag" ) const defaultRemoteName = "origin" +type iprompter interface { + Confirm(string, bool) (bool, error) +} + type ForkOptions struct { HttpClient func() (*http.Client, error) GitClient *git.Client @@ -35,6 +38,7 @@ type ForkOptions struct { Remotes func() (ghContext.Remotes, error) Since func(time.Time) time.Duration BackOff backoff.BackOff + Prompter iprompter GitArgs []string Repository string @@ -65,6 +69,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman Config: f.Config, BaseRepo: f.BaseRepo, Remotes: f.Remotes, + Prompter: f.Prompter, Since: time.Since, } @@ -273,10 +278,9 @@ func forkRun(opts *ForkOptions) error { remoteDesired := opts.Remote if opts.PromptRemote { - //nolint:staticcheck // SA1019: prompt.Confirm is deprecated: use Prompter - err = prompt.Confirm("Would you like to add a remote for the fork?", &remoteDesired) + remoteDesired, err = opts.Prompter.Confirm("Would you like to add a remote for the fork?", false) if err != nil { - return fmt.Errorf("failed to prompt: %w", err) + return err } } @@ -317,10 +321,9 @@ func forkRun(opts *ForkOptions) error { } else { cloneDesired := opts.Clone if opts.PromptClone { - //nolint:staticcheck // SA1019: prompt.Confirm is deprecated: use Prompter - err = prompt.Confirm("Would you like to clone the fork?", &cloneDesired) + cloneDesired, err = opts.Prompter.Confirm("Would you like to clone the fork?", false) if err != nil { - return fmt.Errorf("failed to prompt: %w", err) + return err } } if cloneDesired { diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index 5833acb5d..f59fba1b7 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -14,11 +14,11 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -205,18 +205,18 @@ func TestRepoFork(t *testing.T) { } tests := []struct { - name string - opts *ForkOptions - tty bool - httpStubs func(*httpmock.Registry) - execStubs func(*run.CommandStubber) - askStubs func(*prompt.AskStubber) - cfgStubs func(*config.ConfigMock) - remotes []*context.Remote - wantOut string - wantErrOut string - wantErr bool - errMsg string + name string + opts *ForkOptions + tty bool + httpStubs func(*httpmock.Registry) + execStubs func(*run.CommandStubber) + promptStubs func(*prompter.MockPrompter) + cfgStubs func(*config.ConfigMock) + remotes []*context.Remote + wantOut string + wantErrOut string + wantErr bool + errMsg string }{ { name: "implicit match, configured protocol overrides provided", @@ -272,9 +272,10 @@ func TestRepoFork(t *testing.T) { RemoteName: defaultRemoteName, }, httpStubs: forkPost, - askStubs: func(as *prompt.AskStubber) { - //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt - as.StubOne(false) + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterConfirm("Would you like to add a remote for the fork?", func(_ string, _ bool) (bool, error) { + return false, nil + }) }, wantErrOut: "✓ Created fork someone/REPO\n", }, @@ -291,9 +292,10 @@ func TestRepoFork(t *testing.T) { cs.Register("git remote rename origin upstream", 0, "") cs.Register(`git remote add origin https://github.com/someone/REPO.git`, 0, "") }, - askStubs: func(as *prompt.AskStubber) { - //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt - as.StubOne(true) + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterConfirm("Would you like to add a remote for the fork?", func(_ string, _ bool) (bool, error) { + return true, nil + }) }, wantErrOut: "✓ Created fork someone/REPO\n✓ Added remote origin\n", }, @@ -497,9 +499,10 @@ func TestRepoFork(t *testing.T) { PromptClone: true, }, httpStubs: forkPost, - askStubs: func(as *prompt.AskStubber) { - //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt - as.StubOne(false) + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterConfirm("Would you like to clone the fork?", func(_ string, _ bool) (bool, error) { + return false, nil + }) }, wantErrOut: "✓ Created fork someone/REPO\n", }, @@ -511,9 +514,10 @@ func TestRepoFork(t *testing.T) { PromptClone: true, }, httpStubs: forkPost, - askStubs: func(as *prompt.AskStubber) { - //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt - as.StubOne(true) + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterConfirm("Would you like to clone the fork?", func(_ string, _ bool) (bool, error) { + return true, nil + }) }, execStubs: func(cs *run.CommandStubber) { cs.Register(`git clone https://github.com/someone/REPO\.git`, 0, "") @@ -533,9 +537,10 @@ func TestRepoFork(t *testing.T) { }, }, httpStubs: forkPost, - askStubs: func(as *prompt.AskStubber) { - //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt - as.StubOne(true) + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterConfirm("Would you like to clone the fork?", func(_ string, _ bool) (bool, error) { + return true, nil + }) }, execStubs: func(cs *run.CommandStubber) { cs.Register(`git clone https://github.com/someone/REPO\.git`, 0, "") @@ -746,11 +751,10 @@ func TestRepoFork(t *testing.T) { GitPath: "some/path/git", } - //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber - as, teardown := prompt.InitAskStubber() - defer teardown() - if tt.askStubs != nil { - tt.askStubs(as) + pm := prompter.NewMockPrompter(t) + tt.opts.Prompter = pm + if tt.promptStubs != nil { + tt.promptStubs(pm) } cs, restoreRun := run.Stub() From f2e5ad6dcd9a692fe97badd0b3485f1177a7592e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 16 Aug 2023 21:26:37 -0500 Subject: [PATCH 068/103] fix RegisterPassword --- internal/prompter/test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/test.go b/internal/prompter/test.go index c376e9c83..0dea18e5a 100644 --- a/internal/prompter/test.go +++ b/internal/prompter/test.go @@ -258,7 +258,7 @@ func (m *MockPrompter) RegisterInputHostname(stub func() (string, error)) { } func (m *MockPrompter) RegisterPassword(prompt string, stub func(string) (string, error)) { - m.PasswordStubs = append(m.PasswordStubs, PasswordStub{Fn: stub}) + m.PasswordStubs = append(m.PasswordStubs, PasswordStub{Prompt: prompt, Fn: stub}) } func (m *MockPrompter) RegisterConfirmDeletion(prompt string, stub func(string) error) { From fc73c16fe82c925e72b8f017172315456922a9ce Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 16 Aug 2023 21:26:57 -0500 Subject: [PATCH 069/103] use prompter in secret set --- pkg/cmd/secret/set/set.go | 14 +++++++------- pkg/cmd/secret/set/set_test.go | 12 +++++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 86dd35258..95f80f8b1 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -9,7 +9,6 @@ import ( "os" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" @@ -17,7 +16,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/hashicorp/go-multierror" "github.com/joho/godotenv" "github.com/spf13/cobra" @@ -29,6 +27,7 @@ type SetOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) + Prompter iprompter RandomOverride func() io.Reader @@ -44,11 +43,16 @@ type SetOptions struct { Application string } +type iprompter interface { + Password(string) (string, error) +} + func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { opts := &SetOptions{ IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -375,11 +379,7 @@ func getBody(opts *SetOptions) ([]byte, error) { } if opts.IO.CanPrompt() { - var bodyInput string - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err := prompt.SurveyAskOne(&survey.Password{ - Message: "Paste your secret", - }, &bodyInput) + bodyInput, err := opts.Prompter.Password("Paste your secret") if err != nil { return nil, err } diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 592527c5f..376ef2d53 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -12,11 +12,11 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -595,12 +595,14 @@ func Test_getBodyPrompt(t *testing.T) { ios.SetStdinTTY(true) ios.SetStdoutTTY(true) - //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock - as := prompt.NewAskStubber(t) - as.StubPrompt("Paste your secret").AnswerWith("cool secret") + pm := prompter.NewMockPrompter(t) + pm.RegisterPassword("Paste your secret", func(_ string) (string, error) { + return "cool secret", nil + }) body, err := getBody(&SetOptions{ - IO: ios, + IO: ios, + Prompter: pm, }) assert.NoError(t, err) assert.Equal(t, string(body), "cool secret") From 0d3b7db495d8082df3ade134e85ecb1541750efd Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 16 Aug 2023 21:48:30 -0500 Subject: [PATCH 070/103] use prompter in issue delete --- pkg/cmd/issue/delete/delete.go | 25 +++++++----------- pkg/cmd/issue/delete/delete_test.go | 39 ++++++++++++++--------------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/pkg/cmd/issue/delete/delete.go b/pkg/cmd/issue/delete/delete.go index f0c68de42..cee367cb8 100644 --- a/pkg/cmd/issue/delete/delete.go +++ b/pkg/cmd/issue/delete/delete.go @@ -3,16 +3,13 @@ package delete import ( "fmt" "net/http" - "strconv" - "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/issue/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/shurcooL/githubv4" "github.com/spf13/cobra" ) @@ -22,16 +19,22 @@ type DeleteOptions struct { Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Prompter iprompter SelectorArg string Confirmed bool } +type iprompter interface { + ConfirmDeletion(string) error +} + func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { opts := &DeleteOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -79,22 +82,12 @@ func deleteRun(opts *DeleteOptions) error { // When executed in an interactive shell, require confirmation, unless // already provided. Otherwise skip confirmation. if opts.IO.CanPrompt() && !opts.Confirmed { - answer := "" - //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter - err = prompt.SurveyAskOne( - &survey.Input{ - Message: fmt.Sprintf("You're going to delete issue #%d. This action cannot be reversed. To confirm, type the issue number:", issue.Number), - }, - &answer, - ) + cs := opts.IO.ColorScheme() + fmt.Printf("%s Deleted issues cannot be recovered.\n", cs.WarningIcon()) + err := opts.Prompter.ConfirmDeletion(fmt.Sprintf("%d", issue.Number)) if err != nil { return err } - answerInt, err := strconv.Atoi(answer) - if err != nil || answerInt != issue.Number { - fmt.Fprintf(opts.IO.Out, "Issue #%d was not deleted.\n", issue.Number) - return nil - } } if err := apiDelete(httpClient, baseRepo, issue.ID); err != nil { diff --git a/pkg/cmd/issue/delete/delete_test.go b/pkg/cmd/issue/delete/delete_test.go index e3b2613ce..acc52af65 100644 --- a/pkg/cmd/issue/delete/delete_test.go +++ b/pkg/cmd/issue/delete/delete_test.go @@ -2,6 +2,7 @@ package delete import ( "bytes" + "errors" "io" "net/http" "regexp" @@ -9,16 +10,16 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) -func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { +func runCommand(rt http.RoundTripper, pm *prompter.MockPrompter, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) ios.SetStdinTTY(isTTY) @@ -26,6 +27,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err factory := &cmdutil.Factory{ IOStreams: ios, + Prompter: pm, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, @@ -76,11 +78,10 @@ func TestIssueDelete(t *testing.T) { }), ) - //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock - as := prompt.NewAskStubber(t) - as.StubPrompt("You're going to delete issue #13. This action cannot be reversed. To confirm, type the issue number:").AnswerWith("13") + pm := prompter.NewMockPrompter(t) + pm.RegisterConfirmDeletion("13", func(_ string) error { return nil }) - output, err := runCommand(httpRegistry, true, "13") + output, err := runCommand(httpRegistry, pm, true, "13") if err != nil { t.Fatalf("error running command `issue delete`: %v", err) } @@ -112,7 +113,7 @@ func TestIssueDelete_confirm(t *testing.T) { }), ) - output, err := runCommand(httpRegistry, true, "13 --confirm") + output, err := runCommand(httpRegistry, nil, true, "13 --confirm") if err != nil { t.Fatalf("error running command `issue delete`: %v", err) } @@ -137,19 +138,17 @@ func TestIssueDelete_cancel(t *testing.T) { } } }`), ) - //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock - as := prompt.NewAskStubber(t) - as.StubPrompt("You're going to delete issue #13. This action cannot be reversed. To confirm, type the issue number:").AnswerWith("14") + pm := prompter.NewMockPrompter(t) + pm.RegisterConfirmDeletion("13", func(_ string) error { + return errors.New("You entered 14") + }) - output, err := runCommand(httpRegistry, true, "13") - if err != nil { - t.Fatalf("error running command `issue delete`: %v", err) + _, err := runCommand(httpRegistry, pm, true, "13") + if err == nil { + t.Fatalf("expected error") } - - r := regexp.MustCompile(`Issue #13 was not deleted`) - - if !r.MatchString(output.String()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.String()) + if err.Error() != "You entered 14" { + t.Fatalf("got unexpected error '%s'", err) } } @@ -166,7 +165,7 @@ func TestIssueDelete_doesNotExist(t *testing.T) { `), ) - _, err := runCommand(httpRegistry, true, "13") + _, err := runCommand(httpRegistry, nil, true, "13") if err == nil || err.Error() != "GraphQL: Could not resolve to an Issue with the number of 13." { t.Errorf("error running command `issue delete`: %v", err) } @@ -199,7 +198,7 @@ func TestIssueDelete_issuesDisabled(t *testing.T) { }`), ) - _, err := runCommand(httpRegistry, true, "13") + _, err := runCommand(httpRegistry, nil, true, "13") if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { t.Fatalf("got error: %v", err) } From f04e3398ed3a8623e2842b62e3953309fc93f107 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 16 Aug 2023 22:45:06 -0500 Subject: [PATCH 071/103] use prompter in shared editable code --- pkg/cmd/issue/edit/edit.go | 10 +-- pkg/cmd/issue/edit/edit_test.go | 4 +- pkg/cmd/pr/edit/edit.go | 12 ++-- pkg/cmd/pr/shared/editable.go | 109 ++++++++++++-------------------- 4 files changed, 57 insertions(+), 78 deletions(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 2fe4c62b7..53400fb63 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -21,10 +21,11 @@ type EditOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Prompter prShared.EditPrompter DetermineEditor func() (string, error) - FieldsToEditSurvey func(*prShared.Editable) error - EditFieldsSurvey func(*prShared.Editable, string) error + FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error + EditFieldsSurvey func(prShared.EditPrompter, *prShared.Editable, string) error FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error SelectorArgs []string @@ -41,6 +42,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman FieldsToEditSurvey: prShared.FieldsToEditSurvey, EditFieldsSurvey: prShared.EditFieldsSurvey, FetchOptions: prShared.FetchOptions, + Prompter: f.Prompter, } var bodyFile string @@ -152,7 +154,7 @@ func editRun(opts *EditOptions) error { // Prompt the user which fields they'd like to edit. editable := opts.Editable if opts.Interactive { - err = opts.FieldsToEditSurvey(&editable) + err = opts.FieldsToEditSurvey(opts.Prompter, &editable) if err != nil { return err } @@ -222,7 +224,7 @@ func editRun(opts *EditOptions) error { if err != nil { return err } - err = opts.EditFieldsSurvey(&editable, editorCommand) + err = opts.EditFieldsSurvey(opts.Prompter, &editable, editorCommand) if err != nil { return err } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index d73132f35..db0b09d2d 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -511,7 +511,7 @@ func Test_editRun(t *testing.T) { input: &EditOptions{ SelectorArgs: []string{"123"}, Interactive: true, - FieldsToEditSurvey: func(eo *prShared.Editable) error { + FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error { eo.Title.Edited = true eo.Body.Edited = true eo.Assignees.Edited = true @@ -520,7 +520,7 @@ func Test_editRun(t *testing.T) { eo.Milestone.Edited = true return nil }, - EditFieldsSurvey: func(eo *prShared.Editable, _ string) error { + EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error { eo.Title.Value = "new title" eo.Body.Value = "new body" eo.Assignees.Value = []string{"monalisa", "hubot"} diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index bab7a26d4..c606ae6bb 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -24,6 +24,7 @@ type EditOptions struct { Surveyor Surveyor Fetcher EditableOptionsFetcher EditorRetriever EditorRetriever + Prompter shared.EditPrompter SelectorArg string Interactive bool @@ -35,9 +36,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Surveyor: surveyor{}, + Surveyor: surveyor{P: f.Prompter}, Fetcher: fetcher{}, EditorRetriever: editorRetriever{config: f.Config}, + Prompter: f.Prompter, } var bodyFile string @@ -280,14 +282,16 @@ type Surveyor interface { EditFields(*shared.Editable, string) error } -type surveyor struct{} +type surveyor struct { + P shared.EditPrompter +} func (s surveyor) FieldsToEdit(editable *shared.Editable) error { - return shared.FieldsToEditSurvey(editable) + return shared.FieldsToEditSurvey(s.P, editable) } func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error { - return shared.EditFieldsSurvey(editable, editorCmd) + return shared.EditFieldsSurvey(s.P, editable, editorCmd) } type EditableOptionsFetcher interface { diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 0fbe689fc..decd6456c 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -4,12 +4,9 @@ import ( "fmt" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/set" - "github.com/cli/cli/v2/pkg/surveyext" ) type Editable struct { @@ -255,34 +252,42 @@ func (ep *EditableProjects) clone() EditableProjects { } } -func EditFieldsSurvey(editable *Editable, editorCommand string) error { +type EditPrompter interface { + Select(string, string, []string) (int, error) + Input(string, string) (string, error) + MarkdownEditor(string, string, bool) (string, error) + MultiSelect(string, []string, []string) ([]int, error) + Confirm(string, bool) (bool, error) +} + +func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) error { var err error if editable.Title.Edited { - editable.Title.Value, err = titleSurvey(editable.Title.Default) + editable.Title.Value, err = p.Input("Title", editable.Title.Default) if err != nil { return err } } if editable.Body.Edited { - editable.Body.Value, err = bodySurvey(editable.Body.Default, editorCommand) + editable.Body.Value, err = p.MarkdownEditor("Body", editable.Body.Default, false) if err != nil { return err } } if editable.Reviewers.Edited { - editable.Reviewers.Value, err = multiSelectSurvey("Reviewers", editable.Reviewers.Default, editable.Reviewers.Options) - if err != nil { - return err - } + editable.Reviewers.Value, err = multiSelectSurvey( + p, "Reviewers", editable.Reviewers.Default, editable.Reviewers.Options) } if editable.Assignees.Edited { - editable.Assignees.Value, err = multiSelectSurvey("Assignees", editable.Assignees.Default, editable.Assignees.Options) + editable.Assignees.Value, err = multiSelectSurvey( + p, "Assignees", editable.Assignees.Default, editable.Assignees.Options) if err != nil { return err } } if editable.Labels.Edited { - editable.Labels.Add, err = multiSelectSurvey("Labels", editable.Labels.Default, editable.Labels.Options) + editable.Labels.Add, err = multiSelectSurvey( + p, "Labels", editable.Labels.Default, editable.Labels.Options) if err != nil { return err } @@ -300,18 +305,19 @@ func EditFieldsSurvey(editable *Editable, editorCommand string) error { } } if editable.Projects.Edited { - editable.Projects.Value, err = multiSelectSurvey("Projects", editable.Projects.Default, editable.Projects.Options) + editable.Projects.Value, err = multiSelectSurvey( + p, "Projects", editable.Projects.Default, editable.Projects.Options) if err != nil { return err } } if editable.Milestone.Edited { - editable.Milestone.Value, err = milestoneSurvey(editable.Milestone.Default, editable.Milestone.Options) + editable.Milestone.Value, err = milestoneSurvey(p, editable.Milestone.Default, editable.Milestone.Options) if err != nil { return err } } - confirm, err := confirmSurvey() + confirm, err := p.Confirm("Submit?", true) if err != nil { return err } @@ -322,7 +328,7 @@ func EditFieldsSurvey(editable *Editable, editorCommand string) error { return nil } -func FieldsToEditSurvey(editable *Editable) error { +func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { contains := func(s []string, str string) bool { for _, v := range s { if v == str { @@ -337,7 +343,7 @@ func FieldsToEditSurvey(editable *Editable) error { opts = append(opts, "Reviewers") } opts = append(opts, "Assignees", "Labels", "Projects", "Milestone") - results, err := multiSelectSurvey("What would you like to edit?", []string{}, opts) + results, err := multiSelectSurvey(p, "What would you like to edit?", []string{}, opts) if err != nil { return err } @@ -414,67 +420,34 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) return nil } -func titleSurvey(title string) (string, error) { - var result string - q := &survey.Input{ - Message: "Title", - Default: title, - } - err := survey.AskOne(q, &result) - return result, err -} - -func bodySurvey(body, editorCommand string) (string, error) { - var result string - q := &surveyext.GhEditor{ - EditorCommand: editorCommand, - Editor: &survey.Editor{ - Message: "Body", - FileName: "*.md", - Default: body, - HideDefault: true, - AppendDefault: true, - }, - } - err := survey.AskOne(q, &result) - return result, err -} - -func multiSelectSurvey(message string, defaults, options []string) ([]string, error) { +func multiSelectSurvey(p EditPrompter, message string, defaults, options []string) (results []string, err error) { if len(options) == 0 { return nil, nil } - var results []string - q := &survey.MultiSelect{ - Message: message, - Options: options, - Default: defaults, - Filter: prompter.LatinMatchingFilter, + + var selected []int + selected, err = p.MultiSelect(message, defaults, options) + if err != nil { + return } - err := survey.AskOne(q, &results) + + for _, i := range selected { + results = append(results, options[i]) + } + return results, err } -func milestoneSurvey(title string, opts []string) (string, error) { +func milestoneSurvey(p EditPrompter, title string, opts []string) (result string, err error) { if len(opts) == 0 { return "", nil } - var result string - q := &survey.Select{ - Message: "Milestone", - Options: opts, - Default: title, + var selected int + selected, err = p.Select("Milestone", title, opts) + if err != nil { + return } - err := survey.AskOne(q, &result) - return result, err -} -func confirmSurvey() (bool, error) { - var result bool - q := &survey.Confirm{ - Message: "Submit?", - Default: true, - } - err := survey.AskOne(q, &result) - return result, err + result = opts[selected] + return } From a3539d4f24b17ff97dff383e0e9c115d1838d14f Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 14:43:08 -0500 Subject: [PATCH 072/103] use MultiSelect for metadata survey in pr, issue create --- pkg/cmd/issue/create/create.go | 2 +- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/shared/survey.go | 19 +++++++----------- pkg/cmd/pr/shared/survey_test.go | 34 +++++++++++++++++--------------- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index a4db9d3a1..577f08728 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -268,7 +268,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: baseRepo, State: &tb, } - err = prShared.MetadataSurvey(opts.IO, baseRepo, fetcher, &tb) + err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb) if err != nil { return } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 6809528b3..7321d4f38 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -361,7 +361,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: ctx.BaseRepo, State: state, } - err = shared.MetadataSurvey(opts.IO, ctx.BaseRepo, fetcher, state) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state) if err != nil { return } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 4e1d1a421..60a93d812 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -37,6 +37,7 @@ type Prompt interface { Select(string, string, []string) (int, error) MarkdownEditor(string, string, bool) (string, error) Confirm(string, bool) (bool, error) + MultiSelect(string, []string, []string) ([]int, error) } func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) { @@ -142,7 +143,7 @@ type RepoMetadataFetcher interface { RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error) } -func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { +func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -160,18 +161,12 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher } extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone") - //nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter - err := prompt.SurveyAsk([]*survey.Question{ - { - Name: "metadata", - Prompt: &survey.MultiSelect{ - Message: "What would you like to add?", - Options: extraFieldsOptions, - }, - }, - }, state) + selected, err := p.MultiSelect("What would you like to add?", nil, extraFieldsOptions) if err != nil { - return fmt.Errorf("could not prompt: %w", err) + return err + } + for _, i := range selected { + state.Metadata = append(state.Metadata, extraFieldsOptions[i]) } metadataInput := api.RepoMetadataInput{ diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 0bc27e8d6..88ca4d510 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -5,6 +5,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" "github.com/stretchr/testify/assert" @@ -43,17 +44,17 @@ func TestMetadataSurvey_selectAll(t *testing.T) { }, } + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to add?", + []string{}, []string{"Reviewers", "Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { + // []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, + return []int{0, 1, 2, 3, 4}, nil + }) + //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() defer restoreAsk() - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "metadata", - Value: []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, - }, - }) //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ { @@ -80,8 +81,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { state := &IssueMetadataState{ Assignees: []string{"hubot"}, + Type: PRMetadata, } - err := MetadataSurvey(ios, repo, fetcher, state) + err := MetadataSurvey(pm, ios, repo, fetcher, state) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -112,17 +114,17 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { }, } + pm := prompter.NewMockPrompter(t) + + pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1, 2}, nil + + }) + //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() defer restoreAsk() - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "metadata", - Value: []string{"Labels", "Projects"}, - }, - }) //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ { @@ -138,7 +140,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { state := &IssueMetadataState{ Assignees: []string{"hubot"}, } - err := MetadataSurvey(ios, repo, fetcher, state) + err := MetadataSurvey(pm, ios, repo, fetcher, state) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) From f867eff04ddffe261cd4c653d24c2960812ba7d1 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:41:12 -0500 Subject: [PATCH 073/103] prompter for reviewers --- pkg/cmd/pr/shared/survey.go | 35 +++++++++++++++----------------- pkg/cmd/pr/shared/survey_test.go | 14 ++++++------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 60a93d812..374d99f55 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -210,22 +210,28 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface milestones = append(milestones, m.Title) } - var mqs []*survey.Question + values := struct { + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string + }{} + if isChosen("Reviewers") { if len(reviewers) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "reviewers", - Prompt: &survey.MultiSelect{ - Message: "Reviewers", - Options: reviewers, - Default: state.Reviewers, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Reviewers", state.Reviewers, reviewers) + if err != nil { + return err + } + for _, i := range selected { + values.Reviewers = append(values.Reviewers, reviewers[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } + var mqs []*survey.Question if isChosen("Assignees") { if len(assignees) > 0 { mqs = append(mqs, &survey.Question{ @@ -291,15 +297,6 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } - - values := struct { - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestone string - }{} - //nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter err = prompt.SurveyAsk(mqs, &values) if err != nil { diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 88ca4d510..8f19540fc 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -47,9 +47,11 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm := prompter.NewMockPrompter(t) pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Reviewers", "Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { - // []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, return []int{0, 1, 2, 3, 4}, nil }) + pm.RegisterMultiSelect("Reviewers", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -57,10 +59,10 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "reviewers", - Value: []string{"monalisa"}, - }, + //{ + // Name: "reviewers", + // Value: []string{"monalisa"}, + //}, { Name: "assignees", Value: []string{"hubot"}, @@ -115,10 +117,8 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { } pm := prompter.NewMockPrompter(t) - pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { return []int{1, 2}, nil - }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber From 542c154d077d25ec13703045d62576c7adb877c5 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:45:04 -0500 Subject: [PATCH 074/103] prompter for assignees --- pkg/cmd/pr/shared/survey.go | 18 ++++++++---------- pkg/cmd/pr/shared/survey_test.go | 11 +++-------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 374d99f55..86b8f9021 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -231,22 +231,20 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } - var mqs []*survey.Question if isChosen("Assignees") { if len(assignees) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "assignees", - Prompt: &survey.MultiSelect{ - Message: "Assignees", - Options: assignees, - Default: state.Assignees, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Assignees", state.Assignees, assignees) + if err != nil { + return err + } + for _, i := range selected { + values.Assignees = append(values.Assignees, assignees[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no assignable users") } } + var mqs []*survey.Question if isChosen("Labels") { if len(labels) > 0 { mqs = append(mqs, &survey.Question{ diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 8f19540fc..790bbcd2e 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -52,6 +52,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Reviewers", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) + pm.RegisterMultiSelect("Assignees", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -59,14 +62,6 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - //{ - // Name: "reviewers", - // Value: []string{"monalisa"}, - //}, - { - Name: "assignees", - Value: []string{"hubot"}, - }, { Name: "labels", Value: []string{"good first issue"}, From abf49d13225a62c10eac606502d4633223c286b7 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:50:55 -0500 Subject: [PATCH 075/103] use prompter for labels --- pkg/cmd/pr/shared/survey.go | 18 ++++++++---------- pkg/cmd/pr/shared/survey_test.go | 14 ++++++-------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 86b8f9021..48f621619 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -244,22 +244,20 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no assignable users") } } - var mqs []*survey.Question if isChosen("Labels") { if len(labels) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "labels", - Prompt: &survey.MultiSelect{ - Message: "Labels", - Options: labels, - Default: state.Labels, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Labels", state.Labels, labels) + if err != nil { + return err + } + for _, i := range selected { + values.Labels = append(values.Labels, labels[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no labels in the repository") } } + var mqs []*survey.Question if isChosen("Projects") { if len(projects) > 0 { mqs = append(mqs, &survey.Question{ diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 790bbcd2e..91fbed6ee 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -55,6 +55,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Assignees", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { return []int{0}, nil }) + pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -62,10 +65,6 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "labels", - Value: []string{"good first issue"}, - }, { Name: "projects", Value: []string{"The road to 1.0"}, @@ -115,6 +114,9 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { return []int{1, 2}, nil }) + pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -122,10 +124,6 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "labels", - Value: []string{"good first issue"}, - }, { Name: "projects", Value: []string{"The road to 1.0"}, From 6a4dbf9db3b387ad84da1e2a5ab81b223adc9864 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:54:46 -0500 Subject: [PATCH 076/103] prompter for projects --- pkg/cmd/pr/shared/survey.go | 17 +++++++---------- pkg/cmd/pr/shared/survey_test.go | 20 +++++--------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 48f621619..7d56c9caf 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -7,7 +7,6 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" ) @@ -260,15 +259,13 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface var mqs []*survey.Question if isChosen("Projects") { if len(projects) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "projects", - Prompt: &survey.MultiSelect{ - Message: "Projects", - Options: projects, - Default: state.Projects, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Projects", state.Projects, projects) + if err != nil { + return err + } + for _, i := range selected { + values.Projects = append(values.Projects, projects[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no projects to choose from") } diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 91fbed6ee..ffc2bc55b 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -58,6 +58,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -65,10 +68,6 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "projects", - Value: []string{"The road to 1.0"}, - }, { Name: "milestone", Value: "(none)", @@ -117,17 +116,8 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) - - //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber - as, restoreAsk := prompt.InitAskStubber() - defer restoreAsk() - - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "projects", - Value: []string{"The road to 1.0"}, - }, + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil }) state := &IssueMetadataState{ From 4382efdf69ddd858d5a4e3c6eb208e195e2b1232 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:58:38 -0500 Subject: [PATCH 077/103] prompter for milestone --- pkg/cmd/pr/shared/survey.go | 21 +++++---------------- pkg/cmd/pr/shared/survey_test.go | 14 ++------------ 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 7d56c9caf..393b87757 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -4,11 +4,9 @@ import ( "fmt" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" ) type Action int @@ -256,7 +254,6 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no labels in the repository") } } - var mqs []*survey.Question if isChosen("Projects") { if len(projects) > 0 { selected, err := p.MultiSelect("Projects", state.Projects, projects) @@ -278,23 +275,15 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } else { milestoneDefault = milestones[1] } - mqs = append(mqs, &survey.Question{ - Name: "milestone", - Prompt: &survey.Select{ - Message: "Milestone", - Options: milestones, - Default: milestoneDefault, - }, - }) + selected, err := p.Select("Milestone", milestoneDefault, milestones) + if err != nil { + return err + } + values.Milestone = milestones[selected] } else { fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } - //nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter - err = prompt.SurveyAsk(mqs, &values) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } if isChosen("Reviewers") { var logins []string diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index ffc2bc55b..4653ce08b 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -7,7 +7,6 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/stretchr/testify/assert" ) @@ -61,17 +60,8 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) - - //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber - as, restoreAsk := prompt.InitAskStubber() - defer restoreAsk() - - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "milestone", - Value: "(none)", - }, + pm.RegisterSelect("Milestone", []string{"(none)", "1.2 patch release"}, func(_, _ string, _ []string) (int, error) { + return 0, nil }) state := &IssueMetadataState{ From fe3eeb481dcc5fc8681928265ad1aaf281a62dfc Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 17:14:56 -0500 Subject: [PATCH 078/103] linter appeasement --- pkg/cmd/workflow/run/run.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 4ba0a2ab4..009c48182 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -412,7 +412,6 @@ func findInputs(yamlContent []byte) ([]WorkflowInput, error) { } for name, input := range m { - input.Name = name out = append(out, WorkflowInput{ Name: name, Default: input.Default, From bff47273315daf57ea047fb49024e000149b54b2 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 17:17:26 -0500 Subject: [PATCH 079/103] linter appeasement --- pkg/cmd/pr/shared/editable.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index decd6456c..cec3bfe8c 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -277,6 +277,9 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) if editable.Reviewers.Edited { editable.Reviewers.Value, err = multiSelectSurvey( p, "Reviewers", editable.Reviewers.Default, editable.Reviewers.Options) + if err != nil { + return err + } } if editable.Assignees.Edited { editable.Assignees.Value, err = multiSelectSurvey( From 7860198dd7ffdc35e1afe263d65fd659fca71fbb Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 16 Aug 2023 22:45:06 -0500 Subject: [PATCH 080/103] use prompter in shared editable code --- pkg/cmd/issue/edit/edit.go | 10 +-- pkg/cmd/issue/edit/edit_test.go | 4 +- pkg/cmd/pr/edit/edit.go | 12 ++-- pkg/cmd/pr/shared/editable.go | 109 ++++++++++++-------------------- 4 files changed, 57 insertions(+), 78 deletions(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 2fe4c62b7..53400fb63 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -21,10 +21,11 @@ type EditOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Prompter prShared.EditPrompter DetermineEditor func() (string, error) - FieldsToEditSurvey func(*prShared.Editable) error - EditFieldsSurvey func(*prShared.Editable, string) error + FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error + EditFieldsSurvey func(prShared.EditPrompter, *prShared.Editable, string) error FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error SelectorArgs []string @@ -41,6 +42,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman FieldsToEditSurvey: prShared.FieldsToEditSurvey, EditFieldsSurvey: prShared.EditFieldsSurvey, FetchOptions: prShared.FetchOptions, + Prompter: f.Prompter, } var bodyFile string @@ -152,7 +154,7 @@ func editRun(opts *EditOptions) error { // Prompt the user which fields they'd like to edit. editable := opts.Editable if opts.Interactive { - err = opts.FieldsToEditSurvey(&editable) + err = opts.FieldsToEditSurvey(opts.Prompter, &editable) if err != nil { return err } @@ -222,7 +224,7 @@ func editRun(opts *EditOptions) error { if err != nil { return err } - err = opts.EditFieldsSurvey(&editable, editorCommand) + err = opts.EditFieldsSurvey(opts.Prompter, &editable, editorCommand) if err != nil { return err } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index d73132f35..db0b09d2d 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -511,7 +511,7 @@ func Test_editRun(t *testing.T) { input: &EditOptions{ SelectorArgs: []string{"123"}, Interactive: true, - FieldsToEditSurvey: func(eo *prShared.Editable) error { + FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error { eo.Title.Edited = true eo.Body.Edited = true eo.Assignees.Edited = true @@ -520,7 +520,7 @@ func Test_editRun(t *testing.T) { eo.Milestone.Edited = true return nil }, - EditFieldsSurvey: func(eo *prShared.Editable, _ string) error { + EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error { eo.Title.Value = "new title" eo.Body.Value = "new body" eo.Assignees.Value = []string{"monalisa", "hubot"} diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index bab7a26d4..c606ae6bb 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -24,6 +24,7 @@ type EditOptions struct { Surveyor Surveyor Fetcher EditableOptionsFetcher EditorRetriever EditorRetriever + Prompter shared.EditPrompter SelectorArg string Interactive bool @@ -35,9 +36,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Surveyor: surveyor{}, + Surveyor: surveyor{P: f.Prompter}, Fetcher: fetcher{}, EditorRetriever: editorRetriever{config: f.Config}, + Prompter: f.Prompter, } var bodyFile string @@ -280,14 +282,16 @@ type Surveyor interface { EditFields(*shared.Editable, string) error } -type surveyor struct{} +type surveyor struct { + P shared.EditPrompter +} func (s surveyor) FieldsToEdit(editable *shared.Editable) error { - return shared.FieldsToEditSurvey(editable) + return shared.FieldsToEditSurvey(s.P, editable) } func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error { - return shared.EditFieldsSurvey(editable, editorCmd) + return shared.EditFieldsSurvey(s.P, editable, editorCmd) } type EditableOptionsFetcher interface { diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 0fbe689fc..decd6456c 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -4,12 +4,9 @@ import ( "fmt" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/set" - "github.com/cli/cli/v2/pkg/surveyext" ) type Editable struct { @@ -255,34 +252,42 @@ func (ep *EditableProjects) clone() EditableProjects { } } -func EditFieldsSurvey(editable *Editable, editorCommand string) error { +type EditPrompter interface { + Select(string, string, []string) (int, error) + Input(string, string) (string, error) + MarkdownEditor(string, string, bool) (string, error) + MultiSelect(string, []string, []string) ([]int, error) + Confirm(string, bool) (bool, error) +} + +func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) error { var err error if editable.Title.Edited { - editable.Title.Value, err = titleSurvey(editable.Title.Default) + editable.Title.Value, err = p.Input("Title", editable.Title.Default) if err != nil { return err } } if editable.Body.Edited { - editable.Body.Value, err = bodySurvey(editable.Body.Default, editorCommand) + editable.Body.Value, err = p.MarkdownEditor("Body", editable.Body.Default, false) if err != nil { return err } } if editable.Reviewers.Edited { - editable.Reviewers.Value, err = multiSelectSurvey("Reviewers", editable.Reviewers.Default, editable.Reviewers.Options) - if err != nil { - return err - } + editable.Reviewers.Value, err = multiSelectSurvey( + p, "Reviewers", editable.Reviewers.Default, editable.Reviewers.Options) } if editable.Assignees.Edited { - editable.Assignees.Value, err = multiSelectSurvey("Assignees", editable.Assignees.Default, editable.Assignees.Options) + editable.Assignees.Value, err = multiSelectSurvey( + p, "Assignees", editable.Assignees.Default, editable.Assignees.Options) if err != nil { return err } } if editable.Labels.Edited { - editable.Labels.Add, err = multiSelectSurvey("Labels", editable.Labels.Default, editable.Labels.Options) + editable.Labels.Add, err = multiSelectSurvey( + p, "Labels", editable.Labels.Default, editable.Labels.Options) if err != nil { return err } @@ -300,18 +305,19 @@ func EditFieldsSurvey(editable *Editable, editorCommand string) error { } } if editable.Projects.Edited { - editable.Projects.Value, err = multiSelectSurvey("Projects", editable.Projects.Default, editable.Projects.Options) + editable.Projects.Value, err = multiSelectSurvey( + p, "Projects", editable.Projects.Default, editable.Projects.Options) if err != nil { return err } } if editable.Milestone.Edited { - editable.Milestone.Value, err = milestoneSurvey(editable.Milestone.Default, editable.Milestone.Options) + editable.Milestone.Value, err = milestoneSurvey(p, editable.Milestone.Default, editable.Milestone.Options) if err != nil { return err } } - confirm, err := confirmSurvey() + confirm, err := p.Confirm("Submit?", true) if err != nil { return err } @@ -322,7 +328,7 @@ func EditFieldsSurvey(editable *Editable, editorCommand string) error { return nil } -func FieldsToEditSurvey(editable *Editable) error { +func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { contains := func(s []string, str string) bool { for _, v := range s { if v == str { @@ -337,7 +343,7 @@ func FieldsToEditSurvey(editable *Editable) error { opts = append(opts, "Reviewers") } opts = append(opts, "Assignees", "Labels", "Projects", "Milestone") - results, err := multiSelectSurvey("What would you like to edit?", []string{}, opts) + results, err := multiSelectSurvey(p, "What would you like to edit?", []string{}, opts) if err != nil { return err } @@ -414,67 +420,34 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) return nil } -func titleSurvey(title string) (string, error) { - var result string - q := &survey.Input{ - Message: "Title", - Default: title, - } - err := survey.AskOne(q, &result) - return result, err -} - -func bodySurvey(body, editorCommand string) (string, error) { - var result string - q := &surveyext.GhEditor{ - EditorCommand: editorCommand, - Editor: &survey.Editor{ - Message: "Body", - FileName: "*.md", - Default: body, - HideDefault: true, - AppendDefault: true, - }, - } - err := survey.AskOne(q, &result) - return result, err -} - -func multiSelectSurvey(message string, defaults, options []string) ([]string, error) { +func multiSelectSurvey(p EditPrompter, message string, defaults, options []string) (results []string, err error) { if len(options) == 0 { return nil, nil } - var results []string - q := &survey.MultiSelect{ - Message: message, - Options: options, - Default: defaults, - Filter: prompter.LatinMatchingFilter, + + var selected []int + selected, err = p.MultiSelect(message, defaults, options) + if err != nil { + return } - err := survey.AskOne(q, &results) + + for _, i := range selected { + results = append(results, options[i]) + } + return results, err } -func milestoneSurvey(title string, opts []string) (string, error) { +func milestoneSurvey(p EditPrompter, title string, opts []string) (result string, err error) { if len(opts) == 0 { return "", nil } - var result string - q := &survey.Select{ - Message: "Milestone", - Options: opts, - Default: title, + var selected int + selected, err = p.Select("Milestone", title, opts) + if err != nil { + return } - err := survey.AskOne(q, &result) - return result, err -} -func confirmSurvey() (bool, error) { - var result bool - q := &survey.Confirm{ - Message: "Submit?", - Default: true, - } - err := survey.AskOne(q, &result) - return result, err + result = opts[selected] + return } From 896a6f39ad98de9698ba2ad0edb44bd169b55ed3 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 14:43:08 -0500 Subject: [PATCH 081/103] use MultiSelect for metadata survey in pr, issue create --- pkg/cmd/issue/create/create.go | 2 +- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/shared/survey.go | 19 +++++++----------- pkg/cmd/pr/shared/survey_test.go | 34 +++++++++++++++++--------------- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index a4db9d3a1..577f08728 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -268,7 +268,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: baseRepo, State: &tb, } - err = prShared.MetadataSurvey(opts.IO, baseRepo, fetcher, &tb) + err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb) if err != nil { return } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 6809528b3..7321d4f38 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -361,7 +361,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: ctx.BaseRepo, State: state, } - err = shared.MetadataSurvey(opts.IO, ctx.BaseRepo, fetcher, state) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state) if err != nil { return } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 4e1d1a421..60a93d812 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -37,6 +37,7 @@ type Prompt interface { Select(string, string, []string) (int, error) MarkdownEditor(string, string, bool) (string, error) Confirm(string, bool) (bool, error) + MultiSelect(string, []string, []string) ([]int, error) } func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) { @@ -142,7 +143,7 @@ type RepoMetadataFetcher interface { RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error) } -func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { +func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -160,18 +161,12 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher } extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone") - //nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter - err := prompt.SurveyAsk([]*survey.Question{ - { - Name: "metadata", - Prompt: &survey.MultiSelect{ - Message: "What would you like to add?", - Options: extraFieldsOptions, - }, - }, - }, state) + selected, err := p.MultiSelect("What would you like to add?", nil, extraFieldsOptions) if err != nil { - return fmt.Errorf("could not prompt: %w", err) + return err + } + for _, i := range selected { + state.Metadata = append(state.Metadata, extraFieldsOptions[i]) } metadataInput := api.RepoMetadataInput{ diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 0bc27e8d6..88ca4d510 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -5,6 +5,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" "github.com/stretchr/testify/assert" @@ -43,17 +44,17 @@ func TestMetadataSurvey_selectAll(t *testing.T) { }, } + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to add?", + []string{}, []string{"Reviewers", "Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { + // []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, + return []int{0, 1, 2, 3, 4}, nil + }) + //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() defer restoreAsk() - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "metadata", - Value: []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, - }, - }) //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ { @@ -80,8 +81,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { state := &IssueMetadataState{ Assignees: []string{"hubot"}, + Type: PRMetadata, } - err := MetadataSurvey(ios, repo, fetcher, state) + err := MetadataSurvey(pm, ios, repo, fetcher, state) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -112,17 +114,17 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { }, } + pm := prompter.NewMockPrompter(t) + + pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1, 2}, nil + + }) + //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() defer restoreAsk() - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "metadata", - Value: []string{"Labels", "Projects"}, - }, - }) //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ { @@ -138,7 +140,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { state := &IssueMetadataState{ Assignees: []string{"hubot"}, } - err := MetadataSurvey(ios, repo, fetcher, state) + err := MetadataSurvey(pm, ios, repo, fetcher, state) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) From a2758d3c28c220fd6905851d662e69b1e66dff6f Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:41:12 -0500 Subject: [PATCH 082/103] prompter for reviewers --- pkg/cmd/pr/shared/survey.go | 35 +++++++++++++++----------------- pkg/cmd/pr/shared/survey_test.go | 14 ++++++------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 60a93d812..374d99f55 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -210,22 +210,28 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface milestones = append(milestones, m.Title) } - var mqs []*survey.Question + values := struct { + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string + }{} + if isChosen("Reviewers") { if len(reviewers) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "reviewers", - Prompt: &survey.MultiSelect{ - Message: "Reviewers", - Options: reviewers, - Default: state.Reviewers, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Reviewers", state.Reviewers, reviewers) + if err != nil { + return err + } + for _, i := range selected { + values.Reviewers = append(values.Reviewers, reviewers[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } + var mqs []*survey.Question if isChosen("Assignees") { if len(assignees) > 0 { mqs = append(mqs, &survey.Question{ @@ -291,15 +297,6 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } - - values := struct { - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestone string - }{} - //nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter err = prompt.SurveyAsk(mqs, &values) if err != nil { diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 88ca4d510..8f19540fc 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -47,9 +47,11 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm := prompter.NewMockPrompter(t) pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Reviewers", "Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { - // []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, return []int{0, 1, 2, 3, 4}, nil }) + pm.RegisterMultiSelect("Reviewers", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -57,10 +59,10 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "reviewers", - Value: []string{"monalisa"}, - }, + //{ + // Name: "reviewers", + // Value: []string{"monalisa"}, + //}, { Name: "assignees", Value: []string{"hubot"}, @@ -115,10 +117,8 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { } pm := prompter.NewMockPrompter(t) - pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { return []int{1, 2}, nil - }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber From 9b7cc44c7f450b51d7d480622abe3994afa3dad3 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:45:04 -0500 Subject: [PATCH 083/103] prompter for assignees --- pkg/cmd/pr/shared/survey.go | 18 ++++++++---------- pkg/cmd/pr/shared/survey_test.go | 11 +++-------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 374d99f55..86b8f9021 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -231,22 +231,20 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } - var mqs []*survey.Question if isChosen("Assignees") { if len(assignees) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "assignees", - Prompt: &survey.MultiSelect{ - Message: "Assignees", - Options: assignees, - Default: state.Assignees, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Assignees", state.Assignees, assignees) + if err != nil { + return err + } + for _, i := range selected { + values.Assignees = append(values.Assignees, assignees[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no assignable users") } } + var mqs []*survey.Question if isChosen("Labels") { if len(labels) > 0 { mqs = append(mqs, &survey.Question{ diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 8f19540fc..790bbcd2e 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -52,6 +52,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Reviewers", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) + pm.RegisterMultiSelect("Assignees", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -59,14 +62,6 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - //{ - // Name: "reviewers", - // Value: []string{"monalisa"}, - //}, - { - Name: "assignees", - Value: []string{"hubot"}, - }, { Name: "labels", Value: []string{"good first issue"}, From 81cbf5e9b6fcc3ec493e2a1c062ca9265e7aceaa Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:50:55 -0500 Subject: [PATCH 084/103] use prompter for labels --- pkg/cmd/pr/shared/survey.go | 18 ++++++++---------- pkg/cmd/pr/shared/survey_test.go | 14 ++++++-------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 86b8f9021..48f621619 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -244,22 +244,20 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no assignable users") } } - var mqs []*survey.Question if isChosen("Labels") { if len(labels) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "labels", - Prompt: &survey.MultiSelect{ - Message: "Labels", - Options: labels, - Default: state.Labels, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Labels", state.Labels, labels) + if err != nil { + return err + } + for _, i := range selected { + values.Labels = append(values.Labels, labels[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no labels in the repository") } } + var mqs []*survey.Question if isChosen("Projects") { if len(projects) > 0 { mqs = append(mqs, &survey.Question{ diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 790bbcd2e..91fbed6ee 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -55,6 +55,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Assignees", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) { return []int{0}, nil }) + pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -62,10 +65,6 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "labels", - Value: []string{"good first issue"}, - }, { Name: "projects", Value: []string{"The road to 1.0"}, @@ -115,6 +114,9 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) { return []int{1, 2}, nil }) + pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -122,10 +124,6 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "labels", - Value: []string{"good first issue"}, - }, { Name: "projects", Value: []string{"The road to 1.0"}, From 00c25a8d626f983fd663a0175ac2a6b19ecf3e5b Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:54:46 -0500 Subject: [PATCH 085/103] prompter for projects --- pkg/cmd/pr/shared/survey.go | 17 +++++++---------- pkg/cmd/pr/shared/survey_test.go | 20 +++++--------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 48f621619..7d56c9caf 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -7,7 +7,6 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" ) @@ -260,15 +259,13 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface var mqs []*survey.Question if isChosen("Projects") { if len(projects) > 0 { - mqs = append(mqs, &survey.Question{ - Name: "projects", - Prompt: &survey.MultiSelect{ - Message: "Projects", - Options: projects, - Default: state.Projects, - Filter: prompter.LatinMatchingFilter, - }, - }) + selected, err := p.MultiSelect("Projects", state.Projects, projects) + if err != nil { + return err + } + for _, i := range selected { + values.Projects = append(values.Projects, projects[i]) + } } else { fmt.Fprintln(io.ErrOut, "warning: no projects to choose from") } diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 91fbed6ee..ffc2bc55b 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -58,6 +58,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil + }) //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, restoreAsk := prompt.InitAskStubber() @@ -65,10 +68,6 @@ func TestMetadataSurvey_selectAll(t *testing.T) { //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt as.Stub([]*prompt.QuestionStub{ - { - Name: "projects", - Value: []string{"The road to 1.0"}, - }, { Name: "milestone", Value: "(none)", @@ -117,17 +116,8 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) - - //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber - as, restoreAsk := prompt.InitAskStubber() - defer restoreAsk() - - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "projects", - Value: []string{"The road to 1.0"}, - }, + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) { + return []int{1}, nil }) state := &IssueMetadataState{ From 13a4ebf4dbd6b6cbb7698b469b83e1264a41d209 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 15:58:38 -0500 Subject: [PATCH 086/103] prompter for milestone --- pkg/cmd/pr/shared/survey.go | 21 +++++---------------- pkg/cmd/pr/shared/survey_test.go | 14 ++------------ 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 7d56c9caf..393b87757 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -4,11 +4,9 @@ import ( "fmt" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" ) type Action int @@ -256,7 +254,6 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no labels in the repository") } } - var mqs []*survey.Question if isChosen("Projects") { if len(projects) > 0 { selected, err := p.MultiSelect("Projects", state.Projects, projects) @@ -278,23 +275,15 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } else { milestoneDefault = milestones[1] } - mqs = append(mqs, &survey.Question{ - Name: "milestone", - Prompt: &survey.Select{ - Message: "Milestone", - Options: milestones, - Default: milestoneDefault, - }, - }) + selected, err := p.Select("Milestone", milestoneDefault, milestones) + if err != nil { + return err + } + values.Milestone = milestones[selected] } else { fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } - //nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter - err = prompt.SurveyAsk(mqs, &values) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } if isChosen("Reviewers") { var logins []string diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index ffc2bc55b..4653ce08b 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -7,7 +7,6 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/prompt" "github.com/stretchr/testify/assert" ) @@ -61,17 +60,8 @@ func TestMetadataSurvey_selectAll(t *testing.T) { pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) { return []int{1}, nil }) - - //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber - as, restoreAsk := prompt.InitAskStubber() - defer restoreAsk() - - //nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt - as.Stub([]*prompt.QuestionStub{ - { - Name: "milestone", - Value: "(none)", - }, + pm.RegisterSelect("Milestone", []string{"(none)", "1.2 patch release"}, func(_, _ string, _ []string) (int, error) { + return 0, nil }) state := &IssueMetadataState{ From 79f789b2094c4df418b38dff7460f40188d5823e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 16:04:57 -0500 Subject: [PATCH 087/103] delete prompt package --- pkg/prompt/prompt.go | 34 ------- pkg/prompt/stubber.go | 205 ------------------------------------------ 2 files changed, 239 deletions(-) delete mode 100644 pkg/prompt/prompt.go delete mode 100644 pkg/prompt/stubber.go diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go deleted file mode 100644 index 683fad913..000000000 --- a/pkg/prompt/prompt.go +++ /dev/null @@ -1,34 +0,0 @@ -package prompt - -import "github.com/AlecAivazis/survey/v2" - -// Deprecated: use PrompterMock -func StubConfirm(result bool) func() { - orig := Confirm - Confirm = func(_ string, r *bool) error { - *r = result - return nil - } - return func() { - Confirm = orig - } -} - -// Deprecated: use Prompter -var Confirm = func(prompt string, result *bool) error { - p := &survey.Confirm{ - Message: prompt, - Default: true, - } - return SurveyAskOne(p, result) -} - -// Deprecated: use Prompter -var SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - return survey.AskOne(p, response, opts...) -} - -// Deprecated: use Prompter -var SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { - return survey.Ask(qs, response, opts...) -} diff --git a/pkg/prompt/stubber.go b/pkg/prompt/stubber.go deleted file mode 100644 index 8011c5924..000000000 --- a/pkg/prompt/stubber.go +++ /dev/null @@ -1,205 +0,0 @@ -package prompt - -import ( - "fmt" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/AlecAivazis/survey/v2/core" - "github.com/cli/cli/v2/pkg/surveyext" -) - -type AskStubber struct { - stubs []*QuestionStub -} - -type testing interface { - Errorf(format string, args ...interface{}) - Cleanup(func()) -} - -// Deprecated: use PrompterMock -func NewAskStubber(t testing) *AskStubber { - as, teardown := InitAskStubber() - t.Cleanup(func() { - teardown() - for _, s := range as.stubs { - if !s.matched { - t.Errorf("unmatched prompt stub: %+v", s) - } - } - }) - return as -} - -// Deprecated: use NewAskStubber -func InitAskStubber() (*AskStubber, func()) { - origSurveyAsk := SurveyAsk - origSurveyAskOne := SurveyAskOne - as := AskStubber{} - - answerFromStub := func(p survey.Prompt, fieldName string, response interface{}) error { - var message string - var defaultValue interface{} - var options []string - switch pt := p.(type) { - case *survey.Confirm: - message = pt.Message - defaultValue = pt.Default - case *survey.Input: - message = pt.Message - defaultValue = pt.Default - case *survey.Select: - message = pt.Message - options = pt.Options - case *survey.MultiSelect: - message = pt.Message - options = pt.Options - case *survey.Password: - message = pt.Message - case *surveyext.GhEditor: - message = pt.Message - defaultValue = pt.Default - default: - return fmt.Errorf("prompt type %T is not supported by the stubber", pt) - } - - var stub *QuestionStub - for _, s := range as.stubs { - if !s.matched && (s.message == "" && strings.EqualFold(s.Name, fieldName) || s.message == message) { - stub = s - stub.matched = true - break - } - } - if stub == nil { - return fmt.Errorf("no prompt stub for %q", message) - } - - if len(stub.options) > 0 { - if err := compareOptions(stub.options, options); err != nil { - return fmt.Errorf("stubbed options mismatch for %q: %v", message, err) - } - } - - userValue := stub.Value - - if stringValue, ok := stub.Value.(string); ok && len(options) > 0 { - foundIndex := -1 - for i, o := range options { - if o == stringValue { - foundIndex = i - break - } - } - if foundIndex < 0 { - return fmt.Errorf("answer %q not found in options for %q: %v", stringValue, message, options) - } - userValue = core.OptionAnswer{ - Value: stringValue, - Index: foundIndex, - } - } - - if stub.Default { - if defaultIndex, ok := defaultValue.(int); ok && len(options) > 0 { - userValue = core.OptionAnswer{ - Value: options[defaultIndex], - Index: defaultIndex, - } - } else if defaultValue == nil && len(options) > 0 { - userValue = core.OptionAnswer{ - Value: options[0], - Index: 0, - } - } else { - userValue = defaultValue - } - } - - if err := core.WriteAnswer(response, fieldName, userValue); err != nil { - topic := fmt.Sprintf("field %q", fieldName) - if fieldName == "" { - topic = fmt.Sprintf("%q", message) - } - return fmt.Errorf("AskStubber failed writing the answer for %s: %w", topic, err) - } - return nil - } - - SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - return answerFromStub(p, "", response) - } - - SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { - for _, q := range qs { - if err := answerFromStub(q.Prompt, q.Name, response); err != nil { - return err - } - } - return nil - } - - teardown := func() { - SurveyAsk = origSurveyAsk - SurveyAskOne = origSurveyAskOne - } - return &as, teardown -} - -type QuestionStub struct { - Name string - Value interface{} - Default bool - - matched bool - message string - options []string -} - -// AssertOptions asserts the options presented to the user in Selects and MultiSelects. -func (s *QuestionStub) AssertOptions(opts []string) *QuestionStub { - s.options = opts - return s -} - -// AnswerWith defines an answer for the given stub. -func (s *QuestionStub) AnswerWith(v interface{}) *QuestionStub { - s.Value = v - return s -} - -// AnswerDefault marks the current stub to be answered with the default value for the prompt question. -func (s *QuestionStub) AnswerDefault() *QuestionStub { - s.Default = true - return s -} - -// Deprecated: use StubPrompt -func (as *AskStubber) StubOne(value interface{}) { - as.Stub([]*QuestionStub{{Value: value}}) -} - -// Deprecated: use StubPrompt -func (as *AskStubber) Stub(stubbedQuestions []*QuestionStub) { - as.stubs = append(as.stubs, stubbedQuestions...) -} - -// StubPrompt records a stub for an interactive prompt matched by its message. -func (as *AskStubber) StubPrompt(msg string) *QuestionStub { - stub := &QuestionStub{message: msg} - as.stubs = append(as.stubs, stub) - return stub -} - -func compareOptions(expected, got []string) error { - if len(expected) != len(got) { - return fmt.Errorf("expected %v, got %v (length mismatch)", expected, got) - } - for i, v := range expected { - if v != got[i] { - return fmt.Errorf("expected %v, got %v", expected, got) - } - } - return nil -} From 3a5d47b88e3dd34472b8fd74ffcbf82643020ce3 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 17 Aug 2023 17:17:26 -0500 Subject: [PATCH 088/103] linter appeasement --- pkg/cmd/pr/shared/editable.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index decd6456c..cec3bfe8c 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -277,6 +277,9 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) if editable.Reviewers.Edited { editable.Reviewers.Value, err = multiSelectSurvey( p, "Reviewers", editable.Reviewers.Default, editable.Reviewers.Options) + if err != nil { + return err + } } if editable.Assignees.Edited { editable.Assignees.Value, err = multiSelectSurvey( From bb42fa07aa7fd6044a875a82061ec3fe073a4962 Mon Sep 17 00:00:00 2001 From: azarashi Date: Fri, 18 Aug 2023 23:16:06 +0900 Subject: [PATCH 089/103] codespace: Handle HTTP request retry interruption (#7846) * codespace: Handle HTTP request retry interruption * codespace: Make the error message more detailed --- internal/codespaces/api/api.go | 2 +- internal/codespaces/codespaces.go | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index c55b92400..d6b21c304 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -1159,6 +1159,6 @@ func (a *API) withRetry(f func() (*http.Response, error)) (*http.Response, error if resp.StatusCode < 500 { return resp, nil } - return nil, errors.New("retry") + return nil, fmt.Errorf("received response with status code %d", resp.StatusCode) }, backoff.WithMaxRetries(bo, 3)) } diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index 9db12f01e..8834f0e6c 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -35,6 +35,14 @@ type logger interface { Printf(f string, v ...interface{}) } +type TimeoutError struct { + message string +} + +func (e *TimeoutError) Error() string { + return e.message +} + // ConnectToLiveshare waits for a Codespace to become running, // and connects to it using a Live Share session. func ConnectToLiveshare(ctx context.Context, progress progressIndicator, sessionLogger logger, apiClient apiClient, codespace *api.Codespace) (*liveshare.Session, error) { @@ -63,15 +71,15 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session return nil } - return errors.New("codespace not ready yet") + return &TimeoutError{message: "codespace not ready yet"} }, backoff.WithContext(expBackoff, ctx)) if err != nil { - var permErr *backoff.PermanentError - if errors.As(err, &permErr) { - return nil, err + var timeoutErr *TimeoutError + if errors.As(err, &timeoutErr) { + return nil, errors.New("timed out while waiting for the codespace to start") } - return nil, errors.New("timed out while waiting for the codespace to start") + return nil, err } } From fd7f987e581b8e49dcdcdad266db30a29e9fb41d Mon Sep 17 00:00:00 2001 From: Hermann Stanew Date: Thu, 24 Aug 2023 18:30:55 +0200 Subject: [PATCH 090/103] Fix up Nix installation instructions (#7891) --- docs/install_linux.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 0825c3305..82d3c1a77 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -197,10 +197,10 @@ kiss b github-cli && kiss i github-cli ### Nix/NixOS -Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?show=gitAndTools.gh&query=gh&from=0&size=30&sort=relevance&channel=20.03#disabled): +Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?query=gh&sort=relevance&show=gh): ```bash -nix-env -iA nixos.gitAndTools.gh +nix-env -iA nixos.gh ``` ### openSUSE Tumbleweed From 508065b72dff8087d36f039f54cef2708ae4d1d4 Mon Sep 17 00:00:00 2001 From: Jun Nishimura Date: Sat, 26 Aug 2023 01:37:37 +0900 Subject: [PATCH 091/103] Add verbose flag to api cmd (#7826) --- api/http_client.go | 10 ++++- api/http_client_test.go | 83 ++++++++++---------------------------- pkg/cmd/api/api.go | 58 ++++++++++++++++++-------- pkg/cmd/api/api_test.go | 35 ++++++++++++++++ pkg/cmd/factory/default.go | 1 + pkg/cmd/root/root.go | 27 +------------ pkg/cmdutil/factory.go | 23 ++++++----- 7 files changed, 120 insertions(+), 117 deletions(-) diff --git a/api/http_client.go b/api/http_client.go index 53225b139..9f2d59ce3 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -23,6 +23,7 @@ type HTTPClientOptions struct { EnableCache bool Log io.Writer LogColorize bool + LogVerboseHTTP bool SkipAcceptHeaders bool } @@ -35,10 +36,15 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { LogIgnoreEnv: true, } - if debugEnabled, debugValue := utils.IsDebugEnabled(); debugEnabled { + debugEnabled, debugValue := utils.IsDebugEnabled() + if strings.Contains(debugValue, "api") { + opts.LogVerboseHTTP = true + } + + if opts.LogVerboseHTTP || debugEnabled { clientOpts.Log = opts.Log clientOpts.LogColorize = opts.LogColorize - clientOpts.LogVerboseHTTP = strings.Contains(debugValue, "api") + clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP } headers := map[string]string{ diff --git a/api/http_client_test.go b/api/http_client_test.go index 5bfe0aa05..b1e84e582 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/http/httptest" - "os" "regexp" "strings" "testing" @@ -19,16 +18,14 @@ import ( func TestNewHTTPClient(t *testing.T) { type args struct { - config tokenGetter - appVersion string - setAccept bool + config tokenGetter + appVersion string + setAccept bool + logVerboseHTTP bool } tests := []struct { name string args args - envDebug string - setGhDebug bool - envGhDebug string host string wantHeader map[string]string wantStderr string @@ -36,9 +33,10 @@ func TestNewHTTPClient(t *testing.T) { { name: "github.com with Accept header", args: args{ - config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, - appVersion: "v1.2.3", - setAccept: true, + config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + setAccept: true, + logVerboseHTTP: false, }, host: "github.com", wantHeader: map[string]string{ @@ -51,9 +49,10 @@ func TestNewHTTPClient(t *testing.T) { { name: "github.com no Accept header", args: args{ - config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, - appVersion: "v1.2.3", - setAccept: false, + config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + setAccept: false, + logVerboseHTTP: false, }, host: "github.com", wantHeader: map[string]string{ @@ -66,9 +65,10 @@ func TestNewHTTPClient(t *testing.T) { { name: "github.com no authentication token", args: args{ - config: tinyConfig{"example.com:oauth_token": "MYTOKEN"}, - appVersion: "v1.2.3", - setAccept: true, + config: tinyConfig{"example.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + setAccept: true, + logVerboseHTTP: false, }, host: "github.com", wantHeader: map[string]string{ @@ -81,45 +81,12 @@ func TestNewHTTPClient(t *testing.T) { { name: "github.com in verbose mode", args: args{ - config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, - appVersion: "v1.2.3", - setAccept: true, + config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + setAccept: true, + logVerboseHTTP: true, }, - host: "github.com", - envDebug: "api", - setGhDebug: false, - wantHeader: map[string]string{ - "authorization": "token MYTOKEN", - "user-agent": "GitHub CLI v1.2.3", - "accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", - }, - wantStderr: heredoc.Doc(` - * Request at