Enhance linter and errors (#1572)

Co-authored-by: 6543 <m.huber@kithara.com>
Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
This commit is contained in:
Anbraten 2023-11-03 11:44:03 +01:00 committed by GitHub
parent 4c4fdff5f7
commit 5ff006614f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 912 additions and 342 deletions

View file

@ -7,7 +7,7 @@ repos:
rev: v2.3.0
hooks:
- id: check-yaml
exclude: 'pipeline/schema/.woodpecker/test-merge-map-and-sequence.yml'
exclude: 'pipeline/frontend/yaml/linter/schema/.woodpecker/test-merge-map-and-sequence.yml'
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/golangci/golangci-lint

View file

@ -167,7 +167,7 @@ func execWithAxis(c *cli.Context, file, repoPath string, axis matrix.Axis) error
}
// lint the yaml file
if lerr := linter.New(linter.WithTrusted(true)).Lint(conf); lerr != nil {
if lerr := linter.New(linter.WithTrusted(true)).Lint(confstr, conf); lerr != nil {
return lerr
}

View file

@ -15,15 +15,20 @@
package lint
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"github.com/muesli/termenv"
"github.com/urfave/cli/v2"
"github.com/woodpecker-ci/woodpecker/cli/common"
"github.com/woodpecker-ci/woodpecker/pipeline/schema"
pipeline_errors "github.com/woodpecker-ci/woodpecker/pipeline/errors"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/linter"
)
// Command exports the info command.
@ -68,21 +73,58 @@ func lintDir(c *cli.Context, dir string) error {
}
func lintFile(_ *cli.Context, file string) error {
output := termenv.NewOutput(os.Stdout)
fi, err := os.Open(file)
if err != nil {
return err
}
defer fi.Close()
configErrors, err := schema.Lint(fi)
buf, err := os.ReadFile(file)
if err != nil {
fmt.Println("❌ Config is invalid")
for _, configError := range configErrors {
fmt.Println("In", configError.Field()+":", configError.Description())
}
return err
}
rawConfig := string(buf)
c, err := yaml.ParseString(rawConfig)
if err != nil {
return err
}
err = linter.New(linter.WithTrusted(true)).Lint(string(buf), c)
if err != nil {
fmt.Printf("🔥 %s has errors:\n", output.String(path.Base(file)).Underline())
hasErrors := true
for _, err := range pipeline_errors.GetPipelineErrors(err) {
line := " "
if err.IsWarning {
line = fmt.Sprintf("%s ⚠️ ", line)
} else {
line = fmt.Sprintf("%s ❌", line)
hasErrors = true
}
if data := err.GetLinterData(); data != nil {
line = fmt.Sprintf("%s %s\t%s", line, output.String(data.Field).Bold(), err.Message)
} else {
line = fmt.Sprintf("%s %s", line, err.Message)
}
// TODO: use table output
fmt.Printf("%s\n", line)
}
if hasErrors {
return errors.New("config has errors")
}
return nil
}
fmt.Println("✅ Config is valid")
return nil
}

View file

@ -3951,8 +3951,11 @@ const docTemplate = `{
"enqueued_at": {
"type": "integer"
},
"error": {
"type": "string"
"errors": {
"type": "array",
"items": {
"$ref": "#/definitions/errors.PipelineError"
}
},
"event": {
"$ref": "#/definitions/WebhookEvent"
@ -4248,6 +4251,17 @@ const docTemplate = `{
"blocked",
"declined"
],
"x-enum-comments": {
"StatusBlocked": "waiting for approval",
"StatusDeclined": "blocked and declined",
"StatusError": "error with the config / while parsing / some other system problem",
"StatusFailure": "failed to finish (exit code != 0)",
"StatusKilled": "killed by user",
"StatusPending": "pending to be executed",
"StatusRunning": "currently running",
"StatusSkipped": "skipped as another step failed",
"StatusSuccess": "successfully finished"
},
"x-enum-varnames": [
"StatusSkipped",
"StatusPending",
@ -4407,6 +4421,42 @@ const docTemplate = `{
"EventManual"
]
},
"errors.PipelineError": {
"type": "object",
"properties": {
"data": {},
"is_warning": {
"type": "boolean"
},
"message": {
"type": "string"
},
"type": {
"$ref": "#/definitions/errors.PipelineErrorType"
}
}
},
"errors.PipelineErrorType": {
"type": "string",
"enum": [
"linter",
"deprecation",
"compiler",
"generic"
],
"x-enum-comments": {
"PipelineErrorTypeCompiler": "some error with the config semantics",
"PipelineErrorTypeDeprecation": "using some deprecated feature",
"PipelineErrorTypeGeneric": "some generic error",
"PipelineErrorTypeLinter": "some error with the config syntax"
},
"x-enum-varnames": [
"PipelineErrorTypeLinter",
"PipelineErrorTypeDeprecation",
"PipelineErrorTypeCompiler",
"PipelineErrorTypeGeneric"
]
},
"model.Workflow": {
"type": "object",
"properties": {

7
go.mod
View file

@ -34,6 +34,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.17
github.com/moby/moby v24.0.7+incompatible
github.com/moby/term v0.5.0
github.com/muesli/termenv v0.15.2
github.com/oklog/ulid/v2 v2.1.0
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.17.0
@ -47,6 +48,7 @@ require (
github.com/urfave/cli/v2 v2.25.7
github.com/xanzy/go-gitlab v0.93.2
github.com/xeipuuv/gojsonschema v1.2.0
go.uber.org/multierr v1.11.0
golang.org/x/crypto v0.14.0
golang.org/x/net v0.17.0
golang.org/x/oauth2 v0.13.0
@ -67,6 +69,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
@ -107,9 +110,11 @@ require (
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/libdns/libdns v0.2.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mholt/acmez v1.2.0 // indirect
github.com/miekg/dns v1.1.55 // indirect
@ -124,6 +129,7 @@ require (
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
@ -136,7 +142,6 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.13.0 // indirect

14
go.sum
View file

@ -24,6 +24,8 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u
github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/antonmedv/expr v1.15.3 h1:q3hOJZNvLvhqE8OHBs1cFRdbXFNKuA+bHmRaI+AmRmI=
github.com/antonmedv/expr v1.15.3/go.mod h1:0E/6TxnOlRNp81GMzX9QfDPAmHo2Phg00y4JUv1ihsE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -175,8 +177,6 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w=
github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@ -272,6 +272,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis=
github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
@ -292,6 +294,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
@ -314,6 +318,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@ -350,6 +356,8 @@ github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwa
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@ -666,7 +674,5 @@ sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.3 h1:L5/GOhvgMcwJYYRjzPf3lTTTf6JcaTd1Mb9A/Iqvccw=
xorm.io/xorm v1.3.3/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=
xorm.io/xorm v1.3.4 h1:vWFKzR3DhGUDl5b4srhUjhDwjxkZAc4C7BFszpu0swI=
xorm.io/xorm v1.3.4/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=

77
pipeline/errors/error.go Normal file
View file

@ -0,0 +1,77 @@
package errors
import (
"errors"
"fmt"
"go.uber.org/multierr"
)
type PipelineErrorType string
const (
PipelineErrorTypeLinter PipelineErrorType = "linter" // some error with the config syntax
PipelineErrorTypeDeprecation PipelineErrorType = "deprecation" // using some deprecated feature
PipelineErrorTypeCompiler PipelineErrorType = "compiler" // some error with the config semantics
PipelineErrorTypeGeneric PipelineErrorType = "generic" // some generic error
)
type PipelineError struct {
Type PipelineErrorType `json:"type"`
Message string `json:"message"`
IsWarning bool `json:"is_warning"`
Data interface{} `json:"data"`
}
type LinterErrorData struct {
Field string `json:"field"`
}
func (e *PipelineError) Error() string {
return fmt.Sprintf("[%s] %s", e.Type, e.Message)
}
func (e *PipelineError) GetLinterData() *LinterErrorData {
if e.Type != PipelineErrorTypeLinter {
return nil
}
if data, ok := e.Data.(*LinterErrorData); ok {
return data
}
return nil
}
func GetPipelineErrors(err error) []*PipelineError {
var pipelineErrors []*PipelineError
for _, _err := range multierr.Errors(err) {
var err *PipelineError
if errors.As(_err, &err) {
pipelineErrors = append(pipelineErrors, err)
} else {
pipelineErrors = append(pipelineErrors, &PipelineError{
Message: _err.Error(),
Type: PipelineErrorTypeGeneric,
})
}
}
return pipelineErrors
}
func HasBlockingErrors(err error) bool {
if err == nil {
return false
}
errs := GetPipelineErrors(err)
for _, err := range errs {
if !err.IsWarning {
return true
}
}
return false
}

View file

@ -0,0 +1,158 @@
package errors_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/multierr"
pipeline_errors "github.com/woodpecker-ci/woodpecker/pipeline/errors"
)
func TestGetPipelineErrors(t *testing.T) {
t.Parallel()
tests := []struct {
title string
err error
expected []*pipeline_errors.PipelineError
}{
{
title: "nil error",
err: nil,
expected: nil,
},
{
title: "warning",
err: &pipeline_errors.PipelineError{
IsWarning: true,
},
expected: []*pipeline_errors.PipelineError{
{
IsWarning: true,
},
},
},
{
title: "pipeline error",
err: &pipeline_errors.PipelineError{
IsWarning: false,
},
expected: []*pipeline_errors.PipelineError{
{
IsWarning: false,
},
},
},
{
title: "multiple warnings",
err: multierr.Combine(
&pipeline_errors.PipelineError{
IsWarning: true,
},
&pipeline_errors.PipelineError{
IsWarning: true,
},
),
expected: []*pipeline_errors.PipelineError{
{
IsWarning: true,
},
{
IsWarning: true,
},
},
},
{
title: "multiple errors and warnings",
err: multierr.Combine(
&pipeline_errors.PipelineError{
IsWarning: true,
},
&pipeline_errors.PipelineError{
IsWarning: false,
},
errors.New("some error"),
),
expected: []*pipeline_errors.PipelineError{
{
IsWarning: true,
},
{
IsWarning: false,
},
{
Type: pipeline_errors.PipelineErrorTypeGeneric,
IsWarning: false,
Message: "some error",
},
},
},
}
for _, test := range tests {
assert.Equalf(t, pipeline_errors.GetPipelineErrors(test.err), test.expected, test.title)
}
}
func TestHasBlockingErrors(t *testing.T) {
t.Parallel()
tests := []struct {
title string
err error
expected bool
}{
{
title: "nil error",
err: nil,
expected: false,
},
{
title: "warning",
err: &pipeline_errors.PipelineError{
IsWarning: true,
},
expected: false,
},
{
title: "pipeline error",
err: &pipeline_errors.PipelineError{
IsWarning: false,
},
expected: true,
},
{
title: "multiple warnings",
err: multierr.Combine(
&pipeline_errors.PipelineError{
IsWarning: true,
},
&pipeline_errors.PipelineError{
IsWarning: true,
},
),
expected: false,
},
{
title: "multiple errors and warnings",
err: multierr.Combine(
&pipeline_errors.PipelineError{
IsWarning: true,
},
&pipeline_errors.PipelineError{
IsWarning: false,
},
errors.New("some error"),
),
expected: true,
},
}
for _, test := range tests {
if pipeline_errors.HasBlockingErrors(test.err) != test.expected {
t.Error("Should only return true if there are blocking errors")
}
}
}

View file

@ -15,7 +15,6 @@
package constraint
import (
"errors"
"fmt"
"maps"
"path"
@ -23,6 +22,7 @@ import (
"github.com/antonmedv/expr"
"github.com/bmatcuk/doublestar/v4"
"go.uber.org/multierr"
"gopkg.in/yaml.v3"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/metadata"
@ -261,7 +261,7 @@ func (c *List) UnmarshalYAML(value *yaml.Node) error {
if err1 != nil && err2 != nil {
y, _ := yaml.Marshal(value)
return fmt.Errorf("Could not parse condition: %s: %w", y, errors.Join(err1, err2))
return fmt.Errorf("Could not parse condition: %s: %w", y, multierr.Append(err1, err2))
}
return nil

View file

@ -1,10 +1,10 @@
// Copyright 2022 Woodpecker Authors
// Copyright 2023 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
// 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,
@ -12,21 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package yaml
package linter
import "errors"
import (
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
)
// PipelineParseError is an error that occurs when the pipeline parsing fails.
type PipelineParseError struct {
Err error
}
func (e PipelineParseError) Error() string {
return e.Err.Error()
}
func (e PipelineParseError) Is(err error) bool {
target1 := PipelineParseError{}
target2 := &target1
return errors.As(err, &target1) || errors.As(err, &target2)
func newLinterError(message, field string, isWarning bool) *errors.PipelineError {
return &errors.PipelineError{
Type: errors.PipelineErrorTypeLinter,
Message: message,
Data: &errors.LinterErrorData{Field: field},
IsWarning: isWarning,
}
}

View file

@ -17,13 +17,10 @@ package linter
import (
"fmt"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/types"
)
"go.uber.org/multierr"
const (
blockClone uint8 = iota
blockPipeline
blockServices
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/linter/schema"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/types"
)
// A Linter lints a pipeline configuration.
@ -41,39 +38,59 @@ func New(opts ...Option) *Linter {
}
// Lint lints the configuration.
func (l *Linter) Lint(c *types.Workflow) error {
func (l *Linter) Lint(rawConfig string, c *types.Workflow) error {
var linterErr error
if len(c.Steps.ContainerList) == 0 {
return fmt.Errorf("Invalid or missing pipeline section")
linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing steps section", "steps", false))
}
if err := l.lint(c.Clone.ContainerList, blockClone); err != nil {
return err
if err := l.lintContainers(c.Clone.ContainerList); err != nil {
linterErr = multierr.Append(linterErr, err)
}
if err := l.lint(c.Steps.ContainerList, blockPipeline); err != nil {
return err
if err := l.lintContainers(c.Steps.ContainerList); err != nil {
linterErr = multierr.Append(linterErr, err)
}
return l.lint(c.Services.ContainerList, blockServices)
if err := l.lintContainers(c.Services.ContainerList); err != nil {
linterErr = multierr.Append(linterErr, err)
}
if err := l.lintSchema(rawConfig); err != nil {
linterErr = multierr.Append(linterErr, err)
}
if err := l.lintDeprecations(c); err != nil {
linterErr = multierr.Append(linterErr, err)
}
if err := l.lintBadHabits(c); err != nil {
linterErr = multierr.Append(linterErr, err)
}
return linterErr
}
func (l *Linter) lint(containers []*types.Container, _ uint8) error {
func (l *Linter) lintContainers(containers []*types.Container) error {
var linterErr error
for _, container := range containers {
if err := l.lintImage(container); err != nil {
return err
linterErr = multierr.Append(linterErr, err)
}
if !l.trusted {
if err := l.lintTrusted(container); err != nil {
return err
linterErr = multierr.Append(linterErr, err)
}
}
if err := l.lintCommands(container); err != nil {
return err
linterErr = multierr.Append(linterErr, err)
}
}
return nil
return linterErr
}
func (l *Linter) lintImage(c *types.Container) error {
if len(c.Image) == 0 {
return fmt.Errorf("Invalid or missing image")
return newLinterError("Invalid or missing image", fmt.Sprintf("steps.%s", c.Name), false)
}
return nil
}
@ -87,47 +104,73 @@ func (l *Linter) lintCommands(c *types.Container) error {
for key := range c.Settings {
keys = append(keys, key)
}
return fmt.Errorf("Cannot configure both commands and custom attributes %v", keys)
return newLinterError(fmt.Sprintf("Cannot configure both commands and custom attributes %v", keys), fmt.Sprintf("steps.%s", c.Name), false)
}
return nil
}
func (l *Linter) lintTrusted(c *types.Container) error {
yamlPath := fmt.Sprintf("steps.%s", c.Name)
if c.Privileged {
return fmt.Errorf("Insufficient privileges to use privileged mode")
return newLinterError("Insufficient privileges to use privileged mode", yamlPath, false)
}
if c.ShmSize != 0 {
return fmt.Errorf("Insufficient privileges to override shm_size")
return newLinterError("Insufficient privileges to override shm_size", yamlPath, false)
}
if len(c.DNS) != 0 {
return fmt.Errorf("Insufficient privileges to use custom dns")
return newLinterError("Insufficient privileges to use custom dns", yamlPath, false)
}
if len(c.DNSSearch) != 0 {
return fmt.Errorf("Insufficient privileges to use dns_search")
return newLinterError("Insufficient privileges to use dns_search", yamlPath, false)
}
if len(c.Devices) != 0 {
return fmt.Errorf("Insufficient privileges to use devices")
return newLinterError("Insufficient privileges to use devices", yamlPath, false)
}
if len(c.ExtraHosts) != 0 {
return fmt.Errorf("Insufficient privileges to use extra_hosts")
return newLinterError("Insufficient privileges to use extra_hosts", yamlPath, false)
}
if len(c.NetworkMode) != 0 {
return fmt.Errorf("Insufficient privileges to use network_mode")
return newLinterError("Insufficient privileges to use network_mode", yamlPath, false)
}
if len(c.IpcMode) != 0 {
return fmt.Errorf("Insufficient privileges to use ipc_mode")
return newLinterError("Insufficient privileges to use ipc_mode", yamlPath, false)
}
if len(c.Sysctls) != 0 {
return fmt.Errorf("Insufficient privileges to use sysctls")
return newLinterError("Insufficient privileges to use sysctls", yamlPath, false)
}
if c.Networks.Networks != nil && len(c.Networks.Networks) != 0 {
return fmt.Errorf("Insufficient privileges to use networks")
return newLinterError("Insufficient privileges to use networks", yamlPath, false)
}
if c.Volumes.Volumes != nil && len(c.Volumes.Volumes) != 0 {
return fmt.Errorf("Insufficient privileges to use volumes")
return newLinterError("Insufficient privileges to use volumes", yamlPath, false)
}
if len(c.Tmpfs) != 0 {
return fmt.Errorf("Insufficient privileges to use tmpfs")
return newLinterError("Insufficient privileges to use tmpfs", yamlPath, false)
}
return nil
}
func (l *Linter) lintSchema(rawConfig string) error {
var linterErr error
schemaErrors, err := schema.LintString(rawConfig)
if err != nil {
for _, schemaError := range schemaErrors {
linterErr = multierr.Append(linterErr, newLinterError(
schemaError.Description(),
schemaError.Field(),
true, // TODO: let pipelines fail if the schema is invalid
))
}
}
return linterErr
}
func (l *Linter) lintDeprecations(_ *types.Workflow) error {
// TODO: add deprecation warnings
return nil
}
func (l *Linter) lintBadHabits(_ *types.Workflow) error {
// TODO: add bad habit warnings
return nil
}

View file

@ -17,6 +17,8 @@ package linter
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml"
)
@ -26,8 +28,6 @@ func TestLint(t *testing.T) {
steps:
build:
image: docker
privileged: true
network_mode: host
volumes:
- /tmp:/tmp
commands:
@ -35,8 +35,8 @@ steps:
- go test
publish:
image: plugins/docker
repo: foo/bar
settings:
repo: foo/bar
foo: bar
services:
redis:
@ -47,8 +47,6 @@ services:
steps:
- name: build
image: docker
privileged: true
network_mode: host
volumes:
- /tmp:/tmp
commands:
@ -56,8 +54,8 @@ steps:
- go test
- name: publish
image: plugins/docker
repo: foo/bar
settings:
repo: foo/bar
foo: bar
`,
}, {
@ -81,9 +79,10 @@ steps:
t.Run(testd.Title, func(t *testing.T) {
conf, err := yaml.ParseString(testd.Data)
if err != nil {
t.Fatalf("Cannot unmarshal yaml %q. Error: %s", testd, err)
t.Fatalf("Cannot unmarshal yaml %q. Error: %s", testd.Title, err)
}
if err := New(WithTrusted(true)).Lint(conf); err != nil {
if err := New(WithTrusted(true)).Lint(testd.Data, conf); err != nil {
t.Errorf("Expected lint returns no errors, got %q", err)
}
})
@ -97,7 +96,7 @@ func TestLintErrors(t *testing.T) {
}{
{
from: "",
want: "Invalid or missing pipeline section",
want: "Invalid or missing steps section",
},
{
from: "steps: { build: { image: '' } }",
@ -156,11 +155,19 @@ func TestLintErrors(t *testing.T) {
t.Fatalf("Cannot unmarshal yaml %q. Error: %s", test.from, err)
}
lerr := New().Lint(conf)
lerr := New().Lint(test.from, conf)
if lerr == nil {
t.Errorf("Expected lint error for configuration %q", test.from)
} else if lerr.Error() != test.want {
t.Errorf("Want error %q, got %q", test.want, lerr.Error())
}
lerrors := errors.GetPipelineErrors(lerr)
found := false
for _, lerr := range lerrors {
if lerr.Message == test.want {
found = true
break
}
}
assert.True(t, found, "Expected error %q, got %q", test.want, lerrors)
}
}

View file

@ -15,6 +15,7 @@
package schema
import (
"bytes"
_ "embed"
"fmt"
"io"
@ -62,3 +63,7 @@ func Lint(r io.Reader) ([]gojsonschema.ResultError, error) {
return nil, nil
}
func LintString(s string) ([]gojsonschema.ResultError, error) {
return Lint(bytes.NewBufferString(s))
}

View file

@ -204,6 +204,10 @@
"additionalProperties": false,
"required": ["image"],
"properties": {
"name": {
"description": "The name of the step. Can be used if using the array style steps list.",
"type": "string"
},
"image": {
"$ref": "#/definitions/step_image"
},

View file

@ -21,7 +21,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/woodpecker-ci/woodpecker/pipeline/schema"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/linter/schema"
)
func TestSchema(t *testing.T) {

View file

@ -17,7 +17,7 @@ package matrix
import (
"strings"
pipeline "github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml"
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
"codeberg.org/6543/xyaml"
)
@ -116,7 +116,7 @@ func parse(raw []byte) (Matrix, error) {
Matrix map[string][]string
}{}
if err := xyaml.Unmarshal(raw, &data); err != nil {
return nil, &pipeline.PipelineParseError{Err: err}
return nil, &errors.PipelineError{Message: err.Error(), Type: errors.PipelineErrorTypeCompiler}
}
return data.Matrix, nil
}
@ -129,7 +129,7 @@ func parseList(raw []byte) ([]Axis, error) {
}{}
if err := xyaml.Unmarshal(raw, &data); err != nil {
return nil, &pipeline.PipelineParseError{Err: err}
return nil, &errors.PipelineError{Message: err.Error(), Type: errors.PipelineErrorTypeCompiler}
}
return data.Matrix.Include, nil
}

View file

@ -22,8 +22,11 @@ import (
"github.com/oklog/ulid/v2"
"github.com/rs/zerolog/log"
"go.uber.org/multierr"
backend_types "github.com/woodpecker-ci/woodpecker/pipeline/backend/types"
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
pipeline_errors "github.com/woodpecker-ci/woodpecker/pipeline/errors"
yaml_types "github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/types"
forge_types "github.com/woodpecker-ci/woodpecker/server/forge/types"
@ -60,9 +63,7 @@ type Item struct {
Config *backend_types.Config
}
func (b *StepBuilder) Build() ([]*Item, error) {
var items []*Item
func (b *StepBuilder) Build() (items []*Item, errorsAndWarnings error) {
b.Yamls = forge_types.SortByName(b.Yamls)
pidSequence := 1
@ -86,9 +87,12 @@ func (b *StepBuilder) Build() ([]*Item, error) {
AxisID: i + 1,
}
item, err := b.genItemForWorkflow(workflow, axis, string(y.Data))
if err != nil {
if err != nil && pipeline_errors.HasBlockingErrors(err) {
return nil, err
} else if err != nil {
errorsAndWarnings = multierr.Append(errorsAndWarnings, err)
}
if item == nil {
continue
}
@ -104,13 +108,13 @@ func (b *StepBuilder) Build() ([]*Item, error) {
// check if at least one step can start if slice is not empty
if len(items) > 0 && !stepListContainsItemsToRun(items) {
return nil, fmt.Errorf("pipeline has no startpoint")
return nil, fmt.Errorf("pipeline has no steps to run")
}
return items, nil
return items, errorsAndWarnings
}
func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.Axis, data string) (*Item, error) {
func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.Axis, data string) (item *Item, errorsAndWarnings error) {
workflowMetadata := frontend.MetadataFromStruct(b.Forge, b.Repo, b.Curr, b.Last, workflow, b.Link)
environ := b.environmentVariables(workflowMetadata, axis)
@ -126,20 +130,21 @@ func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.A
// substitute vars
substituted, err := frontend.EnvVarSubst(data, environ)
if err != nil {
return nil, err
return nil, multierr.Append(errorsAndWarnings, err)
}
// parse yaml pipeline
parsed, err := yaml.ParseString(substituted)
if err != nil {
return nil, &yaml.PipelineParseError{Err: err}
return nil, &errors.PipelineError{Message: err.Error(), Type: errors.PipelineErrorTypeCompiler}
}
// lint pipeline
if err := linter.New(
errorsAndWarnings = multierr.Append(errorsAndWarnings, linter.New(
linter.WithTrusted(b.Repo.IsTrusted),
).Lint(parsed); err != nil {
return nil, &yaml.PipelineParseError{Err: err}
).Lint(substituted, parsed))
if pipeline_errors.HasBlockingErrors(errorsAndWarnings) {
return nil, errorsAndWarnings
}
// checking if filtered.
@ -152,19 +157,19 @@ func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.A
log.Debug().Str("pipeline", workflow.Name).Msg(
"Pipeline config could not be parsed",
)
return nil, err
return nil, multierr.Append(errorsAndWarnings, err)
}
ir, err := b.toInternalRepresentation(parsed, environ, workflowMetadata, workflow.ID)
if err != nil {
return nil, err
return nil, multierr.Append(errorsAndWarnings, err)
}
if len(ir.Stages) == 0 {
return nil, nil
}
item := &Item{
item = &Item{
Workflow: workflow,
Config: ir,
Labels: parsed.Labels,
@ -175,7 +180,7 @@ func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.A
item.Labels = map[string]string{}
}
return item, nil
return item, errorsAndWarnings
}
func stepListContainsItemsToRun(items []*Item) bool {

View file

@ -50,7 +50,8 @@ func TestGlobalEnvsubst(t *testing.T) {
steps:
build:
image: ${IMAGE}
yyy: ${CI_COMMIT_MESSAGE}
settings:
yyy: ${CI_COMMIT_MESSAGE}
`)},
},
}
@ -85,7 +86,8 @@ func TestMissingGlobalEnvsubst(t *testing.T) {
steps:
build:
image: ${IMAGE}
yyy: ${CI_COMMIT_MESSAGE}
settings:
yyy: ${CI_COMMIT_MESSAGE}
`)},
},
}
@ -117,13 +119,15 @@ bbb`,
steps:
xxx:
image: scratch
yyy: ${CI_COMMIT_MESSAGE}
settings:
yyy: ${CI_COMMIT_MESSAGE}
`)},
{Data: []byte(`
steps:
build:
image: scratch
yyy: ${CI_COMMIT_MESSAGE}
settings:
yyy: ${CI_COMMIT_MESSAGE}
`)},
},
}
@ -335,7 +339,7 @@ func TestRootWhenFilter(t *testing.T) {
b := StepBuilder{
Forge: getMockForge(t),
Repo: &model.Repo{},
Curr: &model.Pipeline{Event: "tester"},
Curr: &model.Pipeline{Event: "tag"},
Last: &model.Pipeline{},
Netrc: &model.Netrc{},
Secs: []*model.Secret{},
@ -345,7 +349,7 @@ func TestRootWhenFilter(t *testing.T) {
{Data: []byte(`
when:
event:
- tester
- tag
steps:
xxx:
image: scratch

View file

@ -52,15 +52,15 @@ func ValidateWebhookEvent(s WebhookEvent) error {
type StatusValue string // @name StatusValue
const (
StatusSkipped StatusValue = "skipped"
StatusPending StatusValue = "pending"
StatusRunning StatusValue = "running"
StatusSuccess StatusValue = "success"
StatusFailure StatusValue = "failure"
StatusKilled StatusValue = "killed"
StatusError StatusValue = "error"
StatusBlocked StatusValue = "blocked"
StatusDeclined StatusValue = "declined"
StatusSkipped StatusValue = "skipped" // skipped as another step failed
StatusPending StatusValue = "pending" // pending to be executed
StatusRunning StatusValue = "running" // currently running
StatusSuccess StatusValue = "success" // successfully finished
StatusFailure StatusValue = "failure" // failed to finish (exit code != 0)
StatusKilled StatusValue = "killed" // killed by user
StatusError StatusValue = "error" // error with the config / while parsing / some other system problem
StatusBlocked StatusValue = "blocked" // waiting for approval
StatusDeclined StatusValue = "declined" // blocked and declined
)
// SCMKind represent different version control systems

View file

@ -15,40 +15,44 @@
package model
import (
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
)
type Pipeline struct {
ID int64 `json:"id" xorm:"pk autoincr 'pipeline_id'"`
RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX 'pipeline_repo_id'"`
Number int64 `json:"number" xorm:"UNIQUE(s) 'pipeline_number'"`
Author string `json:"author" xorm:"INDEX 'pipeline_author'"`
ConfigID int64 `json:"-" xorm:"pipeline_config_id"`
Parent int64 `json:"parent" xorm:"pipeline_parent"`
Event WebhookEvent `json:"event" xorm:"pipeline_event"`
Status StatusValue `json:"status" xorm:"INDEX 'pipeline_status'"`
Error string `json:"error" xorm:"LONGTEXT 'pipeline_error'"`
Enqueued int64 `json:"enqueued_at" xorm:"pipeline_enqueued"`
Created int64 `json:"created_at" xorm:"pipeline_created"`
Updated int64 `json:"updated_at" xorm:"updated NOT NULL DEFAULT 0 'updated'"`
Started int64 `json:"started_at" xorm:"pipeline_started"`
Finished int64 `json:"finished_at" xorm:"pipeline_finished"`
Deploy string `json:"deploy_to" xorm:"pipeline_deploy"`
Commit string `json:"commit" xorm:"pipeline_commit"`
Branch string `json:"branch" xorm:"pipeline_branch"`
Ref string `json:"ref" xorm:"pipeline_ref"`
Refspec string `json:"refspec" xorm:"pipeline_refspec"`
CloneURL string `json:"clone_url" xorm:"pipeline_clone_url"`
Title string `json:"title" xorm:"pipeline_title"`
Message string `json:"message" xorm:"TEXT 'pipeline_message'"`
Timestamp int64 `json:"timestamp" xorm:"pipeline_timestamp"`
Sender string `json:"sender" xorm:"pipeline_sender"` // uses reported user for webhooks and name of cron for cron pipelines
Avatar string `json:"author_avatar" xorm:"pipeline_avatar"`
Email string `json:"author_email" xorm:"pipeline_email"`
Link string `json:"link_url" xorm:"pipeline_link"`
Reviewer string `json:"reviewed_by" xorm:"pipeline_reviewer"`
Reviewed int64 `json:"reviewed_at" xorm:"pipeline_reviewed"`
Workflows []*Workflow `json:"workflows,omitempty" xorm:"-"`
ChangedFiles []string `json:"changed_files,omitempty" xorm:"LONGTEXT 'changed_files'"`
AdditionalVariables map[string]string `json:"variables,omitempty" xorm:"json 'additional_variables'"`
PullRequestLabels []string `json:"pr_labels,omitempty" xorm:"json 'pr_labels'"`
ID int64 `json:"id" xorm:"pk autoincr 'pipeline_id'"`
RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX 'pipeline_repo_id'"`
Number int64 `json:"number" xorm:"UNIQUE(s) 'pipeline_number'"`
Author string `json:"author" xorm:"INDEX 'pipeline_author'"`
ConfigID int64 `json:"-" xorm:"pipeline_config_id"`
Parent int64 `json:"parent" xorm:"pipeline_parent"`
Event WebhookEvent `json:"event" xorm:"pipeline_event"`
Status StatusValue `json:"status" xorm:"INDEX 'pipeline_status'"`
Errors []*errors.PipelineError `json:"errors" xorm:"json 'pipeline_errors'"`
Enqueued int64 `json:"enqueued_at" xorm:"pipeline_enqueued"`
Created int64 `json:"created_at" xorm:"pipeline_created"`
Updated int64 `json:"updated_at" xorm:"updated NOT NULL DEFAULT 0 'updated'"`
Started int64 `json:"started_at" xorm:"pipeline_started"`
Finished int64 `json:"finished_at" xorm:"pipeline_finished"`
Deploy string `json:"deploy_to" xorm:"pipeline_deploy"`
Commit string `json:"commit" xorm:"pipeline_commit"`
Branch string `json:"branch" xorm:"pipeline_branch"`
Ref string `json:"ref" xorm:"pipeline_ref"`
Refspec string `json:"refspec" xorm:"pipeline_refspec"`
CloneURL string `json:"clone_url" xorm:"pipeline_clone_url"`
Title string `json:"title" xorm:"pipeline_title"`
Message string `json:"message" xorm:"TEXT 'pipeline_message'"`
Timestamp int64 `json:"timestamp" xorm:"pipeline_timestamp"`
Sender string `json:"sender" xorm:"pipeline_sender"` // uses reported user for webhooks and name of cron for cron pipelines
Avatar string `json:"author_avatar" xorm:"pipeline_avatar"`
Email string `json:"author_email" xorm:"pipeline_email"`
Link string `json:"link_url" xorm:"pipeline_link"`
Reviewer string `json:"reviewed_by" xorm:"pipeline_reviewer"`
Reviewed int64 `json:"reviewed_at" xorm:"pipeline_reviewed"`
Workflows []*Workflow `json:"workflows,omitempty" xorm:"-"`
ChangedFiles []string `json:"changed_files,omitempty" xorm:"LONGTEXT 'changed_files'"`
AdditionalVariables map[string]string `json:"variables,omitempty" xorm:"json 'additional_variables'"`
PullRequestLabels []string `json:"pr_labels,omitempty" xorm:"json 'pr_labels'"`
} // @name Pipeline
// TableName return database table name for xorm

View file

@ -20,6 +20,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
forge_types "github.com/woodpecker-ci/woodpecker/server/forge/types"
"github.com/woodpecker-ci/woodpecker/server/model"
"github.com/woodpecker-ci/woodpecker/server/store"
@ -50,10 +51,12 @@ func Approve(ctx context.Context, store store.Store, currentPipeline *model.Pipe
}
currentPipeline, pipelineItems, err := createPipelineItems(ctx, store, currentPipeline, user, repo, yamls, nil)
if err != nil {
msg := fmt.Sprintf("failure to createBuildItems for %s", repo.FullName)
if errors.HasBlockingErrors(err) {
msg := fmt.Sprintf("failure to createPipelineItems for %s", repo.FullName)
log.Error().Err(err).Msg(msg)
return nil, err
} else if err != nil {
currentPipeline.Errors = errors.GetPipelineErrors(err)
}
currentPipeline, err = start(ctx, store, currentPipeline, user, repo, pipelineItems)

View file

@ -22,6 +22,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
"github.com/woodpecker-ci/woodpecker/server"
"github.com/woodpecker-ci/woodpecker/server/forge"
"github.com/woodpecker-ci/woodpecker/server/model"
@ -60,13 +61,15 @@ func Create(ctx context.Context, _store store.Store, repo *model.Repo, pipeline
if configFetchErr != nil {
log.Debug().Str("repo", repo.FullName).Err(configFetchErr).Msgf("cannot find config '%s' in '%s' with user: '%s'", repo.Config, pipeline.Ref, repoUser.Login)
return nil, persistPipelineWithErr(ctx, _store, pipeline, repo, repoUser, fmt.Sprintf("pipeline definition not found in %s", repo.FullName))
return nil, persistPipelineWithErr(ctx, _store, pipeline, repo, repoUser, fmt.Errorf("pipeline definition not found in %s", repo.FullName))
}
pipelineItems, parseErr := parsePipeline(_store, pipeline, repoUser, repo, forgeYamlConfigs, nil)
if parseErr != nil {
if errors.HasBlockingErrors(parseErr) {
log.Debug().Str("repo", repo.FullName).Err(parseErr).Msg("failed to parse yaml")
return nil, persistPipelineWithErr(ctx, _store, pipeline, repo, repoUser, fmt.Sprintf("failed to parse pipeline: %s", parseErr.Error()))
return nil, persistPipelineWithErr(ctx, _store, pipeline, repo, repoUser, parseErr)
} else if parseErr != nil {
pipeline.Errors = errors.GetPipelineErrors(parseErr)
}
if len(pipelineItems) == 0 {
@ -118,11 +121,11 @@ func Create(ctx context.Context, _store store.Store, repo *model.Repo, pipeline
return pipeline, nil
}
func persistPipelineWithErr(ctx context.Context, _store store.Store, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User, err string) error {
func persistPipelineWithErr(ctx context.Context, _store store.Store, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User, err error) error {
pipeline.Started = time.Now().Unix()
pipeline.Finished = pipeline.Started
pipeline.Status = model.StatusError
pipeline.Error = err
pipeline.Errors = errors.GetPipelineErrors(err)
dbErr := _store.CreatePipeline(pipeline)
if dbErr != nil {
msg := fmt.Errorf("failed to save pipeline for %s", repo.FullName)

View file

@ -22,6 +22,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/woodpecker-ci/woodpecker/pipeline"
pipeline_errors "github.com/woodpecker-ci/woodpecker/pipeline/errors"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml/compiler"
"github.com/woodpecker-ci/woodpecker/server"
forge_types "github.com/woodpecker-ci/woodpecker/server/forge/types"
@ -82,12 +83,7 @@ func parsePipeline(store store.Store, currentPipeline *model.Pipeline, user *mod
HTTPSProxy: server.Config.Pipeline.Proxy.HTTPS,
},
}
pipelineItems, err := b.Build()
if err != nil {
return nil, err
}
return pipelineItems, nil
return b.Build()
}
func createPipelineItems(c context.Context, store store.Store,
@ -102,12 +98,15 @@ func createPipelineItems(c context.Context, store store.Store,
} else {
updatePipelineStatus(c, currentPipeline, repo, user)
}
return currentPipeline, nil, err
if pipeline_errors.HasBlockingErrors(err) {
return currentPipeline, nil, err
}
}
currentPipeline = setPipelineStepsOnPipeline(currentPipeline, pipelineItems)
return currentPipeline, pipelineItems, nil
return currentPipeline, pipelineItems, err
}
// setPipelineStepsOnPipeline is the link between pipeline representation in "pipeline package" and server

View file

@ -18,6 +18,7 @@ package pipeline
import (
"time"
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
"github.com/woodpecker-ci/woodpecker/server/model"
)
@ -48,7 +49,7 @@ func UpdateStatusToDone(store model.UpdatePipelineStore, pipeline model.Pipeline
}
func UpdateToStatusError(store model.UpdatePipelineStore, pipeline model.Pipeline, err error) (*model.Pipeline, error) {
pipeline.Error = err.Error()
pipeline.Errors = errors.GetPipelineErrors(err)
pipeline.Status = model.StatusError
pipeline.Started = time.Now().Unix()
pipeline.Finished = pipeline.Started

View file

@ -90,10 +90,12 @@ func TestUpdateToStatusError(t *testing.T) {
now := time.Now().Unix()
pipeline, _ := UpdateToStatusError(&mockUpdatePipelineStore{}, model.Pipeline{}, errors.New("error"))
pipeline, _ := UpdateToStatusError(&mockUpdatePipelineStore{}, model.Pipeline{}, errors.New("this is an error"))
if pipeline.Error != "error" {
t.Errorf("Pipeline error not equals 'error' != '%s'", pipeline.Error)
if len(pipeline.Errors) != 1 {
t.Errorf("Expected one error, got %d", len(pipeline.Errors))
} else if pipeline.Errors[0].Error() != "[generic] this is an error" {
t.Errorf("Pipeline error not equals '[generic] this is an error' != '%s'", pipeline.Errors[0].Error())
} else if model.StatusError != pipeline.Status {
t.Errorf("Pipeline status not equals '%s' != '%s'", model.StatusError, pipeline.Status)
} else if now > pipeline.Started {

View file

@ -22,7 +22,6 @@ import (
"github.com/rs/zerolog/log"
"github.com/woodpecker-ci/woodpecker/pipeline/frontend/yaml"
"github.com/woodpecker-ci/woodpecker/server"
forge_types "github.com/woodpecker-ci/woodpecker/server/forge/types"
"github.com/woodpecker-ci/woodpecker/server/model"
@ -96,10 +95,7 @@ func Restart(ctx context.Context, store store.Store, lastPipeline *model.Pipelin
newPipeline, pipelineItems, err := createPipelineItems(ctx, store, newPipeline, user, repo, pipelineFiles, envs)
if err != nil {
if errors.Is(err, &yaml.PipelineParseError{}) {
return newPipeline, nil
}
msg := fmt.Sprintf("failure to createBuildItems for %s", repo.FullName)
msg := fmt.Sprintf("failure to createPipelineItems for %s", repo.FullName)
log.Error().Err(err).Msg(msg)
return nil, fmt.Errorf(msg)
}
@ -136,6 +132,6 @@ func createNewOutOfOld(old *model.Pipeline) *model.Pipeline {
newPipeline.Started = 0
newPipeline.Finished = 0
newPipeline.Enqueued = time.Now().UTC().Unix()
newPipeline.Error = ""
newPipeline.Errors = nil
return &newPipeline
}

View file

@ -0,0 +1,85 @@
// Copyright 2023 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 migration
import (
"github.com/woodpecker-ci/woodpecker/pipeline/errors"
"github.com/woodpecker-ci/woodpecker/server/model"
"xorm.io/xorm"
)
type oldPipeline026 struct {
ID int64 `json:"id" xorm:"pk autoincr 'pipeline_id'"`
Error string `json:"error" xorm:"LONGTEXT 'pipeline_error'"`
}
func (oldPipeline026) TableName() string {
return "pipelines"
}
type PipelineError026 struct {
Type string `json:"type"`
Message string `json:"message"`
IsWarning bool `json:"is_warning"`
Data interface{} `json:"data"`
}
type newPipeline026 struct {
ID int64 `json:"id" xorm:"pk autoincr 'pipeline_id'"`
Errors []*errors.PipelineError `json:"errors" xorm:"json 'pipeline_errors'"`
}
func (newPipeline026) TableName() string {
return "pipelines"
}
var convertToNewPipelineErrorFormat = task{
name: "convert-to-new-pipeline-error-format",
required: true,
fn: func(sess *xorm.Session) (err error) {
// make sure pipeline_error column exists
if err := sess.Sync(new(oldPipeline026)); err != nil {
return err
}
// add new pipeline_errors column
if err := sess.Sync(new(model.Pipeline)); err != nil {
return err
}
var oldPipelines []*oldPipeline026
if err := sess.Find(&oldPipelines); err != nil {
return err
}
for _, oldPipeline := range oldPipelines {
var newPipeline newPipeline026
newPipeline.ID = oldPipeline.ID
if oldPipeline.Error != "" {
newPipeline.Errors = []*errors.PipelineError{{
Type: "generic",
Message: oldPipeline.Error,
}}
}
if _, err := sess.ID(oldPipeline.ID).Cols("pipeline_errors").Update(&newPipeline); err != nil {
return err
}
}
return dropTableColumns(sess, "pipelines", "pipeline_error")
},
}

View file

@ -58,6 +58,7 @@ var migrationTasks = []*task{
&alterTableTasksUpdateColumnTaskDataType,
&alterTableConfigUpdateColumnConfigDataType,
&removePluginOnlyOptionFromSecretsTable,
&convertToNewPipelineErrorFormat,
}
var allBeans = []interface{}{

View file

@ -230,7 +230,6 @@
"config": "Config",
"files": "Changed files ({files})",
"no_files": "No files have been changed.",
"execution_error": "Execution error",
"no_pipelines": "No pipelines have been started yet.",
"no_pipeline_steps": "No pipeline steps available!",
"step_not_started": "This step hasn't started yet.",
@ -281,7 +280,11 @@
"error": "error",
"failure": "failure",
"killed": "killed"
}
},
"errors": "Errors ({count})",
"warnings": "Warnings ({count})",
"show_errors": "Show errors",
"we_got_some_errors": "Oh no, we got some errors!"
}
},
"org": {

View file

@ -9,12 +9,10 @@ import { computed, onMounted, ref } from 'vue';
import { Tab, useTabsClient } from '~/compositions/useTabs';
export interface Props {
const props = defineProps<{
id?: string;
title: string;
}
const props = defineProps<Props>();
}>();
const { tabs, activeTab } = useTabsClient();
const tab = ref<Tab>();

View file

@ -1,9 +0,0 @@
<template>
<div class="w-full md:px-4 text-wp-text-100">
<div
class="flex flex-col px-4 py-8 gap-4 justify-center items-center text-center flex-shrink-0 rounded-md border bg-wp-background-100 border-wp-background-400 dark:bg-wp-background-200"
>
<slot />
</div>
</div>
</template>

View file

@ -1,5 +1,12 @@
import { WebhookEvents } from './webhook';
export type PipelineError = {
type: string;
message: string;
data?: unknown;
is_warning: boolean;
};
// A pipeline for a repository.
export type Pipeline = {
id: number;
@ -15,7 +22,7 @@ export type Pipeline = {
// The current status of the pipeline.
status: PipelineStatus;
error: string;
errors?: PipelineError[];
// When the pipeline request was received.
created_at: number;

View file

@ -88,6 +88,12 @@ const routes: RouteRecordRaw[] = [
component: (): Component => import('~/views/repo/pipeline/PipelineConfig.vue'),
props: true,
},
{
path: 'errors',
name: 'repo-pipeline-errors',
component: (): Component => import('~/views/repo/pipeline/PipelineErrors.vue'),
props: true,
},
],
},
{

View file

@ -2,54 +2,74 @@
<Container full-width class="flex flex-col flex-grow md:min-h-xs">
<div class="flex w-full min-h-0 flex-grow">
<PipelineStepList
v-if="pipeline?.workflows?.length || 0 > 0"
v-if="pipeline?.workflows && pipeline?.workflows?.length > 0"
v-model:selected-step-id="selectedStepId"
:class="{ 'hidden md:flex': pipeline.status === 'blocked' }"
:pipeline="pipeline"
/>
<div class="flex flex-grow relative">
<PipelineInfo v-if="error">
<Icon name="status-error" class="w-16 h-16 text-wp-state-error-100" />
<div class="flex flex-wrap items-center justify-center gap-2 text-xl">
<span class="capitalize">{{ $t('repo.pipeline.execution_error') }}:</span>
<span>{{ error }}</span>
</div>
</PipelineInfo>
<div class="flex items-start justify-center flex-grow relative">
<Container v-if="selectedStep?.error" class="py-0">
<Panel>
<div class="flex flex-col items-center gap-4">
<Icon name="status-error" class="w-16 h-16 text-wp-state-error-100" />
<span class="text-xl">{{ $t('repo.pipeline.we_got_some_errors') }}</span>
<span class="whitespace-pre">{{ selectedStep?.error }}</span>
</div>
</Panel>
</Container>
<PipelineInfo v-else-if="pipeline.status === 'blocked'">
<Icon name="status-blocked" class="w-16 h-16" />
<span class="text-xl">{{ $t('repo.pipeline.protected.awaits') }}</span>
<div v-if="repoPermissions.push" class="flex gap-2 flex-wrap items-center justify-center">
<Button
color="blue"
:start-icon="forge ?? 'repo'"
:text="$t('repo.pipeline.protected.review')"
:to="pipeline.link_url"
:title="message"
/>
<Button
color="green"
:text="$t('repo.pipeline.protected.approve')"
:is-loading="isApprovingPipeline"
@click="approvePipeline"
/>
<Button
color="red"
:text="$t('repo.pipeline.protected.decline')"
:is-loading="isDecliningPipeline"
@click="declinePipeline"
/>
</div>
</PipelineInfo>
<Container v-else-if="pipeline.errors?.some((e) => !e.is_warning)" class="py-0">
<Panel>
<div class="flex flex-col items-center gap-4">
<Icon name="status-error" class="w-16 h-16 text-wp-state-error-100" />
<span class="text-xl">{{ $t('repo.pipeline.we_got_some_errors') }}</span>
<Button color="red" :text="$t('repo.pipeline.show_errors')" :to="{ name: 'repo-pipeline-errors' }" />
</div>
</Panel>
</Container>
<PipelineInfo v-else-if="pipeline.status === 'declined'">
<Icon name="status-blocked" class="w-16 h-16" />
<p class="text-xl">{{ $t('repo.pipeline.protected.declined') }}</p>
</PipelineInfo>
<Container v-else-if="pipeline.status === 'blocked'" class="py-0">
<Panel>
<div class="flex flex-col items-center gap-4">
<Icon name="status-blocked" class="w-16 h-16" />
<span class="text-xl">{{ $t('repo.pipeline.protected.awaits') }}</span>
<div v-if="repoPermissions.push" class="flex gap-2 flex-wrap items-center justify-center">
<Button
color="blue"
:start-icon="forge ?? 'repo'"
:text="$t('repo.pipeline.protected.review')"
:to="pipeline.link_url"
:title="message"
/>
<Button
color="green"
:text="$t('repo.pipeline.protected.approve')"
:is-loading="isApprovingPipeline"
@click="approvePipeline"
/>
<Button
color="red"
:text="$t('repo.pipeline.protected.decline')"
:is-loading="isDecliningPipeline"
@click="declinePipeline"
/>
</div>
</div>
</Panel>
</Container>
<Container v-else-if="pipeline.status === 'declined'" class="py-0">
<Panel>
<div class="flex flex-col items-center gap-4">
<Icon name="status-declined" class="w-16 h-16 text-wp-state-error-100" />
<p class="text-xl">{{ $t('repo.pipeline.protected.declined') }}</p>
</div>
</Panel>
</Container>
<PipelineLog
v-else-if="selectedStepId"
v-else-if="selectedStepId !== null"
v-model:step-id="selectedStepId"
:pipeline="pipeline"
class="fixed top-0 left-0 w-full h-full md:absolute"
@ -74,7 +94,7 @@ import { useAsyncAction } from '~/compositions/useAsyncAction';
import useConfig from '~/compositions/useConfig';
import useNotifications from '~/compositions/useNotifications';
import usePipeline from '~/compositions/usePipeline';
import { Pipeline, PipelineStep, Repo, RepoPermissions } from '~/lib/api/types';
import { Pipeline, Repo, RepoPermissions } from '~/lib/api/types';
import { findStep } from '~/utils/helpers';
const props = defineProps<{
@ -96,22 +116,13 @@ if (!repo || !repoPermissions || !pipeline) {
const stepId = toRef(props, 'stepId');
const defaultStepId = computed(() => {
if (!pipeline.value || !pipeline.value.workflows || !pipeline.value.workflows[0].children) {
return null;
}
return pipeline.value.workflows[0].children[0].pid;
});
const defaultStepId = computed(() => pipeline.value?.workflows?.[0].children?.[0].pid ?? null);
const selectedStepId = computed({
get() {
if (stepId.value !== '' && stepId.value !== null && stepId.value !== undefined) {
const id = parseInt(stepId.value, 10);
const step = pipeline.value?.workflows?.reduce(
(prev, p) => prev || p.children?.find((c) => c.pid === id),
undefined as PipelineStep | undefined,
);
const step = pipeline.value?.workflows?.find((p) => p.children?.find((c) => c.pid === id));
if (step) {
return step.pid;
}
@ -128,7 +139,7 @@ const selectedStepId = computed({
return null;
},
set(_selectedStepId: number | null) {
if (!_selectedStepId) {
if (_selectedStepId === null) {
router.replace({ params: { ...route.params, stepId: '' } });
return;
}
@ -141,7 +152,6 @@ const { forge } = useConfig();
const { message } = usePipeline(pipeline);
const selectedStep = computed(() => findStep(pipeline.value.workflows || [], selectedStepId.value || -1));
const error = computed(() => pipeline.value?.error || selectedStep.value?.error);
const { doSubmit: approvePipeline, isLoading: isApprovingPipeline } = useAsyncAction(async () => {
if (!repo) {

View file

@ -0,0 +1,31 @@
<template>
<Panel>
<div class="grid justify-center gap-2 text-left grid-3-1">
<template v-for="(error, i) in pipeline.errors" :key="i">
<span>{{ error.is_warning ? '⚠️' : '❌' }}</span>
<span>[{{ error.type }}]</span>
<span v-if="error.type === 'linter'" class="underline">{{ (error.data as any)?.field }}</span>
<span v-else />
<span class="ml-4">{{ error.message }}</span>
</template>
</div>
</Panel>
</template>
<script lang="ts" setup>
import { inject, Ref } from 'vue';
import Panel from '~/components/layout/Panel.vue';
import { Pipeline } from '~/lib/api/types';
const pipeline = inject<Ref<Pipeline>>('pipeline');
if (!pipeline) {
throw new Error('Unexpected: "pipeline" should be provided at this place');
}
</script>
<style scoped>
.grid-3-1 {
grid-template-columns: auto auto auto 1fr;
}
</style>

View file

@ -1,84 +1,97 @@
<template>
<template v-if="pipeline && repo">
<Scaffold
v-model:activeTab="activeTab"
enable-tabs
disable-hash-mode
:go-back="goBack"
:fluid-content="activeTab === 'tasks'"
full-width-header
>
<template #title>{{ repo.full_name }}</template>
<Scaffold
v-if="pipeline && repo"
v-model:activeTab="activeTab"
enable-tabs
disable-hash-mode
:go-back="goBack"
:fluid-content="activeTab === 'tasks'"
full-width-header
>
<template #title>{{ repo.full_name }}</template>
<template #titleActions>
<div class="flex md:items-center flex-col gap-2 md:flex-row md:justify-between min-w-0">
<div class="flex content-start gap-2 min-w-0">
<PipelineStatusIcon :status="pipeline.status" class="flex flex-shrink-0" />
<span class="flex-shrink-0 text-center">{{ $t('repo.pipeline.pipeline', { pipelineId }) }}</span>
<span class="hidden md:inline-block">-</span>
<span class="min-w-0 whitespace-nowrap overflow-hidden overflow-ellipsis" :title="message">{{
title
}}</span>
</div>
<template v-if="repoPermissions.push && pipeline.status !== 'declined' && pipeline.status !== 'blocked'">
<div class="flex content-start gap-x-2">
<Button
v-if="pipeline.status === 'pending' || pipeline.status === 'running'"
class="flex-shrink-0"
:text="$t('repo.pipeline.actions.cancel')"
:is-loading="isCancelingPipeline"
@click="cancelPipeline"
/>
<Button
class="flex-shrink-0"
:text="$t('repo.pipeline.actions.restart')"
:is-loading="isRestartingPipeline"
@click="restartPipeline"
/>
<Button
v-if="pipeline.status === 'success'"
class="flex-shrink-0"
:text="$t('repo.pipeline.actions.deploy')"
@click="showDeployPipelinePopup = true"
/>
<DeployPipelinePopup
:pipeline-number="pipelineId"
:open="showDeployPipelinePopup"
@close="showDeployPipelinePopup = false"
/>
</div>
</template>
<template #titleActions>
<div class="flex md:items-center flex-col gap-2 md:flex-row md:justify-between min-w-0">
<div class="flex content-start gap-2 min-w-0">
<PipelineStatusIcon :status="pipeline.status" class="flex flex-shrink-0" />
<span class="flex-shrink-0 text-center">{{ $t('repo.pipeline.pipeline', { pipelineId }) }}</span>
<span class="hidden md:inline-block">-</span>
<span class="min-w-0 whitespace-nowrap overflow-hidden overflow-ellipsis" :title="message">{{ title }}</span>
</div>
</template>
<template #tabActions>
<div class="flex gap-x-4">
<div class="flex space-x-1 items-center flex-shrink-0" :title="created">
<Icon name="since" />
<span>{{ since }}</span>
</div>
<div class="flex space-x-1 items-center flex-shrink-0">
<Icon name="duration" />
<span>{{ duration }}</span>
<template v-if="repoPermissions.push && pipeline.status !== 'declined' && pipeline.status !== 'blocked'">
<div class="flex content-start gap-x-2">
<Button
v-if="pipeline.status === 'pending' || pipeline.status === 'running'"
class="flex-shrink-0"
:text="$t('repo.pipeline.actions.cancel')"
:is-loading="isCancelingPipeline"
@click="cancelPipeline"
/>
<Button
class="flex-shrink-0"
:text="$t('repo.pipeline.actions.restart')"
:is-loading="isRestartingPipeline"
@click="restartPipeline"
/>
<Button
v-if="pipeline.status === 'success'"
class="flex-shrink-0"
:text="$t('repo.pipeline.actions.deploy')"
@click="showDeployPipelinePopup = true"
/>
<DeployPipelinePopup
:pipeline-number="pipelineId"
:open="showDeployPipelinePopup"
@close="showDeployPipelinePopup = false"
/>
</div>
</template>
</div>
</template>
<template #tabActions>
<div class="flex gap-x-4">
<div class="flex space-x-1 items-center flex-shrink-0" :title="created">
<Icon name="since" />
<span>{{ since }}</span>
</div>
</template>
<div class="flex space-x-1 items-center flex-shrink-0">
<Icon name="duration" />
<span>{{ duration }}</span>
</div>
</div>
</template>
<Tab id="tasks" :title="$t('repo.pipeline.tasks')" />
<Tab id="config" :title="$t('repo.pipeline.config')" />
<Tab
v-if="
(pipeline.event === 'push' || pipeline.event === 'pull_request') &&
pipeline.changed_files &&
pipeline.changed_files.length > 0
"
id="changed-files"
:title="$t('repo.pipeline.files', { files: pipeline.changed_files.length })"
/>
<router-view />
</Scaffold>
</template>
<Tab id="tasks" :title="$t('repo.pipeline.tasks')" />
<Tab
v-if="pipeline.errors && pipeline.errors.length > 0"
id="errors"
:title="
pipeline.errors.some((e) => !e.is_warning)
? '❌ ' +
$t('repo.pipeline.errors', {
count: pipeline.errors?.length,
})
: '⚠️ ' +
$t('repo.pipeline.warnings', {
count: pipeline.errors?.length,
})
"
/>
<Tab id="config" :title="$t('repo.pipeline.config')" />
<Tab
v-if="
(pipeline.event === 'push' || pipeline.event === 'pull_request') &&
pipeline.changed_files &&
pipeline.changed_files.length > 0
"
id="changed-files"
:title="$t('repo.pipeline.files', { files: pipeline.changed_files?.length })"
/>
<router-view />
</Scaffold>
</template>
<script lang="ts" setup>
@ -182,6 +195,10 @@ const activeTab = computed({
return 'config';
}
if (route.name === 'repo-pipeline-errors') {
return 'errors';
}
return 'tasks';
},
set(tab: string) {
@ -196,6 +213,10 @@ const activeTab = computed({
if (tab === 'config') {
router.replace({ name: 'repo-pipeline-config' });
}
if (tab === 'errors') {
router.replace({ name: 'repo-pipeline-errors' });
}
},
});

View file

@ -60,36 +60,43 @@ type (
PipelineCounter *int `json:"pipeline_counter,omitempty"`
}
PipelineError struct {
Type string `json:"type"`
Message string `json:"message"`
IsWarning bool `json:"is_warning"`
Data interface{} `json:"data"`
}
// Pipeline defines a pipeline object.
Pipeline struct {
ID int64 `json:"id"`
Number int64 `json:"number"`
Parent int64 `json:"parent"`
Event string `json:"event"`
Status string `json:"status"`
Error string `json:"error"`
Enqueued int64 `json:"enqueued_at"`
Created int64 `json:"created_at"`
Updated int64 `json:"updated_at"`
Started int64 `json:"started_at"`
Finished int64 `json:"finished_at"`
Deploy string `json:"deploy_to"`
Commit string `json:"commit"`
Branch string `json:"branch"`
Ref string `json:"ref"`
Refspec string `json:"refspec"`
CloneURL string `json:"clone_url"`
Title string `json:"title"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
Sender string `json:"sender"`
Author string `json:"author"`
Avatar string `json:"author_avatar"`
Email string `json:"author_email"`
Link string `json:"link_url"`
Reviewer string `json:"reviewed_by"`
Reviewed int64 `json:"reviewed_at"`
Workflows []*Workflow `json:"workflows,omitempty"`
ID int64 `json:"id"`
Number int64 `json:"number"`
Parent int64 `json:"parent"`
Event string `json:"event"`
Status string `json:"status"`
Errors PipelineError `json:"errors"`
Enqueued int64 `json:"enqueued_at"`
Created int64 `json:"created_at"`
Updated int64 `json:"updated_at"`
Started int64 `json:"started_at"`
Finished int64 `json:"finished_at"`
Deploy string `json:"deploy_to"`
Commit string `json:"commit"`
Branch string `json:"branch"`
Ref string `json:"ref"`
Refspec string `json:"refspec"`
CloneURL string `json:"clone_url"`
Title string `json:"title"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
Sender string `json:"sender"`
Author string `json:"author"`
Avatar string `json:"author_avatar"`
Email string `json:"author_email"`
Link string `json:"link_url"`
Reviewer string `json:"reviewed_by"`
Reviewed int64 `json:"reviewed_at"`
Workflows []*Workflow `json:"workflows,omitempty"`
}
// Workflow represents a workflow in the pipeline.