Added support for managing manual pipeline parameters

Introduced backend API endpoints, database operations, and web UI components for creating, updating, listing, and deleting manual pipeline parameters. This update includes tests for datastore functions, integration with the Vue.js web interface, and mock implementation for parameter-related methods.
This commit is contained in:
Arthur
2025-10-12 15:03:58 +02:00
committed by Robert Kaussow
parent 87c93841ce
commit 7e960b4a2b
27 changed files with 2005 additions and 6 deletions
+152
View File
@@ -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)
}
+63
View File
@@ -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
}
}
+7
View File
@@ -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)
+12
View File
@@ -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
}
+295
View File
@@ -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
}
+61
View File
@@ -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)
}
+45
View File
@@ -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)
}
+219
View File
@@ -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
}
+32
View File
@@ -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
}
+5
View File
@@ -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
@@ -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),
+72
View File
@@ -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(&parameters)
}
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
}
+118
View File
@@ -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)
}
+192
View File
@@ -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)
+10
View File
@@ -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)
+37 -1
View File
@@ -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.",
@@ -0,0 +1,234 @@
<template>
<div v-if="innerParameter" class="space-y-4">
<form @submit.prevent="handleSubmit">
<InputField v-slot="{ id }" :label="$t('parameters.name')">
<TextField
:id="id"
v-model="innerParameter.name"
:placeholder="$t('parameters.name')"
required
/>
</InputField>
<InputField v-slot="{ id }" :label="$t('parameters.branch')">
<select
:id="id"
v-model="innerParameter.branch"
class="block w-full rounded-md border border-wp-control-neutral-200 bg-wp-control-neutral-100 px-3 py-2 text-sm text-wp-text-100"
>
<option value="*">{{ $t('parameters.any_branch') }}</option>
<option
v-for="branch in branches"
:key="branch"
:value="branch"
>
{{ branch }}
</option>
</select>
</InputField>
<InputField v-slot="{ id }" :label="$t('parameters.type')">
<select
:id="id"
v-model="innerParameter.type"
class="block w-full rounded-md border border-wp-control-neutral-200 bg-wp-control-neutral-100 px-3 py-2 text-sm text-wp-text-100"
required
>
<option v-for="type in parameterTypes" :key="type" :value="type">
{{ $t(`parameters.types.${type}`) }}
</option>
</select>
</InputField>
<template v-if="innerParameter.type">
<InputField v-if="showDefaultValue" v-slot="{ id }" :label="$t('parameters.default_value')">
<template v-if="isBoolean">
<Checkbox
:id="id"
v-model="innerParameter.default_value"
:label="$t('parameters.set_by_default')"
/>
</template>
<template v-else-if="isPassword">
<TextField
:id="id"
v-model="innerParameter.default_value"
:type="passwordVisibility ? 'text' : 'password'"
@dblclick="passwordVisibility = true"
@blur="passwordVisibility = false"
class="text-sm"
/>
</template>
<template v-else-if="isChoice">
<TextField
:id="id"
v-model="innerParameter.default_value"
:lines="5"
:placeholder="$t('parameters.choices_placeholder')"
/>
<span class="mt-1 text-sm text-wp-text-alt-100">{{ $t('parameters.choices_help') }}</span>
</template>
<template v-else-if="isText">
<TextField
:id="id"
v-model="innerParameter.default_value"
:lines="3"
class="text-sm"
/>
</template>
<template v-else>
<TextField
:id="id"
v-model="innerParameter.default_value"
class="text-sm"
/>
</template>
</InputField>
<InputField v-if="showTrimString" v-slot="{ id }" :label="$t('parameters.trim_string')">
<Checkbox
:id="id"
v-model="innerParameter.trim_string"
:label="innerParameter.trim_string ? 'true' : 'false'"
/>
</InputField>
</template>
<InputField v-slot="{ id }" :label="$t('parameters.description')">
<TextField
:id="id"
v-model="innerParameter.description"
:lines="3"
class="text-sm"
/>
</InputField>
<div class="flex gap-2">
<Button type="button" color="gray" :text="$t('cancel')" @click="$emit('cancel')" />
<Button
type="submit"
color="green"
:is-loading="isSaving"
:text="existingParameter ? $t('parameters.save') : $t('parameters.add')"
/>
</div>
</form>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, ref, watch } from 'vue';
import type { Ref } from 'vue';
import Button from '~/components/atomic/Button.vue';
import Checkbox from '~/components/form/Checkbox.vue';
import InputField from '~/components/form/InputField.vue';
import TextField from '~/components/form/TextField.vue';
import useApiClient from '~/compositions/useApiClient';
import type { Parameter, Repo } from '~/lib/api/types';
import { ParameterType } from '~/lib/api/types';
const props = defineProps<{
modelValue: Partial<Parameter>;
existingParameter?: boolean;
isSaving?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: Partial<Parameter>): void;
(e: 'save', value: Partial<Parameter>): void;
(e: 'cancel'): void;
}>();
const apiClient = useApiClient();
const repo = inject('repo') as Ref<Repo>;
const branches = ref<string[]>([]);
const parameterTypes = Object.values(ParameterType);
// Create a local copy of the parameter to avoid mutating props directly
const innerParameter = ref<Partial<Parameter>>({ ...props.modelValue });
const passwordVisibility = ref(false);
// Update local copy when prop changes
watch(() => props.modelValue, (newVal) => {
innerParameter.value = { ...newVal };
}, { deep: true });
// Update parent when local copy changes
watch(innerParameter, (newVal) => {
emit('update:modelValue', newVal);
}, { deep: true });
// Computed properties to control field visibility
const isBoolean = computed(() => innerParameter.value.type === ParameterType.Boolean);
const isPassword = computed(() => innerParameter.value.type === ParameterType.Password);
const isText = computed(() => innerParameter.value.type === ParameterType.Text);
const isChoice = computed(() =>
innerParameter.value.type === ParameterType.SingleChoice ||
innerParameter.value.type === ParameterType.MultipleChoice
);
const showDefaultValue = computed(() => innerParameter.value.type !== undefined);
const showTrimString = computed(() => !isBoolean.value && !isPassword.value);
// Initialize default values based on type
watch(() => innerParameter.value.type, (newType) => {
// Don't override existing values when editing
if (props.existingParameter) {
return;
}
if (newType === ParameterType.Boolean) {
innerParameter.value.default_value = 'false';
innerParameter.value.trim_string = false;
} else if (newType === ParameterType.SingleChoice || newType === ParameterType.MultipleChoice) {
innerParameter.value.default_value = '';
innerParameter.value.trim_string = true;
} else {
innerParameter.value.default_value = '';
innerParameter.value.trim_string = true;
}
}, { immediate: true });
// Special handling for boolean values
watch(() => innerParameter.value.default_value, (newVal) => {
if (isBoolean.value && typeof newVal === 'string') {
// Convert string 'true'/'false' to boolean for checkbox
innerParameter.value.default_value = newVal === 'true';
}
}, { immediate: true });
// Load branches when component is mounted
async function loadBranches() {
if (!repo.value) return;
try {
const branchList = await apiClient.getRepoBranches(repo.value.id);
branches.value = branchList.map(b => b);
} catch (error) {
console.error('Failed to load branches:', error);
}
}
loadBranches();
function handleSubmit() {
// Ensure all required fields are present
if (!innerParameter.value.name || !innerParameter.value.type) {
return;
}
// Ensure branch has a value
if (!innerParameter.value.branch) {
innerParameter.value.branch = '*';
}
// Convert boolean default_value to string
if (isBoolean.value) {
innerParameter.value.default_value = innerParameter.value.default_value ? 'true' : 'false';
}
// Emit the save event with the parameter data
emit('save', innerParameter.value);
}
</script>
@@ -0,0 +1,50 @@
<template>
<div class="flex flex-col gap-4">
<ListItem v-for="parameter in parameters" :key="parameter.name" class="flex flex-col gap-2 !bg-wp-background-200 dark:!bg-wp-background-100">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<h3 class="font-bold">{{ parameter.name }}</h3>
<div class="flex items-center gap-1">
<Icon name="push" class="h-4 w-4" />
<span>{{ parameter.branch }}</span>
</div>
</div>
<div class="flex gap-2">
<div class="md:display-unset ml-auto hidden space-x-2">
<Badge :label="$t(`parameters.types.${parameter.type}`)" />
</div>
<IconButton
:title="$t('parameters.edit')"
icon="edit"
@click="$emit('edit', parameter)"
/>
<IconButton
:title="$t('parameters.delete')"
icon="trash"
@click="$emit('delete', parameter)"
/>
</div>
</div>
<p v-if="parameter.description" class="text-sm text-wp-text-alt-100">
{{ parameter.description }}
</p>
</ListItem>
</div>
</template>
<script lang="ts" setup>
import Badge from "~/components/atomic/Badge.vue";
import Icon from '~/components/atomic/Icon.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import type { Parameter } from '~/lib/api/types/parameter';
defineProps<{
parameters: Parameter[];
}>();
defineEmits<{
(e: 'edit', parameter: Parameter): void;
(e: 'delete', parameter: Parameter): void;
}>();
</script>
+21
View File
@@ -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<Parameter[] | null> {
return this._get(`/api/repos/${repo.id}/parameters`) as Promise<Parameter[] | null>;
}
async getParameter(repo: Repo, id: number): Promise<Parameter | null> {
return this._get(`/api/repos/${repo.id}/parameters/${id}`) as Promise<Parameter | null>;
}
async createParameter(repo: Repo, parameter: Partial<Parameter>): Promise<Parameter | null> {
return this._post(`/api/repos/${repo.id}/parameters`, parameter) as Promise<Parameter | null>;
}
async updateParameter(repo: Repo, parameter: Partial<Parameter>): Promise<Parameter | null> {
return this._patch(`/api/repos/${repo.id}/parameters/${parameter.id}`, parameter) as Promise<Parameter | null>;
}
async deleteParameter(repo: Repo, id: number): Promise<unknown> {
return this._delete(`/api/repos/${repo.id}/parameters/${id}`);
}
async getSecretList(repoId: number, opts?: PaginationOptions): Promise<Secret[] | null> {
const query = encodeQueryString(opts);
return this._get(`/api/repos/${repoId}/secrets?${query}`) as Promise<Secret[] | null>;
+1
View File
@@ -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';
+19
View File
@@ -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;
}
+6
View File
@@ -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',
+216 -5
View File
@@ -3,8 +3,104 @@
<form @submit.prevent="triggerManualPipeline">
<span class="text-wp-text-100 text-xl">{{ $t('repo.manual_pipeline.title') }}</span>
<InputField v-slot="{ id }" :label="$t('repo.manual_pipeline.select_branch')">
<SelectField :id="id" v-model="payload.branch" :options="branches" required />
<SelectField
:id="id"
v-model="payload.branch"
:options="branches"
required
@update:model-value="loadParameters"
/>
</InputField>
<!-- Parameters section -->
<template v-if="parameters.length > 0">
<InputField :label="$t('repo.manual_pipeline.parameters.title')">
<span class="text-wp-text-alt-100 mb-2 text-sm">{{ $t('repo.manual_pipeline.parameters.desc') }}</span>
</InputField>
<div
v-for="param in parameters"
:key="param.id"
class="border-wp-background-300 !bg-wp-background-200 dark:!bg-wp-background-100 mb-4 items-center rounded-md border p-4"
>
<InputField v-slot="{ id }" :label="param.name">
<!-- Boolean parameter -->
<Checkbox
v-if="param.type === 'boolean'"
:id="id"
v-model="paramValues[param.name]"
:label="`${paramValues[param.name]}`"
class="text-sm"
/>
<!-- Single choice parameter -->
<SelectField
v-else-if="param.type === 'single_choice'"
:id="id"
v-model="paramValues[param.name]"
:options="getOptionsFromDefaultValue(param.default_value, true)"
class="dark:bg-wp-background-200 text-sm"
/>
<!-- Multiple choice parameter -->
<SelectField
v-else-if="param.type === 'multiple_choice'"
:id="id"
v-model="paramValues[param.name]"
:options="getOptionsFromDefaultValue(param.default_value)"
multiple
class="dark:bg-wp-background-200 text-sm"
:style="{ height: `${getOptionsFromDefaultValue(param.default_value).length * 19 + 2}px` }"
/>
<!-- String parameter -->
<TextField
v-else-if="param.type === 'string'"
:id="id"
v-model="paramValues[param.name]"
:placeholder="param.name"
class="dark:bg-wp-background-200 text-sm"
/>
<!-- Text parameter -->
<textarea
v-else-if="param.type === 'text'"
:id="id"
v-model="paramValues[param.name]"
class="border-wp-control-neutral-200 text-wp-text-100 dark:bg-wp-background-200 block w-full rounded-md border bg-white px-3 py-2 text-sm"
rows="4"
/>
<!-- Password parameter -->
<TextField
v-else-if="param.type === 'password'"
:id="id"
v-model="paramValues[param.name]"
:placeholder="param.name"
:type="passwordVisibility[param.name] ? 'text' : 'password'"
class="dark:bg-wp-background-200 text-sm"
@dblclick="passwordVisibility[param.name] = true"
@blur="passwordVisibility[param.name] = false"
/>
<!-- Trim string checkbox if applicable -->
<div v-if="param.trim_string && ['string', 'text'].includes(param.type)" class="mt-2">
<Checkbox
:id="`${id}-trim`"
v-model="paramTrimEnabled[param.name]"
:label="$t('repo.manual_pipeline.parameters.trim')"
class="text-sm"
/>
</div>
<!-- Parameter description -->
<div v-if="param.description" class="text-wp-text-alt-100 mt-2 text-sm">
{{ param.description }}
</div>
</InputField>
</div>
</template>
<InputField v-slot="{ id }" :label="$t('repo.manual_pipeline.variables.title')">
<span class="text-wp-text-alt-100 mb-2 text-sm">{{ $t('repo.manual_pipeline.variables.desc') }}</span>
<KeyValueEditor
@@ -32,14 +128,17 @@ import { useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue';
import Icon from '~/components/atomic/Icon.vue';
import Checkbox from '~/components/form/Checkbox.vue';
import InputField from '~/components/form/InputField.vue';
import KeyValueEditor from '~/components/form/KeyValueEditor.vue';
import SelectField from '~/components/form/SelectField.vue';
import TextField from '~/components/form/TextField.vue';
import Panel from '~/components/layout/Panel.vue';
import useApiClient from '~/compositions/useApiClient';
import { requiredInject } from '~/compositions/useInjectProvide';
import { usePaginate } from '~/compositions/usePaginate';
import { useWPTitle } from '~/compositions/useWPTitle';
import type { Parameter } from '~/lib/api/types';
defineProps<{
open: boolean;
@@ -69,12 +168,115 @@ const isFormValid = computed(() => {
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<Parameter[]>([]);
const paramValues = ref<Record<string, any>>({});
const paramTrimEnabled = ref<Record<string, boolean>>({});
const passwordVisibility = ref<Record<string, boolean>>({});
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<string, Parameter>();
// 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]));
</script>
<style scoped>
.dark input[type='checkbox'] {
background-color: var(--wp-background-200) !important;
}
</style>
@@ -1,5 +1,23 @@
<template>
<div class="flex flex-col gap-y-4">
<Panel
v-if="pipelineVariables && Object.keys(pipelineVariables).length > 0"
collapsable
collapsed-by-default
:title="$t('repo.pipeline.variables')"
>
<div class="overflow-auto font-mono whitespace-pre">
<div
v-for="(value, key) in pipelineVariables"
:key="key"
class="border-wp-background-300 flex border-b py-2 last:border-b-0"
>
<span class="text-wp-text-100 min-w-[100px]">{{ key }}:</span>
<span class="text-wp-text-100 flex-1">{{ value }}</span>
</div>
</div>
</Panel>
<Panel
v-for="pipelineConfig in pipelineConfigsDecoded"
:key="pipelineConfig.hash"
@@ -29,6 +47,7 @@ import { useWPTitle } from '~/compositions/useWPTitle';
const repo = requiredInject('repo');
const pipeline = requiredInject('pipeline');
const pipelineConfigs = requiredInject('pipeline-configs');
const pipelineVariables = requiredInject('pipeline-variables');
const pipelineConfigsDecoded = computed(
() =>
@@ -156,6 +156,9 @@ provide('pipeline', pipeline as Ref<Pipeline>); // can't be undefined because of
const pipelineConfigs = ref<PipelineConfig[]>();
provide('pipeline-configs', pipelineConfigs);
const pipelineVariables = ref<Record<string, string>>();
provide('pipeline-variables', pipelineVariables);
watch(
pipeline,
() => {
@@ -174,6 +177,7 @@ async function loadPipeline(): Promise<void> {
}
pipelineConfigs.value = await apiClient.getPipelineConfig(repo.value.id, pipeline.value.number);
pipelineVariables.value = pipeline.value.variables;
}
const { doSubmit: cancelPipeline, isLoading: isCancelingPipeline } = useAsyncAction(async () => {
+113
View File
@@ -0,0 +1,113 @@
<template>
<Settings :title="$t('parameters.parameters')" :description="$t('parameters.desc')" docs-url="docs/usage/parameters">
<template #headerActions>
<Button
v-if="selectedParameter"
:text="$t('parameters.show')"
start-icon="back"
@click="selectedParameter = undefined"
/>
<Button
v-else
:text="$t('parameters.add')"
start-icon="plus"
@click="showAddParameter()"
/>
</template>
<ParameterList
v-if="!selectedParameter"
:parameters="parameters"
:is-deleting="isDeleting"
@edit="editParameter"
@delete="deleteParameter"
/>
<ParameterEdit
v-else
v-model="selectedParameter"
:existing-parameter="isEditingParameter"
:is-saving="isSaving"
@save="createParameter"
@cancel="selectedParameter = undefined"
/>
</Settings>
</template>
<script lang="ts" setup>
import { cloneDeep } from 'lodash';
import { computed, inject, ref } from 'vue';
import type { Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
import Settings from '~/components/layout/Settings.vue';
import ParameterEdit from '~/components/parameters/ParameterEdit.vue';
import ParameterList from '~/components/parameters/ParameterList.vue';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import type { Parameter, Repo } from '~/lib/api/types';
import { ParameterType } from '~/lib/api/types';
import useApiClient from '~/compositions/useApiClient';
const apiClient = useApiClient();
const notifications = useNotifications();
const i18n = useI18n();
const repo = inject('repo') as Ref<Repo>;
const parameters = ref<Parameter[]>([]);
const selectedParameter = ref<Partial<Parameter>>();
const emptyParameter: Partial<Parameter> = {
repo_id: repo.value?.id,
name: '',
branch: '*',
type: ParameterType.String,
description: '',
default_value: '',
trim_string: true,
};
const isEditingParameter = computed(() => selectedParameter.value?.id !== undefined);
async function resetPage() {
parameters.value = (await apiClient.getParameters(repo.value)) || [];
}
function showAddParameter() {
selectedParameter.value = cloneDeep(emptyParameter);
}
const { doSubmit: createParameter, isLoading: isSaving } = useAsyncAction(async () => {
if (!repo?.value || !selectedParameter.value) {
throw new Error("Unexpected: Can't load repo");
}
if (isEditingParameter.value) {
await apiClient.updateParameter(repo.value, selectedParameter.value);
} else {
await apiClient.createParameter(repo.value, selectedParameter.value);
}
notifications.notify({
title: isEditingParameter.value ? i18n.t('parameters.saved') : i18n.t('parameters.created'),
type: 'success',
});
selectedParameter.value = undefined;
await resetPage();
});
const { doSubmit: deleteParameter, isLoading: isDeleting } = useAsyncAction(async (_parameter: Parameter) => {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
await apiClient.deleteParameter(repo.value, _parameter.id);
notifications.notify({ title: i18n.t('parameters.deleted'), type: 'success' });
await resetPage();
});
function editParameter(parameter: Parameter) {
selectedParameter.value = cloneDeep(parameter);
}
resetPage();
</script>
@@ -18,6 +18,7 @@
<Tab icon="settings-outline" :to="{ name: 'repo-settings' }" :title="$t('repo.settings.general.general')" />
<Tab icon="secret" :to="{ name: 'repo-settings-secrets' }" :title="$t('secrets.secrets')" />
<Tab icon="secret" :to="{ name: 'repo-settings-parameters' }" :title="$t('parameters.parameters')" />
<Tab icon="docker" :to="{ name: 'repo-settings-registries' }" :title="$t('registries.registries')" />
<Tab icon="cron" :to="{ name: 'repo-settings-crons' }" :title="$t('repo.settings.crons.crons')" />
<Tab icon="tag" :to="{ name: 'repo-settings-badge' }" :title="$t('repo.settings.badge.badge')" />