mirror of https://gitlab.com/gitlab-org/cli.git
Merge branch 'release-assets-links-direct-asset-path' into 'main'
feat(release): support `direct_asset_path` and alias `filepath` Closes #1195 See merge request https://gitlab.com/gitlab-org/cli/-/merge_requests/1446 Merged-by: Tomas Vik <tvik@gitlab.com> Approved-by: Toon Claes <toon@gitlab.com> Approved-by: Tomas Vik <tvik@gitlab.com> Reviewed-by: Tomas Vik <tvik@gitlab.com> Co-authored-by: Timo Furrer <tfurrer@gitlab.com>
This commit is contained in:
commit
8a7c7f55cf
|
@ -107,7 +107,7 @@ func NewCmdCreate(f *cmdutils.Factory) *cobra.Command {
|
|||
"name": "Asset1",
|
||||
"url":"https://<domain>/some/location/1",
|
||||
"link_type": "other",
|
||||
"filepath": "path/to/file"
|
||||
"direct_asset_path": "path/to/file"
|
||||
}
|
||||
]'
|
||||
`),
|
||||
|
@ -160,7 +160,7 @@ func NewCmdCreate(f *cmdutils.Factory) *cobra.Command {
|
|||
cmd.Flags().StringVarP(&opts.NotesFile, "notes-file", "F", "", "Read release notes `file`. Specify `-` as value to read from stdin")
|
||||
cmd.Flags().StringVarP(&opts.ReleasedAt, "released-at", "D", "", "The `date` when the release is/was ready. Defaults to the current datetime. Expected in ISO 8601 format (2019-03-15T08:00:00Z)")
|
||||
cmd.Flags().StringSliceVarP(&opts.Milestone, "milestone", "m", []string{}, "The title of each milestone the release is associated with")
|
||||
cmd.Flags().StringVarP(&opts.AssetLinksAsJson, "assets-links", "a", "", "`JSON` string representation of assets links (e.g. `--assets-links='[{\"name\": \"Asset1\", \"url\":\"https://<domain>/some/location/1\", \"link_type\": \"other\", \"filepath\": \"path/to/file\"}]')`")
|
||||
cmd.Flags().StringVarP(&opts.AssetLinksAsJson, "assets-links", "a", "", "`JSON` string representation of assets links (e.g. `--assets-links='[{\"name\": \"Asset1\", \"url\":\"https://<domain>/some/location/1\", \"link_type\": \"other\", \"direct_asset_path\": \"path/to/file\"}]')`")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
@ -202,3 +202,99 @@ func TestReleaseCreateWithFiles(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCreate_WithAssetsLinksJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "with direct_asset_path",
|
||||
cli: `0.0.1 --assets-links='[{"name": "any-name", "url": "https://example.com/any-asset-url", "direct_asset_path": "/any-path"}]'`,
|
||||
expectedOutput: `• Validating tag 0.0.1
|
||||
|
||||
• Creating or updating release repo=OWNER/REPO tag=0.0.1
|
||||
✓ Release created url=https://gitlab.com/OWNER/REPO/-/releases/0.0.1
|
||||
• Uploading release assets repo=OWNER/REPO tag=0.0.1
|
||||
✓ Added release asset name=any-name url=https://gitlab.example.com/OWNER/REPO/releases/0.0.1/downloads/any-path
|
||||
✓ Release succeeded after`,
|
||||
},
|
||||
{
|
||||
name: "with filepath aliased to direct_asset_path",
|
||||
cli: `0.0.1 --assets-links='[{"name": "any-name", "url": "https://example.com/any-asset-url", "filepath": "/any-path"}]'`,
|
||||
expectedOutput: `• Validating tag 0.0.1
|
||||
|
||||
• Creating or updating release repo=OWNER/REPO tag=0.0.1
|
||||
✓ Release created url=https://gitlab.com/OWNER/REPO/-/releases/0.0.1
|
||||
• Uploading release assets repo=OWNER/REPO tag=0.0.1
|
||||
✓ Added release asset name=any-name url=https://gitlab.example.com/OWNER/REPO/releases/0.0.1/downloads/any-path
|
||||
! Aliased deprecated ` + "`filepath`" + ` field to ` + "`direct_asset_path`" + `. Replace ` + "`filepath`" + ` with ` + "`direct_asset_path`" + ` name=any-name
|
||||
✓ Release succeeded after`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeHTTP := &httpmock.Mocker{
|
||||
MatchURL: httpmock.PathAndQuerystring,
|
||||
}
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/OWNER/REPO/releases/0%2E0%2E1",
|
||||
httpmock.NewStringResponse(http.StatusNotFound, `{"message":"404 Not Found"}`))
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/projects/OWNER/REPO/releases",
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
rb, _ := io.ReadAll(req.Body)
|
||||
|
||||
assert.NotContains(t, string(rb), `"direct_asset_path":`)
|
||||
assert.NotContains(t, string(rb), `"filepath":`)
|
||||
|
||||
resp, _ := httpmock.NewStringResponse(http.StatusCreated, `
|
||||
{
|
||||
"name": "test_release",
|
||||
"tag_name": "0.0.1",
|
||||
"description": null,
|
||||
"created_at": "2023-01-19T02:58:32.622Z",
|
||||
"released_at": "2023-01-19T02:58:32.622Z",
|
||||
"upcoming_release": false,
|
||||
"tag_path": "/OWNER/REPO/-/tags/0.0.1",
|
||||
"_links": {
|
||||
"self": "https://gitlab.com/OWNER/REPO/-/releases/0.0.1"
|
||||
}
|
||||
}
|
||||
`)(req)
|
||||
return resp, nil
|
||||
},
|
||||
)
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/projects/OWNER/REPO/releases/0%2E0%2E1/assets/links",
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
rb, _ := io.ReadAll(req.Body)
|
||||
|
||||
assert.Contains(t, string(rb), `"direct_asset_path":"/any-path"`)
|
||||
assert.NotContains(t, string(rb), `"filepath":`)
|
||||
|
||||
resp, _ := httpmock.NewStringResponse(http.StatusCreated, `
|
||||
{
|
||||
"id":1,
|
||||
"name":"any-name",
|
||||
"url":"https://example.com/any-asset-url",
|
||||
"direct_asset_url":"https://gitlab.example.com/OWNER/REPO/releases/0.0.1/downloads/any-path",
|
||||
"link_type":"other"
|
||||
}
|
||||
`)(req)
|
||||
return resp, nil
|
||||
},
|
||||
)
|
||||
|
||||
output, err := runCommand(fakeHTTP, false, tt.cli)
|
||||
|
||||
if assert.NoErrorf(t, err, "error running command `create %s`: %v", tt.cli, err) {
|
||||
assert.Contains(t, output.Stderr(), tt.expectedOutput)
|
||||
assert.Empty(t, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,11 +8,28 @@ import (
|
|||
"gitlab.com/gitlab-org/cli/pkg/iostreams"
|
||||
)
|
||||
|
||||
// ConflictDirectAssetPathError is returned when both direct_asset_path and the deprecated filepath as specified for an asset link.
|
||||
type ConflictDirectAssetPathError struct {
|
||||
assetLinkName *string
|
||||
}
|
||||
|
||||
func (e *ConflictDirectAssetPathError) Error() string {
|
||||
var name string
|
||||
if e.assetLinkName != nil {
|
||||
name = *e.assetLinkName
|
||||
} else {
|
||||
name = "(without a name)"
|
||||
}
|
||||
return fmt.Sprintf("asset link %s contains both `direct_asset_path` and `filepath` (deprecated) fields. Remove the deprecated `filepath` field.", name)
|
||||
}
|
||||
|
||||
type ReleaseAsset struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
FilePath *string `json:"filepath,omitempty"`
|
||||
LinkType *gitlab.LinkTypeValue `json:"link_type,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
// Deprecated FilePath use DirectAssetPath instead.
|
||||
FilePath *string `json:"filepath,omitempty"`
|
||||
DirectAssetPath *string `json:"direct_asset_path,omitempty"`
|
||||
LinkType *gitlab.LinkTypeValue `json:"link_type,omitempty"`
|
||||
}
|
||||
|
||||
type ReleaseFile struct {
|
||||
|
@ -23,17 +40,23 @@ type ReleaseFile struct {
|
|||
Type *gitlab.LinkTypeValue
|
||||
}
|
||||
|
||||
func CreateLink(c *gitlab.Client, projectID, tagName string, asset *ReleaseAsset) (*gitlab.ReleaseLink, error) {
|
||||
func CreateLink(c *gitlab.Client, projectID, tagName string, asset *ReleaseAsset) (*gitlab.ReleaseLink, bool /* aliased deprecated filepath */, error) {
|
||||
aliased, err := aliasFilePathToDirectAssetPath(asset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
releaseLink, _, err := c.ReleaseLinks.CreateReleaseLink(projectID, tagName, &gitlab.CreateReleaseLinkOptions{
|
||||
Name: asset.Name,
|
||||
URL: asset.URL,
|
||||
FilePath: asset.FilePath,
|
||||
LinkType: asset.LinkType,
|
||||
Name: asset.Name,
|
||||
URL: asset.URL,
|
||||
DirectAssetPath: asset.DirectAssetPath,
|
||||
LinkType: asset.LinkType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
return releaseLink, nil
|
||||
|
||||
return releaseLink, aliased, nil
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
|
@ -75,11 +98,11 @@ func (c *Context) UploadFiles(projectID, tagName string) error {
|
|||
linkURL := baseURL.String() + projectID + projectFile.URL
|
||||
filename := "/" + file.Name
|
||||
|
||||
_, err = CreateLink(c.Client, projectID, tagName, &ReleaseAsset{
|
||||
Name: &file.Label,
|
||||
URL: &linkURL,
|
||||
FilePath: &filename,
|
||||
LinkType: file.Type,
|
||||
_, _, err = CreateLink(c.Client, projectID, tagName, &ReleaseAsset{
|
||||
Name: &file.Label,
|
||||
URL: &linkURL,
|
||||
DirectAssetPath: &filename,
|
||||
LinkType: file.Type,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -96,15 +119,44 @@ func (c *Context) CreateReleaseAssetLinks(projectID string, tagName string) erro
|
|||
}
|
||||
color := c.IO.Color()
|
||||
for _, asset := range c.AssetsLinks {
|
||||
releaseLink, err := CreateLink(c.Client, projectID, tagName, asset)
|
||||
releaseLink, aliased, err := CreateLink(c.Client, projectID, tagName, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.IO.StdErr, "%s Added release asset\t%s=%s %s=%s\n",
|
||||
color.GreenCheck(), color.Blue("name"), *asset.Name,
|
||||
color.Blue("url"), releaseLink.DirectAssetURL)
|
||||
|
||||
if aliased {
|
||||
fmt.Fprintf(c.IO.StdErr, "\t%s Aliased deprecated `filepath` field to `direct_asset_path`. Replace `filepath` with `direct_asset_path`\t%s=%s\n",
|
||||
color.WarnIcon(),
|
||||
color.Blue("name"), *asset.Name)
|
||||
}
|
||||
|
||||
}
|
||||
c.AssetsLinks = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// aliasFilePathToDirectAssetPath ensures that the Asset Link uses the direct_asset_path and not the filepath.
|
||||
// The filepath is deprecated and will be fully removed in GitLab 17.0.
|
||||
// See https://docs.gitlab.com/ee/update/deprecations.html?removal_milestone=17.0#filepath-field-in-releases-and-release-links-apis
|
||||
func aliasFilePathToDirectAssetPath(asset *ReleaseAsset) (bool /* aliased */, error) {
|
||||
if asset.FilePath == nil || *asset.FilePath == "" {
|
||||
// There is no deprecated filepath set, so we are good.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if asset.DirectAssetPath != nil && *asset.DirectAssetPath != "" {
|
||||
// Both, filepath and direct_asset_path are set, so we have a conflict
|
||||
return false, &ConflictDirectAssetPathError{asset.Name}
|
||||
}
|
||||
|
||||
// We use the set filepath as direct_asset_path
|
||||
// and clear the filepath to net send it via API.
|
||||
asset.DirectAssetPath = asset.FilePath
|
||||
asset.FilePath = nil
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
package upload
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
func TestReleaseUtilsUpload_AliasFilePathToAssetDirectPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
givenReleaseAsset *ReleaseAsset
|
||||
expectedReleaseAsset *ReleaseAsset
|
||||
expectedAliased bool
|
||||
}{
|
||||
{
|
||||
name: "no filepath, no direct_asset_path",
|
||||
givenReleaseAsset: &ReleaseAsset{Name: gitlab.Ptr("any-name")},
|
||||
expectedReleaseAsset: &ReleaseAsset{Name: gitlab.Ptr("any-name")},
|
||||
expectedAliased: false,
|
||||
},
|
||||
{
|
||||
name: "no filepath, but direct_asset_path",
|
||||
givenReleaseAsset: &ReleaseAsset{Name: gitlab.Ptr("any-name"), DirectAssetPath: gitlab.Ptr("/any-path")},
|
||||
expectedReleaseAsset: &ReleaseAsset{Name: gitlab.Ptr("any-name"), DirectAssetPath: gitlab.Ptr("/any-path")},
|
||||
expectedAliased: false,
|
||||
},
|
||||
{
|
||||
name: "filepath, but not direct_asset_path",
|
||||
givenReleaseAsset: &ReleaseAsset{Name: gitlab.Ptr("any-name"), FilePath: gitlab.Ptr("/any-path")},
|
||||
expectedReleaseAsset: &ReleaseAsset{Name: gitlab.Ptr("any-name"), DirectAssetPath: gitlab.Ptr("/any-path")},
|
||||
expectedAliased: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
aliased, err := aliasFilePathToDirectAssetPath(tt.givenReleaseAsset)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedAliased, aliased)
|
||||
assert.Equal(t, tt.expectedReleaseAsset, tt.givenReleaseAsset)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseUtilsUpload_AliasFilePathToAssetDirectPath_Conflict(t *testing.T) {
|
||||
asset := &ReleaseAsset{
|
||||
FilePath: gitlab.Ptr("/any-path"),
|
||||
DirectAssetPath: gitlab.Ptr("/any-path"),
|
||||
}
|
||||
|
||||
aliased, err := aliasFilePathToDirectAssetPath(asset)
|
||||
|
||||
target := &ConflictDirectAssetPathError{}
|
||||
assert.ErrorAs(t, err, &target)
|
||||
assert.False(t, aliased)
|
||||
}
|
|
@ -76,7 +76,7 @@ func NewCmdUpload(f *cmdutils.Factory) *cobra.Command {
|
|||
"name": "Asset1",
|
||||
"url":"https://<domain>/some/location/1",
|
||||
"link_type": "other",
|
||||
"filepath": "path/to/file"
|
||||
"direct_asset_path": "path/to/file"
|
||||
}
|
||||
]'
|
||||
`),
|
||||
|
@ -107,7 +107,7 @@ func NewCmdUpload(f *cmdutils.Factory) *cobra.Command {
|
|||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.AssetLinksAsJson, "assets-links", "a", "", "`JSON` string representation of assets links (e.g. `--assets-links='[{\"name\": \"Asset1\", \"url\":\"https://<domain>/some/location/1\", \"link_type\": \"other\", \"filepath\": \"path/to/file\"}]')`")
|
||||
cmd.Flags().StringVarP(&opts.AssetLinksAsJson, "assets-links", "a", "", "`JSON` string representation of assets links (e.g. `--assets-links='[{\"name\": \"Asset1\", \"url\":\"https://<domain>/some/location/1\", \"link_type\": \"other\", \"direct_asset_path\": \"path/to/file\"}]')`")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
@ -117,3 +117,77 @@ func TestReleaseUpload(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseUpload_WithAssetsLinksJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "with direct_asset_path",
|
||||
cli: `0.0.1 --assets-links='[{"name": "any-name", "url": "https://example.com/any-asset-url", "direct_asset_path": "/any-path"}]'`,
|
||||
expectedOutput: `• Validating tag repo=OWNER/REPO tag=0.0.1
|
||||
• Uploading release assets repo=OWNER/REPO tag=0.0.1
|
||||
✓ Added release asset name=any-name url=https://gitlab.example.com/OWNER/REPO/releases/0.0.1/downloads/any-path
|
||||
✓ Upload succeeded after`,
|
||||
},
|
||||
{
|
||||
name: "with filepath aliased to direct_asset_path",
|
||||
cli: `0.0.1 --assets-links='[{"name": "any-name", "url": "https://example.com/any-asset-url", "filepath": "/any-path"}]'`,
|
||||
expectedOutput: `• Validating tag repo=OWNER/REPO tag=0.0.1
|
||||
• Uploading release assets repo=OWNER/REPO tag=0.0.1
|
||||
✓ Added release asset name=any-name url=https://gitlab.example.com/OWNER/REPO/releases/0.0.1/downloads/any-path
|
||||
! Aliased deprecated ` + "`filepath`" + ` field to ` + "`direct_asset_path`" + `. Replace ` + "`filepath`" + ` with ` + "`direct_asset_path`" + ` name=any-name
|
||||
✓ Upload succeeded after`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeHTTP := &httpmock.Mocker{
|
||||
MatchURL: httpmock.PathAndQuerystring,
|
||||
}
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/OWNER/REPO/releases/0%2E0%2E1", httpmock.NewStringResponse(http.StatusOK, `
|
||||
{
|
||||
"name": "test1",
|
||||
"tag_name": "0.0.1",
|
||||
"description": null,
|
||||
"created_at": "2023-01-19T02:58:32.622Z",
|
||||
"released_at": "2023-01-19T02:58:32.622Z",
|
||||
"upcoming_release": false,
|
||||
"tag_path": "/OWNER/REPO/-/tags/0.0.1"
|
||||
}
|
||||
`))
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodPost, "/api/v4/projects/OWNER/REPO/releases/0%2E0%2E1/assets/links",
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
rb, _ := io.ReadAll(req.Body)
|
||||
|
||||
assert.Contains(t, string(rb), `"direct_asset_path":"/any-path"`)
|
||||
assert.NotContains(t, string(rb), `"filepath":`)
|
||||
|
||||
resp, _ := httpmock.NewStringResponse(http.StatusCreated, `
|
||||
{
|
||||
"id":1,
|
||||
"name":"any-name",
|
||||
"url":"https://example.com/any-asset-url",
|
||||
"direct_asset_url":"https://gitlab.example.com/OWNER/REPO/releases/0.0.1/downloads/any-path",
|
||||
"link_type":"other"
|
||||
}
|
||||
`)(req)
|
||||
return resp, nil
|
||||
},
|
||||
)
|
||||
|
||||
output, err := runCommand(fakeHTTP, false, tt.cli)
|
||||
|
||||
if assert.NoErrorf(t, err, "error running command `release upload %s`: %v", tt.cli, err) {
|
||||
assert.Contains(t, output.Stderr(), tt.expectedOutput)
|
||||
assert.Empty(t, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ $ glab release create v1.0.1 --assets-links='
|
|||
"name": "Asset1",
|
||||
"url":"https://<domain>/some/location/1",
|
||||
"link_type": "other",
|
||||
"filepath": "path/to/file"
|
||||
"direct_asset_path": "path/to/file"
|
||||
}
|
||||
]'
|
||||
|
||||
|
@ -74,7 +74,7 @@ $ glab release create v1.0.1 --assets-links='
|
|||
## Options
|
||||
|
||||
```plaintext
|
||||
-a, --assets-links JSON JSON string representation of assets links (e.g. `--assets-links='[{"name": "Asset1", "url":"https://<domain>/some/location/1", "link_type": "other", "filepath": "path/to/file"}]')`
|
||||
-a, --assets-links JSON JSON string representation of assets links (e.g. `--assets-links='[{"name": "Asset1", "url":"https://<domain>/some/location/1", "link_type": "other", "direct_asset_path": "path/to/file"}]')`
|
||||
-m, --milestone strings The title of each milestone the release is associated with
|
||||
-n, --name string The release name or title
|
||||
-N, --notes string The release notes/description. You can use Markdown
|
||||
|
|
|
@ -46,7 +46,7 @@ $ glab release upload v1.0.1 --assets-links='
|
|||
"name": "Asset1",
|
||||
"url":"https://<domain>/some/location/1",
|
||||
"link_type": "other",
|
||||
"filepath": "path/to/file"
|
||||
"direct_asset_path": "path/to/file"
|
||||
}
|
||||
]'
|
||||
|
||||
|
@ -55,7 +55,7 @@ $ glab release upload v1.0.1 --assets-links='
|
|||
## Options
|
||||
|
||||
```plaintext
|
||||
-a, --assets-links JSON JSON string representation of assets links (e.g. `--assets-links='[{"name": "Asset1", "url":"https://<domain>/some/location/1", "link_type": "other", "filepath": "path/to/file"}]')`
|
||||
-a, --assets-links JSON JSON string representation of assets links (e.g. `--assets-links='[{"name": "Asset1", "url":"https://<domain>/some/location/1", "link_type": "other", "direct_asset_path": "path/to/file"}]')`
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
|
Loading…
Reference in New Issue