Skip to content

Commit

Permalink
Multi-version yank with confirmation
Browse files Browse the repository at this point in the history
  • Loading branch information
rykov committed Jun 27, 2024
1 parent c46e49f commit 8885d94
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 86 deletions.
24 changes: 23 additions & 1 deletion api/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (c *Client) Packages(cc context.Context, body *PaginationRequest) (*Package
}

// Versions returns the details of the versions listing for a package
func (c *Client) Versions(cc context.Context, pkg string, body *PaginationRequest) (*VersionsResponse, error) {
func (c *Client) PackageVersions(cc context.Context, pkg string, body *PaginationRequest) (*VersionsResponse, error) {
req := c.newRequest(cc, "GET", "/packages/"+url.PathEscape(pkg)+"/versions?expand=package", true)

if body != nil {
Expand All @@ -36,6 +36,21 @@ func (c *Client) Versions(cc context.Context, pkg string, body *PaginationReques
return &resp, err
}

// Versions returns the details of the versions listing for specified filters
func (c *Client) Versions(cc context.Context, filter url.Values, body *PaginationRequest) (*VersionsResponse, error) {
req := c.newRequest(cc, "GET", "/versions?expand=package&"+filter.Encode(), true)

if body != nil {
c.prepareJSONBody(req, body)
}

resp := VersionsResponse{}
pagination, err := req.doPaginatedJSON(&resp.Versions)
resp.Pagination = pagination

return &resp, err
}

// Version returns the details of a specific version of a package
func (c *Client) Version(cc context.Context, pkg, ver string) (*Version, error) {
path := "/packages/" + url.PathEscape(pkg) + "/versions/" + url.PathEscape(ver)
Expand Down Expand Up @@ -108,3 +123,10 @@ func (v Version) DisplayCreatedBy() string {
}
return "N/A"
}

func (v Version) Kind() string {
if p := v.Package; p != nil && p.Kind != "" {
return p.Kind
}
return "N/A"
}
13 changes: 9 additions & 4 deletions cli/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/briandowns/spinner"
"github.com/gemfury/cli/api"
"github.com/gemfury/cli/internal/ctx"
"github.com/gemfury/cli/pkg/terminal"
"github.com/spf13/cobra"

"context"
Expand Down Expand Up @@ -89,7 +90,7 @@ func listVersions(cmd *cobra.Command, args []string) error {

// Paginate over package listings until no more pages
err = iterateAllPages(cc, func(pageReq *api.PaginationRequest) (*api.PaginationResponse, error) {
resp, err := c.Versions(cc, args[0], pageReq)
resp, err := c.PackageVersions(cc, args[0], pageReq)
if err != nil {
return nil, err
}
Expand All @@ -100,16 +101,20 @@ func listVersions(cmd *cobra.Command, args []string) error {

// Print results
term.Printf("\n*** %s versions ***\n\n", args[0])
termPrintVersions(term, versions)
return err
}

func termPrintVersions(term terminal.Terminal, versions []*api.Version) {
w := tabwriter.NewWriter(term.IOOut(), 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "version\tuploaded_by\tuploaded_at\tfilename\n")
fmt.Fprintf(w, "version\tuploaded_by\tuploaded_at\tkind\tfilename\n")

for _, v := range versions {
uploadedAt := timeStringWithAgo(v.CreatedAt)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", v.Version, v.DisplayCreatedBy(), uploadedAt, v.Filename)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", v.Version, v.DisplayCreatedBy(), uploadedAt, v.Kind(), v.Filename)
}

w.Flush()
return err
}

func iterateAllPages(cc context.Context, fn func(req *api.PaginationRequest) (*api.PaginationResponse, error)) error {
Expand Down
14 changes: 12 additions & 2 deletions cli/packages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,23 @@ var versionsResponses = []string{`[{
"id": "ver_a1b2c3",
"version": "1.2.3",
"created_at": "2011-05-27T00:39:07+00:00",
"filename": "foo-1.2.3.tgz",
"created_by": {
"name": "user1"
},
"package": {
"id": "pkg_x9y8z7",
"kind": "js"
}
}]`, `[{
"id": "ver_z1y2x3",
"version": "3.2.1",
"created_at": "2011-01-27T00:44:00+00:00"
"created_at": "2011-01-27T00:44:00+00:00",
"filename": "foo-3.2.1.tgz",
"package": {
"id": "pkg_x9y8z7",
"kind": "js"
}
}]`}

func TestVersionsCommandSuccess(t *testing.T) {
Expand All @@ -95,7 +105,7 @@ func TestVersionsCommandSuccess(t *testing.T) {
t.Fatal(err)
}

exp := "1.2.3 user1 2011-05-26 17:39 3.2.1 N/A 2011-01-26 16:44"
exp := "1.2.3 user1 2011-05-26 17:39 js foo-1.2.3.tgz 3.2.1 N/A 2011-01-26 16:44 js foo-3.2.1.tgz"
if outStr := compactString(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
t.Errorf("Expected output to include %q, got %q", exp, outStr)
}
Expand Down
65 changes: 62 additions & 3 deletions cli/yank.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package cli

import (
"github.com/gemfury/cli/api"
"github.com/gemfury/cli/internal/ctx"
"github.com/hashicorp/go-multierror"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"

"context"
"errors"
"fmt"
"net/url"
"strings"
)

// NewCmdYank generates the Cobra command for "yank"
func NewCmdYank() *cobra.Command {
var versionFlag string
var forceFlag bool

yankCmd := &cobra.Command{
Use: "yank PACKAGE@VERSION",
Expand All @@ -32,6 +38,7 @@ func NewCmdYank() *cobra.Command {
return err
}

versions := make([]*api.Version, 0, len(args))
var multiErr *multierror.Error
for _, pkg := range args {
var ver string = ""
Expand All @@ -48,13 +55,39 @@ func NewCmdYank() *cobra.Command {
continue
}

err = c.Yank(cc, pkg, ver)
pkgVersions, err := filterVersions(cc, c, pkg, ver)
versions = append(versions, pkgVersions...)
multiErr = multierror.Append(multiErr, err)
}

if err := multiErr.Unwrap(); err != nil {
return err
} else if len(versions) == 0 {
term.Printf("No matching versions found\n")
return nil
}

if !forceFlag {
termPrintVersions(term, versions)
prompt := promptui.Prompt{
Label: "Are you sure you want to delete these files? [y/N]",
IsConfirm: true,
}
_, err := term.RunPrompt(&prompt)
if errors.Is(err, promptui.ErrAbort) {
return nil
} else if err != nil {
return err
}
}

for _, v := range versions {
err = c.Yank(cc, v.Package.ID, v.ID)
if err != nil {
multiErr = multierror.Append(multiErr, err)
continue
}

term.Printf("Removed package %q version %q\n", pkg, ver)
term.Printf("Removed %q\n", v.Filename)
}

return multiErr.Unwrap()
Expand All @@ -63,6 +96,32 @@ func NewCmdYank() *cobra.Command {

// Flags and options
yankCmd.Flags().StringVarP(&versionFlag, "version", "v", "", "Version")
yankCmd.Flags().BoolVar(&forceFlag, "force", false, "Skip confirmation")

return yankCmd
}

func filterVersions(cc context.Context, c *api.Client, pkg, ver string) ([]*api.Version, error) {
versions := []*api.Version{}

// Default search filters for listed versions
filter := url.Values(map[string][]string{"name": {pkg}, "version": {ver}})

// Extract "kind:" from package name, if present
if at := strings.Index(pkg, ":"); at > 0 {
filter["name"] = []string{pkg[at+1:]}
filter["kind"] = []string{pkg[0:at]}
}

// Paginate over package listings until no more pages
err := iterateAllPages(cc, func(pageReq *api.PaginationRequest) (*api.PaginationResponse, error) {
resp, err := c.Versions(cc, filter, pageReq)
if err != nil {
return nil, err
}
versions = append(versions, resp.Versions...)
return resp.Pagination, nil
})

return versions, err
}
92 changes: 67 additions & 25 deletions cli/yank_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,56 @@ func TestYankCommandOnePackage(t *testing.T) {
term := terminal.NewForTest()

// Fire up test server
path := "/packages/foo/versions/0.0.1"
server := testutil.APIServer(t, "DELETE", path, "{}", 200)
server := testutil.APIServerCustom(t, func(mux *http.ServeMux) {
mux.HandleFunc("/versions", func(w http.ResponseWriter, r *http.Request) {
if q := r.URL.Query(); q.Get("name") != "foo" || q.Get("version") != "0.0.1" {
t.Errorf("Invalid request: %s %s", r.Method, r.URL.Path)
} else if k := q.Get("kind"); k == "js" {
w.Write([]byte(versionsResponses[0])) // One page
} else if method := r.Method; method != "GET" {
t.Errorf("Invalid method: %s %s", method, r.URL.Path)
}
testutil.APIPaginatedResponse(t, w, r, versionsResponses, 200)
})
mux.HandleFunc("/packages/{pid}/versions/{vid}", func(w http.ResponseWriter, r *http.Request) {
if method := r.Method; method != "DELETE" {
t.Errorf("Invalid request: %s %s", method, r.URL.Path)
w.WriteHeader(500)
}
w.Write([]byte("{}"))
})
})
defer server.Close()

cc := cli.TestContext(term, auth)
flags := ctx.GlobalFlags(cc)
flags.Endpoint = server.URL

// Removing using version flag
err := runCommandNoErr(cc, []string{"yank", "foo", "-v", "0.0.1"})
err := runCommandNoErr(cc, []string{"yank", "foo", "-v", "0.0.1", "--force"})
if err != nil {
t.Fatal(err)
}

exp := "Removed package \"foo\" version \"0.0.1\"\n"
exp := "Removed \"foo-1.2.3.tgz\"\nRemoved \"foo-3.2.1.tgz\"\n"
if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
t.Errorf("Expected output to include %q, got %q", exp, outStr)
}

// Removing using PACKAGE@VERSION
err = runCommandNoErr(cc, []string{"yank", "foo@0.0.1"})
err = runCommandNoErr(cc, []string{"yank", "foo@0.0.1", "--force"})
if err != nil {
t.Fatal(err)
} else if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
t.Errorf("Expected output to include %q, got %q", exp, outStr)
}

exp = "Removed package \"foo\" version \"0.0.1\"\n"
if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
// Removing using KIND:PACKAGE@VERSION
exp = "Removed \"foo-1.2.3.tgz\"\n" // JS kind returns one Version
err = runCommandNoErr(cc, []string{"yank", "js:foo@0.0.1", "--force"})
if err != nil {
t.Fatal(err)
} else if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
t.Errorf("Expected output to include %q, got %q", exp, outStr)
}

Expand All @@ -61,28 +84,35 @@ func TestYankCommandMultiPackage(t *testing.T) {

// Fire up test server
server := testutil.APIServerCustom(t, func(mux *http.ServeMux) {
mux.HandleFunc("/packages/foo/versions/0.0.1", func(w http.ResponseWriter, r *http.Request) {
if method := r.Method; method != "DELETE" {
t.Errorf("Invalid request: %s %s", method, r.URL.Path)
mux.HandleFunc("/versions", func(w http.ResponseWriter, r *http.Request) {
if q := r.URL.Query(); q.Get("name") != "foo" {
t.Errorf("Invalid name: %s %s", r.Method, r.URL.Path)
} else if v := q.Get("version"); v == "0.0.2" {
w.Write([]byte("[]")) // Nothing found
} else if v != "0.0.1" {
t.Errorf("Invalid version: %s %s", r.Method, r.URL.Path)
} else if method := r.Method; method != "GET" {
t.Errorf("Invalid method: %s %s", method, r.URL.Path)
} else {
testutil.APIPaginatedResponse(t, w, r, versionsResponses, 200)
}
w.Write([]byte("{}"))
})
mux.HandleFunc("/packages/foo/versions/0.0.2", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/packages/{pid}/versions/{vid}", func(w http.ResponseWriter, r *http.Request) {
if method := r.Method; method != "DELETE" {
t.Errorf("Invalid request: %s %s", method, r.URL.Path)
w.WriteHeader(500)
}
http.NotFound(w, r)
w.Write([]byte("{}"))
})
})

defer server.Close()

cc := cli.TestContext(term, auth)
flags := ctx.GlobalFlags(cc)
flags.Endpoint = server.URL

// Expected successful output
exp := "Removed package \"foo\" version \"0.0.1\"\n"
exp := "Removed \"foo-1.2.3.tgz\"\nRemoved \"foo-3.2.1.tgz\"\n"

// Failure for multiple packages without version
err := runCommandNoErr(cc, []string{"yank", "foo", "bar"})
Expand All @@ -96,32 +126,44 @@ func TestYankCommandMultiPackage(t *testing.T) {
t.Errorf("Expected invalid error, got %q", err)
}

// Partial failure for multiple packages
err = runCommandNoErr(cc, []string{"yank", "foo@0.0.1", "foo@0.0.2"})
if err == nil || !strings.Contains(err.Error(), "Doesn't look like this exists") {
t.Errorf("Expected invalid error, got %q", err)
// When nothing is found, we expect "nothing found" error message
expNone := "No matching versions found\n"
err = runCommandNoErr(cc, []string{"yank", "foo@0.0.2", "--force"})
if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, expNone) {
t.Errorf("Expected output to include %q, got %q", expNone, outStr)
}
if outStr := string(term.OutBytes()); !strings.Contains(outStr, exp) {

// No partial failure for multiple packages when some return nothing
err = runCommandNoErr(cc, []string{"yank", "foo@0.0.1", "foo@0.0.2", "--force"})
if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
t.Errorf("Expected output to include %q, got %q", exp, outStr)
}

// Success all around (reusing the same test package URL)
err = runCommandNoErr(cc, []string{"yank", "foo@0.0.1", "foo@0.0.1"})
err = runCommandNoErr(cc, []string{"yank", "foo@0.0.1", "foo@0.0.1", "--force"})
if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
t.Errorf("Expected output to include %q, got %q", exp, outStr)
}

// Success all around with confirmation prompt
term.SetPromptResponses(map[string]string{
"Are you sure you want to delete these files? [y/N]": "Y",
})

err = runCommandNoErr(cc, []string{"yank", "foo@0.0.1"})
if outStr := string(term.OutBytes()); !strings.HasSuffix(outStr, exp) {
t.Errorf("Expected output to include %q, got %q", exp, outStr)
}
}

func TestYankCommandUnauthorized(t *testing.T) {
path := "/packages/foo/versions/0.0.1"
server := testutil.APIServer(t, "DELETE", path, "{}", 200)
server := testutil.APIServer(t, "GET", "/versions", "[]", 200)
testCommandLoginPreCheck(t, []string{"yank", "foo", "-v", "0.0.1"}, server)
server.Close()
}

func TestYankCommandForbidden(t *testing.T) {
path := "/packages/foo/versions/0.0.1"
server := testutil.APIServer(t, "DELETE", path, "", 403)
server := testutil.APIServer(t, "GET", "/versions", "", 403)
testCommandForbiddenResponse(t, []string{"yank", "foo", "-v", "0.0.1"}, server)
server.Close()
}
Loading

0 comments on commit 8885d94

Please sign in to comment.