diff --git a/server/api/repo_parameter.go b/server/api/repo_parameter.go new file mode 100644 index 000000000..c728528bc --- /dev/null +++ b/server/api/repo_parameter.go @@ -0,0 +1,152 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "go.woodpecker-ci.org/woodpecker/v3/server" + "go.woodpecker-ci.org/woodpecker/v3/server/model" + "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" + "go.woodpecker-ci.org/woodpecker/v3/server/store/types" +) + +// GetParameter returns a repository parameter by ID. +func GetParameter(c *gin.Context) { + repo := session.Repo(c) + paramID, err := strconv.ParseInt(c.Param("parameter"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid parameter ID") + return + } + + parameterService := server.Config.Services.Manager.ParameterServiceFromRepo(repo) + parameter, err := parameterService.ParameterFindByID(repo, paramID) + if err != nil { + handleDBError(c, err) + return + } + c.JSON(http.StatusOK, parameter) +} + +// PostParameter persists a parameter. +func PostParameter(c *gin.Context) { + repo := session.Repo(c) + + in := new(model.Parameter) + if err := c.Bind(in); err != nil { + c.String(http.StatusBadRequest, "Error parsing parameter. %s", err) + return + } + in.RepoID = repo.ID + + if err := in.Validate(); err != nil { + c.String(http.StatusBadRequest, "Error validating parameter. %s", err) + return + } + + parameterService := server.Config.Services.Manager.ParameterServiceFromRepo(repo) + + // Check if parameter with same name and branch already exists + existing, err := parameterService.ParameterFindByNameAndBranch(repo, in.Name, in.Branch) + if err != nil && !errors.Is(err, types.RecordNotExist) { + handleDBError(c, err) + return + } + if existing != nil && existing.ID != 0 { + c.String(http.StatusConflict, "Parameter with name '%s' already exists for branch '%s': existing: %d, new: %d", in.Name, in.Branch, existing.ID, in.ID) + return + } + + err = parameterService.ParameterCreate(repo, in) + if err != nil { + handleDBError(c, err) + return + } + parameter, err := parameterService.ParameterFind(repo, in.Name) + c.JSON(http.StatusOK, parameter) +} + +// PatchParameter updates an existing parameter by ID +func PatchParameter(c *gin.Context) { + repo := session.Repo(c) + paramID, err := strconv.ParseInt(c.Param("parameter"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid parameter ID") + return + } + + in := new(model.Parameter) + if err := c.Bind(in); err != nil { + c.String(http.StatusBadRequest, "Error parsing parameter. %s", err) + return + } + in.RepoID = repo.ID + in.ID = paramID + + if err := in.Validate(); err != nil { + c.String(http.StatusBadRequest, "Error validating parameter. %s", err) + return + } + + parameterService := server.Config.Services.Manager.ParameterServiceFromRepo(repo) + + // Get existing parameter to check if name/branch changed + existing, err := parameterService.ParameterFindByID(repo, paramID) + if err != nil { + handleDBError(c, err) + return + } + + // If name or branch changed, check for conflicts + if existing.Name != in.Name || existing.Branch != in.Branch { + conflict, err := parameterService.ParameterFindByNameAndBranch(repo, in.Name, in.Branch) + if err != nil && !errors.Is(err, types.RecordNotExist) { + handleDBError(c, err) + return + } + if conflict != nil && conflict.ID != 0 && conflict.ID != paramID { + c.String(http.StatusConflict, "Parameter with name '%s' already exists for branch '%s'", in.Name, in.Branch) + return + } + } + + err = parameterService.ParameterUpdate(repo, in) + if err != nil { + handleDBError(c, err) + return + } + parameter, err := parameterService.ParameterFindByID(repo, paramID) + c.JSON(http.StatusOK, parameter) +} + +// GetParameterList returns all repository parameters. +func GetParameterList(c *gin.Context) { + repo := session.Repo(c) + parameterService := server.Config.Services.Manager.ParameterServiceFromRepo(repo) + list, err := parameterService.ParameterList(repo) + if err != nil { + handleDBError(c, err) + return + } + c.JSON(http.StatusOK, list) +} + +// DeleteParameter deletes a parameter by ID. +func DeleteParameter(c *gin.Context) { + repo := session.Repo(c) + paramID, err := strconv.ParseInt(c.Param("parameter"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Invalid parameter ID") + return + } + + parameterService := server.Config.Services.Manager.ParameterServiceFromRepo(repo) + if err := parameterService.ParameterDeleteByID(repo, paramID); err != nil { + handleDBError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/server/model/parameter.go b/server/model/parameter.go new file mode 100644 index 000000000..7befbe9b7 --- /dev/null +++ b/server/model/parameter.go @@ -0,0 +1,63 @@ +package model + +import ( + "errors" + "fmt" +) + +var ( + ErrParameterNameInvalid = errors.New("invalid parameter name") + ErrParameterTypeInvalid = errors.New("invalid parameter type") +) + +type ParameterType string + +const ( + ParameterTypeBoolean ParameterType = "boolean" + ParameterTypeSingleChoice ParameterType = "single_choice" + ParameterTypeMultipleChoice ParameterType = "multiple_choice" + ParameterTypeString ParameterType = "string" + ParameterTypeText ParameterType = "text" + ParameterTypePassword ParameterType = "password" +) + +// Parameter represents a configurable parameter for a repository. +type Parameter struct { + ID int64 `json:"id" xorm:"pk autoincr 'parameter_id'"` + RepoID int64 `json:"repo_id" xorm:"UNIQUE(s) 'parameter_repo_id'"` + Name string `json:"name" xorm:"UNIQUE(s) 'parameter_name'"` + Branch string `json:"branch" xorm:"UNIQUE(s) 'parameter_branch'"` + Type ParameterType `json:"type" xorm:"'parameter_type'"` + Description string `json:"description" xorm:"TEXT 'parameter_description'"` + DefaultValue string `json:"default_value" xorm:"TEXT 'parameter_default_value'"` + TrimString bool `json:"trim_string" xorm:"'parameter_trim_string'"` +} + +// TableName return database table name for xorm. +func (Parameter) TableName() string { + return "parameters" +} + +// Validate validates the required fields and formats. +func (p *Parameter) Validate() error { + switch { + case len(p.Name) == 0: + return fmt.Errorf("%w: empty name", ErrParameterNameInvalid) + case len(p.Branch) == 0: + return fmt.Errorf("%w: empty branch", ErrParameterNameInvalid) + case !validParameterType(p.Type): + return fmt.Errorf("%w: %s", ErrParameterTypeInvalid, p.Type) + default: + return nil + } +} + +func validParameterType(t ParameterType) bool { + switch t { + case ParameterTypeBoolean, ParameterTypeSingleChoice, ParameterTypeMultipleChoice, + ParameterTypeString, ParameterTypeText, ParameterTypePassword: + return true + default: + return false + } +} diff --git a/server/router/api.go b/server/router/api.go index 23f306aa3..d7e06bbd6 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -126,6 +126,13 @@ func apiRoutes(e *gin.RouterGroup) { // requires push permissions repo.DELETE("/logs/:number", session.MustPush, api.DeletePipelineLogs) + // requires push permissions + repo.GET("/parameters", api.GetParameterList) + repo.POST("/parameters", session.MustPush, api.PostParameter) + repo.GET("/parameters/:parameter", api.GetParameter) + repo.PATCH("/parameters/:parameter", session.MustPush, api.PatchParameter) + repo.DELETE("/parameters/:parameter", session.MustPush, api.DeleteParameter) + // requires push permissions repo.GET("/secrets", session.MustPush, api.GetSecretList) repo.POST("/secrets", session.MustPush, api.PostSecret) diff --git a/server/services/manager.go b/server/services/manager.go index 43e370cd5..d115beb52 100644 --- a/server/services/manager.go +++ b/server/services/manager.go @@ -26,6 +26,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/config" "go.woodpecker-ci.org/woodpecker/v3/server/services/environment" + "go.woodpecker-ci.org/woodpecker/v3/server/services/parameter" "go.woodpecker-ci.org/woodpecker/v3/server/services/registry" "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" @@ -38,6 +39,7 @@ type SetupForge func(forge *model.Forge) (forge.Forge, error) type Manager interface { SignaturePublicKey() crypto.PublicKey + ParameterServiceFromRepo(repo *model.Repo) parameter.Service SecretServiceFromRepo(repo *model.Repo) secret.Service SecretService() secret.Service RegistryServiceFromRepo(repo *model.Repo) registry.Service @@ -53,6 +55,7 @@ type manager struct { signaturePrivateKey crypto.PrivateKey signaturePublicKey crypto.PublicKey store store.Store + parameter parameter.Service secret secret.Service registry registry.Service config config.Service @@ -87,6 +90,7 @@ func NewManager(c *cli.Command, store store.Store, setupForge SetupForge) (Manag signaturePrivateKey: signaturePrivateKey, signaturePublicKey: signaturePublicKey, store: store, + parameter: setupParameterService(store), secret: setupSecretService(store), registry: setupRegistryService(store, c.String("docker-config")), config: configService, @@ -101,10 +105,18 @@ func (m *manager) SignaturePublicKey() crypto.PublicKey { return m.signaturePublicKey } +func (m *manager) ParameterServiceFromRepo(_ *model.Repo) parameter.Service { + return m.ParameterService() +} + func (m *manager) SecretServiceFromRepo(_ *model.Repo) secret.Service { return m.SecretService() } +func (m *manager) ParameterService() parameter.Service { + return m.parameter +} + func (m *manager) SecretService() secret.Service { return m.secret } diff --git a/server/services/mocks/manager.go b/server/services/mocks/manager.go new file mode 100644 index 000000000..12330c516 --- /dev/null +++ b/server/services/mocks/manager.go @@ -0,0 +1,295 @@ +// Code generated by mockery. DO NOT EDIT. + +//go:build test +// +build test + +package mocks + +import ( + crypto "crypto" + + config "go.woodpecker-ci.org/woodpecker/v3/server/services/config" + + environment "go.woodpecker-ci.org/woodpecker/v3/server/services/environment" + + forge "go.woodpecker-ci.org/woodpecker/v3/server/forge" + + mock "github.com/stretchr/testify/mock" + + model "go.woodpecker-ci.org/woodpecker/v3/server/model" + + parameter "go.woodpecker-ci.org/woodpecker/v3/server/services/parameter" + + registry "go.woodpecker-ci.org/woodpecker/v3/server/services/registry" + + secret "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" +) + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +// ConfigServiceFromRepo provides a mock function with given fields: repo +func (_m *Manager) ConfigServiceFromRepo(repo *model.Repo) config.Service { + ret := _m.Called(repo) + + if len(ret) == 0 { + panic("no return value specified for ConfigServiceFromRepo") + } + + var r0 config.Service + if rf, ok := ret.Get(0).(func(*model.Repo) config.Service); ok { + r0 = rf(repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(config.Service) + } + } + + return r0 +} + +// EnvironmentService provides a mock function with no fields +func (_m *Manager) EnvironmentService() environment.Service { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for EnvironmentService") + } + + var r0 environment.Service + if rf, ok := ret.Get(0).(func() environment.Service); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(environment.Service) + } + } + + return r0 +} + +// ForgeByID provides a mock function with given fields: forgeID +func (_m *Manager) ForgeByID(forgeID int64) (forge.Forge, error) { + ret := _m.Called(forgeID) + + if len(ret) == 0 { + panic("no return value specified for ForgeByID") + } + + var r0 forge.Forge + var r1 error + if rf, ok := ret.Get(0).(func(int64) (forge.Forge, error)); ok { + return rf(forgeID) + } + if rf, ok := ret.Get(0).(func(int64) forge.Forge); ok { + r0 = rf(forgeID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(forge.Forge) + } + } + + if rf, ok := ret.Get(1).(func(int64) error); ok { + r1 = rf(forgeID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ForgeFromRepo provides a mock function with given fields: repo +func (_m *Manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) { + ret := _m.Called(repo) + + if len(ret) == 0 { + panic("no return value specified for ForgeFromRepo") + } + + var r0 forge.Forge + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo) (forge.Forge, error)); ok { + return rf(repo) + } + if rf, ok := ret.Get(0).(func(*model.Repo) forge.Forge); ok { + r0 = rf(repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(forge.Forge) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo) error); ok { + r1 = rf(repo) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ForgeFromUser provides a mock function with given fields: user +func (_m *Manager) ForgeFromUser(user *model.User) (forge.Forge, error) { + ret := _m.Called(user) + + if len(ret) == 0 { + panic("no return value specified for ForgeFromUser") + } + + var r0 forge.Forge + var r1 error + if rf, ok := ret.Get(0).(func(*model.User) (forge.Forge, error)); ok { + return rf(user) + } + if rf, ok := ret.Get(0).(func(*model.User) forge.Forge); ok { + r0 = rf(user) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(forge.Forge) + } + } + + if rf, ok := ret.Get(1).(func(*model.User) error); ok { + r1 = rf(user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterServiceFromRepo provides a mock function with given fields: repo +func (_m *Manager) ParameterServiceFromRepo(repo *model.Repo) parameter.Service { + ret := _m.Called(repo) + + if len(ret) == 0 { + panic("no return value specified for ParameterServiceFromRepo") + } + + var r0 parameter.Service + if rf, ok := ret.Get(0).(func(*model.Repo) parameter.Service); ok { + r0 = rf(repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(parameter.Service) + } + } + + return r0 +} + +// RegistryService provides a mock function with no fields +func (_m *Manager) RegistryService() registry.Service { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for RegistryService") + } + + var r0 registry.Service + if rf, ok := ret.Get(0).(func() registry.Service); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(registry.Service) + } + } + + return r0 +} + +// RegistryServiceFromRepo provides a mock function with given fields: repo +func (_m *Manager) RegistryServiceFromRepo(repo *model.Repo) registry.Service { + ret := _m.Called(repo) + + if len(ret) == 0 { + panic("no return value specified for RegistryServiceFromRepo") + } + + var r0 registry.Service + if rf, ok := ret.Get(0).(func(*model.Repo) registry.Service); ok { + r0 = rf(repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(registry.Service) + } + } + + return r0 +} + +// SecretService provides a mock function with no fields +func (_m *Manager) SecretService() secret.Service { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for SecretService") + } + + var r0 secret.Service + if rf, ok := ret.Get(0).(func() secret.Service); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(secret.Service) + } + } + + return r0 +} + +// SecretServiceFromRepo provides a mock function with given fields: repo +func (_m *Manager) SecretServiceFromRepo(repo *model.Repo) secret.Service { + ret := _m.Called(repo) + + if len(ret) == 0 { + panic("no return value specified for SecretServiceFromRepo") + } + + var r0 secret.Service + if rf, ok := ret.Get(0).(func(*model.Repo) secret.Service); ok { + r0 = rf(repo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(secret.Service) + } + } + + return r0 +} + +// SignaturePublicKey provides a mock function with no fields +func (_m *Manager) SignaturePublicKey() crypto.PublicKey { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for SignaturePublicKey") + } + + var r0 crypto.PublicKey + if rf, ok := ret.Get(0).(func() crypto.PublicKey); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(crypto.PublicKey) + } + } + + return r0 +} + +// NewManager creates a new instance of Manager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewManager(t interface { + mock.TestingT + Cleanup(func()) +}) *Manager { + mock := &Manager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/services/parameter/db.go b/server/services/parameter/db.go new file mode 100644 index 000000000..8f5c4cb25 --- /dev/null +++ b/server/services/parameter/db.go @@ -0,0 +1,61 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parameter + +import ( + "go.woodpecker-ci.org/woodpecker/v3/server/model" + "go.woodpecker-ci.org/woodpecker/v3/server/store" +) + +type db struct { + store store.Store +} + +// NewDB returns a new parameter service. +func NewDB(store store.Store) Service { + return &db{store: store} +} + +func (d *db) ParameterFind(repo *model.Repo, name string) (*model.Parameter, error) { + return d.store.ParameterFind(repo, name) +} + +func (d *db) ParameterFindByID(repo *model.Repo, id int64) (*model.Parameter, error) { + return d.store.ParameterFindByID(repo, id) +} + +func (d *db) ParameterFindByNameAndBranch(repo *model.Repo, name string, branch string) (*model.Parameter, error) { + return d.store.ParameterFindByNameAndBranch(repo, name, branch) +} + +func (d *db) ParameterList(repo *model.Repo) ([]*model.Parameter, error) { + return d.store.ParameterList(repo) +} + +func (d *db) ParameterCreate(repo *model.Repo, parameter *model.Parameter) error { + return d.store.ParameterCreate(repo, parameter) +} + +func (d *db) ParameterUpdate(repo *model.Repo, parameter *model.Parameter) error { + return d.store.ParameterUpdate(repo, parameter) +} + +func (d *db) ParameterDelete(repo *model.Repo, name string) error { + return d.store.ParameterDelete(repo, name) +} + +func (d *db) ParameterDeleteByID(repo *model.Repo, id int64) error { + return d.store.ParameterDeleteByID(repo, id) +} diff --git a/server/services/parameter/db_test.go b/server/services/parameter/db_test.go new file mode 100644 index 000000000..1c3595513 --- /dev/null +++ b/server/services/parameter/db_test.go @@ -0,0 +1,45 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parameter_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" + "go.woodpecker-ci.org/woodpecker/v3/server/services/parameter" + mocks_store "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" +) + +func TestParameterList(t *testing.T) { + mockStore := mocks_store.NewStore(t) + + testParam := &model.Parameter{ + ID: 1, + RepoID: 1, + Name: "test", + Type: "string", + Description: "test parameter", + } + + mockStore.On("ParameterList", mock.Anything).Return([]*model.Parameter{testParam}, nil) + + s, err := parameter.NewDB(mockStore).ParameterList(&model.Repo{}) + assert.NoError(t, err) + assert.Len(t, s, 1) + assert.Equal(t, "test", s[0].Name) +} diff --git a/server/services/parameter/mocks/service.go b/server/services/parameter/mocks/service.go new file mode 100644 index 000000000..9e6359884 --- /dev/null +++ b/server/services/parameter/mocks/service.go @@ -0,0 +1,219 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + model "go.woodpecker-ci.org/woodpecker/v3/server/model" +) + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +// ParameterCreate provides a mock function with given fields: _a0, _a1 +func (_m *Service) ParameterCreate(_a0 *model.Repo, _a1 *model.Parameter) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterCreate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, *model.Parameter) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ParameterDelete provides a mock function with given fields: _a0, _a1 +func (_m *Service) ParameterDelete(_a0 *model.Repo, _a1 string) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterDelete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, string) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ParameterDeleteByID provides a mock function with given fields: _a0, _a1 +func (_m *Service) ParameterDeleteByID(_a0 *model.Repo, _a1 int64) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterDeleteByID") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, int64) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ParameterFind provides a mock function with given fields: _a0, _a1 +func (_m *Service) ParameterFind(_a0 *model.Repo, _a1 string) (*model.Parameter, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterFind") + } + + var r0 *model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo, string) (*model.Parameter, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(*model.Repo, string) *model.Parameter); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterFindByID provides a mock function with given fields: _a0, _a1 +func (_m *Service) ParameterFindByID(_a0 *model.Repo, _a1 int64) (*model.Parameter, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterFindByID") + } + + var r0 *model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo, int64) (*model.Parameter, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(*model.Repo, int64) *model.Parameter); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo, int64) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterFindByNameAndBranch provides a mock function with given fields: repo, name, branch +func (_m *Service) ParameterFindByNameAndBranch(repo *model.Repo, name string, branch string) (*model.Parameter, error) { + ret := _m.Called(repo, name, branch) + + if len(ret) == 0 { + panic("no return value specified for ParameterFindByNameAndBranch") + } + + var r0 *model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo, string, string) (*model.Parameter, error)); ok { + return rf(repo, name, branch) + } + if rf, ok := ret.Get(0).(func(*model.Repo, string, string) *model.Parameter); ok { + r0 = rf(repo, name, branch) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo, string, string) error); ok { + r1 = rf(repo, name, branch) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterList provides a mock function with given fields: _a0 +func (_m *Service) ParameterList(_a0 *model.Repo) ([]*model.Parameter, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for ParameterList") + } + + var r0 []*model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo) ([]*model.Parameter, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(*model.Repo) []*model.Parameter); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterUpdate provides a mock function with given fields: _a0, _a1 +func (_m *Service) ParameterUpdate(_a0 *model.Repo, _a1 *model.Parameter) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterUpdate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, *model.Parameter) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewService creates a new instance of Service. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewService(t interface { + mock.TestingT + Cleanup(func()) +}) *Service { + mock := &Service{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/services/parameter/service.go b/server/services/parameter/service.go new file mode 100644 index 000000000..1c41ed742 --- /dev/null +++ b/server/services/parameter/service.go @@ -0,0 +1,32 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parameter + +import "go.woodpecker-ci.org/woodpecker/v3/server/model" + +//go:generate mockery --name Service --output mocks --case underscore + +// Service defines a service for managing parameters. +type Service interface { + // Repository parameters + ParameterFind(*model.Repo, string) (*model.Parameter, error) + ParameterFindByID(*model.Repo, int64) (*model.Parameter, error) + ParameterFindByNameAndBranch(repo *model.Repo, name string, branch string) (*model.Parameter, error) + ParameterList(*model.Repo) ([]*model.Parameter, error) + ParameterCreate(*model.Repo, *model.Parameter) error + ParameterUpdate(*model.Repo, *model.Parameter) error + ParameterDelete(*model.Repo, string) error + ParameterDeleteByID(*model.Repo, int64) error +} diff --git a/server/services/setup.go b/server/services/setup.go index 1079326f6..4660dab1b 100644 --- a/server/services/setup.go +++ b/server/services/setup.go @@ -28,6 +28,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/config" + "go.woodpecker-ci.org/woodpecker/v3/server/services/parameter" "go.woodpecker-ci.org/woodpecker/v3/server/services/registry" "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" @@ -46,6 +47,10 @@ func setupRegistryService(store store.Store, dockerConfig string) registry.Servi return registry.NewDB(store) } +func setupParameterService(store store.Store) parameter.Service { + return parameter.NewDB(store) +} + func setupSecretService(store store.Store) secret.Service { // TODO(1544): fix encrypted store // // encryption diff --git a/server/store/datastore/migration/migration.go b/server/store/datastore/migration/migration.go index e5e72ea78..3971807e1 100644 --- a/server/store/datastore/migration/migration.go +++ b/server/store/datastore/migration/migration.go @@ -67,6 +67,7 @@ var allBeans = []any{ new(model.Step), new(model.Registry), new(model.Repo), + new(model.Parameter), new(model.Secret), new(model.Task), new(model.User), diff --git a/server/store/datastore/parameter.go b/server/store/datastore/parameter.go new file mode 100644 index 000000000..23106ae9a --- /dev/null +++ b/server/store/datastore/parameter.go @@ -0,0 +1,72 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package datastore + +import ( + "go.woodpecker-ci.org/woodpecker/v3/server/model" +) + +func (s storage) ParameterFind(repo *model.Repo, name string) (*model.Parameter, error) { + parameter := new(model.Parameter) + return parameter, wrapGet(s.engine.Where("parameter_repo_id = ? AND parameter_name = ?", repo.ID, name).Get(parameter)) +} + +func (s storage) ParameterFindByID(repo *model.Repo, id int64) (*model.Parameter, error) { + parameter := new(model.Parameter) + return parameter, wrapGet(s.engine.Where("parameter_repo_id = ? AND parameter_id = ?", repo.ID, id).Get(parameter)) +} + +func (s storage) ParameterFindByNameAndBranch(repo *model.Repo, name string, branch string) (*model.Parameter, error) { + parameter := new(model.Parameter) + return parameter, wrapGet(s.engine.Where("parameter_repo_id = ? AND parameter_name = ? AND parameter_branch = ?", repo.ID, name, branch).Get(parameter)) +} + +func (s storage) ParameterList(repo *model.Repo) ([]*model.Parameter, error) { + var parameters []*model.Parameter + return parameters, s.engine.Where("parameter_repo_id = ?", repo.ID).OrderBy("parameter_name").Find(¶meters) +} + +func (s storage) ParameterCreate(repo *model.Repo, parameter *model.Parameter) error { + if err := parameter.Validate(); err != nil { + return err + } + parameter.RepoID = repo.ID + // only Insert set auto created ID back to object + _, err := s.engine.Insert(parameter) + return err +} + +func (s storage) ParameterUpdate(repo *model.Repo, parameter *model.Parameter) error { + if err := parameter.Validate(); err != nil { + return err + } + parameter.RepoID = repo.ID + _, err := s.engine.Where("parameter_repo_id = ? AND parameter_id = ?", repo.ID, parameter.ID). + AllCols(). + Update(parameter) + return err +} + +func (s storage) ParameterDelete(repo *model.Repo, name string) error { + _, err := s.engine.Where("parameter_repo_id = ? AND parameter_id = ?", repo.ID, name). + Delete(&model.Parameter{}) + return err +} + +func (s storage) ParameterDeleteByID(repo *model.Repo, id int64) error { + _, err := s.engine.Where("parameter_repo_id = ? AND parameter_id = ?", repo.ID, id). + Delete(&model.Parameter{}) + return err +} diff --git a/server/store/datastore/parameter_test.go b/server/store/datastore/parameter_test.go new file mode 100644 index 000000000..b0afe62f4 --- /dev/null +++ b/server/store/datastore/parameter_test.go @@ -0,0 +1,118 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package datastore + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" +) + +func TestParameterFind(t *testing.T) { + store, closer := newTestStore(t, new(model.Parameter)) + defer closer() + + repo := &model.Repo{ + ID: 1, + } + parameter := &model.Parameter{ + RepoID: repo.ID, + Name: "foo", + Type: model.ParameterTypeString, + Description: "test parameter", + } + + assert.NoError(t, store.ParameterCreate(repo, parameter)) + parameter, err := store.ParameterFind(repo, parameter.Name) + assert.NoError(t, err) + assert.Equal(t, "foo", parameter.Name) +} + +func TestParameterList(t *testing.T) { + store, closer := newTestStore(t, new(model.Parameter)) + defer closer() + + repo := &model.Repo{ + ID: 1, + } + parameters := []*model.Parameter{ + { + RepoID: repo.ID, + Name: "foo", + Type: model.ParameterTypeString, + Description: "test parameter 1", + }, + { + RepoID: repo.ID, + Name: "bar", + Type: model.ParameterTypeBoolean, + Description: "test parameter 2", + }, + } + + for _, parameter := range parameters { + assert.NoError(t, store.ParameterCreate(repo, parameter)) + } + + list, err := store.ParameterList(repo) + assert.NoError(t, err) + assert.Len(t, list, len(parameters)) +} + +func TestParameterUpdate(t *testing.T) { + store, closer := newTestStore(t, new(model.Parameter)) + defer closer() + + repo := &model.Repo{ + ID: 1, + } + parameter := &model.Parameter{ + RepoID: repo.ID, + Name: "foo", + Type: model.ParameterTypeString, + Description: "test parameter", + } + + assert.NoError(t, store.ParameterCreate(repo, parameter)) + parameter.Description = "updated description" + assert.NoError(t, store.ParameterUpdate(repo, parameter)) + + updated, err := store.ParameterFind(repo, parameter.Name) + assert.NoError(t, err) + assert.Equal(t, "updated description", updated.Description) +} + +func TestParameterDelete(t *testing.T) { + store, closer := newTestStore(t, new(model.Parameter)) + defer closer() + + repo := &model.Repo{ + ID: 1, + } + parameter := &model.Parameter{ + RepoID: repo.ID, + Name: "foo", + Type: model.ParameterTypeString, + Description: "test parameter", + } + + assert.NoError(t, store.ParameterCreate(repo, parameter)) + assert.NoError(t, store.ParameterDelete(repo, parameter.Name)) + + _, err := store.ParameterFind(repo, parameter.Name) + assert.Error(t, err) +} diff --git a/server/store/mocks/store.go b/server/store/mocks/store.go index 1a9fead5a..0bca107c7 100644 --- a/server/store/mocks/store.go +++ b/server/store/mocks/store.go @@ -1808,6 +1808,198 @@ func (_m *Store) OrgUpdate(_a0 *model.Org) error { return r0 } +// ParameterCreate provides a mock function with given fields: _a0, _a1 +func (_m *Store) ParameterCreate(_a0 *model.Repo, _a1 *model.Parameter) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterCreate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, *model.Parameter) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ParameterDelete provides a mock function with given fields: _a0, _a1 +func (_m *Store) ParameterDelete(_a0 *model.Repo, _a1 string) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterDelete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, string) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ParameterDeleteByID provides a mock function with given fields: _a0, _a1 +func (_m *Store) ParameterDeleteByID(_a0 *model.Repo, _a1 int64) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterDeleteByID") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, int64) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ParameterFind provides a mock function with given fields: _a0, _a1 +func (_m *Store) ParameterFind(_a0 *model.Repo, _a1 string) (*model.Parameter, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterFind") + } + + var r0 *model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo, string) (*model.Parameter, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(*model.Repo, string) *model.Parameter); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterFindByID provides a mock function with given fields: _a0, _a1 +func (_m *Store) ParameterFindByID(_a0 *model.Repo, _a1 int64) (*model.Parameter, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterFindByID") + } + + var r0 *model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo, int64) (*model.Parameter, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(*model.Repo, int64) *model.Parameter); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo, int64) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterFindByNameAndBranch provides a mock function with given fields: _a0, _a1, _a2 +func (_m *Store) ParameterFindByNameAndBranch(_a0 *model.Repo, _a1 string, _a2 string) (*model.Parameter, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for ParameterFindByNameAndBranch") + } + + var r0 *model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo, string, string) (*model.Parameter, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(*model.Repo, string, string) *model.Parameter); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo, string, string) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterList provides a mock function with given fields: _a0 +func (_m *Store) ParameterList(_a0 *model.Repo) ([]*model.Parameter, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for ParameterList") + } + + var r0 []*model.Parameter + var r1 error + if rf, ok := ret.Get(0).(func(*model.Repo) ([]*model.Parameter, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(*model.Repo) []*model.Parameter); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Parameter) + } + } + + if rf, ok := ret.Get(1).(func(*model.Repo) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParameterUpdate provides a mock function with given fields: _a0, _a1 +func (_m *Store) ParameterUpdate(_a0 *model.Repo, _a1 *model.Parameter) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for ParameterUpdate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Repo, *model.Parameter) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // PermFind provides a mock function with given fields: user, repo func (_m *Store) PermFind(user *model.User, repo *model.Repo) (*model.Perm, error) { ret := _m.Called(user, repo) diff --git a/server/store/store.go b/server/store/store.go index e12b85a05..81180354e 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -110,6 +110,16 @@ type Store interface { ConfigPersist(*model.Config) (*model.Config, error) PipelineConfigCreate(*model.PipelineConfig) error + // Parameters + ParameterFind(*model.Repo, string) (*model.Parameter, error) + ParameterFindByID(*model.Repo, int64) (*model.Parameter, error) + ParameterFindByNameAndBranch(*model.Repo, string, string) (*model.Parameter, error) + ParameterList(*model.Repo) ([]*model.Parameter, error) + ParameterCreate(*model.Repo, *model.Parameter) error + ParameterUpdate(*model.Repo, *model.Parameter) error + ParameterDelete(*model.Repo, string) error + ParameterDeleteByID(*model.Repo, int64) error + // Secrets SecretFind(*model.Repo, string) (*model.Secret, error) SecretList(*model.Repo, bool, *model.ListOptions) ([]*model.Secret, error) diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 358b72741..1384f3beb 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -48,7 +48,12 @@ "name": "Variable name", "value": "Variable value" }, - "show_pipelines": "Show pipelines" + "show_pipelines": "Show pipelines", + "parameters": { + "title": "Parameters", + "trim": "Trim whitespace", + "desc": "Parameters are predefined variables passed to your manual pipeline." + } }, "deploy_pipeline": { "title": "Trigger a deployment for current pipeline #{pipelineId}", @@ -210,6 +215,7 @@ "loading": "Loading…", "no_logs": "No logs", "pipeline": "Pipeline #{pipelineId}", + "variables": "Pipeline Variables", "log_title": "Step Logs", "log_download_error": "An error occurred while downloading the log file", "log_delete_confirm": "Do you really want to delete the step logs?", @@ -453,6 +459,36 @@ } } }, + "parameters": { + "parameters": "Parameters", + "desc": "Parameters are exposed as environment variables in manual pipelines.", + "description": "Description", + "add": "Add Parameter", + "show": "Show Parameters", + "edit": "Edit Parameter", + "delete": "Delete Parameter", + "created": "Parameter created", + "save": "Save parameter", + "saved": "Parameter saved", + "deleted": "Parameter deleted", + "name": "Name", + "branch": "Branch", + "type": "Type", + "default_value": "Default Value", + "trim_string": "Allow Whitespace Trim", + "set_by_default": "Set by Default", + "types": { + "boolean": "Boolean", + "single_choice": "Single Choice", + "multiple_choice": "Multiple Choice", + "string": "String", + "text": "Text", + "password": "Password" + }, + "choices_placeholder": "Enter one choice per line", + "choices_help": "Enter each choice on a new line.", + "any_branch": "Any (*)" + }, "secrets": { "secrets": "Secrets", "desc": "Secrets can be used in all pipelines of this repository.", diff --git a/web/src/components/parameters/ParameterEdit.vue b/web/src/components/parameters/ParameterEdit.vue new file mode 100644 index 000000000..152c0f177 --- /dev/null +++ b/web/src/components/parameters/ParameterEdit.vue @@ -0,0 +1,234 @@ + + + + + + + + + + {{ $t('parameters.any_branch') }} + + {{ branch }} + + + + + + + + {{ $t(`parameters.types.${type}`) }} + + + + + + + + + + + + + + + {{ $t('parameters.choices_help') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/components/parameters/ParameterList.vue b/web/src/components/parameters/ParameterList.vue new file mode 100644 index 000000000..e086ebd92 --- /dev/null +++ b/web/src/components/parameters/ParameterList.vue @@ -0,0 +1,50 @@ + + + + + + {{ parameter.name }} + + + {{ parameter.branch }} + + + + + + + + + + + + {{ parameter.description }} + + + + + + diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 588b054b7..cea03fc80 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -6,6 +6,7 @@ import type { Forge, Org, OrgPermissions, + Parameter, Pipeline, PipelineConfig, PipelineFeed, @@ -157,6 +158,26 @@ export default class WoodpeckerClient extends ApiClient { return this._delete(`/api/repos/${repoId}/logs/${pipeline}/${step}`); } + async getParameters(repo: Repo): Promise { + return this._get(`/api/repos/${repo.id}/parameters`) as Promise; + } + + async getParameter(repo: Repo, id: number): Promise { + return this._get(`/api/repos/${repo.id}/parameters/${id}`) as Promise; + } + + async createParameter(repo: Repo, parameter: Partial): Promise { + return this._post(`/api/repos/${repo.id}/parameters`, parameter) as Promise; + } + + async updateParameter(repo: Repo, parameter: Partial): Promise { + return this._patch(`/api/repos/${repo.id}/parameters/${parameter.id}`, parameter) as Promise; + } + + async deleteParameter(repo: Repo, id: number): Promise { + return this._delete(`/api/repos/${repo.id}/parameters/${id}`); + } + async getSecretList(repoId: number, opts?: PaginationOptions): Promise { const query = encodeQueryString(opts); return this._get(`/api/repos/${repoId}/secrets?${query}`) as Promise; diff --git a/web/src/lib/api/types/index.ts b/web/src/lib/api/types/index.ts index 1015ebf94..2ddf12094 100644 --- a/web/src/lib/api/types/index.ts +++ b/web/src/lib/api/types/index.ts @@ -8,6 +8,7 @@ export * from './pull_request'; export * from './queue'; export * from './registry'; export * from './repo'; +export * from './parameter'; export * from './secret'; export * from './user'; export * from './webhook'; diff --git a/web/src/lib/api/types/parameter.ts b/web/src/lib/api/types/parameter.ts new file mode 100644 index 000000000..18a68c027 --- /dev/null +++ b/web/src/lib/api/types/parameter.ts @@ -0,0 +1,19 @@ +export enum ParameterType { + Boolean = 'boolean', + SingleChoice = 'single_choice', + MultipleChoice = 'multiple_choice', + String = 'string', + Text = 'text', + Password = 'password' +} + +export interface Parameter { + id: string; + repo_id: number; + name: string; + branch: string; + type: ParameterType; + description: string; + default_value: string; + trim_string: boolean; +} diff --git a/web/src/router.ts b/web/src/router.ts index a0cabdabb..3bc7b6ebe 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -130,6 +130,12 @@ const routes: RouteRecordRaw[] = [ component: (): Component => import('~/views/repo/settings/Secrets.vue'), props: true, }, + { + path: 'parameters', + name: 'repo-settings-parameters', + component: (): Component => import('~/views/repo/settings/Parameters.vue'), + props: true, + }, { path: 'registries', name: 'repo-settings-registries', diff --git a/web/src/views/repo/RepoManualPipeline.vue b/web/src/views/repo/RepoManualPipeline.vue index d698a0b4d..a5aa5e117 100644 --- a/web/src/views/repo/RepoManualPipeline.vue +++ b/web/src/views/repo/RepoManualPipeline.vue @@ -3,8 +3,104 @@ {{ $t('repo.manual_pipeline.title') }} - + + + + + + {{ $t('repo.manual_pipeline.parameters.desc') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ param.description }} + + + + + {{ $t('repo.manual_pipeline.variables.desc') }} { return payload.value.branch !== '' && isVariablesValid.value; }); -const pipelineOptions = computed(() => ({ - ...payload.value, - variables: payload.value.variables, -})); +const pipelineOptions = computed(() => { + // Start with the base payload + const options = { + ...payload.value, + variables: { ...payload.value.variables }, + }; + + // Add parameter values + parameters.value.forEach((param) => { + let value = paramValues.value[param.name]; + + // Convert boolean to string + if (param.type === 'boolean') { + value = value ? 'true' : 'false'; + } + + // Join multiple choice values + if (param.type === 'multiple_choice' && Array.isArray(value)) { + value = value.join(','); + } + + // Apply trim if enabled + if (paramTrimEnabled.value[param.name] && typeof value === 'string') { + value = value.trim(); + } + + //options.variables[param.name.toUpperCase()] = value; + options.variables[param.name] = value; + }); + + return options; +}); const loading = ref(true); +const parameters = ref([]); +const paramValues = ref>({}); +const paramTrimEnabled = ref>({}); +const passwordVisibility = ref>({}); + +function getOptionsFromDefaultValue(defaultValue: string, addEmptyOption = false) { + const options = defaultValue + .split('\n') + .filter(Boolean) + .map((value) => ({ + text: value, + value, + })); + + // For single choice, add empty option at the start + if (addEmptyOption && options.length > 0) { + options.unshift({ text: '-- empty --', value: '' }); + } + + return options; +} + +async function loadParameters() { + if (!payload.value.branch) return; + + try { + const allParams = await apiClient.getParameters(repo.value); + const selectedBranch = payload.value.branch; + + // Create a map to store parameters by name + const paramMap = new Map(); + + // First pass: add wildcard (*) parameters + allParams.forEach((param) => { + if (param.branch === '*') { + paramMap.set(param.name, param); + } + }); + + // Second pass: override with branch-specific parameters + allParams.forEach((param) => { + if (param.branch === selectedBranch) { + paramMap.set(param.name, param); + } + }); + + // Convert map back to array + parameters.value = Array.from(paramMap.values()); + + // Initialize parameter values with defaults + parameters.value.forEach((param) => { + if (param.type === 'boolean') { + paramValues.value[param.name] = param.default_value === 'true'; + } else if (param.type === 'single_choice') { + paramValues.value[param.name] = ''; // Empty string for default empty selection + } else if (param.type === 'multiple_choice') { + paramValues.value[param.name] = []; // Empty array for no selections + } else { + paramValues.value[param.name] = param.default_value; + } + + if (param.type === 'password') { + passwordVisibility.value[param.name] = false; + } + + if (param.trim_string) { + paramTrimEnabled.value[param.name] = true; + } + }); + } catch (error) { + console.error('Failed to load parameters:', error); + } +} + +// Load parameters when component mounts onMounted(async () => { if (!repoPermissions.value.push) { notifications.notify({ type: 'error', title: i18n.t('repo.settings.not_allowed') }); @@ -87,6 +289,9 @@ onMounted(async () => { value: e, })); loading.value = false; + if (payload.value.branch) { + loadParameters(); + } }); async function triggerManualPipeline() { @@ -107,3 +312,9 @@ async function triggerManualPipeline() { useWPTitle(computed(() => [i18n.t('repo.manual_pipeline.trigger'), repo.value.full_name])); + + diff --git a/web/src/views/repo/pipeline/PipelineConfig.vue b/web/src/views/repo/pipeline/PipelineConfig.vue index 141933eba..46d264301 100644 --- a/web/src/views/repo/pipeline/PipelineConfig.vue +++ b/web/src/views/repo/pipeline/PipelineConfig.vue @@ -1,5 +1,23 @@ + + + + {{ key }}: + {{ value }} + + + + diff --git a/web/src/views/repo/pipeline/PipelineWrapper.vue b/web/src/views/repo/pipeline/PipelineWrapper.vue index 9ef41a536..383aa5852 100644 --- a/web/src/views/repo/pipeline/PipelineWrapper.vue +++ b/web/src/views/repo/pipeline/PipelineWrapper.vue @@ -156,6 +156,9 @@ provide('pipeline', pipeline as Ref); // can't be undefined because of const pipelineConfigs = ref(); provide('pipeline-configs', pipelineConfigs); +const pipelineVariables = ref>(); +provide('pipeline-variables', pipelineVariables); + watch( pipeline, () => { @@ -174,6 +177,7 @@ async function loadPipeline(): Promise { } pipelineConfigs.value = await apiClient.getPipelineConfig(repo.value.id, pipeline.value.number); + pipelineVariables.value = pipeline.value.variables; } const { doSubmit: cancelPipeline, isLoading: isCancelingPipeline } = useAsyncAction(async () => { diff --git a/web/src/views/repo/settings/Parameters.vue b/web/src/views/repo/settings/Parameters.vue new file mode 100644 index 000000000..45fc70ebb --- /dev/null +++ b/web/src/views/repo/settings/Parameters.vue @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + diff --git a/web/src/views/repo/settings/RepoSettings.vue b/web/src/views/repo/settings/RepoSettings.vue index 2fc4b3a1b..768acd337 100644 --- a/web/src/views/repo/settings/RepoSettings.vue +++ b/web/src/views/repo/settings/RepoSettings.vue @@ -18,6 +18,7 @@ +
+ {{ parameter.description }} +