Repository: cli/cli (go)

Issue:
Can't delete or query packages with forward slash in their names
### Describe the bug

Can't delete or query packages with forward slash in their names

### Steps to reproduce the behavior

List packages:

```
gh api /users/skarlso/packages\?package_type=container --jq '.[].name'
...
ocm/v0.0.1/component-descriptors/github.com/skarlso/ocm-test
ocm/v0.0.2/component-descriptors/github.com/skarlso/ocm-workflow-class
ocm-workflow-class/v0.0.1
ocm/v0.0.1/component-descriptors/github.com/skarlso/ocm-workflow-class
ocm-destination/v0.0.1/component-descriptors/github.com/skarlso/ocm-workflow-class
ocm/component-descriptors/github.com/skarlso/ocm-workflow-class
ocm-destination/component-descriptors/github.com/skarlso/ocm-workflow-class
ocm-replication-test/component-descriptors/github.com/skarlso/ocm-replication-test
ocm-replication-test-example/v0.0.1
ocm-replication/component-descriptors/github.com/skarlso/ocm-replication
ocm-destination/skarlso/ocm-replication-test-example/v0.0.1
ocm-destination/component-descriptors/github.com/skarlso/ocm-replication
ocm-delivery/weaveworksdemos/carts
ocm-delivery/gardener-project/test/jensh007/mongo
ocm-delivery/component-descriptors/github.com/weaveworks/carts
ocm-delivery/weaveworksdemos/catalogue
ocm-delivery/weaveworksdemos/catalogue-db
ocm-delivery/component-descriptors/github.com/weaveworks/catalogue
ocm-delivery/weaveworksdemos/front-end
ocm-delivery/component-descriptors/github.com/weaveworks/front-end
ocm-delivery/weaveworksdemos/orders
ocm-delivery/component-descriptors/github.com/weaveworks/orders
ocm-delivery/weaveworksdemos/payment
ocm-delivery/component-descriptors/github.com/weaveworks/payment
ocm-delivery/weaveworksdemos/queue-master
ocm-delivery/library/rabbitmq
ocm-delivery/kbudde/rabbitmq-exporter
ocm-delivery/library/redis
ocm-delivery/component-descriptors/github.com/weaveworks/queue-master
ocm-delivery/weaveworksdemos/shipping
```

Query:

```
gh api -H "Accept: application/vnd.github+json" /users/skarlso/packages/container/ocm-delivery/weaveworksdemos/shipping
...
gh: Not Found (HTTP 404)
{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest",
  "status": "404"
}


## Or

gh api \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  /user/packages/container/ocm-delivery/weaveworksdemos/shipping


gh: Not Found (HTTP 404)
{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest",
  "status": "404"
}

```

### Expected vs actual behavior

A clear and concise description of what you expected to happen and what actually happened.

### Logs

Paste the activity from your command line. Redact if needed.

<!-- Note: Set `GH_DEBUG=true` for verbose logs or `GH_DEBUG=api` for verbose logs with HTTP traffic details. -->


Pull request:
[gh api] Escape package name (URL encoding) for packages endpoint
Fixes #9798.

According to the [REST API endpoints for packages](https://docs.github.com/en/rest/packages/packages?apiVersion=2022-11-28), the following endpoints need to be handled (duplicates removed):

```text
/orgs/{org}/packages/{package_type}/{package_name}
/orgs/{org}/packages/{package_type}/{package_name}/restore
/orgs/{org}/packages/{package_type}/{package_name}/versions
/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}
/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}/restore
/user/packages/{package_type}/{package_name}
/user/packages/{package_type}/{package_name}/restore
/user/packages/{package_type}/{package_name}/versions
/user/packages/{package_type}/{package_name}/versions/{package_version_id}
/user/packages/{package_type}/{package_name}/versions/{package_version_id}/restore
/users/{username}/packages/{package_type}/{package_name}
/users/{username}/packages/{package_type}/{package_name}/restore
/users/{username}/packages/{package_type}/{package_name}/versions
/users/{username}/packages/{package_type}/{package_name}/versions/{package_version_id}
/users/{username}/packages/{package_type}/{package_name}/versions/{package_version_id}/restore
```

where `package_type` can be one of:

```text
npm, maven, rubygems, docker, nuget, container
```

Usually, `docker` and `container` packages may have such naming conventions.
However, this PR handles all these package types, just to be sure.

Used regex to extract the package name:

- regex101: https://regex101.com/r/ftredu/1
- go playground: https://go.dev/play/p/6xK_iva7r-h

### Tests

- Packages for testing: https://github.com/Skarlso?ecosystem=container&tab=packages

```shell
# FAILURE
$ gh api /users/Skarlso/packages/container/helm/crd-bootstrap
{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest",
  "status": "404"
}
gh: Not Found (HTTP 404)

# SUCCESS
$ bin/gh api /users/Skarlso/packages/container/helm/crd-bootstrap
{
  "id": 6080213,
  "name": "helm/crd-bootstrap",
  "package_type": "container",
  ...
}
```

With `/versions` and `/versions/{package_version_id}`:

```shell
# FAILURE
$ gh api /users/Skarlso/packages/container/helm/crd-bootstrap/versions
{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest",
  "status": "404"
}
gh: Not Found (HTTP 404)

# SUCCESS
$ bin/gh api /users/Skarlso/packages/container/helm/crd-bootstrap/versions
[
  {
    "id": 331534419,
    "name": "sha256:b19cca6a3c790aa1a7df132112a778a41fff887fcdd08639276c2955f4b0e123",
    "url": "https://api.github.com/users/Skarlso/packages/container/helm%2Fcrd-bootstrap/versions/331534419",
    "package_html_url": "https://github.com/users/Skarlso/packages/container/package/helm%2Fcrd-bootstrap",
    "created_at": "2025-01-06T06:12:17Z",
    "updated_at": "2025-01-06T06:12:17Z",
    "description": "This is the official Helm chart for the Project crd-bootstrap.",
    "html_url": "https://github.com/users/Skarlso/packages/container/helm%2Fcrd-bootstrap/331534419",
    "metadata": {
      "package_type": "container",
      "container": {
        "tags": [
          "v0.9.9"
        ]
      }
    }
  },
  ...
]


# FAILURE
$ gh api /users/Skarlso/packages/container/helm/crd-bootstrap/versions/331534419
{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest",
  "status": "404"
}
gh: Not Found (HTTP 404)

# SUCCESS
$ bin/gh api /users/Skarlso/packages/container/helm/crd-bootstrap/versions/331534419
{
  "id": 331534419,
  "name": "sha256:b19cca6a3c790aa1a7df132112a778a41fff887fcdd08639276c2955f4b0e123",
  "url": "https://api.github.com/users/Skarlso/packages/container/helm%2Fcrd-bootstrap/versions/331534419",
  "package_html_url": "https://github.com/users/Skarlso/packages/container/package/helm%2Fcrd-bootstrap",
  "created_at": "2025-01-06T06:12:17Z",
  "updated_at": "2025-01-06T06:12:17Z",
  "description": "This is the official Helm chart for the Project crd-bootstrap.",
  "html_url": "https://github.com/users/Skarlso/packages/container/helm%2Fcrd-bootstrap/331534419",
  "metadata": {
    "package_type": "container",
    "container": {
      "tags": [
        "v0.9.9"
      ]
    }
  }
}
```

Patch:
diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go
index a6eb69718..86b26fbfc 100644
--- a/pkg/cmd/api/api.go
+++ b/pkg/cmd/api/api.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -264,6 +265,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
 				return err
 			}
 
+			opts.RequestPath = escapePackageNameInPath(opts.RequestPath)
+
 			if runF != nil {
 				return runF(&opts)
 			}
@@ -689,3 +692,37 @@ func previewNamesToMIMETypes(names []string) string {
 	}
 	return strings.Join(types, ", ")
 }
+
+// The package name part in the `packages` endpoints may contain slashes and
+// other characters that need to be URL encoded.
+//
+// The `escapePackageNameInPath` function extracts and normalizes package names
+// in the path. The regex `pathWithPackageNameRE` is being used to extract the
+// package name with a capture group named `package`.
+//
+// See https://docs.github.com/en/rest/packages/packages APIs for more details.
+//
+// Here's an example:
+//
+// The package name `orders/cache` needs to be URL encoded because it contains
+// a slash `/`. The `escapePackageNameInPath` function will extract the
+// `orders/cache` part, perform the URL encoding, and return the normalized API
+// endpoint with `%2F` replacing the slash `/` in the package name part only.
+//
+// - Package name: `orders/cache`
+// - API endpoint: `/users/USER/packages/container/orders/cache`
+// - Normalized:   `/users/USER/packages/container/orders%2Fcache`
+
+var pathWithPackageNameRE = regexp.MustCompile(`^\/(?:orgs|user|users)(?:\/.*)?\/packages\/(?:npm|maven|rubygems|docker|nuget|container)\/(?<package>.*?)(?:\/(?:restore|versions)|$)`)
+
+func escapePackageNameInPath(path string) string {
+	matches := pathWithPackageNameRE.FindStringSubmatch(path)
+	if len(matches) > 0 {
+		i := pathWithPackageNameRE.SubexpIndex("package")
+		packageName := matches[i]
+		if packageName != "" {
+			return strings.Replace(path, packageName, url.QueryEscape(packageName), 1)
+		}
+	}
+	return path
+}


Test patch:
diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go
index a565312cd..7ba03932c 100644
--- a/pkg/cmd/api/api_test.go
+++ b/pkg/cmd/api/api_test.go
@@ -367,6 +367,72 @@ func Test_NewCmdApi(t *testing.T) {
 			},
 			wantsErr: false,
 		},
+		{
+			name: "request path with container package name containing slashes",
+			cli:  "/user/packages/container/github.com/username/package_name --verbose",
+			wants: ApiOptions{
+				Hostname:            "",
+				RequestMethod:       "GET",
+				RequestMethodPassed: false,
+				RequestPath:         "/user/packages/container/github.com%2Fusername%2Fpackage_name",
+				RequestInputFile:    "",
+				RawFields:           []string(nil),
+				MagicFields:         []string(nil),
+				RequestHeaders:      []string(nil),
+				ShowResponseHeaders: false,
+				Paginate:            false,
+				Silent:              false,
+				CacheTTL:            0,
+				Template:            "",
+				FilterOutput:        "",
+				Verbose:             true,
+			},
+			wantsErr: false,
+		},
+		{
+			name: "request path with container package name containing slashes and restore",
+			cli:  "/user/packages/container/github.com/username/package_name/restore --verbose",
+			wants: ApiOptions{
+				Hostname:            "",
+				RequestMethod:       "GET",
+				RequestMethodPassed: false,
+				RequestPath:         "/user/packages/container/github.com%2Fusername%2Fpackage_name/restore",
+				RequestInputFile:    "",
+				RawFields:           []string(nil),
+				MagicFields:         []string(nil),
+				RequestHeaders:      []string(nil),
+				ShowResponseHeaders: false,
+				Paginate:            false,
+				Silent:              false,
+				CacheTTL:            0,
+				Template:            "",
+				FilterOutput:        "",
+				Verbose:             true,
+			},
+			wantsErr: false,
+		},
+		{
+			name: "request path with container package name containing slashes and versions",
+			cli:  "/user/packages/container/github.com/username/package_name/versions --verbose",
+			wants: ApiOptions{
+				Hostname:            "",
+				RequestMethod:       "GET",
+				RequestMethodPassed: false,
+				RequestPath:         "/user/packages/container/github.com%2Fusername%2Fpackage_name/versions",
+				RequestInputFile:    "",
+				RawFields:           []string(nil),
+				MagicFields:         []string(nil),
+				RequestHeaders:      []string(nil),
+				ShowResponseHeaders: false,
+				Paginate:            false,
+				Silent:              false,
+				CacheTTL:            0,
+				Template:            "",
+				FilterOutput:        "",
+				Verbose:             true,
+			},
+			wantsErr: false,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {