create cache commands

This commit is contained in:
Josh Kraft 2023-04-28 15:02:49 -06:00
parent 50e16890f8
commit 213a59b8bd
8 changed files with 980 additions and 0 deletions

29
pkg/cmd/cache/cache.go vendored Normal file
View file

@ -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 <command>",
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
}

155
pkg/cmd/cache/delete/delete.go vendored Normal file
View file

@ -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 [<cache-id>| <cache--key> | --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
}

209
pkg/cmd/cache/delete/delete_test.go vendored Normal file
View file

@ -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())
})
}
}

148
pkg/cmd/cache/list/list.go vendored Normal file
View file

@ -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]
}

296
pkg/cmd/cache/list/list_test.go vendored Normal file
View file

@ -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)
}
})
}
}

73
pkg/cmd/cache/shared/shared.go vendored Normal file
View file

@ -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
}

68
pkg/cmd/cache/shared/shared_test.go vendored Normal file
View file

@ -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))
})
}
}

View file

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