mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-02-13 21:00:00 +00:00
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:
@@ -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
2
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
15
server/badges/color.go
Normal 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
130
server/badges/drawer.go
Normal 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
|
||||
}
|
||||
BIN
server/badges/fonts/DejaVuSans.ttf
Normal file
BIN
server/badges/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
13
server/badges/fonts/dejavusans.go
Normal file
13
server/badges/fonts/dejavusans.go
Normal 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
8
server/badges/style.go
Normal 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>`
|
||||
@@ -177,7 +177,9 @@
|
||||
"type_markdown": "Markdown",
|
||||
"type_html": "HTML",
|
||||
"branch": "Branch",
|
||||
"events": "Events"
|
||||
"events": "Events",
|
||||
"workflow": "Workflow",
|
||||
"step": "Step"
|
||||
},
|
||||
"actions": {
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user