Add ability to trigger manual builds (#1156)

closes #83 
closes #240 

Co-authored-by: Anbraten <anton@ju60.de>
Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
Co-authored-by: 6543 <6543@obermui.de>
This commit is contained in:
[X] 2022-09-27 11:05:00 +02:00 committed by GitHub
parent 86bc751b95
commit b4d89a1cce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1096 additions and 806 deletions

View file

@ -23,5 +23,6 @@ var Command = &cli.Command{
buildQueueCmd, buildQueueCmd,
buildKillCmd, buildKillCmd,
buildPsCmd, buildPsCmd,
buildCreateCmd,
}, },
} }

78
cli/build/build_create.go Normal file
View file

@ -0,0 +1,78 @@
package build
import (
"os"
"strings"
"text/template"
"github.com/woodpecker-ci/woodpecker/woodpecker-go/woodpecker"
"github.com/urfave/cli/v2"
"github.com/woodpecker-ci/woodpecker/cli/common"
"github.com/woodpecker-ci/woodpecker/cli/internal"
)
var buildCreateCmd = &cli.Command{
Name: "create",
Usage: "create new build",
ArgsUsage: "<repo/name>",
Action: buildCreate,
Flags: append(common.GlobalFlags,
common.FormatFlag(tmplBuildList),
&cli.StringFlag{
Name: "branch",
Usage: "branch to create build from",
Required: true,
},
&cli.StringSliceFlag{
Name: "var",
Usage: "key=value",
},
),
}
func buildCreate(c *cli.Context) error {
repo := c.Args().First()
owner, name, err := internal.ParseRepo(repo)
if err != nil {
return err
}
client, err := internal.NewClient(c)
if err != nil {
return err
}
branch := c.String("branch")
variables := make(map[string]string)
for _, vaz := range c.StringSlice("var") {
sp := strings.SplitN(vaz, "=", 2)
if len(sp) == 2 {
variables[sp[0]] = sp[1]
}
}
options := &woodpecker.BuildOptions{
Branch: branch,
Variables: variables,
}
build, err := client.BuildCreate(owner, name, options)
if err != nil {
return err
}
tmpl, err := template.New("_").Parse(c.String("format") + "\n")
if err != nil {
return err
}
if err := tmpl.Execute(os.Stdout, build); err != nil {
return err
}
return nil
}

View file

@ -273,10 +273,10 @@ when:
:::info :::info
**By default steps are filtered by following event types:** **By default steps are filtered by following event types:**
`push`, `pull_request`, `tag`, `deployment`. `push`, `pull_request`, `tag`, `deployment`, `manual`.
::: :::
Available events: `push`, `pull_request`, `tag`, `deployment`, `cron` Available events: `push`, `pull_request`, `tag`, `deployment`, `cron`, `manual`
Execute a step if the build event is a `tag`: Execute a step if the build event is a `tag`:

View file

@ -198,6 +198,30 @@ State: {{ .State }}
**--token, -t**="": server auth token **--token, -t**="": server auth token
### create
create new build
**--branch**="": branch to create build from
**--format**="": format output (default: Build #{{ .Number }} 
Status: {{ .Status }}
Event: {{ .Event }}
Commit: {{ .Commit }}
Branch: {{ .Branch }}
Ref: {{ .Ref }}
Author: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}
Message: {{ .Message }}
)
**--log-level**="": set logging level (default: info)
**--server, -s**="": server address
**--token, -t**="": server auth token
**--var**="": key=value
## log ## log
manage logs manage logs

2
go.mod
View file

@ -3,7 +3,7 @@ module github.com/woodpecker-ci/woodpecker
go 1.18 go 1.18
require ( require (
code.gitea.io/sdk/gitea v0.15.1-0.20220720025709-de34275bb64e code.gitea.io/sdk/gitea v0.15.1-0.20220831004139-a0127ed0e7fe
codeberg.org/6543/go-yaml2json v0.2.1 codeberg.org/6543/go-yaml2json v0.2.1
github.com/bmatcuk/doublestar/v4 v4.2.0 github.com/bmatcuk/doublestar/v4 v4.2.0
github.com/caddyserver/certmagic v0.17.1-0.20220901172127-2e22c6fa8c47 github.com/caddyserver/certmagic v0.17.1-0.20220901172127-2e22c6fa8c47

4
go.sum
View file

@ -54,8 +54,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
code.gitea.io/sdk/gitea v0.15.1-0.20220720025709-de34275bb64e h1:xayGBU2DwsrA5ZyqKNpXB91w3BfnkNcLDWZ7Ynn/w+g= code.gitea.io/sdk/gitea v0.15.1-0.20220831004139-a0127ed0e7fe h1:PeLyxnUZE85QuJtBZ4P8qCQcgWG5Ked67mlNgr0WkCQ=
code.gitea.io/sdk/gitea v0.15.1-0.20220720025709-de34275bb64e/go.mod h1:aRmrQC3CAHdJAU1LQt0C9zqzqI8tUB/5oQtNE746aYE= code.gitea.io/sdk/gitea v0.15.1-0.20220831004139-a0127ed0e7fe/go.mod h1:aRmrQC3CAHdJAU1LQt0C9zqzqI8tUB/5oQtNE746aYE=
codeberg.org/6543/go-yaml2json v0.2.1 h1:S0dxlzRRpYnSLODxpbqaUfmJYZZg0Wcpf8bI9YzyOXo= codeberg.org/6543/go-yaml2json v0.2.1 h1:S0dxlzRRpYnSLODxpbqaUfmJYZZg0Wcpf8bI9YzyOXo=
codeberg.org/6543/go-yaml2json v0.2.1/go.mod h1:mz61q14LWF4ZABrgMEDMmk3t9dPi6zgR1uBh2VKV2RQ= codeberg.org/6543/go-yaml2json v0.2.1/go.mod h1:mz61q14LWF4ZABrgMEDMmk3t9dPi6zgR1uBh2VKV2RQ=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=

View file

@ -29,6 +29,7 @@ const (
EventTag = "tag" EventTag = "tag"
EventDeploy = "deployment" EventDeploy = "deployment"
EventCron = "cron" EventCron = "cron"
EventManual = "manual"
) )
type ( type (

View file

@ -166,6 +166,7 @@ func (c *Constraint) SetDefaultEventFilter() {
frontend.EventPull, frontend.EventPull,
frontend.EventTag, frontend.EventTag,
frontend.EventDeploy, frontend.EventDeploy,
frontend.EventManual,
} }
} }
} }

View file

@ -19,12 +19,15 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/woodpecker-ci/woodpecker/server"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -34,6 +37,53 @@ import (
"github.com/woodpecker-ci/woodpecker/server/store" "github.com/woodpecker-ci/woodpecker/server/store"
) )
func CreateBuild(c *gin.Context) {
_store := store.FromContext(c)
repo := session.Repo(c)
var p model.BuildOptions
err := json.NewDecoder(c.Request.Body).Decode(&p)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
user := session.User(c)
lastCommit, _ := server.Config.Services.Remote.BranchHead(c, user, repo, p.Branch)
tmpBuild := createTmpBuild(model.EventManual, lastCommit, repo, user, &p)
build, err := pipeline.Create(c, _store, repo, tmpBuild)
if err != nil {
handlePipelineErr(c, err)
} else {
c.JSON(http.StatusOK, build)
}
}
func createTmpBuild(event model.WebhookEvent, commitSHA string, repo *model.Repo, user *model.User, opts *model.BuildOptions) *model.Build {
return &model.Build{
Event: event,
Commit: commitSHA,
Branch: opts.Branch,
Timestamp: time.Now().UTC().Unix(),
Avatar: user.Avatar,
Message: "MANUAL BUILD @ " + opts.Branch,
Ref: opts.Branch,
AdditionalVariables: opts.Variables,
Author: user.Login,
Email: user.Email,
// TODO: Generate proper link to commit
Link: repo.Link,
}
}
func GetBuilds(c *gin.Context) { func GetBuilds(c *gin.Context) {
repo := session.Repo(c) repo := session.Repo(c)
page, err := strconv.Atoi(c.DefaultQuery("page", "1")) page, err := strconv.Atoi(c.DefaultQuery("page", "1"))

View file

@ -17,40 +17,41 @@ package model
// swagger:model build // swagger:model build
type Build struct { type Build struct {
ID int64 `json:"id" xorm:"pk autoincr 'build_id'"` ID int64 `json:"id" xorm:"pk autoincr 'build_id'"`
RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX 'build_repo_id'"` RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX 'build_repo_id'"`
Number int64 `json:"number" xorm:"UNIQUE(s) 'build_number'"` Number int64 `json:"number" xorm:"UNIQUE(s) 'build_number'"`
Author string `json:"author" xorm:"INDEX 'build_author'"` Author string `json:"author" xorm:"INDEX 'build_author'"`
ConfigID int64 `json:"-" xorm:"build_config_id"` ConfigID int64 `json:"-" xorm:"build_config_id"`
Parent int64 `json:"parent" xorm:"build_parent"` Parent int64 `json:"parent" xorm:"build_parent"`
Event WebhookEvent `json:"event" xorm:"build_event"` Event WebhookEvent `json:"event" xorm:"build_event"`
Status StatusValue `json:"status" xorm:"INDEX 'build_status'"` Status StatusValue `json:"status" xorm:"INDEX 'build_status'"`
Error string `json:"error" xorm:"build_error"` Error string `json:"error" xorm:"build_error"`
Enqueued int64 `json:"enqueued_at" xorm:"build_enqueued"` Enqueued int64 `json:"enqueued_at" xorm:"build_enqueued"`
Created int64 `json:"created_at" xorm:"build_created"` Created int64 `json:"created_at" xorm:"build_created"`
Updated int64 `json:"updated_at" xorm:"updated NOT NULL DEFAULT 0 'updated'"` Updated int64 `json:"updated_at" xorm:"updated NOT NULL DEFAULT 0 'updated'"`
Started int64 `json:"started_at" xorm:"build_started"` Started int64 `json:"started_at" xorm:"build_started"`
Finished int64 `json:"finished_at" xorm:"build_finished"` Finished int64 `json:"finished_at" xorm:"build_finished"`
Deploy string `json:"deploy_to" xorm:"build_deploy"` Deploy string `json:"deploy_to" xorm:"build_deploy"`
Commit string `json:"commit" xorm:"build_commit"` Commit string `json:"commit" xorm:"build_commit"`
Branch string `json:"branch" xorm:"build_branch"` Branch string `json:"branch" xorm:"build_branch"`
Ref string `json:"ref" xorm:"build_ref"` Ref string `json:"ref" xorm:"build_ref"`
Refspec string `json:"refspec" xorm:"build_refspec"` Refspec string `json:"refspec" xorm:"build_refspec"`
Remote string `json:"remote" xorm:"build_remote"` Remote string `json:"remote" xorm:"build_remote"`
Title string `json:"title" xorm:"build_title"` Title string `json:"title" xorm:"build_title"`
Message string `json:"message" xorm:"build_message"` Message string `json:"message" xorm:"build_message"`
Timestamp int64 `json:"timestamp" xorm:"build_timestamp"` Timestamp int64 `json:"timestamp" xorm:"build_timestamp"`
Sender string `json:"sender" xorm:"build_sender"` // uses reported user for webhooks and name of cron for cron pipelines Sender string `json:"sender" xorm:"build_sender"` // uses reported user for webhooks and name of cron for cron pipelines
Avatar string `json:"author_avatar" xorm:"build_avatar"` Avatar string `json:"author_avatar" xorm:"build_avatar"`
Email string `json:"author_email" xorm:"build_email"` Email string `json:"author_email" xorm:"build_email"`
Link string `json:"link_url" xorm:"build_link"` Link string `json:"link_url" xorm:"build_link"`
Signed bool `json:"signed" xorm:"build_signed"` // deprecate Signed bool `json:"signed" xorm:"build_signed"` // deprecate
Verified bool `json:"verified" xorm:"build_verified"` // deprecate Verified bool `json:"verified" xorm:"build_verified"` // deprecate
Reviewer string `json:"reviewed_by" xorm:"build_reviewer"` Reviewer string `json:"reviewed_by" xorm:"build_reviewer"`
Reviewed int64 `json:"reviewed_at" xorm:"build_reviewed"` Reviewed int64 `json:"reviewed_at" xorm:"build_reviewed"`
Procs []*Proc `json:"procs,omitempty" xorm:"-"` Procs []*Proc `json:"procs,omitempty" xorm:"-"`
Files []*File `json:"files,omitempty" xorm:"-"` Files []*File `json:"files,omitempty" xorm:"-"`
ChangedFiles []string `json:"changed_files,omitempty" xorm:"json 'changed_files'"` ChangedFiles []string `json:"changed_files,omitempty" xorm:"json 'changed_files'"`
AdditionalVariables map[string]string `json:"variables,omitempty" xorm:"json 'additional_variables'"`
} }
// TableName return database table name for xorm // TableName return database table name for xorm
@ -61,3 +62,8 @@ func (Build) TableName() string {
type UpdateBuildStore interface { type UpdateBuildStore interface {
UpdateBuild(*Build) error UpdateBuild(*Build) error
} }
type BuildOptions struct {
Branch string `json:"branch"`
Variables map[string]string `json:"variables"`
}

View file

@ -22,6 +22,7 @@ const (
EventTag WebhookEvent = "tag" EventTag WebhookEvent = "tag"
EventDeploy WebhookEvent = "deployment" EventDeploy WebhookEvent = "deployment"
EventCron WebhookEvent = "cron" EventCron WebhookEvent = "cron"
EventManual WebhookEvent = "manual"
) )
func ValidateWebhookEvent(s WebhookEvent) bool { func ValidateWebhookEvent(s WebhookEvent) bool {

View file

@ -60,6 +60,10 @@ func createBuildItems(ctx context.Context, store store.Store, build *model.Build
} }
} }
for k, v := range build.AdditionalVariables {
envs[k] = v
}
b := shared.ProcBuilder{ b := shared.ProcBuilder{
Repo: repo, Repo: repo,
Curr: build, Curr: build,

View file

@ -76,6 +76,7 @@ func apiRoutes(e *gin.Engine) {
repo.GET("/branches", api.GetRepoBranches) repo.GET("/branches", api.GetRepoBranches)
repo.GET("/builds", api.GetBuilds) repo.GET("/builds", api.GetBuilds)
repo.POST("/builds", session.MustPush, api.CreateBuild)
repo.GET("/builds/:number", api.GetBuild) repo.GET("/builds/:number", api.GetBuild)
repo.GET("/builds/:number/config", api.GetBuildConfig) repo.GET("/builds/:number/config", api.GetBuildConfig)

4
web/components.d.ts vendored
View file

@ -46,10 +46,12 @@ declare module '@vue/runtime-core' {
IIcRoundLightMode: typeof import('~icons/ic/round-light-mode')['default'] IIcRoundLightMode: typeof import('~icons/ic/round-light-mode')['default']
IIcSharpTimelapse: typeof import('~icons/ic/sharp-timelapse')['default'] IIcSharpTimelapse: typeof import('~icons/ic/sharp-timelapse')['default']
IIcTwotoneAdd: typeof import('~icons/ic/twotone-add')['default'] IIcTwotoneAdd: typeof import('~icons/ic/twotone-add')['default']
ILaTimes: typeof import('~icons/la/times')['default']
IMdiBitbucket: typeof import('~icons/mdi/bitbucket')['default'] IMdiBitbucket: typeof import('~icons/mdi/bitbucket')['default']
IMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] IMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IMdiClockTimeEightOutline: typeof import('~icons/mdi/clock-time-eight-outline')['default'] IMdiClockTimeEightOutline: typeof import('~icons/mdi/clock-time-eight-outline')['default']
IMdiFormatListBulleted: typeof import('~icons/mdi/format-list-bulleted')['default'] IMdiFormatListBulleted: typeof import('~icons/mdi/format-list-bulleted')['default']
IMdiGestureTap: typeof import('~icons/mdi/gesture-tap')['default']
IMdiGithub: typeof import('~icons/mdi/github')['default'] IMdiGithub: typeof import('~icons/mdi/github')['default']
IMdiLoading: typeof import('~icons/mdi/loading')['default'] IMdiLoading: typeof import('~icons/mdi/loading')['default']
IMdiSourceBranch: typeof import('~icons/mdi/source-branch')['default'] IMdiSourceBranch: typeof import('~icons/mdi/source-branch')['default']
@ -70,10 +72,12 @@ declare module '@vue/runtime-core' {
ITeenyiconsGitSolid: typeof import('~icons/teenyicons/git-solid')['default'] ITeenyiconsGitSolid: typeof import('~icons/teenyicons/git-solid')['default']
IVaadinQuestionCircleO: typeof import('~icons/vaadin/question-circle-o')['default'] IVaadinQuestionCircleO: typeof import('~icons/vaadin/question-circle-o')['default']
ListItem: typeof import('./src/components/atomic/ListItem.vue')['default'] ListItem: typeof import('./src/components/atomic/ListItem.vue')['default']
ManualPipelinePopup: typeof import('./src/components/layout/popups/ManualPipelinePopup.vue')['default']
Navbar: typeof import('./src/components/layout/header/Navbar.vue')['default'] Navbar: typeof import('./src/components/layout/header/Navbar.vue')['default']
NumberField: typeof import('./src/components/form/NumberField.vue')['default'] NumberField: typeof import('./src/components/form/NumberField.vue')['default']
OrgSecretsTab: typeof import('./src/components/org/settings/OrgSecretsTab.vue')['default'] OrgSecretsTab: typeof import('./src/components/org/settings/OrgSecretsTab.vue')['default']
Panel: typeof import('./src/components/layout/Panel.vue')['default'] Panel: typeof import('./src/components/layout/Panel.vue')['default']
Popup: typeof import('./src/components/layout/Popup.vue')['default']
RadioField: typeof import('./src/components/form/RadioField.vue')['default'] RadioField: typeof import('./src/components/form/RadioField.vue')['default']
RegistriesTab: typeof import('./src/components/repo/settings/RegistriesTab.vue')['default'] RegistriesTab: typeof import('./src/components/repo/settings/RegistriesTab.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View file

@ -17,56 +17,56 @@
"test": "echo 'No tests configured' && exit 0" "test": "echo 'No tests configured' && exit 0"
}, },
"dependencies": { "dependencies": {
"@intlify/vite-plugin-vue-i18n": "^6.0.0", "@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@kyvg/vue3-notification": "^2.3.6", "@kyvg/vue3-notification": "^2.4.1",
"@meforma/vue-toaster": "^1.3.0", "@meforma/vue-toaster": "^1.3.0",
"@vueuse/core": "^9.1.1", "@vueuse/core": "^9.2.0",
"ansi_up": "^5.1.0", "ansi_up": "^5.1.0",
"dayjs": "^1.11.4", "dayjs": "^1.11.5",
"floating-vue": "^2.0.0-beta.19", "floating-vue": "^2.0.0-beta.20",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"humanize-duration": "^3.27.2", "humanize-duration": "^3.27.3",
"javascript-time-ago": "^2.5.7", "javascript-time-ago": "^2.5.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-emoji": "^1.11.0", "node-emoji": "^1.11.0",
"pinia": "^2.0.17", "pinia": "^2.0.22",
"prismjs": "^1.28.0", "prismjs": "^1.29.0",
"vue": "^3.2.37", "vue": "^3.2.39",
"vue-i18n": "^9.2.2", "vue-i18n": "^9.2.2",
"vue-router": "^4.1.3" "vue-router": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@iconify/json": "^2.1.88", "@iconify/json": "^2.1.106",
"@types/humanize-duration": "^3.27.1", "@types/humanize-duration": "^3.27.1",
"@types/javascript-time-ago": "^2.0.3", "@types/javascript-time-ago": "^2.0.3",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.185",
"@types/node": "^16.11.6", "@types/node": "^18.7.17",
"@types/node-emoji": "^1.8.1", "@types/node-emoji": "^1.8.1",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
"@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/eslint-plugin": "^5.37.0",
"@typescript-eslint/parser": "^5.33.0", "@typescript-eslint/parser": "^5.37.0",
"@vitejs/plugin-vue": "^3.0.1", "@vitejs/plugin-vue": "^3.1.0",
"@vue/compiler-sfc": "^3.2.37", "@vue/compiler-sfc": "^3.2.39",
"eslint": "^8.21.0", "eslint": "^8.23.1",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.0.0", "eslint-plugin-promise": "^6.0.1",
"eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-vue": "^9.3.0", "eslint-plugin-vue": "^9.4.0",
"eslint-plugin-vue-scoped-css": "^2.2.0", "eslint-plugin-vue-scoped-css": "^2.2.0",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"typescript": "4.4.4", "typescript": "4.8.3",
"unplugin-icons": "^0.14.8", "unplugin-icons": "^0.14.9",
"unplugin-vue-components": "^0.22.3", "unplugin-vue-components": "^0.22.7",
"vite": "^3.0.4", "vite": "^3.1.0",
"vite-plugin-prismjs": "^0.0.8", "vite-plugin-prismjs": "^0.0.8",
"vite-plugin-windicss": "^1.8.7", "vite-plugin-windicss": "^1.8.8",
"vite-svg-loader": "^3.4.0", "vite-svg-loader": "^3.6.0",
"vue-eslint-parser": "^9.0.3", "vue-eslint-parser": "^9.1.0",
"vue-tsc": "^0.39.5", "vue-tsc": "^0.40.13",
"windicss": "^3.5.6" "windicss": "^3.5.6"
} }
} }

View file

@ -24,6 +24,18 @@
"not_started": "not started yet" "not_started": "not started yet"
}, },
"repo": { "repo": {
"manual_pipeline": {
"title": "Trigger a manual pipeline run",
"trigger": "Run pipeline",
"select_branch": "Select branch",
"variables": {
"add": "Add variable",
"title": "Additional pipeline variables",
"desc": "Specify additional variables to use in your pipeline. Variables with the same name will be overwritten.",
"name": "Variable name",
"value": "Variable value"
}
},
"activity": "Activity", "activity": "Activity",
"branches": "Branches", "branches": "Branches",
"add": "Add repository", "add": "Add repository",
@ -207,7 +219,8 @@
"tag": "Tag", "tag": "Tag",
"pr": "Pull Request", "pr": "Pull Request",
"deploy": "Deploy", "deploy": "Deploy",
"cron": "Cron" "cron": "Cron",
"manual": "Manual"
} }
} }
}, },

View file

@ -3,6 +3,7 @@
<i-mdi-clock-time-eight-outline v-else-if="name === 'since'" class="h-6 w-6" /> <i-mdi-clock-time-eight-outline v-else-if="name === 'since'" class="h-6 w-6" />
<i-mdi-source-branch v-else-if="name === 'push'" class="h-6 w-6" /> <i-mdi-source-branch v-else-if="name === 'push'" class="h-6 w-6" />
<i-mdi-source-pull v-else-if="name === 'pull_request'" class="h-6 w-6" /> <i-mdi-source-pull v-else-if="name === 'pull_request'" class="h-6 w-6" />
<i-mdi-gesture-tap v-else-if="name === 'manual-pipeline'" class="h-6 w-6" />
<i-mdi-tag-outline v-else-if="name === 'tag'" class="h-6 w-6" /> <i-mdi-tag-outline v-else-if="name === 'tag'" class="h-6 w-6" />
<i-clarity-deploy-line v-else-if="name === 'deployment'" class="h-6 w-6" /> <i-clarity-deploy-line v-else-if="name === 'deployment'" class="h-6 w-6" />
<i-mdisource-commit v-else-if="name === 'commit'" class="h-6 w-6" /> <i-mdisource-commit v-else-if="name === 'commit'" class="h-6 w-6" />
@ -51,6 +52,7 @@ export type IconNames =
| 'since' | 'since'
| 'push' | 'push'
| 'pull_request' | 'pull_request'
| 'manual-pipeline'
| 'tag' | 'tag'
| 'deployment' | 'deployment'
| 'commit' | 'commit'

View file

@ -1,6 +1,7 @@
<template> <template>
<div <div
class="w-full border border-gray-200 py-1 px-2 rounded-md bg-white hover:border-gray-300 dark:bg-dark-gray-700 dark:border-dark-400 dark:hover:border-dark-800" class="w-full border border-gray-200 py-1 px-2 rounded-md bg-white hover:border-gray-300 dark:bg-dark-gray-700 dark:border-dark-400 dark:hover:border-dark-800"
:class="{ 'bg-gray-200 dark:bg-gray-600': disabled }"
> >
<input <input
v-if="lines === 1" v-if="lines === 1"

View file

@ -0,0 +1,24 @@
<template>
<!-- overlay -->
<div
v-if="open"
class="fixed bg-gray-900 opacity-80 left-0 top-0 right-0 bottom-0 z-500 print:hidden"
@click="$emit('close')"
/>
<!-- overlay end -->
<transition class="print:hidden fixed flex top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div v-if="open" class="m-auto flex flex-col shadow-all z-1000 max-w-3/5 max-h-3/5 h-auto">
<slot />
</div>
</transition>
</template>
<script lang="ts" setup>
defineProps<{
open: boolean;
}>();
defineEmits<{
(event: 'close'): void;
}>();
</script>

View file

@ -0,0 +1,120 @@
<template>
<Popup :open="open" @close="$emit('close')">
<Panel v-if="!loading">
<span class="text-xl text-color">{{ $t('repo.manual_pipeline.title') }}</span>
<InputField :label="$t('repo.manual_pipeline.select_branch')">
<SelectField
v-model="payload.branch"
:options="branches"
required
class="dark:bg-dark-gray-700 bg-transparent text-color border-gray-200 dark:border-dark-400"
/>
</InputField>
<InputField :label="$t('repo.manual_pipeline.variables.title')">
<span class="text-sm text-color-alt mb-2">{{ $t('repo.manual_pipeline.variables.desc') }}</span>
<div class="flex flex-col gap-2">
<div v-for="(value, name) in payload.variables" :key="name" class="flex gap-4">
<TextField :model-value="name" disabled />
<TextField :model-value="value" disabled />
<div class="w-34 flex-shrink-0">
<Button type="submit" class="ml-auto" @click="deleteVar(name)">
<i-la-times />
</Button>
</div>
</div>
<form class="flex gap-4" @submit.prevent="addPipelineVariable">
<TextField
v-model="newPipelineVariable.name"
:placeholder="$t('repo.manual_pipeline.variables.name')"
required
/>
<TextField
v-model="newPipelineVariable.value"
:placeholder="$t('repo.manual_pipeline.variables.value')"
required
/>
<Button
class="w-34 flex-shrink-0"
start-icon="plus"
type="submit"
:text="$t('repo.manual_pipeline.variables.add')"
/>
</form>
</div>
</InputField>
<Button type="submit" :text="$t('repo.manual_pipeline.trigger')" @click="triggerManualPipeline" />
</Panel>
</Popup>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import InputField from '~/components/form/InputField.vue';
import SelectField from '~/components/form/SelectField.vue';
import TextField from '~/components/form/TextField.vue';
import Panel from '~/components/layout/Panel.vue';
import Popup from '~/components/layout/Popup.vue';
import useApiClient from '~/compositions/useApiClient';
import { inject } from '~/compositions/useInjectProvide';
defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
(event: 'close'): void;
}>();
const apiClient = useApiClient();
const repo = inject('repo');
const router = useRouter();
const branches = ref<{ text: string; value: string }[]>([]);
const payload = ref<{ branch: string; variables: Record<string, string> }>({
branch: 'main',
variables: {},
});
const newPipelineVariable = ref<{ name: string; value: string }>({ name: '', value: '' });
const loading = ref(true);
onMounted(async () => {
const data = await apiClient.getRepoBranches(repo.value.owner, repo.value.name);
branches.value = data.map((e) => ({
text: e,
value: e,
}));
loading.value = false;
});
function addPipelineVariable() {
if (!newPipelineVariable.value.name || !newPipelineVariable.value.value) {
return;
}
payload.value.variables[newPipelineVariable.value.name] = newPipelineVariable.value.value;
newPipelineVariable.value.name = '';
newPipelineVariable.value.value = '';
}
function deleteVar(key: string) {
delete payload.value.variables[key];
}
async function triggerManualPipeline() {
loading.value = true;
const build = await apiClient.createBuild(repo.value.owner, repo.value.name, payload.value);
emit('close');
await router.push({
name: 'repo-build',
params: {
buildId: build.number,
},
});
loading.value = false;
}
</script>

View file

@ -35,6 +35,7 @@
<Icon v-else-if="build.event === 'deployment'" name="deployment" /> <Icon v-else-if="build.event === 'deployment'" name="deployment" />
<Icon v-else-if="build.event === 'tag'" name="tag" /> <Icon v-else-if="build.event === 'tag'" name="tag" />
<Icon v-else-if="build.event === 'cron'" name="push" /> <Icon v-else-if="build.event === 'cron'" name="push" />
<Icon v-else-if="build.event === 'manual'" name="manual-pipeline" />
<Icon v-else name="push" /> <Icon v-else name="push" />
<span class="truncate">{{ prettyRef }}</span> <span class="truncate">{{ prettyRef }}</span>
</div> </div>

View file

@ -11,6 +11,7 @@
<span>{{ build.author }}</span> <span>{{ build.author }}</span>
</div> </div>
<div class="flex space-x-1 items-center min-w-0"> <div class="flex space-x-1 items-center min-w-0">
<Icon v-if="build.event === 'manual'" name="manual-pipeline" />
<Icon v-if="build.event === 'push'" name="push" /> <Icon v-if="build.event === 'push'" name="push" />
<Icon v-if="build.event === 'deployment'" name="deployment" /> <Icon v-if="build.event === 'deployment'" name="deployment" />
<Icon v-else-if="build.event === 'tag'" name="tag" /> <Icon v-else-if="build.event === 'tag'" name="tag" />
@ -45,7 +46,7 @@
<div class="md:absolute top-0 left-0 w-full"> <div class="md:absolute top-0 left-0 w-full">
<div v-for="proc in build.procs" :key="proc.id"> <div v-for="proc in build.procs" :key="proc.id">
<div class="p-4 pb-1 flex flex-wrap items-center justify-between"> <div class="p-4 pb-1 flex flex-wrap items-center justify-between">
<div v-if="build.procs.length > 1" class="flex items-center"> <div v-if="build.procs && build.procs.length > 1" class="flex items-center">
<span class="ml-2">{{ proc.name }}</span> <span class="ml-2">{{ proc.name }}</span>
</div> </div>
<div v-if="proc.environ" class="text-xs"> <div v-if="proc.environ" class="text-xs">

View file

@ -31,8 +31,8 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed, defineComponent, PropType, toRef } from 'vue'; import { computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue'; import Button from '~/components/atomic/Button.vue';
@ -42,92 +42,58 @@ import InputField from '~/components/form/InputField.vue';
import TextField from '~/components/form/TextField.vue'; import TextField from '~/components/form/TextField.vue';
import { Secret, WebhookEvents } from '~/lib/api/types'; import { Secret, WebhookEvents } from '~/lib/api/types';
export default defineComponent({ const props = defineProps<{
name: 'SecretEdit', modelValue: Partial<Secret>;
isSaving: boolean;
i18nPrefix: string;
}>();
components: { const emit = defineEmits<{
Button, (event: 'update:modelValue', value: Partial<Secret> | undefined): void;
InputField, (event: 'save', value: Partial<Secret>): void;
TextField, }>();
CheckboxesField,
},
props: { const i18n = useI18n();
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: Object as PropType<Partial<Secret>>,
default: undefined,
},
isSaving: { const modelValue = toRef(props, 'modelValue');
type: Boolean, const innerValue = computed({
}, get: () => modelValue.value,
set: (value) => {
i18nPrefix: { emit('update:modelValue', value);
type: String,
required: true,
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: Partial<Secret> | undefined): boolean => true,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
save: (_value: Partial<Secret>): boolean => true,
},
setup: (props, ctx) => {
const i18n = useI18n();
const modelValue = toRef(props, 'modelValue');
const innerValue = computed({
get: () => modelValue.value,
set: (value) => {
ctx.emit('update:modelValue', value);
},
});
const images = computed<string>({
get() {
return innerValue.value?.image?.join(',') || '';
},
set(value) {
if (innerValue.value) {
innerValue.value.image = value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
}
},
});
const isEditingSecret = computed(() => !!innerValue.value?.id);
const secretEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: i18n.t('repo.build.event.push') },
{ value: WebhookEvents.Tag, text: i18n.t('repo.build.event.tag') },
{
value: WebhookEvents.PullRequest,
text: i18n.t('repo.build.event.pr'),
description: i18n.t('repo.settings.secrets.events.pr_warning'),
},
{ value: WebhookEvents.Deploy, text: i18n.t('repo.build.event.deploy') },
{ value: WebhookEvents.Cron, text: i18n.t('repo.build.event.cron') },
];
function save() {
if (!innerValue.value) {
return;
}
ctx.emit('save', innerValue.value);
}
return {
innerValue,
isEditingSecret,
secretEventsOptions,
images,
save,
};
}, },
}); });
const images = computed<string>({
get() {
return innerValue.value?.image?.join(',') || '';
},
set(value) {
if (innerValue.value) {
innerValue.value.image = value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
}
},
});
const isEditingSecret = computed(() => !!innerValue.value?.id);
const secretEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: i18n.t('repo.build.event.push') },
{ value: WebhookEvents.Tag, text: i18n.t('repo.build.event.tag') },
{
value: WebhookEvents.PullRequest,
text: i18n.t('repo.build.event.pr'),
description: i18n.t('repo.settings.secrets.events.pr_warning'),
},
{ value: WebhookEvents.Deploy, text: i18n.t('repo.build.event.deploy') },
{ value: WebhookEvents.Cron, text: i18n.t('repo.build.event.cron') },
{ value: WebhookEvents.Manual, text: i18n.t('repo.build.event.manual') },
];
function save() {
if (!innerValue.value) {
return;
}
emit('save', innerValue.value);
}
</script> </script>

View file

@ -24,59 +24,31 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, PropType, toRef } from 'vue'; import { toRef } from 'vue';
import IconButton from '~/components/atomic/IconButton.vue'; import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue'; import ListItem from '~/components/atomic/ListItem.vue';
import { Secret } from '~/lib/api/types'; import { Secret } from '~/lib/api/types';
export default defineComponent({ const props = defineProps<{
name: 'SecretList', modelValue: Secret[];
isDeleting: boolean;
i18nPrefix: string;
}>();
components: { const emit = defineEmits<{
ListItem, (event: 'edit', secret: Secret): void;
IconButton, (event: 'delete', secret: Secret): void;
}, }>();
props: { const secrets = toRef(props, 'modelValue');
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: Array as PropType<Secret[]>,
required: true,
},
isDeleting: { function editSecret(secret: Secret) {
type: Boolean, emit('edit', secret);
required: true, }
},
i18nPrefix: { function deleteSecret(secret: Secret) {
type: String, emit('delete', secret);
required: true, }
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
edit: (secret: Secret): boolean => true,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
delete: (secret: Secret): boolean => true,
},
setup(props, ctx) {
const secrets = toRef(props, 'modelValue');
function editSecret(secret: Secret) {
ctx.emit('edit', secret);
}
function deleteSecret(secret: Secret) {
ctx.emit('delete', secret);
}
return { secrets, editSecret, deleteSecret };
},
});
</script> </script>

View file

@ -0,0 +1,19 @@
import { inject as vueInject, provide as vueProvide, Ref } from 'vue';
import { Repo } from '~/lib/api/types';
export type InjectKeys = {
repo: Ref<Repo>;
};
export function inject<T extends keyof InjectKeys>(key: T): InjectKeys[T] {
const value = vueInject<InjectKeys[T]>(key);
if (value === undefined) {
throw new Error(`Please provide a value for ${key}`);
}
return value;
}
export function provide<T extends keyof InjectKeys>(key: T, value: InjectKeys[T]): void {
return vueProvide(key, value);
}

View file

@ -18,6 +18,11 @@ type RepoListOptions = {
all?: boolean; all?: boolean;
flush?: boolean; flush?: boolean;
}; };
type BuildOptions = {
branch: string;
variables: Record<string, string>;
};
export default class WoodpeckerClient extends ApiClient { export default class WoodpeckerClient extends ApiClient {
getRepoList(opts?: RepoListOptions): Promise<Repo[]> { getRepoList(opts?: RepoListOptions): Promise<Repo[]> {
const query = encodeQueryString(opts); const query = encodeQueryString(opts);
@ -58,6 +63,10 @@ export default class WoodpeckerClient extends ApiClient {
return this._get(`/api/repos/${owner}/${repo}/builds?${query}`) as Promise<Build[]>; return this._get(`/api/repos/${owner}/${repo}/builds?${query}`) as Promise<Build[]>;
} }
createBuild(owner: string, repo: string, options: BuildOptions): Promise<Build> {
return this._post(`/api/repos/${owner}/${repo}/builds`, options) as Promise<Build>;
}
getBuild(owner: string, repo: string, number: number | 'latest'): Promise<Build> { getBuild(owner: string, repo: string, number: number | 'latest'): Promise<Build> {
return this._get(`/api/repos/${owner}/${repo}/builds/${number}`) as Promise<Build>; return this._get(`/api/repos/${owner}/${repo}/builds/${number}`) as Promise<Build>;
} }

View file

@ -8,7 +8,7 @@ export type Build = {
parent: number; parent: number;
event: 'push' | 'tag' | 'pull_request' | 'deployment' | 'cron'; event: 'push' | 'tag' | 'pull_request' | 'deployment' | 'cron' | 'manual';
// The current status of the build. // The current status of the build.
status: BuildStatus; status: BuildStatus;

View file

@ -4,4 +4,5 @@ export enum WebhookEvents {
PullRequest = 'pull_request', PullRequest = 'pull_request',
Deploy = 'deployment', Deploy = 'deployment',
Cron = 'cron', Cron = 'cron',
Manual = 'manual',
} }

View file

@ -23,12 +23,21 @@
</a> </a>
<IconButton v-if="repoPermissions.admin" class="ml-2" :to="{ name: 'repo-settings' }" icon="settings" /> <IconButton v-if="repoPermissions.admin" class="ml-2" :to="{ name: 'repo-settings' }" icon="settings" />
</div> </div>
<div class="flex flex-wrap gap-y-2 items-center justify-between">
<Tabs v-model="activeTab" disable-hash-mode class="mb-4">
<Tab id="activity" :title="$t('repo.activity')" />
<Tab id="branches" :title="$t('repo.branches')" />
</Tabs>
<Tabs v-model="activeTab" disable-hash-mode class="mb-4"> <Button
<Tab id="activity" :title="$t('repo.activity')" /> v-if="repoPermissions.push"
<Tab id="branches" :title="$t('repo.branches')" /> type="submit"
</Tabs> :text="$t('repo.manual_pipeline.trigger')"
class="ml-auto"
@click="showManualPipelinePopup = true"
/>
<ManualPipelinePopup :open="showManualPipelinePopup" @close="showManualPipelinePopup = false" />
</div>
<router-view /> <router-view />
</FluidContainer> </FluidContainer>
<router-view v-else-if="repo && repoPermissions" /> <router-view v-else-if="repo && repoPermissions" />
@ -42,6 +51,7 @@ import { useRoute, useRouter } from 'vue-router';
import Icon from '~/components/atomic/Icon.vue'; import Icon from '~/components/atomic/Icon.vue';
import IconButton from '~/components/atomic/IconButton.vue'; import IconButton from '~/components/atomic/IconButton.vue';
import FluidContainer from '~/components/layout/FluidContainer.vue'; import FluidContainer from '~/components/layout/FluidContainer.vue';
import ManualPipelinePopup from '~/components/layout/popups/ManualPipelinePopup.vue';
import Tab from '~/components/tabs/Tab.vue'; import Tab from '~/components/tabs/Tab.vue';
import Tabs from '~/components/tabs/Tabs.vue'; import Tabs from '~/components/tabs/Tabs.vue';
import useApiClient from '~/compositions/useApiClient'; import useApiClient from '~/compositions/useApiClient';
@ -53,15 +63,11 @@ import BuildStore from '~/store/builds';
import RepoStore from '~/store/repos'; import RepoStore from '~/store/repos';
const props = defineProps({ const props = defineProps({
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
repoOwner: { repoOwner: {
type: String, type: String,
required: true, required: true,
}, },
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
repoName: { repoName: {
type: String, type: String,
required: true, required: true,
@ -87,6 +93,8 @@ provide('repo', repo);
provide('repo-permissions', repoPermissions); provide('repo-permissions', repoPermissions);
provide('builds', builds); provide('builds', builds);
const showManualPipelinePopup = ref(false);
async function loadRepo() { async function loadRepo() {
repoPermissions.value = await apiClient.getRepoPermissions(repoOwner.value, repoName.value); repoPermissions.value = await apiClient.getRepoPermissions(repoOwner.value, repoName.value);
if (!repoPermissions.value.pull) { if (!repoPermissions.value.pull) {

File diff suppressed because it is too large Load diff

View file

@ -215,6 +215,13 @@ func (c *client) BuildList(owner, name string) ([]*Build, error) {
return out, err return out, err
} }
func (c *client) BuildCreate(owner, name string, options *BuildOptions) (*Build, error) {
var out *Build
uri := fmt.Sprintf(pathBuilds, c.addr, owner, name)
err := c.post(uri, options, &out)
return out, err
}
// BuildQueue returns a list of enqueued builds. // BuildQueue returns a list of enqueued builds.
func (c *client) BuildQueue() ([]*Activity, error) { func (c *client) BuildQueue() ([]*Activity, error) {
var out []*Activity var out []*Activity

View file

@ -70,6 +70,9 @@ type Client interface {
// the specified repository. // the specified repository.
BuildList(string, string) ([]*Build, error) BuildList(string, string) ([]*Build, error)
// BuildCreate returns creates a build on specified branch.
BuildCreate(string, string, *BuildOptions) (*Build, error)
// BuildQueue returns a list of enqueued builds. // BuildQueue returns a list of enqueued builds.
BuildQueue() ([]*Activity, error) BuildQueue() ([]*Activity, error)

View file

@ -175,4 +175,10 @@ type (
Created int64 `json:"created_at"` Created int64 `json:"created_at"`
Branch string `json:"branch"` Branch string `json:"branch"`
} }
// BuildOptions is the JSON data for forging a new build
BuildOptions struct {
Branch string `json:"branch"`
Variables map[string]string `json:"variables"`
}
) )