Repository: regclient/regclient (go)

Issue:
[Feature] Add ability to test tag `allow` statements (mainly for regexes)
We are using the regsync utility with great success but we run into the problem where people are adding `allow` for what to sync but they end up syncing a lot more than they wanted. Example will be where you want to sync version `1.9.0` but then you do not realize there is also many different suffixes to that tag that you did not want.

### Expected Behavior

If we can have something native to `regctl tag ls` that does either `--regex` or `--filter` that runs the tags through the exact same logic as the regsync config file, that would be amazing improvement.

```
❯ ./bin/regctl tag ls hashicorp/vault-secrets-operator | grep -E "0.\\d+.\\d+-*"
0.1.0
0.1.0-beta
0.1.0-beta.1
0.1.0-rc.1
❯ ./bin/regctl tag ls hashicorp/vault-secrets-operator | grep -E "0.\\d+.\\d+-.*"
0.1.0-beta
0.1.0-beta.1
0.1.0-rc.1
```

### Example Solution

```
./bin/regctl tag ls hashicorp/vault-secrets-operator --regex "0.\\d+.\\d+-.*"
./bin/regctl tag ls hashicorp/vault-secrets-operator --filter "0.\\d+.\\d+-.*"
```


Pull request description:
Add regctl tag list filtering
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Fixes issue

Fixes #469
<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

This adds filtering options to the `regctl tag ls` command. Both `--include` and `--exclude` are supported and can be passed multiple times.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

```
regctl tag ls ghcr.io/regclient/regctl --exclude 'sha256-.*' --exclude 'buildcache.*'
regctl tag ls ghcr.io/regclient/regctl --include 'v\d+.*' --exclude '.*-alpine'
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

Add regctl tag list filtering
<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed


Patch:
diff --git a/cmd/regctl/tag.go b/cmd/regctl/tag.go
index 85b768b..6a29838 100644
--- a/cmd/regctl/tag.go
+++ b/cmd/regctl/tag.go
@@ -1,6 +1,9 @@
 package main
 
 import (
+	"fmt"
+	"regexp"
+
 	"github.com/regclient/regclient/pkg/template"
 	"github.com/regclient/regclient/scheme"
 	"github.com/regclient/regclient/types/ref"
@@ -34,17 +37,22 @@ Note: most registries ignore the pagination options.`,
 }
 
 var tagOpts struct {
-	Limit  int
-	Last   string
-	format string
+	limit   int
+	last    string
+	include []string
+	exclude []string
+	format  string
 }
 
 func init() {
-	tagLsCmd.Flags().StringVarP(&tagOpts.Last, "last", "", "", "Specify the last tag from a previous request for pagination")
-	tagLsCmd.Flags().IntVarP(&tagOpts.Limit, "limit", "", 0, "Specify the number of tags to retrieve")
+	tagLsCmd.Flags().StringVarP(&tagOpts.last, "last", "", "", "Specify the last tag from a previous request for pagination (depends on registry support)")
+	tagLsCmd.Flags().IntVarP(&tagOpts.limit, "limit", "", 0, "Specify the number of tags to retrieve (depends on registry support)")
+	tagLsCmd.Flags().StringArrayVar(&tagOpts.include, "include", []string{}, "Regexp of tags to include (expression is bound to beginning and ending of tag)")
+	tagLsCmd.Flags().StringArrayVar(&tagOpts.exclude, "exclude", []string{}, "Regexp of tags to exclude (expression is bound to beginning and ending of tag)")
 	tagLsCmd.Flags().StringVarP(&tagOpts.format, "format", "", "{{printPretty .}}", "Format output with go template syntax")
 	tagLsCmd.RegisterFlagCompletionFunc("last", completeArgNone)
 	tagLsCmd.RegisterFlagCompletionFunc("limit", completeArgNone)
+	tagLsCmd.RegisterFlagCompletionFunc("filter", completeArgNone)
 	tagLsCmd.RegisterFlagCompletionFunc("format", completeArgNone)
 
 	tagCmd.AddCommand(tagDeleteCmd)
@@ -78,6 +86,22 @@ func runTagLs(cmd *cobra.Command, args []string) error {
 	if err != nil {
 		return err
 	}
+	reInclude := []*regexp.Regexp{}
+	reExclude := []*regexp.Regexp{}
+	for _, expr := range tagOpts.include {
+		re, err := regexp.Compile("^" + expr + "$")
+		if err != nil {
+			return fmt.Errorf("failed to parse regexp \"%s\": %w", expr, err)
+		}
+		reInclude = append(reInclude, re)
+	}
+	for _, expr := range tagOpts.exclude {
+		re, err := regexp.Compile("^" + expr + "$")
+		if err != nil {
+			return fmt.Errorf("failed to parse regexp \"%s\": %w", expr, err)
+		}
+		reExclude = append(reExclude, re)
+	}
 	rc := newRegClient()
 	defer rc.Close(ctx, r)
 	log.WithFields(logrus.Fields{
@@ -85,16 +109,41 @@ func runTagLs(cmd *cobra.Command, args []string) error {
 		"repository": r.Repository,
 	}).Debug("Listing tags")
 	opts := []scheme.TagOpts{}
-	if tagOpts.Limit != 0 {
-		opts = append(opts, scheme.WithTagLimit(tagOpts.Limit))
+	if tagOpts.limit != 0 {
+		opts = append(opts, scheme.WithTagLimit(tagOpts.limit))
 	}
-	if tagOpts.Last != "" {
-		opts = append(opts, scheme.WithTagLast(tagOpts.Last))
+	if tagOpts.last != "" {
+		opts = append(opts, scheme.WithTagLast(tagOpts.last))
 	}
 	tl, err := rc.TagList(ctx, r, opts...)
 	if err != nil {
 		return err
 	}
+	if len(reInclude) > 0 || len(reExclude) > 0 {
+		filtered := []string{}
+		var included, excluded bool
+		for _, tag := range tl.Tags {
+			included = len(reInclude) == 0
+			excluded = false
+			for _, re := range reInclude {
+				if re.MatchString(tag) {
+					included = true
+					break
+				}
+			}
+			if included {
+				for _, re := range reExclude {
+					if re.MatchString(tag) {
+						excluded = true
+					}
+				}
+			}
+			if included && !excluded {
+				filtered = append(filtered, tag)
+			}
+		}
+		tl.Tags = filtered
+	}
 	switch tagOpts.format {
 	case "raw":
 		tagOpts.format = "{{ range $key,$vals := .RawHeaders}}{{range $val := $vals}}{{printf \"%s: %s\\n\" $key $val }}{{end}}{{end}}{{printf \"\\n%s\" .RawBody}}"


Test patch:
diff --git a/cmd/regctl/tag_test.go b/cmd/regctl/tag_test.go
new file mode 100644
index 0000000..e2753de
--- /dev/null
+++ b/cmd/regctl/tag_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+	"errors"
+	"fmt"
+	"io/fs"
+	"strings"
+	"testing"
+
+	"github.com/regclient/regclient/types"
+)
+
+func TestTagList(t *testing.T) {
+	tt := []struct {
+		name        string
+		args        []string
+		expectErr   error
+		expectOut   string
+		outContains bool
+	}{
+		{
+			name:      "Missing arg",
+			args:      []string{"tag", "ls"},
+			expectErr: fmt.Errorf("accepts 1 arg(s), received 0"),
+		},
+		{
+			name:      "Invalid ref",
+			args:      []string{"tag", "ls", "invalid*ref"},
+			expectErr: types.ErrInvalidReference,
+		},
+		{
+			name:      "Missing repo",
+			args:      []string{"tag", "ls", "ocidir://../../testdata/test-missing"},
+			expectErr: fs.ErrNotExist,
+		},
+		{
+			name:        "List tags",
+			args:        []string{"tag", "ls", "ocidir://../../testdata/testrepo"},
+			expectOut:   "v1\nv2\nv3",
+			outContains: true,
+		},
+		{
+			name:        "List tags filtered",
+			args:        []string{"tag", "ls", "--include", "sha256.*", "--exclude", ".*\\.meta", "ocidir://../../testdata/testrepo"},
+			expectOut:   "sha256-",
+			outContains: true,
+		},
+		{
+			name:        "List tags limited",
+			args:        []string{"tag", "ls", "--include", "v.*", "--limit", "5", "ocidir://../../testdata/testrepo"},
+			expectOut:   "v1\nv2\nv3",
+			outContains: true,
+		},
+		{
+			name:        "List tags formatted",
+			args:        []string{"tag", "ls", "--format", "raw", "ocidir://../../testdata/testrepo"},
+			expectOut:   "application/vnd.oci.image.index.v1+json",
+			outContains: true,
+		},
+	}
+	optInit := tagOpts
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			tagOpts = optInit
+			out, err := cobraTest(t, tc.args...)
+			if tc.expectErr != nil {
+				if err == nil {
+					t.Errorf("did not receive expected error: %v", tc.expectErr)
+				} else if !errors.Is(err, tc.expectErr) && err.Error() != tc.expectErr.Error() {
+					t.Errorf("unexpected error, received %v, expected %v", err, tc.expectErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Errorf("returned unexpected error: %v", err)
+				return
+			}
+			if (!tc.outContains && out != tc.expectOut) || (tc.outContains && !strings.Contains(out, tc.expectOut)) {
+				t.Errorf("unexpected output, expected %s, received %s", tc.expectOut, out)
+			}
+		})
+	}
+
+}