From 97ab3b032f364a0147dcd28b6d1c4b5922cc0997 Mon Sep 17 00:00:00 2001 From: danielr Date: Wed, 17 Jun 2026 10:16:26 +0300 Subject: [PATCH 1/6] APP-4658 - Add path-mapping flags to version-promote and version-release Add --map-in, --map-out, and --map-type flags to the promote and release commands for regex-based artifact path transformations during promotion. Usage: jf at version-promote my-app 1.0.0 PROD \ --map-type=".*" --map-in="(.*)" --map-out="stable-release/$1" Multiple mappings via semicolon separation: --map-in="(.*);(.*\.jar)" --map-out="release/$1;jars/$1" --map-type=".*;maven" The mappings are forwarded as modifications.mappings in the promotion request body to be applied by the Bundle Handler Service. Co-authored-by: Cursor --- apptrust/commands/flags.go | 12 ++ .../version/promote_app_version_cmd.go | 6 + .../version/release_app_version_cmd.go | 6 + .../version/release_app_version_cmd_test.go | 2 + apptrust/commands/version/version_utils.go | 62 ++++++++++ .../commands/version/version_utils_test.go | 116 ++++++++++++++++++ apptrust/model/promote_app_version_request.go | 21 +++- apptrust/model/release_app_version_request.go | 2 + .../service/versions/version_service_test.go | 4 + 9 files changed, 226 insertions(+), 5 deletions(-) diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index a5fdaa1..96eee22 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -61,6 +61,9 @@ const ( DeletePropertiesFlag = "delete-properties" IncludeFilterFlag = "include-filter" ExcludeFilterFlag = "exclude-filter" + MapInFlag = "map-in" + MapOutFlag = "map-out" + MapTypeFlag = "map-type" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -103,6 +106,9 @@ var flagsMap = map[string]components.Flag{ SourceTypeArtifactsFlag: components.NewStringFlag(SourceTypeArtifactsFlag, "List of semicolon-separated (;) artifacts in the form of 'path=repo/path/to/artifact1[, sha256=hash1]; path=repo/path/to/artifact2[, sha256=hash2]' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), PropertiesFlag: components.NewStringFlag(PropertiesFlag, "Sets or updates custom properties for the application version in format 'key1=value1[,value2,...];key2=value3[,value4,...]'", func(f *components.StringFlag) { f.Mandatory = false }), DeletePropertiesFlag: components.NewStringFlag(DeletePropertiesFlag, "Remove a property key and all its values", func(f *components.StringFlag) { f.Mandatory = false }), + MapInFlag: components.NewStringFlag(MapInFlag, "Semicolon-separated list of regex patterns for path mapping input. Each entry corresponds to a mapping rule.", func(f *components.StringFlag) { f.Mandatory = false }), + MapOutFlag: components.NewStringFlag(MapOutFlag, "Semicolon-separated list of output path templates for path mapping. Supports regex group references (e.g. $1). Must match the number of --map-in entries.", func(f *components.StringFlag) { f.Mandatory = false }), + MapTypeFlag: components.NewStringFlag(MapTypeFlag, "Semicolon-separated list of package type regex filters for path mapping. If fewer entries than --map-in, remaining mappings apply to all types.", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -137,6 +143,9 @@ var commandFlags = map[string][]string{ IncludeReposFlag, PropsFlag, OverwriteStrategyFlag, + MapInFlag, + MapOutFlag, + MapTypeFlag, }, VersionRelease: { url, @@ -149,6 +158,9 @@ var commandFlags = map[string][]string{ IncludeReposFlag, PropsFlag, OverwriteStrategyFlag, + MapInFlag, + MapOutFlag, + MapTypeFlag, }, VersionDelete: { url, diff --git a/apptrust/commands/version/promote_app_version_cmd.go b/apptrust/commands/version/promote_app_version_cmd.go index 9353f77..a8fee52 100644 --- a/apptrust/commands/version/promote_app_version_cmd.go +++ b/apptrust/commands/version/promote_app_version_cmd.go @@ -109,6 +109,11 @@ func (pv *promoteAppVersionCommand) buildRequestPayload(ctx *components.Context) return nil, err } + modifications, err := ParsePathMappings(ctx) + if err != nil { + return nil, err + } + return &model.PromoteAppVersionRequest{ Stage: stage, CommonPromoteAppVersion: model.CommonPromoteAppVersion{ @@ -117,6 +122,7 @@ func (pv *promoteAppVersionCommand) buildRequestPayload(ctx *components.Context) ExcludedRepositoryKeys: excludedRepos, ArtifactAdditionalProperties: artifactProps, OverwriteStrategy: overwriteStrategy, + Modifications: modifications, }, }, nil } diff --git a/apptrust/commands/version/release_app_version_cmd.go b/apptrust/commands/version/release_app_version_cmd.go index edf267e..22a36cd 100644 --- a/apptrust/commands/version/release_app_version_cmd.go +++ b/apptrust/commands/version/release_app_version_cmd.go @@ -106,12 +106,18 @@ func (rv *releaseAppVersionCommand) buildRequestPayload(ctx *components.Context) return nil, err } + modifications, err := ParsePathMappings(ctx) + if err != nil { + return nil, err + } + return model.NewReleaseAppVersionRequest( promotionType, includedRepos, excludedRepos, artifactProps, overwriteStrategy, + modifications, ), nil } diff --git a/apptrust/commands/version/release_app_version_cmd_test.go b/apptrust/commands/version/release_app_version_cmd_test.go index 87f25bd..05247f9 100644 --- a/apptrust/commands/version/release_app_version_cmd_test.go +++ b/apptrust/commands/version/release_app_version_cmd_test.go @@ -57,6 +57,7 @@ func TestReleaseAppVersionCommand_Run(t *testing.T) { nil, // excludedRepos nil, // artifactProps tt.overwriteStrategy, + nil, // modifications ) mockVersionService := mockversions.NewMockVersionService(ctrl) @@ -91,6 +92,7 @@ func TestReleaseAppVersionCommand_Run_Error(t *testing.T) { nil, // excludedRepos nil, // artifactProps "", // overwriteStrategy + nil, // modifications ) expectedError := errors.New("service error occurred") diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 359d25e..cbd07f8 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -1,6 +1,7 @@ package version import ( + "fmt" "strings" "github.com/jfrog/jfrog-cli-application/apptrust/commands" @@ -75,3 +76,64 @@ func ParseOverwriteStrategy(ctx *components.Context) (string, error) { // Convert to uppercase for API request return strings.ToUpper(validatedStrategy), nil } + +// ParsePathMappings extracts path mapping rules from --map-in, --map-out, and --map-type flags. +// Returns nil if no mapping flags are provided. +func ParsePathMappings(ctx *components.Context) (*model.PromotionModifications, error) { + mapInStr := ctx.GetStringFlagValue(commands.MapInFlag) + mapOutStr := ctx.GetStringFlagValue(commands.MapOutFlag) + + if mapInStr == "" && mapOutStr == "" { + return nil, nil + } + + if mapInStr == "" || mapOutStr == "" { + return nil, errorutils.CheckErrorf("both --%s and --%s must be provided together", commands.MapInFlag, commands.MapOutFlag) + } + + inputs := utils.ParseSliceFlag(mapInStr) + outputs := utils.ParseSliceFlag(mapOutStr) + + if len(inputs) != len(outputs) { + return nil, errorutils.CheckErrorf("--%s and --%s must have the same number of entries (got %d and %d)", + commands.MapInFlag, commands.MapOutFlag, len(inputs), len(outputs)) + } + + var packageTypes []string + if mapTypeStr := ctx.GetStringFlagValue(commands.MapTypeFlag); mapTypeStr != "" { + packageTypes = utils.ParseSliceFlag(mapTypeStr) + if len(packageTypes) > len(inputs) { + return nil, errorutils.CheckErrorf("--%s has more entries (%d) than --%s (%d)", + commands.MapTypeFlag, len(packageTypes), commands.MapInFlag, len(inputs)) + } + } + + mappings := make([]model.PromotionPathMapping, len(inputs)) + for i := range inputs { + mappings[i] = model.PromotionPathMapping{ + Input: inputs[i], + Output: outputs[i], + } + if i < len(packageTypes) { + mappings[i].PackageType = packageTypes[i] + } + } + + return &model.PromotionModifications{Mappings: mappings}, nil +} + +// BuildPathMappingsDescription returns a human-readable description of path mappings for logging. +func BuildPathMappingsDescription(modifications *model.PromotionModifications) string { + if modifications == nil || len(modifications.Mappings) == 0 { + return "" + } + var parts []string + for _, m := range modifications.Mappings { + desc := fmt.Sprintf("%s → %s", m.Input, m.Output) + if m.PackageType != "" { + desc = fmt.Sprintf("[%s] %s", m.PackageType, desc) + } + parts = append(parts, desc) + } + return strings.Join(parts, "; ") +} diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 3db8a85..73868c0 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -7,6 +7,7 @@ import ( "github.com/jfrog/jfrog-cli-application/apptrust/model" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseOverwriteStrategy(t *testing.T) { @@ -162,3 +163,118 @@ func TestBuildPromotionParams(t *testing.T) { }) } } + +func TestParsePathMappings(t *testing.T) { + tests := []struct { + name string + mapIn string + mapOut string + mapType string + expected *model.PromotionModifications + expectError bool + errContains string + }{ + { + name: "no flags - returns nil", + expected: nil, + }, + { + name: "single mapping without package type", + mapIn: "(.*)", + mapOut: "stable-release/$1", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {Input: "(.*)", Output: "stable-release/$1"}, + }, + }, + }, + { + name: "single mapping with package type", + mapIn: "(.*)", + mapOut: "stable-release/$1", + mapType: ".*", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {PackageType: ".*", Input: "(.*)", Output: "stable-release/$1"}, + }, + }, + }, + { + name: "multiple mappings", + mapIn: "(.*);(.*\\.jar)", + mapOut: "release/$1;jars/$1", + mapType: ".*;maven", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {PackageType: ".*", Input: "(.*)", Output: "release/$1"}, + {PackageType: "maven", Input: "(.*\\.jar)", Output: "jars/$1"}, + }, + }, + }, + { + name: "fewer package types than inputs - partial assignment", + mapIn: "(.*);(.*\\.jar)", + mapOut: "release/$1;jars/$1", + mapType: "maven", + expected: &model.PromotionModifications{ + Mappings: []model.PromotionPathMapping{ + {PackageType: "maven", Input: "(.*)", Output: "release/$1"}, + {Input: "(.*\\.jar)", Output: "jars/$1"}, + }, + }, + }, + { + name: "map-in without map-out - error", + mapIn: "(.*)", + expectError: true, + errContains: "must be provided together", + }, + { + name: "map-out without map-in - error", + mapOut: "target/$1", + expectError: true, + errContains: "must be provided together", + }, + { + name: "mismatched count - error", + mapIn: "(.*);(.*\\.jar)", + mapOut: "release/$1", + expectError: true, + errContains: "same number of entries", + }, + { + name: "more package types than inputs - error", + mapIn: "(.*)", + mapOut: "release/$1", + mapType: "maven;npm", + expectError: true, + errContains: "more entries", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + if tt.mapIn != "" { + ctx.AddStringFlag(commands.MapInFlag, tt.mapIn) + } + if tt.mapOut != "" { + ctx.AddStringFlag(commands.MapOutFlag, tt.mapOut) + } + if tt.mapType != "" { + ctx.AddStringFlag(commands.MapTypeFlag, tt.mapType) + } + + result, err := ParsePathMappings(ctx) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/apptrust/model/promote_app_version_request.go b/apptrust/model/promote_app_version_request.go index 08b0abe..7feca1c 100644 --- a/apptrust/model/promote_app_version_request.go +++ b/apptrust/model/promote_app_version_request.go @@ -28,12 +28,23 @@ var OverwriteStrategyValues = []string{ OverwriteStrategyAll, } +type PromotionPathMapping struct { + PackageType string `json:"package_type,omitempty"` + Input string `json:"input"` + Output string `json:"output"` +} + +type PromotionModifications struct { + Mappings []PromotionPathMapping `json:"mappings"` +} + type CommonPromoteAppVersion struct { - PromotionType string `json:"promotion_type,omitempty"` - IncludedRepositoryKeys []string `json:"included_repository_keys,omitempty"` - ExcludedRepositoryKeys []string `json:"excluded_repository_keys,omitempty"` - ArtifactAdditionalProperties []ArtifactProperty `json:"artifact_additional_properties,omitempty"` - OverwriteStrategy string `json:"overwrite_strategy,omitempty"` + PromotionType string `json:"promotion_type,omitempty"` + IncludedRepositoryKeys []string `json:"included_repository_keys,omitempty"` + ExcludedRepositoryKeys []string `json:"excluded_repository_keys,omitempty"` + ArtifactAdditionalProperties []ArtifactProperty `json:"artifact_additional_properties,omitempty"` + OverwriteStrategy string `json:"overwrite_strategy,omitempty"` + Modifications *PromotionModifications `json:"modifications,omitempty"` } type ArtifactProperty struct { diff --git a/apptrust/model/release_app_version_request.go b/apptrust/model/release_app_version_request.go index 92a2679..19e62e6 100644 --- a/apptrust/model/release_app_version_request.go +++ b/apptrust/model/release_app_version_request.go @@ -15,6 +15,7 @@ func NewReleaseAppVersionRequest( excludedRepositoryKeys []string, artifactProperties []ArtifactProperty, overwriteStrategy string, + modifications *PromotionModifications, ) *ReleaseAppVersionRequest { return &ReleaseAppVersionRequest{ CommonPromoteAppVersion: CommonPromoteAppVersion{ @@ -23,6 +24,7 @@ func NewReleaseAppVersionRequest( ExcludedRepositoryKeys: excludedRepositoryKeys, ArtifactAdditionalProperties: artifactProperties, OverwriteStrategy: overwriteStrategy, + Modifications: modifications, }, } } diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index 634e69a..a4b1fb7 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -252,6 +252,7 @@ func TestReleaseAppVersion(t *testing.T) { []string{"repo3"}, []model.ArtifactProperty{{Key: "key1", Values: []string{"value1"}}}, "", + nil, ), sync: true, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", @@ -270,6 +271,7 @@ func TestReleaseAppVersion(t *testing.T) { []string{"repo3"}, []model.ArtifactProperty{{Key: "key1", Values: []string{"value1"}}}, "", + nil, ), sync: false, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", @@ -288,6 +290,7 @@ func TestReleaseAppVersion(t *testing.T) { nil, nil, "", + nil, ), sync: true, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", @@ -306,6 +309,7 @@ func TestReleaseAppVersion(t *testing.T) { nil, nil, "", + nil, ), sync: false, expectedEndpoint: "/v1/applications/test-app/versions/1.0.0/release", From b916905c6a0b11b37876266d852971a8f3694f41 Mon Sep 17 00:00:00 2001 From: danielr Date: Wed, 17 Jun 2026 10:33:07 +0300 Subject: [PATCH 2/6] APP-4658 - Add e2e tests for path-mapping promotion - TestPromoteVersion_WithPathMappings: promotes with --map-in, --map-out, --map-type and verifies stage advances to DEV - TestPromoteVersion_WithPathMappings_InvalidRegex: verifies invalid regex in --map-in returns an error Co-authored-by: Cursor --- e2e/version_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/e2e/version_test.go b/e2e/version_test.go index b37555f..271fa08 100644 --- a/e2e/version_test.go +++ b/e2e/version_test.go @@ -375,6 +375,61 @@ func TestPromoteVersion(t *testing.T) { assert.Equal(t, targetStage, versionContent.CurrentStage) } +func TestPromoteVersion_WithPathMappings(t *testing.T) { + t.Skip("Skipping until path-mapping backend is deployed") + // Prepare + appKey := utils.GenerateUniqueKey("app-promote-mapping") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.60" + + packageFlag := fmt.Sprintf("--source-type-packages=type=%s, name=%s, version=%s, repo-key=%s", + testPackage.PackageType, testPackage.PackageName, testPackage.PackageVersion, testPackage.RepoKey) + err := utils.AppTrustCli.Exec("version-create", appKey, version, packageFlag) + require.NoError(t, err) + defer utils.DeleteApplicationVersion(t, appKey, version) + + // Execute - promote with path mapping flags + targetStage := "DEV" + err = utils.AppTrustCli.Exec("version-promote", appKey, version, targetStage, + `--map-in=(.*)`, `--map-out=promoted/$1`, `--map-type=.*`) + require.NoError(t, err) + + // Assert - promotion succeeded with mappings applied + versionContent, statusCode, err := utils.GetApplicationVersion(appKey, version) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + require.NotNil(t, versionContent) + assert.Equal(t, appKey, versionContent.ApplicationKey) + assert.Equal(t, version, versionContent.Version) + assert.Equal(t, targetStage, versionContent.CurrentStage) +} + +func TestPromoteVersion_WithPathMappings_InvalidRegex(t *testing.T) { + t.Skip("Skipping until path-mapping backend is deployed") + // Prepare + appKey := utils.GenerateUniqueKey("app-promote-map-err") + utils.CreateBasicApplication(t, appKey) + defer utils.DeleteApplication(t, appKey) + + testPackage := utils.GetTestPackage(t) + version := "1.0.61" + + packageFlag := fmt.Sprintf("--source-type-packages=type=%s, name=%s, version=%s, repo-key=%s", + testPackage.PackageType, testPackage.PackageName, testPackage.PackageVersion, testPackage.RepoKey) + err := utils.AppTrustCli.Exec("version-create", appKey, version, packageFlag) + require.NoError(t, err) + defer utils.DeleteApplicationVersion(t, appKey, version) + + // Execute - promote with invalid regex in --map-in + targetStage := "DEV" + err = utils.AppTrustCli.Exec("version-promote", appKey, version, targetStage, + `--map-in=[unclosed`, `--map-out=target/$1`) + assert.Error(t, err) +} + func TestReleaseVersion(t *testing.T) { // Prepare appKey := utils.GenerateUniqueKey("app-version-release") From 86b5f31ab9198dc43996e36108ea96cdfaa766c2 Mon Sep 17 00:00:00 2001 From: danielr Date: Wed, 17 Jun 2026 10:44:05 +0300 Subject: [PATCH 3/6] APP-4658 - Address code review: validate flags and remove dead code - Reject --map-type when --map-in/--map-out are missing - Reject empty entries from trailing semicolons in all mapping flags - Remove unused BuildPathMappingsDescription function Co-authored-by: Cursor --- apptrust/commands/version/version_utils.go | 41 ++++++++++--------- .../commands/version/version_utils_test.go | 28 +++++++++++++ 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index cbd07f8..7da88a6 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -1,7 +1,6 @@ package version import ( - "fmt" "strings" "github.com/jfrog/jfrog-cli-application/apptrust/commands" @@ -82,26 +81,44 @@ func ParseOverwriteStrategy(ctx *components.Context) (string, error) { func ParsePathMappings(ctx *components.Context) (*model.PromotionModifications, error) { mapInStr := ctx.GetStringFlagValue(commands.MapInFlag) mapOutStr := ctx.GetStringFlagValue(commands.MapOutFlag) + mapTypeStr := ctx.GetStringFlagValue(commands.MapTypeFlag) - if mapInStr == "" && mapOutStr == "" { + if mapInStr == "" && mapOutStr == "" && mapTypeStr == "" { return nil, nil } if mapInStr == "" || mapOutStr == "" { - return nil, errorutils.CheckErrorf("both --%s and --%s must be provided together", commands.MapInFlag, commands.MapOutFlag) + return nil, errorutils.CheckErrorf("--%s and --%s must be provided together (both are required for path mappings)", + commands.MapInFlag, commands.MapOutFlag) } inputs := utils.ParseSliceFlag(mapInStr) outputs := utils.ParseSliceFlag(mapOutStr) + for i, v := range inputs { + if v == "" { + return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.MapInFlag, i+1) + } + } + for i, v := range outputs { + if v == "" { + return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.MapOutFlag, i+1) + } + } + if len(inputs) != len(outputs) { return nil, errorutils.CheckErrorf("--%s and --%s must have the same number of entries (got %d and %d)", commands.MapInFlag, commands.MapOutFlag, len(inputs), len(outputs)) } var packageTypes []string - if mapTypeStr := ctx.GetStringFlagValue(commands.MapTypeFlag); mapTypeStr != "" { + if mapTypeStr != "" { packageTypes = utils.ParseSliceFlag(mapTypeStr) + for i, v := range packageTypes { + if v == "" { + return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.MapTypeFlag, i+1) + } + } if len(packageTypes) > len(inputs) { return nil, errorutils.CheckErrorf("--%s has more entries (%d) than --%s (%d)", commands.MapTypeFlag, len(packageTypes), commands.MapInFlag, len(inputs)) @@ -121,19 +138,3 @@ func ParsePathMappings(ctx *components.Context) (*model.PromotionModifications, return &model.PromotionModifications{Mappings: mappings}, nil } - -// BuildPathMappingsDescription returns a human-readable description of path mappings for logging. -func BuildPathMappingsDescription(modifications *model.PromotionModifications) string { - if modifications == nil || len(modifications.Mappings) == 0 { - return "" - } - var parts []string - for _, m := range modifications.Mappings { - desc := fmt.Sprintf("%s → %s", m.Input, m.Output) - if m.PackageType != "" { - desc = fmt.Sprintf("[%s] %s", m.PackageType, desc) - } - parts = append(parts, desc) - } - return strings.Join(parts, "; ") -} diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index 73868c0..dae979c 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -250,6 +250,34 @@ func TestParsePathMappings(t *testing.T) { expectError: true, errContains: "more entries", }, + { + name: "map-type alone without map-in/map-out - error", + mapType: "maven", + expectError: true, + errContains: "must be provided together", + }, + { + name: "trailing semicolon in map-in produces empty entry - error", + mapIn: "(.*);", + mapOut: "release/$1;target/$1", + expectError: true, + errContains: "entry 2 is empty", + }, + { + name: "trailing semicolon in map-out produces empty entry - error", + mapIn: "(.*);(.*\\.jar)", + mapOut: "release/$1;", + expectError: true, + errContains: "entry 2 is empty", + }, + { + name: "empty map-type entry - error", + mapIn: "(.*)", + mapOut: "release/$1", + mapType: ";", + expectError: true, + errContains: "entry 1 is empty", + }, } for _, tt := range tests { From 193ed068348919692d760ed19b3af26f27d1f0a9 Mon Sep 17 00:00:00 2001 From: danielr Date: Thu, 18 Jun 2026 14:02:34 +0300 Subject: [PATCH 4/6] APP-4658 - Refactor to single --path-mapping flag Replace --map-in, --map-out, --map-type with a single structured flag following the same pattern as --source-type-packages: --path-mapping="input=(.*), output=stable-release/$1, package-type=.*" Multiple mappings via semicolons: --path-mapping="input=(.*), output=release/$1; input=(.*\.jar), output=jars/$1, package-type=maven" Co-authored-by: Cursor --- apptrust/commands/flags.go | 16 +--- apptrust/commands/version/version_utils.go | 77 +++++++-------- .../commands/version/version_utils_test.go | 94 +++++-------------- e2e-tests-report.xml | 70 ++++++++++++++ e2e/version_test.go | 5 +- 5 files changed, 133 insertions(+), 129 deletions(-) create mode 100644 e2e-tests-report.xml diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 96eee22..0bccd25 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -61,9 +61,7 @@ const ( DeletePropertiesFlag = "delete-properties" IncludeFilterFlag = "include-filter" ExcludeFilterFlag = "exclude-filter" - MapInFlag = "map-in" - MapOutFlag = "map-out" - MapTypeFlag = "map-type" + PathMappingFlag = "path-mapping" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -106,9 +104,7 @@ var flagsMap = map[string]components.Flag{ SourceTypeArtifactsFlag: components.NewStringFlag(SourceTypeArtifactsFlag, "List of semicolon-separated (;) artifacts in the form of 'path=repo/path/to/artifact1[, sha256=hash1]; path=repo/path/to/artifact2[, sha256=hash2]' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), PropertiesFlag: components.NewStringFlag(PropertiesFlag, "Sets or updates custom properties for the application version in format 'key1=value1[,value2,...];key2=value3[,value4,...]'", func(f *components.StringFlag) { f.Mandatory = false }), DeletePropertiesFlag: components.NewStringFlag(DeletePropertiesFlag, "Remove a property key and all its values", func(f *components.StringFlag) { f.Mandatory = false }), - MapInFlag: components.NewStringFlag(MapInFlag, "Semicolon-separated list of regex patterns for path mapping input. Each entry corresponds to a mapping rule.", func(f *components.StringFlag) { f.Mandatory = false }), - MapOutFlag: components.NewStringFlag(MapOutFlag, "Semicolon-separated list of output path templates for path mapping. Supports regex group references (e.g. $1). Must match the number of --map-in entries.", func(f *components.StringFlag) { f.Mandatory = false }), - MapTypeFlag: components.NewStringFlag(MapTypeFlag, "Semicolon-separated list of package type regex filters for path mapping. If fewer entries than --map-in, remaining mappings apply to all types.", func(f *components.StringFlag) { f.Mandatory = false }), + PathMappingFlag: components.NewStringFlag(PathMappingFlag, "List of semicolon-separated (;) path mapping rules in the form of 'input=(.*), output=stable-release/$1[, package-type=.*]; input=(.*\\.jar), output=jars/$1, package-type=maven'. Note: quote the value to prevent shell expansion of $1.", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -143,9 +139,7 @@ var commandFlags = map[string][]string{ IncludeReposFlag, PropsFlag, OverwriteStrategyFlag, - MapInFlag, - MapOutFlag, - MapTypeFlag, + PathMappingFlag, }, VersionRelease: { url, @@ -158,9 +152,7 @@ var commandFlags = map[string][]string{ IncludeReposFlag, PropsFlag, OverwriteStrategyFlag, - MapInFlag, - MapOutFlag, - MapTypeFlag, + PathMappingFlag, }, VersionDelete: { url, diff --git a/apptrust/commands/version/version_utils.go b/apptrust/commands/version/version_utils.go index 7da88a6..48dd737 100644 --- a/apptrust/commands/version/version_utils.go +++ b/apptrust/commands/version/version_utils.go @@ -76,64 +76,53 @@ func ParseOverwriteStrategy(ctx *components.Context) (string, error) { return strings.ToUpper(validatedStrategy), nil } -// ParsePathMappings extracts path mapping rules from --map-in, --map-out, and --map-type flags. -// Returns nil if no mapping flags are provided. +// ParsePathMappings extracts path mapping rules from the --path-mapping flag. +// Format: "input=(.*), output=stable-release/$1[, package-type=.*]; input=(...), output=..." +// Returns nil if flag is not provided. func ParsePathMappings(ctx *components.Context) (*model.PromotionModifications, error) { - mapInStr := ctx.GetStringFlagValue(commands.MapInFlag) - mapOutStr := ctx.GetStringFlagValue(commands.MapOutFlag) - mapTypeStr := ctx.GetStringFlagValue(commands.MapTypeFlag) - - if mapInStr == "" && mapOutStr == "" && mapTypeStr == "" { + const ( + inputField = "input" + outputField = "output" + packageTypeField = "package-type" + ) + + flagValue := ctx.GetStringFlagValue(commands.PathMappingFlag) + if flagValue == "" { return nil, nil } - if mapInStr == "" || mapOutStr == "" { - return nil, errorutils.CheckErrorf("--%s and --%s must be provided together (both are required for path mappings)", - commands.MapInFlag, commands.MapOutFlag) - } - - inputs := utils.ParseSliceFlag(mapInStr) - outputs := utils.ParseSliceFlag(mapOutStr) + entries := utils.ParseSliceFlag(flagValue) + var mappings []model.PromotionPathMapping - for i, v := range inputs { - if v == "" { - return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.MapInFlag, i+1) + for i, entry := range entries { + if entry == "" { + return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.PathMappingFlag, i+1) } - } - for i, v := range outputs { - if v == "" { - return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.MapOutFlag, i+1) + + entryMap, err := utils.ParseKeyValueString(entry, ",") + if err != nil { + return nil, errorutils.CheckErrorf("--%s entry %d: %s", commands.PathMappingFlag, i+1, err.Error()) } - } - if len(inputs) != len(outputs) { - return nil, errorutils.CheckErrorf("--%s and --%s must have the same number of entries (got %d and %d)", - commands.MapInFlag, commands.MapOutFlag, len(inputs), len(outputs)) - } + input, hasInput := entryMap[inputField] + output, hasOutput := entryMap[outputField] - var packageTypes []string - if mapTypeStr != "" { - packageTypes = utils.ParseSliceFlag(mapTypeStr) - for i, v := range packageTypes { - if v == "" { - return nil, errorutils.CheckErrorf("--%s entry %d is empty", commands.MapTypeFlag, i+1) - } + if !hasInput || input == "" { + return nil, errorutils.CheckErrorf("--%s entry %d: '%s' is required", commands.PathMappingFlag, i+1, inputField) } - if len(packageTypes) > len(inputs) { - return nil, errorutils.CheckErrorf("--%s has more entries (%d) than --%s (%d)", - commands.MapTypeFlag, len(packageTypes), commands.MapInFlag, len(inputs)) + if !hasOutput || output == "" { + return nil, errorutils.CheckErrorf("--%s entry %d: '%s' is required", commands.PathMappingFlag, i+1, outputField) } - } - mappings := make([]model.PromotionPathMapping, len(inputs)) - for i := range inputs { - mappings[i] = model.PromotionPathMapping{ - Input: inputs[i], - Output: outputs[i], + mapping := model.PromotionPathMapping{ + Input: input, + Output: output, } - if i < len(packageTypes) { - mappings[i].PackageType = packageTypes[i] + if pt, ok := entryMap[packageTypeField]; ok { + mapping.PackageType = pt } + + mappings = append(mappings, mapping) } return &model.PromotionModifications{Mappings: mappings}, nil diff --git a/apptrust/commands/version/version_utils_test.go b/apptrust/commands/version/version_utils_test.go index dae979c..e21eb7b 100644 --- a/apptrust/commands/version/version_utils_test.go +++ b/apptrust/commands/version/version_utils_test.go @@ -167,21 +167,18 @@ func TestBuildPromotionParams(t *testing.T) { func TestParsePathMappings(t *testing.T) { tests := []struct { name string - mapIn string - mapOut string - mapType string + flagValue string expected *model.PromotionModifications expectError bool errContains string }{ { - name: "no flags - returns nil", + name: "no flag - returns nil", expected: nil, }, { - name: "single mapping without package type", - mapIn: "(.*)", - mapOut: "stable-release/$1", + name: "single mapping without package type", + flagValue: "input=(.*), output=stable-release/$1", expected: &model.PromotionModifications{ Mappings: []model.PromotionPathMapping{ {Input: "(.*)", Output: "stable-release/$1"}, @@ -189,10 +186,8 @@ func TestParsePathMappings(t *testing.T) { }, }, { - name: "single mapping with package type", - mapIn: "(.*)", - mapOut: "stable-release/$1", - mapType: ".*", + name: "single mapping with package type", + flagValue: "input=(.*), output=stable-release/$1, package-type=.*", expected: &model.PromotionModifications{ Mappings: []model.PromotionPathMapping{ {PackageType: ".*", Input: "(.*)", Output: "stable-release/$1"}, @@ -200,10 +195,8 @@ func TestParsePathMappings(t *testing.T) { }, }, { - name: "multiple mappings", - mapIn: "(.*);(.*\\.jar)", - mapOut: "release/$1;jars/$1", - mapType: ".*;maven", + name: "multiple mappings", + flagValue: "input=(.*), output=release/$1, package-type=.*; input=(.*\\.jar), output=jars/$1, package-type=maven", expected: &model.PromotionModifications{ Mappings: []model.PromotionPathMapping{ {PackageType: ".*", Input: "(.*)", Output: "release/$1"}, @@ -212,85 +205,46 @@ func TestParsePathMappings(t *testing.T) { }, }, { - name: "fewer package types than inputs - partial assignment", - mapIn: "(.*);(.*\\.jar)", - mapOut: "release/$1;jars/$1", - mapType: "maven", + name: "mapping without package-type field", + flagValue: "input=(.*), output=release/$1; input=(.*\\.jar), output=jars/$1", expected: &model.PromotionModifications{ Mappings: []model.PromotionPathMapping{ - {PackageType: "maven", Input: "(.*)", Output: "release/$1"}, + {Input: "(.*)", Output: "release/$1"}, {Input: "(.*\\.jar)", Output: "jars/$1"}, }, }, }, { - name: "map-in without map-out - error", - mapIn: "(.*)", + name: "missing input field - error", + flagValue: "output=target/$1", expectError: true, - errContains: "must be provided together", + errContains: "'input' is required", }, { - name: "map-out without map-in - error", - mapOut: "target/$1", + name: "missing output field - error", + flagValue: "input=(.*)", expectError: true, - errContains: "must be provided together", + errContains: "'output' is required", }, { - name: "mismatched count - error", - mapIn: "(.*);(.*\\.jar)", - mapOut: "release/$1", - expectError: true, - errContains: "same number of entries", - }, - { - name: "more package types than inputs - error", - mapIn: "(.*)", - mapOut: "release/$1", - mapType: "maven;npm", - expectError: true, - errContains: "more entries", - }, - { - name: "map-type alone without map-in/map-out - error", - mapType: "maven", - expectError: true, - errContains: "must be provided together", - }, - { - name: "trailing semicolon in map-in produces empty entry - error", - mapIn: "(.*);", - mapOut: "release/$1;target/$1", - expectError: true, - errContains: "entry 2 is empty", - }, - { - name: "trailing semicolon in map-out produces empty entry - error", - mapIn: "(.*);(.*\\.jar)", - mapOut: "release/$1;", + name: "empty entry from trailing semicolon - error", + flagValue: "input=(.*), output=release/$1;", expectError: true, errContains: "entry 2 is empty", }, { - name: "empty map-type entry - error", - mapIn: "(.*)", - mapOut: "release/$1", - mapType: ";", + name: "invalid key-value format - error", + flagValue: "not-a-valid-format", expectError: true, - errContains: "entry 1 is empty", + errContains: "entry 1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := &components.Context{} - if tt.mapIn != "" { - ctx.AddStringFlag(commands.MapInFlag, tt.mapIn) - } - if tt.mapOut != "" { - ctx.AddStringFlag(commands.MapOutFlag, tt.mapOut) - } - if tt.mapType != "" { - ctx.AddStringFlag(commands.MapTypeFlag, tt.mapType) + if tt.flagValue != "" { + ctx.AddStringFlag(commands.PathMappingFlag, tt.flagValue) } result, err := ParsePathMappings(ctx) diff --git a/e2e-tests-report.xml b/e2e-tests-report.xml new file mode 100644 index 0000000..d8ecd7a --- /dev/null +++ b/e2e-tests-report.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/e2e/version_test.go b/e2e/version_test.go index 271fa08..a2eaa3c 100644 --- a/e2e/version_test.go +++ b/e2e/version_test.go @@ -394,7 +394,7 @@ func TestPromoteVersion_WithPathMappings(t *testing.T) { // Execute - promote with path mapping flags targetStage := "DEV" err = utils.AppTrustCli.Exec("version-promote", appKey, version, targetStage, - `--map-in=(.*)`, `--map-out=promoted/$1`, `--map-type=.*`) + `--path-mapping=input=(.*), output=promoted/$1, package-type=.*`) require.NoError(t, err) // Assert - promotion succeeded with mappings applied @@ -423,10 +423,9 @@ func TestPromoteVersion_WithPathMappings_InvalidRegex(t *testing.T) { require.NoError(t, err) defer utils.DeleteApplicationVersion(t, appKey, version) - // Execute - promote with invalid regex in --map-in targetStage := "DEV" err = utils.AppTrustCli.Exec("version-promote", appKey, version, targetStage, - `--map-in=[unclosed`, `--map-out=target/$1`) + `--path-mapping=input=[unclosed, output=target/$1`) assert.Error(t, err) } From 048dd47c64ad647b288b1d16408539304d469f1e Mon Sep 17 00:00:00 2001 From: danielr Date: Thu, 18 Jun 2026 14:06:40 +0300 Subject: [PATCH 5/6] Remove accidentally committed test report artifact Co-authored-by: Cursor --- e2e-tests-report.xml | 70 -------------------------------------------- 1 file changed, 70 deletions(-) delete mode 100644 e2e-tests-report.xml diff --git a/e2e-tests-report.xml b/e2e-tests-report.xml deleted file mode 100644 index d8ecd7a..0000000 --- a/e2e-tests-report.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 56ba666ab426c7bec8950508f97befe1fec90d5e Mon Sep 17 00:00:00 2001 From: danielr Date: Thu, 18 Jun 2026 14:08:00 +0300 Subject: [PATCH 6/6] Add e2e-tests-report.xml to .gitignore Co-authored-by: Cursor --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0908a7b..c12055f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ *.iml bin .tools + +# Test reports +e2e-tests-report.xml