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"))