Per-Workflow and Per-Workflow-Step badge generation (#5977)

Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Robert Kaussow <mail@thegeeklab.de>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
This commit is contained in:
David Loewe
2026-02-01 16:44:09 +01:00
committed by GitHub
parent df8dc5de33
commit e2270ae95c
13 changed files with 358 additions and 30 deletions

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="pipeline: success"><title>pipeline: success</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="53" height="20" fill="#555"/><rect x="53" width="53" height="20" fill="#44cc11"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="275" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">pipeline</text><text x="275" y="140" transform="scale(.1)" fill="#fff" textLength="430">pipeline</text><text aria-hidden="true" x="785" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">success</text><text x="785" y="140" transform="scale(.1)" fill="#fff" textLength="430">success</text></g></svg>`
badgeFailure = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="98" height="20" role="img" aria-label="pipeline: failure"><title>pipeline: failure</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="98" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="53" height="20" fill="#555"/><rect x="53" width="45" height="20" fill="#e05d44"/><rect width="98" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="275" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">pipeline</text><text x="275" y="140" transform="scale(.1)" fill="#fff" textLength="430">pipeline</text><text aria-hidden="true" x="745" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="350">failure</text><text x="745" y="140" transform="scale(.1)" fill="#fff" textLength="350">failure</text></g></svg>`
badgeStarted = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="20" role="img" aria-label="pipeline: started"><title>pipeline: started</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="102" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="53" height="20" fill="#555"/><rect x="53" width="49" height="20" fill="#dfb317"/><rect width="102" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="275" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">pipeline</text><text x="275" y="140" transform="scale(.1)" fill="#fff" textLength="430">pipeline</text><text aria-hidden="true" x="765" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="390">started</text><text x="765" y="140" transform="scale(.1)" fill="#fff" textLength="390">started</text></g></svg>`
badgeError = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="pipeline: error"><title>pipeline: error</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="90" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="53" height="20" fill="#555"/><rect x="53" width="37" height="20" fill="#9f9f9f"/><rect width="90" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="275" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">pipeline</text><text x="275" y="140" transform="scale(.1)" fill="#fff" textLength="430">pipeline</text><text aria-hidden="true" x="705" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">error</text><text x="705" y="140" transform="scale(.1)" fill="#fff" textLength="270">error</text></g></svg>`
badgeNone = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="pipeline: none"><title>pipeline: none</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="90" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="53" height="20" fill="#555"/><rect x="53" width="37" height="20" fill="#9f9f9f"/><rect width="90" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="275" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">pipeline</text><text x="275" y="140" transform="scale(.1)" fill="#fff" textLength="430">pipeline</text><text aria-hidden="true" x="705" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">none</text><text x="705" y="140" transform="scale(.1)" fill="#fff" textLength="270">none</text></g></svg>`
"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
}

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="round"><rect width="82" height="20" rx="3" fill="#fff"/></mask><g mask="url(#round)"><rect width="49" height="20" fill="#555"/><rect x="49" width="33" height="20" fill="#9f9f9f"/><rect width="82" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="25.5" y="15" fill="#010101" fill-opacity=".3">pipeline</text><text x="25.5" y="14">pipeline</text><text x="64.5" y="15" fill="#010101" fill-opacity=".3">none</text><text x="64.5" y="14">none</text></g></svg>`
badgeSuccess = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="101" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="round"><rect width="101" height="20" rx="3" fill="#fff"/></mask><g mask="url(#round)"><rect width="49" height="20" fill="#555"/><rect x="49" width="52" height="20" fill="#44cc11"/><rect width="101" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="25.5" y="15" fill="#010101" fill-opacity=".3">pipeline</text><text x="25.5" y="14">pipeline</text><text x="74" y="15" fill="#010101" fill-opacity=".3">success</text><text x="74" y="14">success</text></g></svg>`
badgeFailure = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="round"><rect width="90" height="20" rx="3" fill="#fff"/></mask><g mask="url(#round)"><rect width="49" height="20" fill="#555"/><rect x="49" width="41" height="20" fill="#e05d44"/><rect width="90" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="25.5" y="15" fill="#010101" fill-opacity=".3">pipeline</text><text x="25.5" y="14">pipeline</text><text x="68.5" y="15" fill="#010101" fill-opacity=".3">failure</text><text x="68.5" y="14">failure</text></g></svg>`
badgeError = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="83" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="round"><rect width="83" height="20" rx="3" fill="#fff"/></mask><g mask="url(#round)"><rect width="49" height="20" fill="#555"/><rect x="49" width="34" height="20" fill="#9f9f9f"/><rect width="83" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="25.5" y="15" fill="#010101" fill-opacity=".3">pipeline</text><text x="25.5" y="14">pipeline</text><text x="65" y="15" fill="#010101" fill-opacity=".3">error</text><text x="65" y="14">error</text></g></svg>`
badgeStarted = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="95" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="round"><rect width="95" height="20" rx="3" fill="#fff"/></mask><g mask="url(#round)"><rect width="49" height="20" fill="#555"/><rect x="49" width="46" height="20" fill="#dfb317"/><rect width="95" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="25.5" y="15" fill="#010101" fill-opacity=".3">pipeline</text><text x="25.5" y="14">pipeline</text><text x="71" y="15" fill="#010101" fill-opacity=".3">started</text><text x="71" y="14">started</text></g></svg>`
)
// 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))
}

15
server/badges/color.go Normal file
View File

@@ -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"
)

130
server/badges/drawer.go Normal file
View File

@@ -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
}

Binary file not shown.

View File

@@ -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

8
server/badges/style.go Normal file
View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{{.Bounds.Dx}}" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><mask id="round"><rect width="{{.Bounds.Dx}}" height="20" rx="3" fill="#fff"/></mask><g mask="url(#round)"><rect width="{{.Bounds.SubjectDx}}" height="20" fill="#555"/><rect x="{{.Bounds.SubjectDx}}" width="{{.Bounds.StatusDx}}" height="20" fill="{{or .Color "#4c1" | html}}"/><rect width="{{.Bounds.Dx}}" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="{{.Bounds.SubjectX}}" y="15" fill="#010101" fill-opacity=".3">{{.Subject | html}}</text><text x="{{.Bounds.SubjectX}}" y="14">{{.Subject | html}}</text><text x="{{.Bounds.StatusX}}" y="15" fill="#010101" fill-opacity=".3">{{.Status | html}}</text><text x="{{.Bounds.StatusX}}" y="14">{{.Status | html}}</text></g></svg>`