diff --git a/go.mod b/go.mod index 9802d367..b63f7d8f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.81.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.82.0 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index cc3746ee..2f468c58 100644 --- a/go.sum +++ b/go.sum @@ -46,10 +46,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4VDhOQ0Ven0= github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.80.2 h1:fsCyBGYEE0hN2xLfc0/q3FP5GM5udioL0Gx3CyBdrJ4= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.80.2/go.mod h1:ZCOnCz9ae/uuOk7AIQ9NzjnzFbuN8Q7H3oj2Eq4QSgQ= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.81.0 h1:uw6MnuuAn4XRVavwlT8ZzgTuDktne0WVk3jEIfOl7eA= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.81.0/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.82.0 h1:4Pc2W74VKp7Qm0uV0Dv99QKqRWg8WriVikdZPBpIZgY= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.82.0/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index d9b949ec..c363e691 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" "time" "github.com/OctopusDeploy/cli/pkg/apiclient" @@ -39,6 +40,7 @@ const ( FlagChannel = "channel" FlagPackageVersionSpec = "package" FlagGitResourceRefSpec = "git-resource" + FlagCustomField = "custom-field" FlagVersion = "version" FlagAliasReleaseNumberLegacy = "releaseNumber" // alias for FlagVersion @@ -124,6 +126,7 @@ type CreateFlags struct { IgnoreChannelRules *flag.Flag[bool] PackageVersionSpec *flag.Flag[[]string] GitResourceRefsSpec *flag.Flag[[]string] + CustomFields *flag.Flag[[]string] } func NewCreateFlags() *CreateFlags { @@ -140,6 +143,7 @@ func NewCreateFlags() *CreateFlags { IgnoreChannelRules: flag.New[bool](FlagIgnoreChannelRules, false), PackageVersionSpec: flag.New[[]string](FlagPackageVersionSpec, false), GitResourceRefsSpec: flag.New[[]string](FlagGitResourceRefSpec, false), + CustomFields: flag.New[[]string](FlagCustomField, false), } } @@ -173,6 +177,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { flags.BoolVarP(&createFlags.IgnoreChannelRules.Value, createFlags.IgnoreChannelRules.Name, "", false, "Allow creation of a release where channel rules would otherwise prevent it.") flags.StringArrayVarP(&createFlags.PackageVersionSpec.Value, createFlags.PackageVersionSpec.Name, "", []string{}, "Version specification for a specific package.\nFormat as {package}:{version}, {step}:{version} or {package-ref-name}:{packageOrStep}:{version}\nYou may specify this multiple times") flags.StringArrayVarP(&createFlags.GitResourceRefsSpec.Value, createFlags.GitResourceRefsSpec.Name, "", []string{}, "Git reference for a specific Git resource.\nFormat as {step}:{git-ref}, {step}:{git-resource-name}:{git-ref}\nYou may specify this multiple times") + flags.StringArrayVarP(&createFlags.CustomFields.Value, createFlags.CustomFields.Name, "", []string{}, "Custom field value to set on the release.\nFormat as {name}:{value}. You may specify multiple times") // we want the help text to display in the above order, rather than alphabetical flags.SortFlags = false @@ -221,6 +226,24 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error GitResourceRefs: flags.GitResourceRefsSpec.Value, } + if len(flags.CustomFields.Value) > 0 { + customFields := make(map[string]string) + for _, raw := range flags.CustomFields.Value { + // expect first ':' to split name and value; allow value to contain additional ':' characters + parts := strings.SplitN(raw, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid custom-field value '%s'; expected format name:value", raw) + } + name := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if name == "" { + return fmt.Errorf("invalid custom-field value '%s'; field name cannot be empty", raw) + } + customFields[name] = value + } + options.CustomFields = customFields + } + if flags.ReleaseNotesFile.Value != "" { fileContents, err := os.ReadFile(flags.ReleaseNotesFile.Value) if err != nil { @@ -254,6 +277,11 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error resolvedFlags.ReleaseNotes.Value = options.ReleaseNotes resolvedFlags.IgnoreExisting.Value = options.IgnoreIfAlreadyExists resolvedFlags.IgnoreChannelRules.Value = options.IgnoreChannelRules + if len(options.CustomFields) > 0 { + for k, v := range options.CustomFields { + resolvedFlags.CustomFields.Value = append(resolvedFlags.CustomFields.Value, fmt.Sprintf("%s: %s", k, v)) + } + } autoCmd := flag.GenerateAutomationCmd(constants.ExecutableName+" release create", resolvedFlags.Project, @@ -265,6 +293,7 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error resolvedFlags.IgnoreChannelRules, resolvedFlags.PackageVersionSpec, resolvedFlags.GitResourceRefsSpec, + resolvedFlags.CustomFields, resolvedFlags.Version, ) cmd.Printf("\nAutomation Command: %s\n", autoCmd) @@ -588,9 +617,46 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } } + + if len(selectedChannel.CustomFieldDefinitions) > 0 { + if options.CustomFields == nil { // ensure map initialised + options.CustomFields = make(map[string]string, len(selectedChannel.CustomFieldDefinitions)) + } + for _, customFieldDefinition := range selectedChannel.CustomFieldDefinitions { + // skip if already provided via automation + if _, exists := options.CustomFields[customFieldDefinition.FieldName]; exists { + continue + } + + customFieldValue, err := askCustomField(customFieldDefinition, asker) + if err != nil { + return err + } + options.CustomFields[customFieldDefinition.FieldName] = customFieldValue + } + } + return nil } +func askCustomField(customFieldDefinition channels.ChannelCustomFieldDefinition, asker question.Asker) (string, error) { + msg := fmt.Sprint(customFieldDefinition.FieldName) + helpText := customFieldDefinition.Description + var answer string + + validator := func(val interface{}) error { + str, _ := val.(string) + if strings.TrimSpace(str) == "" { + return fmt.Errorf("%s is required", customFieldDefinition.FieldName) + } + return nil + } + if err := asker(&survey.Input{Message: msg, Help: helpText}, &answer, survey.WithValidator(validator)); err != nil { + return "", err + } + return answer, nil +} + func askVersion(ask question.Asker, defaultVersion string) (string, error) { var result string if err := ask(&survey.Input{ diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 346c17e7..07044c83 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -1287,6 +1287,36 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"release creation specifying custom field", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", "--project", cacProject.Name, "--custom-field", "My Field: Some Value"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProject.GetName()).RespondWith(cacProject) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1") + + requestBody, err := testutil.ReadJson[releases.CreateReleaseCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, map[string]string{"My Field": "Some Value"}, requestBody.CustomFields) + + req.RespondWith(&releases.CreateReleaseResponseV1{ReleaseID: "Releases-999", ReleaseVersion: "1.2.3"}) + + // follow-up lookups + releaseInfo := releases.NewRelease("Channels-32", cacProject.ID, "1.2.3") + api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/Releases-999").RespondWith(releaseInfo) + channelInfo := fixtures.NewChannel(space1.ID, "Channels-32", "Alpha channel", cacProject.ID) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/Channels-32").RespondWith(channelInfo) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + }}, + {"release creation specifying project only (bare minimum)", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { defer api.Close() diff --git a/pkg/executor/release.go b/pkg/executor/release.go index 97e82311..7e57a9c1 100644 --- a/pkg/executor/release.go +++ b/pkg/executor/release.go @@ -3,12 +3,13 @@ package executor import ( "errors" "fmt" + "strconv" + "strings" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" - "strconv" - "strings" ) // ----- Create Release -------------------------------------- @@ -21,17 +22,18 @@ type TaskResultCreateRelease struct { // and looking them up for their ID's; we should only deal with strong references at this level type TaskOptionsCreateRelease struct { - ProjectName string // Required - DefaultPackageVersion string // Optional - GitCommit string // Optional - GitReference string // Required for version controlled projects - Version string // optional - ChannelName string // optional - ReleaseNotes string // optional - IgnoreIfAlreadyExists bool // optional - IgnoreChannelRules bool // optional - PackageVersionOverrides []string // optional - GitResourceRefs []string //optional + ProjectName string // Required + DefaultPackageVersion string // Optional + GitCommit string // Optional + GitReference string // Required for version controlled projects + Version string // optional + ChannelName string // optional + ReleaseNotes string // optional + IgnoreIfAlreadyExists bool // optional + IgnoreChannelRules bool // optional + PackageVersionOverrides []string // optional + GitResourceRefs []string //optional + CustomFields map[string]string // optional // if the task succeeds, the resulting output will be stored here Response *releases.CreateReleaseResponseV1 } @@ -74,6 +76,10 @@ func releaseCreate(octopus *client.Client, space *spaces.Space, input any) error createReleaseParams.IgnoreIfAlreadyExists = params.IgnoreIfAlreadyExists createReleaseParams.IgnoreChannelRules = params.IgnoreChannelRules + if len(params.CustomFields) > 0 { + createReleaseParams.CustomFields = params.CustomFields + } + createReleaseResponse, err := releases.CreateReleaseV1(octopus, createReleaseParams) if err != nil { return err