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

@@ -49,6 +49,7 @@
"datacenter",
"DATASOURCE",
"Debugf",
"dejavusans",
"Demilestoned",
"desaturate",
"devx",
@@ -129,6 +130,7 @@
"mstruebing",
"multiarch",
"multierr",
"narqo",
"netdns",
"Netrc",
"Nextcloud",

2
go.mod
View File

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

4
go.sum
View File

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

View File

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

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

View File

@@ -177,7 +177,9 @@
"type_markdown": "Markdown",
"type_html": "HTML",
"branch": "Branch",
"events": "Events"
"events": "Events",
"workflow": "Workflow",
"step": "Step"
},
"actions": {
"actions": "Actions",

View File

@@ -39,6 +39,12 @@
@update:model-value="eventsChanged"
/>
</InputField>
<InputField v-slot="{ id }" :label="$t('repo.settings.badge.workflow')">
<TextField :id="id" v-model="workflow" />
</InputField>
<InputField v-slot="{ id }" :label="$t('repo.settings.badge.step')">
<TextField :id="id" v-model="step" />
</InputField>
<div v-if="badgeContent" class="flex flex-col space-y-4">
<div>
@@ -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<SelectOption[]>([]);
const branch = ref<string>('');
const events = ref<string[]>([WebhookEvents.Push]);
const workflow = ref<string>('');
const step = ref<string>('');
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(