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 = ``
- badgeFailure = ``
- badgeStarted = ``
- badgeError = ``
- badgeNone = ``
+ "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 = ``
+ badgeSuccess = ``
+ badgeFailure = ``
+ badgeError = ``
+ badgeStarted = ``
+)
+
// 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 = ``
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"
/>
+