Repository: cli/cli
Pull request #9883 (2024-11-06 12:01:38)

Print empty array for `gh cache list` when `--json` is provided
## Description

Fixes https://github.com/cli/cli/issues/9882

### Acceptance

**Given** I have no actions cache entries
**When** I run `gh cache list --json ...`
**Then** I get an empty array

This should be true for both interactive and non-interactive invocations.

#### Before

```
➜ GH_FORCE_TTY=true gh cache list --repo williammartin-test-org/test-repo --json id | cat
No caches found in williammartin-test-org/test-repo
```

```
➜ gh cache list --repo williammartin-test-org/test-repo --json id | cat
```

#### After

```
➜ GH_FORCE_TTY=true ./bin/gh cache list --repo williammartin-test-org/test-repo --json id | cat
[]
```

```
➜ ./bin/gh cache list --repo williammartin-test-org/test-repo --json id | cat
[]
```



Patch:
diff --git a/pkg/cmd/cache/list/list.go b/pkg/cmd/cache/list/list.go
index f5aa8fd5a..902285df6 100644
--- a/pkg/cmd/cache/list/list.go
+++ b/pkg/cmd/cache/list/list.go
@@ -106,7 +106,7 @@ func listRun(opts *ListOptions) error {
 		return fmt.Errorf("%s Failed to get caches: %w", opts.IO.ColorScheme().FailureIcon(), err)
 	}
 
-	if len(result.ActionsCaches) == 0 {
+	if len(result.ActionsCaches) == 0 && opts.Exporter == nil {
 		return cmdutil.NewNoResultsError(fmt.Sprintf("No caches found in %s", ghrepo.FullName(repo)))
 	}
 


Test patch:
diff --git a/acceptance/testdata/workflow/cache-list-empty.txtar b/acceptance/testdata/workflow/cache-list-empty.txtar
new file mode 100644
index 000000000..0e6d32cb7
--- /dev/null
+++ b/acceptance/testdata/workflow/cache-list-empty.txtar
@@ -0,0 +1,36 @@
+# It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default
+skip
+
+# Set up env vars
+env REPO=${ORG}/${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Create a repository with a file so it has a default branch
+exec gh repo create ${REPO} --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes ${REPO}
+
+# Set the repo to be targeted by all following commands
+env GH_REPO=${REPO}
+
+# Listing the cache non-interactively shows nothing
+exec gh cache list
+! stdout '.'
+
+# Listing the cache non-interactively with --json shows an empty array
+exec gh cache list --json id
+stdout '\[\]'
+
+# Now set an env var so the commands run interactively and without colour for stdout matching
+# Unfortunately testscript provides no way to turn them off again, and since this
+# script is for discovery, we're not adding a new command.
+env GH_FORCE_TTY=true
+env CLICOLOR=0
+
+# Listing the cache interactively shows an informative message on stderr
+exec gh cache list
+stderr 'No caches found in'
+
+# Listing the cache interactively with --json shows an empty array
+exec gh cache list --json id
+stdout '\[\]'
diff --git a/pkg/cmd/cache/list/list_test.go b/pkg/cmd/cache/list/list_test.go
index 24d835bca..d4810cbcc 100644
--- a/pkg/cmd/cache/list/list_test.go
+++ b/pkg/cmd/cache/list/list_test.go
@@ -2,6 +2,7 @@ package list
 
 import (
 	"bytes"
+	"fmt"
 	"net/http"
 	"testing"
 	"time"
@@ -243,7 +244,8 @@ ID  KEY  SIZE      CREATED            ACCESSED
 			wantErrMsg: "No caches found in OWNER/REPO",
 		},
 		{
-			name: "displays no results",
+			name: "displays no results when there is a tty",
+			tty:  true,
 			stubs: func(reg *httpmock.Registry) {
 				reg.Register(
 					httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
@@ -267,6 +269,48 @@ ID  KEY  SIZE      CREATED            ACCESSED
 			wantErr:    true,
 			wantErrMsg: "X Failed to get caches: HTTP 404 (https://api.github.com/repos/OWNER/REPO/actions/caches?per_page=100)",
 		},
+		{
+			name: "calls the exporter when requested",
+			opts: ListOptions{
+				Exporter: &verboseExporter{},
+			},
+			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,
+							},
+						},
+						TotalCount: 1,
+					}),
+				)
+			},
+			wantErr:    false,
+			wantStdout: "[{CreatedAt:2021-01-01 01:01:01.000000001 +0000 UTC Id:1 Key:foo LastAccessedAt:2022-01-01 01:01:01.000000001 +0000 UTC Ref: SizeInBytes:100 Version:}]",
+		},
+		{
+			name: "calls the exporter even when there are no results",
+			opts: ListOptions{
+				Exporter: &verboseExporter{},
+			},
+			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:    false,
+			wantStdout: "[]",
+		},
 	}
 
 	for _, tt := range tests {
@@ -305,6 +349,22 @@ ID  KEY  SIZE      CREATED            ACCESSED
 	}
 }
 
+// The verboseExporter just writes data formatted as %+v to stdout.
+// This allows for easy assertion on the data provided to the exporter.
+type verboseExporter struct{}
+
+func (e *verboseExporter) Fields() []string {
+	return nil
+}
+
+func (e *verboseExporter) Write(io *iostreams.IOStreams, data interface{}) error {
+	_, err := io.Out.Write([]byte(fmt.Sprintf("%+v", data)))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
 func Test_humanFileSize(t *testing.T) {
 	tests := []struct {
 		name string