Repository: cli/cli (go)

Issue:
Add additional exit codes for `gh cache delete --all`
### Describe the feature or problem you’d like to solve

I use `gh cache delete --all` to purge all cache related to a workflow that stores ephemeral items in cache.

* When cache items are present and they are successfully removed, the command returns an exit code of 0.
* When no cache items are present, the command returns an exit code of 1.

Unless I parse standard error to read the message for the first case, I can't disambiguate it between an invocation that did not find any cache to remove, and one that did but failed to successfully remove it.

### Proposed solution

I propose a unique exit code for the specific case where no cache items were found. This would allow callers to treat this use case as a success and disambiguate it from any genuine errors.

This would simplify its usage for this use case significantly.

### Additional context

According to the [manual](https://cli.github.com/manual/gh_help_exit-codes), it appears this would not fall outside of the cli's existing behavior.


Pull request description:
[gh cache delete --all] Add `--succeed-on-no-caches` flag to return exit code 0
Fixes #9897

Patch:
diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go
index 65a9d696a..ab76368ad 100644
--- a/pkg/cmd/cache/delete/delete.go
+++ b/pkg/cmd/cache/delete/delete.go
@@ -22,8 +22,9 @@ type DeleteOptions struct {
 	HttpClient func() (*http.Client, error)
 	IO         *iostreams.IOStreams
 
-	DeleteAll  bool
-	Identifier string
+	DeleteAll         bool
+	SucceedOnNoCaches bool
+	Identifier        string
 }
 
 func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
@@ -33,7 +34,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 	}
 
 	cmd := &cobra.Command{
-		Use:   "delete [<cache-id>| <cache-key> | --all]",
+		Use:   "delete [<cache-id> | <cache-key> | --all]",
 		Short: "Delete GitHub Actions caches",
 		Long: heredoc.Docf(`
 			Delete GitHub Actions caches.
@@ -50,8 +51,11 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 			# Delete a cache by id in a specific repo
 			$ gh cache delete 1234 --repo cli/cli
 
-			# Delete all caches
+			# Delete all caches (exit code 1 on no caches)
 			$ gh cache delete --all
+
+			# Delete all caches (exit code 0 on no caches)
+			$ gh cache delete --all --succeed-on-no-caches
 		`),
 		Args: cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
@@ -65,6 +69,10 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 				return err
 			}
 
+			if !opts.DeleteAll && opts.SucceedOnNoCaches {
+				return cmdutil.FlagErrorf("--succeed-on-no-caches must be used in conjunction with --all")
+			}
+
 			if !opts.DeleteAll && len(args) == 0 {
 				return cmdutil.FlagErrorf("must provide either cache id, cache key, or use --all")
 			}
@@ -82,6 +90,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 	}
 
 	cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches")
+	cmd.Flags().BoolVar(&opts.SucceedOnNoCaches, "succeed-on-no-caches", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`")
 
 	return cmd
 }
@@ -100,12 +109,21 @@ func deleteRun(opts *DeleteOptions) error {
 
 	var toDelete []string
 	if opts.DeleteAll {
+		opts.IO.StartProgressIndicator()
 		caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1})
+		opts.IO.StopProgressIndicator()
 		if err != nil {
 			return err
 		}
 		if len(caches.ActionsCaches) == 0 {
-			return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon())
+			if opts.SucceedOnNoCaches {
+				if opts.IO.IsStdoutTTY() {
+					fmt.Fprintf(opts.IO.Out, "%s No caches to delete\n", opts.IO.ColorScheme().SuccessIcon())
+				}
+				return nil
+			} else {
+				return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon())
+			}
 		}
 		for _, cache := range caches.ActionsCaches {
 			toDelete = append(toDelete, strconv.Itoa(cache.Id))


Test patch:
diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go
index 43fc7d213..8660d693b 100644
--- a/pkg/cmd/cache/delete/delete_test.go
+++ b/pkg/cmd/cache/delete/delete_test.go
@@ -43,6 +43,21 @@ func TestNewCmdDelete(t *testing.T) {
 			cli:   "--all",
 			wants: DeleteOptions{DeleteAll: true},
 		},
+		{
+			name:  "delete all and succeed-on-no-caches flags",
+			cli:   "--all --succeed-on-no-caches",
+			wants: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true},
+		},
+		{
+			name:     "succeed-on-no-caches flag",
+			cli:      "--succeed-on-no-caches",
+			wantsErr: "--succeed-on-no-caches must be used in conjunction with --all",
+		},
+		{
+			name:     "succeed-on-no-caches flag and id argument",
+			cli:      "--succeed-on-no-caches 123",
+			wantsErr: "--succeed-on-no-caches must be used in conjunction with --all",
+		},
 		{
 			name:     "id argument and delete all flag",
 			cli:      "1 --all",
@@ -72,6 +87,7 @@ func TestNewCmdDelete(t *testing.T) {
 			}
 			assert.NoError(t, err)
 			assert.Equal(t, tt.wants.DeleteAll, gotOpts.DeleteAll)
+			assert.Equal(t, tt.wants.SucceedOnNoCaches, gotOpts.SucceedOnNoCaches)
 			assert.Equal(t, tt.wants.Identifier, gotOpts.Identifier)
 		})
 	}
@@ -160,6 +176,19 @@ func TestDeleteRun(t *testing.T) {
 			tty:        true,
 			wantStdout: "✓ Deleted 2 caches from OWNER/REPO\n",
 		},
+		{
+			name: "attempts to delete all caches but api errors",
+			opts: DeleteOptions{DeleteAll: true},
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
+					httpmock.StatusStringResponse(500, ""),
+				)
+			},
+			tty:        true,
+			wantErr:    true,
+			wantErrMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO/actions/caches?per_page=100)",
+		},
 		{
 			name: "displays delete error",
 			opts: DeleteOptions{Identifier: "123"},
@@ -186,6 +215,54 @@ func TestDeleteRun(t *testing.T) {
 			tty:        true,
 			wantStdout: "✓ Deleted 1 cache from OWNER/REPO\n",
 		},
+		{
+			name: "no caches to delete when deleting all",
+			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{},
+						TotalCount:    0,
+					}),
+				)
+			},
+			tty:        false,
+			wantErr:    true,
+			wantErrMsg: "X No caches to delete",
+		},
+		{
+			name: "no caches to delete when deleting all but succeed on no cache tty",
+			opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true},
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
+					httpmock.JSONResponse(shared.CachePayload{
+						ActionsCaches: []shared.Cache{},
+						TotalCount:    0,
+					}),
+				)
+			},
+			tty:        true,
+			wantErr:    false,
+			wantStdout: "✓ No caches to delete\n",
+		},
+		{
+			name: "no caches to delete when deleting all but succeed on no cache non-tty",
+			opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true},
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
+					httpmock.JSONResponse(shared.CachePayload{
+						ActionsCaches: []shared.Cache{},
+						TotalCount:    0,
+					}),
+				)
+			},
+			tty:        false,
+			wantErr:    false,
+			wantStdout: "",
+		},
 	}
 
 	for _, tt := range tests {