diff --git a/.cspell.json b/.cspell.json index 5ceafba755..baf59df869 100644 --- a/.cspell.json +++ b/.cspell.json @@ -49,6 +49,7 @@ "datacenter", "DATASOURCE", "Debugf", + "dejavusans", "Demilestoned", "desaturate", "devx", @@ -129,6 +130,7 @@ "mstruebing", "multiarch", "multierr", + "narqo", "netdns", "Netrc", "Nextcloud", diff --git a/go.mod b/go.mod index 67fdeaddff..ffee783f33 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/go-sql-driver/mysql v1.9.3 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/google/go-github/v82 v82.0.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 @@ -59,6 +60,7 @@ require ( gitlab.com/gitlab-org/api/client-go v1.24.0 go.uber.org/multierr v1.11.0 golang.org/x/crypto v0.47.0 + golang.org/x/image v0.35.0 golang.org/x/net v0.49.0 golang.org/x/oauth2 v0.34.0 golang.org/x/sync v0.19.0 diff --git a/go.sum b/go.sum index 0a0ac7ae89..4dd70e2d0a 100644 --- a/go.sum +++ b/go.sum @@ -227,6 +227,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -660,6 +662,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= +golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= diff --git a/server/api/badge.go b/server/api/badge.go index ef5a590913..88188fff1d 100644 --- a/server/api/badge.go +++ b/server/api/badge.go @@ -93,17 +93,58 @@ func GetBadge(c *gin.Context) { } } + name := "pipeline" + var status *model.StatusValue = nil + pipeline, err := _store.GetPipelineBadge(repo, branch, events) if err != nil { if !errors.Is(err, types.RecordNotExist) { log.Warn().Err(err).Msg("could not get last pipeline for badge") } - pipeline = nil + } else { + status = &pipeline.Status } // we serve an SVG, so set content type appropriately. c.Writer.Header().Set("Content-Type", "image/svg+xml") - c.String(http.StatusOK, badges.Generate(pipeline)) + + // Allow workflow (and step) specific badges + workflowName := c.Query("workflow") + if len(workflowName) != 0 { + name = workflowName + status = nil + + workflows, err := _store.WorkflowGetTree(pipeline) + if err == nil { + for _, wf := range workflows { + if wf.Name == workflowName { + stepName := c.Query("step") + if len(stepName) == 0 { + if status == nil || wf.Failing() { + status = &wf.State + } + continue + } + // If step is explicitly requested + name = workflowName + ": " + stepName + for _, s := range wf.Children { + if s.Name == stepName { + if status == nil || s.Failing() { + status = &s.State + } + } + } + } + } + } + } + + badge, err := badges.Generate(name, status) + if err != nil { + c.String(http.StatusInternalServerError, "Failed to generate badge.") + } else { + c.String(http.StatusOK, badge) + } } // GetCC diff --git a/server/badges/badges.go b/server/badges/badges.go index e05b356fbc..57af0a7066 100644 --- a/server/badges/badges.go +++ b/server/badges/badges.go @@ -14,33 +14,47 @@ package badges -import "go.woodpecker-ci.org/woodpecker/v3/server/model" +import ( + "github.com/rs/zerolog/log" -// cspell:words Verdana - -var ( - badgeSuccess = `pipeline: successpipelinesuccess` - badgeFailure = `pipeline: failurepipelinefailure` - badgeStarted = `pipeline: startedpipelinestarted` - badgeError = `pipeline: errorpipelineerror` - badgeNone = `pipeline: nonepipelinenone` + "go.woodpecker-ci.org/woodpecker/v3/server/model" ) -// Generate an SVG badge based on a pipeline. -func Generate(pipeline *model.Pipeline) string { - if pipeline == nil { - return badgeNone +var ( + // Status labels. + badgeStatusSuccess = "success" + badgeStatusFailure = "failure" + badgeStatusStarted = "started" + badgeStatusError = "error" + badgeStatusNone = "none" +) + +func getBadgeStatusLabelAndColor(status *model.StatusValue) (string, Color) { + if status == nil { + return badgeStatusNone, ColorGray } - switch pipeline.Status { + + switch *status { case model.StatusSuccess: - return badgeSuccess + return badgeStatusSuccess, ColorGreen case model.StatusFailure: - return badgeFailure - case model.StatusError, model.StatusKilled: - return badgeError + return badgeStatusFailure, ColorRed case model.StatusPending, model.StatusRunning: - return badgeStarted + return badgeStatusStarted, ColorYellow + case model.StatusError, model.StatusKilled: + return badgeStatusError, ColorGray default: - return badgeNone + return badgeStatusNone, ColorGray } } + +// Generate an SVG badge based on a pipeline. +func Generate(name string, status *model.StatusValue) (string, error) { + label, color := getBadgeStatusLabelAndColor(status) + bytes, err := RenderBytes(name, label, color) + if err != nil { + log.Warn().Err(err).Msg("could not render badge") + return "", err + } + return string(bytes), nil +} diff --git a/server/badges/badges_test.go b/server/badges/badges_test.go index 7829be16d1..b837556a76 100644 --- a/server/badges/badges_test.go +++ b/server/badges/badges_test.go @@ -15,6 +15,10 @@ package badges import ( + "bytes" + "html/template" + "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -22,13 +26,89 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/model" ) +var ( + badgeNone = `pipelinepipelinenonenone` + badgeSuccess = `pipelinepipelinesuccesssuccess` + badgeFailure = `pipelinepipelinefailurefailure` + badgeError = `pipelinepipelineerrorerror` + badgeStarted = `pipelinepipelinestartedstarted` +) + // Generate an SVG badge based on a pipeline. func TestGenerate(t *testing.T) { - assert.Equal(t, badgeNone, Generate(nil)) - assert.Equal(t, badgeSuccess, Generate(&model.Pipeline{Status: model.StatusSuccess})) - assert.Equal(t, badgeFailure, Generate(&model.Pipeline{Status: model.StatusFailure})) - assert.Equal(t, badgeError, Generate(&model.Pipeline{Status: model.StatusError})) - assert.Equal(t, badgeError, Generate(&model.Pipeline{Status: model.StatusKilled})) - assert.Equal(t, badgeStarted, Generate(&model.Pipeline{Status: model.StatusPending})) - assert.Equal(t, badgeStarted, Generate(&model.Pipeline{Status: model.StatusRunning})) + status := model.StatusDeclined + badge, err := Generate("pipeline", &status) + assert.NoError(t, err) + assert.Equal(t, badgeNone, badge) + status = model.StatusSuccess + badge, err = Generate("pipeline", &status) + assert.NoError(t, err) + assert.Equal(t, badgeSuccess, badge) + status = model.StatusFailure + badge, err = Generate("pipeline", &status) + assert.NoError(t, err) + assert.Equal(t, badgeFailure, badge) + status = model.StatusError + badge, err = Generate("pipeline", &status) + assert.NoError(t, err) + assert.Equal(t, badgeError, badge) + status = model.StatusKilled + badge, err = Generate("pipeline", &status) + assert.NoError(t, err) + assert.Equal(t, badgeError, badge) + status = model.StatusPending + badge, err = Generate("pipeline", &status) + assert.NoError(t, err) + assert.Equal(t, badgeStarted, badge) + status = model.StatusRunning + badge, err = Generate("pipeline", &status) + assert.NoError(t, err) + assert.Equal(t, badgeStarted, badge) +} + +func TestBadgeDrawerRender(t *testing.T) { + mockTemplate := strings.TrimSpace(` + {{.Subject}},{{.Status}},{{.Color}},{{with .Bounds}}{{.SubjectX}},{{.SubjectDx}},{{.StatusX}},{{.StatusDx}},{{.Dx}}{{end}} + `) + mockFontSize := 11.0 + mockDPI := 72.0 + + fd, err := mustNewFontDrawer(mockFontSize, mockDPI) + assert.NoError(t, err) + + d := &badgeDrawer{ + fd: fd, + tmpl: template.Must(template.New("mock-template").Parse(mockTemplate)), + mutex: &sync.Mutex{}, + } + + output := "XXX,YYY,#c0c0c0,14,26,38,26,52" + + var buf bytes.Buffer + assert.NoError(t, d.Render("XXX", "YYY", "#c0c0c0", &buf)) + assert.Equal(t, output, buf.String()) +} + +func TestBadgeDrawerRenderBytes(t *testing.T) { + mockTemplate := strings.TrimSpace(` + {{.Subject}},{{.Status}},{{.Color}},{{with .Bounds}}{{.SubjectX}},{{.SubjectDx}},{{.StatusX}},{{.StatusDx}},{{.Dx}}{{end}} + `) + mockFontSize := 11.0 + mockDPI := 72.0 + + fd, err := mustNewFontDrawer(mockFontSize, mockDPI) + assert.NoError(t, err) + + d := &badgeDrawer{ + fd: fd, + tmpl: template.Must(template.New("mock-template").Parse(mockTemplate)), + mutex: &sync.Mutex{}, + } + + output := "XXX,YYY,#c0c0c0,14,26,38,26,52" + + bytes, err := d.RenderBytes("XXX", "YYY", "#c0c0c0") + + assert.NoError(t, err) + assert.Equal(t, output, string(bytes)) } diff --git a/server/badges/color.go b/server/badges/color.go new file mode 100644 index 0000000000..617ba24381 --- /dev/null +++ b/server/badges/color.go @@ -0,0 +1,15 @@ +// Copyright 2023 The narqo/go-badge Authors. All rights reserved. +// SPDX-License-Identifier: MIT. + +package badges + +// Color represents color of the badge. +type Color string + +// Standard colors. +const ( + ColorGreen = "#44cc11" + ColorYellow = "#dfb317" + ColorRed = "#e05d44" + ColorGray = "#9f9f9f" +) diff --git a/server/badges/drawer.go b/server/badges/drawer.go new file mode 100644 index 0000000000..e4cef5b62a --- /dev/null +++ b/server/badges/drawer.go @@ -0,0 +1,130 @@ +// Copyright 2023 The narqo/go-badge Authors. All rights reserved. +// SPDX-License-Identifier: MIT. + +package badges + +// cspell:words Verdana + +import ( + "bytes" + "html/template" + "io" + "sync" + + "github.com/golang/freetype/truetype" + "golang.org/x/image/font" + + "go.woodpecker-ci.org/woodpecker/v3/server/badges/fonts" +) + +type badge struct { + Subject string + Status string + Color Color + Bounds bounds +} + +type bounds struct { + // SubjectDx is the width of subject string of the badge. + SubjectDx float64 + SubjectX float64 + // StatusDx is the width of status string of the badge. + StatusDx float64 + StatusX float64 +} + +func (b bounds) Dx() float64 { + return b.SubjectDx + b.StatusDx +} + +type badgeDrawer struct { + fd *font.Drawer + tmpl *template.Template + mutex *sync.Mutex +} + +func (d *badgeDrawer) Render(subject, status string, color Color, w io.Writer) error { + d.mutex.Lock() + subjectDx := d.measureString(subject) + statusDx := d.measureString(status) + d.mutex.Unlock() + + bdg := badge{ + Subject: subject, + Status: status, + Color: color, + Bounds: bounds{ + SubjectDx: subjectDx, + SubjectX: subjectDx/2.0 + 1, + StatusDx: statusDx, + StatusX: subjectDx + statusDx/2.0 - 1, + }, + } + return d.tmpl.Execute(w, bdg) +} + +func (d *badgeDrawer) RenderBytes(subject, status string, color Color) ([]byte, error) { + buf := &bytes.Buffer{} + err := d.Render(subject, status, color, buf) + return buf.Bytes(), err +} + +// shields.io uses Verdana.ttf to measure text width with an extra 10px. +// As we use DejaVuSans.ttf, we have to tune this value a little. +const extraDx = 5 + +func (d *badgeDrawer) measureString(s string) float64 { + SHIFT := 6 + return float64(d.fd.MeasureString(s)>>SHIFT) + extraDx +} + +// RenderBytes renders a badge of the given color, with given subject and status to bytes. +func RenderBytes(subject, status string, color Color) ([]byte, error) { + drawer, err := initDrawer() + if err != nil { + return nil, err + } + return drawer.RenderBytes(subject, status, color) +} + +const ( + dpi = 72 + fontSize = 11 +) + +var ( + drawer *badgeDrawer + initError error + initOnce sync.Once +) + +func initDrawer() (*badgeDrawer, error) { + initOnce.Do(func() { + fd, err := mustNewFontDrawer(fontSize, dpi) + if err != nil { + initError = err + return + } + drawer = &badgeDrawer{ + fd: fd, + tmpl: template.Must(template.New("flat-template").Parse(flatTemplate)), + mutex: &sync.Mutex{}, + } + initError = nil + }) + return drawer, initError +} + +func mustNewFontDrawer(size, dpi float64) (*font.Drawer, error) { + ttf, err := truetype.Parse(fonts.DejaVuSans) + if err != nil { + return nil, err + } + return &font.Drawer{ + Face: truetype.NewFace(ttf, &truetype.Options{ + Size: size, + DPI: dpi, + Hinting: font.HintingFull, + }), + }, nil +} diff --git a/server/badges/fonts/DejaVuSans.ttf b/server/badges/fonts/DejaVuSans.ttf new file mode 100644 index 0000000000..9d40c32569 Binary files /dev/null and b/server/badges/fonts/DejaVuSans.ttf differ diff --git a/server/badges/fonts/dejavusans.go b/server/badges/fonts/dejavusans.go new file mode 100644 index 0000000000..0c5a0b916f --- /dev/null +++ b/server/badges/fonts/dejavusans.go @@ -0,0 +1,13 @@ +// Copyright 2023 The narqo/go-badge Authors. All rights reserved. +// SPDX-License-Identifier: MIT. + +package fonts + +import ( + _ "embed" +) + +// DejaVuSans is DejaVuSans.ttf font inlined to the bytes slice. +// +//go:embed DejaVuSans.ttf +var DejaVuSans []byte diff --git a/server/badges/style.go b/server/badges/style.go new file mode 100644 index 0000000000..f09463f930 --- /dev/null +++ b/server/badges/style.go @@ -0,0 +1,8 @@ +// Copyright 2023 The narqo/go-badge Authors. All rights reserved. +// SPDX-License-Identifier: MIT. + +package badges + +// cspell:words Verdana + +var flatTemplate = `{{.Subject | html}}{{.Subject | html}}{{.Status | html}}{{.Status | html}}` diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index f3ad0db9b3..a26e87caef 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -177,7 +177,9 @@ "type_markdown": "Markdown", "type_html": "HTML", "branch": "Branch", - "events": "Events" + "events": "Events", + "workflow": "Workflow", + "step": "Step" }, "actions": { "actions": "Actions", diff --git a/web/src/views/repo/settings/Badge.vue b/web/src/views/repo/settings/Badge.vue index 2a6ff08990..7f05cb0e27 100644 --- a/web/src/views/repo/settings/Badge.vue +++ b/web/src/views/repo/settings/Badge.vue @@ -39,6 +39,12 @@ @update:model-value="eventsChanged" /> + + + + + +
@@ -57,6 +63,7 @@ import CheckboxesField from '~/components/form/CheckboxesField.vue'; import type { CheckboxOption, SelectOption } from '~/components/form/form.types'; import InputField from '~/components/form/InputField.vue'; import SelectField from '~/components/form/SelectField.vue'; +import TextField from '~/components/form/TextField.vue'; import Settings from '~/components/layout/Settings.vue'; import useApiClient from '~/compositions/useApiClient'; import useConfig from '~/compositions/useConfig'; @@ -74,6 +81,8 @@ const defaultBranch = computed(() => repo.value.default_branch); const branches = ref([]); const branch = ref(''); const events = ref([WebhookEvents.Push]); +const workflow = ref(''); +const step = ref(''); async function loadBranches() { branches.value = (await usePaginate((page) => apiClient.getRepoBranches(repo.value.id, { page }))) @@ -106,6 +115,14 @@ const badgeUrl = computed(() => { } } + if (workflow.value.trim().length > 0) { + params.push(`workflow=${encodeURIComponent(workflow.value.trim())}`); + + if (step.value.trim().length > 0) { + params.push(`step=${encodeURIComponent(step.value.trim())}`); + } + } + return `${rootPath}/api/badges/${repo.value.id}/status.svg${params.length > 0 ? `?${params.join('&')}` : ''}`; }); const repoUrl = computed(