diff --git a/modules/badge/badge.go b/modules/badge/badge.go index d2e9bd9d1b..a54911ded4 100644 --- a/modules/badge/badge.go +++ b/modules/badge/badge.go @@ -1,129 +1,79 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later package badge import ( - "strings" - "sync" - "unicode" - - actions_model "code.gitea.io/gitea/models/actions" + "bytes" + "fmt" + "html/template" ) -// The Badge layout: |offset|label|message| -// We use 10x scale to calculate more precisely -// Then scale down to normal size in tmpl file - -type Text struct { - text string - width int - x int -} - -func (t Text) Text() string { - return t.text -} - -func (t Text) Width() int { - return t.width -} - -func (t Text) X() int { - return t.x -} - -func (t Text) TextLength() int { - return int(float64(t.width-defaultOffset) * 10) -} - +// Badge holds the data for rendering an SVG badge. type Badge struct { - IDPrefix string - FontFamily string - Color string - FontSize int - Label Text - Message Text + Label string + Message string + Color string } -func (b Badge) Width() int { - return b.Label.width + b.Message.width -} - -// Style follows https://shields.io/badges +// Color presets const ( - StyleFlat = "flat" - StyleFlatSquare = "flat-square" + ColorGreen = "#4c1" + ColorYellow = "#dfb317" + ColorRed = "#e05d44" + ColorGrey = "#9f9f9f" + ColorBlue = "#007ec6" + ColorOrange = "#fe7d37" ) -const ( - defaultOffset = 10 - defaultFontSize = 11 - DefaultColor = "#9f9f9f" // Grey - DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif" - DefaultStyle = StyleFlat -) +const charWidth = 6.8 -var GlobalVars = sync.OnceValue(func() (ret struct { - StatusColorMap map[actions_model.Status]string - DejaVuGlyphWidthData map[rune]uint8 - AllStyles []string -}, -) { - ret.StatusColorMap = map[actions_model.Status]string{ - actions_model.StatusSuccess: "#4c1", // Green - actions_model.StatusSkipped: "#dfb317", // Yellow - actions_model.StatusUnknown: "#97ca00", // Light Green - actions_model.StatusFailure: "#e05d44", // Red - actions_model.StatusCancelled: "#fe7d37", // Orange - actions_model.StatusWaiting: "#dfb317", // Yellow - actions_model.StatusRunning: "#dfb317", // Yellow - actions_model.StatusBlocked: "#dfb317", // Yellow - } - ret.DejaVuGlyphWidthData = dejaVuGlyphWidthDataFunc() - ret.AllStyles = []string{StyleFlat, StyleFlatSquare} - return ret -}) - -// GenerateBadge generates badge with given template -func GenerateBadge(label, message, color string) Badge { - lw := calculateTextWidth(label) + defaultOffset - mw := calculateTextWidth(message) + defaultOffset - - lx := lw * 5 - mx := lw*10 + mw*5 - 10 - return Badge{ - FontFamily: DefaultFontFamily, - Label: Text{ - text: label, - width: lw, - x: lx, - }, - Message: Text{ - text: message, - width: mw, - x: mx, - }, - FontSize: defaultFontSize * 10, - Color: color, - } +func textWidth(s string) float64 { + return float64(len(s)) * charWidth } -func calculateTextWidth(text string) int { - width := 0 - widthData := GlobalVars().DejaVuGlyphWidthData - for _, char := range strings.TrimSpace(text) { - charWidth, ok := widthData[char] - if !ok { - // use the width of 'm' in case of missing glyph width data for a printable character - if unicode.IsPrint(char) { - charWidth = widthData['m'] - } else { - charWidth = 0 - } - } - width += int(charWidth) +var svgTemplate = template.Must(template.New("badge").Parse(` + {{.Label}}: {{.Message}} + + + + + + + + + + + + + {{.Label}} + + {{.Message}} + +`)) + +type templateData struct { + Label, Message, Color string + LabelWidth, MessageWidth int + TotalWidth int + LabelX, MessageX float64 +} + +// Render generates an SVG badge as a byte slice. +func (b Badge) Render() ([]byte, error) { + padding := 12.0 + lw := int(textWidth(b.Label) + padding) + mw := int(textWidth(b.Message) + padding) + + data := templateData{ + Label: b.Label, Message: b.Message, Color: b.Color, + LabelWidth: lw, MessageWidth: mw, TotalWidth: lw + mw, + LabelX: float64(lw) / 2, MessageX: float64(lw) + float64(mw)/2, } - return width + var buf bytes.Buffer + if err := svgTemplate.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("rendering badge: %w", err) + } + return buf.Bytes(), nil } diff --git a/modules/badge/presets.go b/modules/badge/presets.go new file mode 100644 index 0000000000..8a24454234 --- /dev/null +++ b/modules/badge/presets.go @@ -0,0 +1,88 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package badge + +import ( + "context" + "fmt" + "strings" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" +) + +// Generate creates a badge for the given repo and badge type. +func Generate(ctx context.Context, repo *repo_model.Repository, badgeType string) (Badge, error) { + switch strings.ToLower(badgeType) { + case "version": + return versionBadge(ctx, repo) + case "build": + return buildBadge(ctx, repo) + case "license": + return licenseBadge(repo) + case "health": + return healthBadge(ctx, repo) + default: + return Badge{Label: "badge", Message: "unknown", Color: ColorGrey}, + fmt.Errorf("unknown badge type: %s", badgeType) + } +} + +func versionBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) { + release, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID) + if err != nil || release == nil { + return Badge{Label: "version", Message: "none", Color: ColorGrey}, nil + } + return Badge{Label: "version", Message: release.TagName, Color: ColorBlue}, nil +} + +func buildBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) { + status, err := git_model.GetLatestCommitStatus(ctx, repo.ID, repo.DefaultBranch, 1) + if err != nil || len(status) == 0 { + return Badge{Label: "build", Message: "unknown", Color: ColorGrey}, nil + } + + switch status[0].State.String() { + case "success": + return Badge{Label: "build", Message: "passing", Color: ColorGreen}, nil + case "failure", "error": + return Badge{Label: "build", Message: "failing", Color: ColorRed}, nil + case "pending": + return Badge{Label: "build", Message: "pending", Color: ColorYellow}, nil + default: + return Badge{Label: "build", Message: status[0].State.String(), Color: ColorGrey}, nil + } +} + +func licenseBadge(repo *repo_model.Repository) (Badge, error) { + if len(repo.License) > 0 { + return Badge{Label: "license", Message: repo.License, Color: ColorBlue}, nil + } + return Badge{Label: "license", Message: "none", Color: ColorGrey}, nil +} + +func healthBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) { + score := 0 + if repo.HasWiki() { + score++ + } + if len(repo.License) > 0 { + score++ + } + if repo.Description != "" { + score++ + } + + var color string + var msg string + switch { + case score >= 3: + color, msg = ColorGreen, "healthy" + case score >= 2: + color, msg = ColorYellow, "fair" + default: + color, msg = ColorRed, "needs work" + } + return Badge{Label: "health", Message: msg, Color: color}, nil +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9fec54471b..64966856f1 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1430,7 +1430,9 @@ func Routes() *web.Router { Delete(reqToken(), repo.DeleteTopic) }, reqAdmin()) }, reqAnyRepoReader()) - m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates) + // MokoGitea badge engine + m.Get("/badge/{type}.svg", repo.GetRepoBadge) + m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates) m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig) m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig) m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages) diff --git a/routers/api/v1/repo/badge.go b/routers/api/v1/repo/badge.go new file mode 100644 index 0000000000..cd33c56ea6 --- /dev/null +++ b/routers/api/v1/repo/badge.go @@ -0,0 +1,34 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/badge" + "code.gitea.io/gitea/services/context" +) + +// GetRepoBadge returns an SVG badge for the repository. +func GetRepoBadge(ctx *context.APIContext) { + badgeType := ctx.PathParam("type") + + b, err := badge.Generate(ctx, ctx.Repo.Repository, badgeType) + if err != nil { + b = badge.Badge{Label: "badge", Message: "error", Color: badge.ColorGrey} + } + + svg, err := b.Render() + if err != nil { + ctx.APIErrorInternal(fmt.Errorf("rendering badge: %w", err)) + return + } + + ctx.Resp.Header().Set("Content-Type", "image/svg+xml") + ctx.Resp.Header().Set("Cache-Control", "public, max-age=300") + ctx.Resp.Header().Set("ETag", fmt.Sprintf(`"%s-%s"`, b.Label, b.Message)) + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(svg) +}