[GITEA] Add support for shields.io-based badges

Adds a new `/{username}/{repo}/badges` family of routes, which redirect
to various shields.io badges. The goal is to not reimplement badge
generation, and delegate it to shields.io (or a similar service), which
are already used by many. This way, we get all the goodies that come
with it: different styles, colors, logos, you name it.

So these routes are just thin wrappers around shields.io that make it
easier to display the information we want. The URL is configurable via
`app.ini`, and is templatable, allowing to use alternative badge
generator services with slightly different URL patterns.

Additionally, for compatibility with GitHub, there's an
`/{username}/{repo}/actions/workflows/{workflow_file}/badge.svg` route
that works much the same way as on GitHub. Change the hostname in the
URL, and done.

Fixes gitea#5633, gitea#23688, and also fixes #126.

Work sponsored by Codeberg e.V.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
(cherry picked from commit fcd0f61212)
This commit is contained in:
Gergely Nagy 2024-01-01 13:38:49 +01:00 committed by Earl Warren
parent 1868dec2e4
commit 20d14f7844
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
7 changed files with 471 additions and 0 deletions

View file

@ -905,6 +905,14 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[badges]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Enable repository badges (via shields.io or a similar generator)
;ENABLED = true
;; Template for the badge generator.
;GENERATOR_URL_TEMPLATE = https://img.shields.io/badge/{{.label}}-{{.text}}-{{.color}}
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[repository] ;[repository]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -319,6 +319,21 @@ func GetLatestRun(ctx context.Context, repoID int64) (*ActionRun, error) {
return &run, nil return &run, nil
} }
func GetLatestRunForBranchAndWorkflow(ctx context.Context, repoID int64, branch, workflowFile, event string) (*ActionRun, error) {
var run ActionRun
q := db.GetEngine(ctx).Where("repo_id=?", repoID).And("ref=?", branch).And("workflow_id=?", workflowFile)
if event != "" {
q = q.And("event=?", event)
}
has, err := q.Desc("id").Get(&run)
if err != nil {
return nil, err
} else if !has {
return nil, util.NewNotExistErrorf("run with repo_id %d, ref %s, workflow_id %s", repoID, branch, workflowFile)
}
return &run, nil
}
func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) { func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) {
var run ActionRun var run ActionRun
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run) has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)

24
modules/setting/badges.go Normal file
View file

@ -0,0 +1,24 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"text/template"
)
// Badges settings
var Badges = struct {
Enabled bool `ini:"ENABLED"`
GeneratorURLTemplate string `ini:"GENERATOR_URL_TEMPLATE"`
GeneratorURLTemplateTemplate *template.Template `ini:"-"`
}{
Enabled: true,
GeneratorURLTemplate: "https://img.shields.io/badge/{{.label}}-{{.text}}-{{.color}}",
}
func loadBadgesFrom(rootCfg ConfigProvider) {
mustMapSetting(rootCfg, "badges", &Badges)
Badges.GeneratorURLTemplateTemplate = template.Must(template.New("").Parse(Badges.GeneratorURLTemplate))
}

View file

@ -147,6 +147,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadUIFrom(cfg) loadUIFrom(cfg)
loadAdminFrom(cfg) loadAdminFrom(cfg)
loadAPIFrom(cfg) loadAPIFrom(cfg)
loadBadgesFrom(cfg)
loadMetricsFrom(cfg) loadMetricsFrom(cfg)
loadCamoFrom(cfg) loadCamoFrom(cfg)
loadI18nFrom(cfg) loadI18nFrom(cfg)

View file

@ -0,0 +1,165 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package badges
import (
"fmt"
"net/url"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
context_module "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/setting"
)
func getBadgeURL(ctx *context_module.Context, label, text, color string) string {
sb := &strings.Builder{}
_ = setting.Badges.GeneratorURLTemplateTemplate.Execute(sb, map[string]string{
"label": url.PathEscape(label),
"text": url.PathEscape(text),
"color": url.PathEscape(color),
})
badgeURL := sb.String()
q := ctx.Req.URL.Query()
// Remove any `branch` or `event` query parameters. They're used by the
// workflow badge route, and do not need forwarding to the badge generator.
delete(q, "branch")
delete(q, "event")
if len(q) > 0 {
return fmt.Sprintf("%s?%s", badgeURL, q.Encode())
}
return badgeURL
}
func redirectToBadge(ctx *context_module.Context, label, text, color string) {
ctx.Redirect(getBadgeURL(ctx, label, text, color))
}
func errorBadge(ctx *context_module.Context, label, text string) {
ctx.Redirect(getBadgeURL(ctx, label, text, "crimson"))
}
func GetWorkflowBadge(ctx *context_module.Context) {
branch := ctx.Req.URL.Query().Get("branch")
if branch == "" {
branch = ctx.Repo.Repository.DefaultBranch
}
branch = fmt.Sprintf("refs/heads/%s", branch)
event := ctx.Req.URL.Query().Get("event")
workflowFile := ctx.Params("workflow_name")
run, err := actions_model.GetLatestRunForBranchAndWorkflow(ctx, ctx.Repo.Repository.ID, branch, workflowFile, event)
if err != nil {
errorBadge(ctx, workflowFile, "Not found")
return
}
var color string
switch run.Status {
case actions_model.StatusUnknown:
color = "lightgrey"
case actions_model.StatusWaiting:
color = "lightgrey"
case actions_model.StatusRunning:
color = "gold"
case actions_model.StatusSuccess:
color = "brightgreen"
case actions_model.StatusFailure:
color = "crimson"
case actions_model.StatusCancelled:
color = "orange"
case actions_model.StatusSkipped:
color = "blue"
case actions_model.StatusBlocked:
color = "yellow"
default:
color = "lightgrey"
}
redirectToBadge(ctx, workflowFile, run.Status.String(), color)
}
func getIssueOrPullBadge(ctx *context_module.Context, label, variant string, num int) {
var text string
if len(variant) > 0 {
text = fmt.Sprintf("%d %s", num, variant)
} else {
text = fmt.Sprintf("%d", num)
}
redirectToBadge(ctx, label, text, "blue")
}
func getIssueBadge(ctx *context_module.Context, variant string, num int) {
if !ctx.Repo.CanRead(unit.TypeIssues) &&
!ctx.Repo.CanRead(unit.TypeExternalTracker) {
errorBadge(ctx, "issues", "Not found")
return
}
_, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
if err == nil {
errorBadge(ctx, "issues", "Not found")
return
}
getIssueOrPullBadge(ctx, "issues", variant, num)
}
func getPullBadge(ctx *context_module.Context, variant string, num int) {
if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) {
errorBadge(ctx, "pulls", "Not found")
return
}
getIssueOrPullBadge(ctx, "pulls", variant, num)
}
func GetOpenIssuesBadge(ctx *context_module.Context) {
getIssueBadge(ctx, "open", ctx.Repo.Repository.NumOpenIssues)
}
func GetClosedIssuesBadge(ctx *context_module.Context) {
getIssueBadge(ctx, "closed", ctx.Repo.Repository.NumClosedIssues)
}
func GetTotalIssuesBadge(ctx *context_module.Context) {
getIssueBadge(ctx, "", ctx.Repo.Repository.NumIssues)
}
func GetOpenPullsBadge(ctx *context_module.Context) {
getPullBadge(ctx, "open", ctx.Repo.Repository.NumOpenPulls)
}
func GetClosedPullsBadge(ctx *context_module.Context) {
getPullBadge(ctx, "closed", ctx.Repo.Repository.NumClosedPulls)
}
func GetTotalPullsBadge(ctx *context_module.Context) {
getPullBadge(ctx, "", ctx.Repo.Repository.NumPulls)
}
func GetStarsBadge(ctx *context_module.Context) {
redirectToBadge(ctx, "stars", fmt.Sprintf("%d", ctx.Repo.Repository.NumStars), "blue")
}
func GetLatestReleaseBadge(ctx *context_module.Context) {
release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
if repo_model.IsErrReleaseNotExist(err) {
errorBadge(ctx, "release", "Not found")
return
}
ctx.ServerError("GetLatestReleaseByRepoID", err)
}
if err := release.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return
}
redirectToBadge(ctx, "release", release.TagName, "blue")
}

View file

@ -37,6 +37,7 @@ import (
org_setting "code.gitea.io/gitea/routers/web/org/setting" org_setting "code.gitea.io/gitea/routers/web/org/setting"
"code.gitea.io/gitea/routers/web/repo" "code.gitea.io/gitea/routers/web/repo"
"code.gitea.io/gitea/routers/web/repo/actions" "code.gitea.io/gitea/routers/web/repo/actions"
"code.gitea.io/gitea/routers/web/repo/badges"
repo_setting "code.gitea.io/gitea/routers/web/repo/setting" repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
"code.gitea.io/gitea/routers/web/user" "code.gitea.io/gitea/routers/web/user"
user_setting "code.gitea.io/gitea/routers/web/user/setting" user_setting "code.gitea.io/gitea/routers/web/user/setting"
@ -1314,6 +1315,24 @@ func registerRoutes(m *web.Route) {
m.Get("/packages", repo.Packages) m.Get("/packages", repo.Packages)
} }
if setting.Badges.Enabled {
m.Group("/badges", func() {
m.Get("/workflows/{workflow_name}/badge.svg", badges.GetWorkflowBadge)
m.Group("/issues", func() {
m.Get(".svg", badges.GetTotalIssuesBadge)
m.Get("/open.svg", badges.GetOpenIssuesBadge)
m.Get("/closed.svg", badges.GetClosedIssuesBadge)
})
m.Group("/pulls", func() {
m.Get(".svg", badges.GetTotalPullsBadge)
m.Get("/open.svg", badges.GetOpenPullsBadge)
m.Get("/closed.svg", badges.GetClosedPullsBadge)
})
m.Get("/stars.svg", badges.GetStarsBadge)
m.Get("/release.svg", badges.GetLatestReleaseBadge)
})
}
m.Group("/projects", func() { m.Group("/projects", func() {
m.Get("", repo.Projects) m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject) m.Get("/{id}", repo.ViewProject)
@ -1365,6 +1384,8 @@ func registerRoutes(m *web.Route) {
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
}) })
}) })
m.Get("/workflows/{workflow_name}/badge.svg", badges.GetWorkflowBadge)
}, reqRepoActionsReader, actions.MustEnableActions) }, reqRepoActionsReader, actions.MustEnableActions)
m.Group("/wiki", func() { m.Group("/wiki", func() {

View file

@ -0,0 +1,237 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/test"
repo_service "code.gitea.io/gitea/services/repository"
files_service "code.gitea.io/gitea/services/repository/files"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func assertBadge(t *testing.T, resp *httptest.ResponseRecorder, badge string) {
assert.Equal(t, fmt.Sprintf("https://img.shields.io/badge/%s", badge), test.RedirectURL(resp))
}
func createMinimalRepo(t *testing.T) func() {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// Create a new repository
repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
Name: "minimal",
Description: "minimal repo for badge testing",
AutoInit: true,
Gitignores: "Go",
License: "MIT",
Readme: "Default",
DefaultBranch: "main",
IsPrivate: false,
})
assert.NoError(t, err)
assert.NotEmpty(t, repo)
// Enable Actions, and disable Issues, PRs and Releases
err = repo_model.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
RepoID: repo.ID,
Type: unit_model.TypeActions,
}}, []unit_model.Type{unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases})
assert.NoError(t, err)
return func() {
repo_service.DeleteRepository(db.DefaultContext, user2, repo, false)
}
}
func addWorkflow(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "minimal")
assert.NoError(t, err)
// Add a workflow file to the repo
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/pr.yml",
ContentReader: strings.NewReader("name: test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
},
},
Message: "add workflow",
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Committer: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
assert.NoError(t, err)
assert.NotEmpty(t, addWorkflowToBaseResp)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
}
func TestWorkflowBadges(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
defer tests.PrintCurrentTest(t)()
defer createMinimalRepo(t)()
addWorkflow(t)
// Actions disabled
req := NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg")
resp := MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "test.yaml-Not%20found-crimson")
req = NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg?branch=no-such-branch")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "test.yaml-Not%20found-crimson")
// Actions enabled
req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-waiting-lightgrey")
req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg?branch=main")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-waiting-lightgrey")
req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg?branch=no-such-branch")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-Not%20found-crimson")
req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg?event=cron")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-Not%20found-crimson")
// GitHub compatibility
req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-waiting-lightgrey")
req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg?branch=main")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-waiting-lightgrey")
req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg?branch=no-such-branch")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-Not%20found-crimson")
req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg?event=cron")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pr.yml-Not%20found-crimson")
})
}
func TestBadges(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Stars", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/badges/stars.svg")
resp := MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "stars-0-blue")
})
t.Run("Issues", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createMinimalRepo(t)()
// Issues enabled
req := NewRequest(t, "GET", "/user2/repo1/badges/issues.svg")
resp := MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "issues-2-blue")
req = NewRequest(t, "GET", "/user2/repo1/badges/issues/open.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "issues-1%20open-blue")
req = NewRequest(t, "GET", "/user2/repo1/badges/issues/closed.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "issues-1%20closed-blue")
// Issues disabled
req = NewRequest(t, "GET", "/user2/minimal/badges/issues.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "issues-Not%20found-crimson")
req = NewRequest(t, "GET", "/user2/minimal/badges/issues/open.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "issues-Not%20found-crimson")
req = NewRequest(t, "GET", "/user2/minimal/badges/issues/closed.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "issues-Not%20found-crimson")
})
t.Run("Pulls", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createMinimalRepo(t)()
// Pull requests enabled
req := NewRequest(t, "GET", "/user2/repo1/badges/pulls.svg")
resp := MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pulls-3-blue")
req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/open.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pulls-3%20open-blue")
req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/closed.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pulls-0%20closed-blue")
// Pull requests disabled
req = NewRequest(t, "GET", "/user2/minimal/badges/pulls.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pulls-Not%20found-crimson")
req = NewRequest(t, "GET", "/user2/minimal/badges/pulls/open.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pulls-Not%20found-crimson")
req = NewRequest(t, "GET", "/user2/minimal/badges/pulls/closed.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "pulls-Not%20found-crimson")
})
t.Run("Release", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createMinimalRepo(t)()
req := NewRequest(t, "GET", "/user2/repo1/badges/release.svg")
resp := MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "release-v1.1-blue")
req = NewRequest(t, "GET", "/user2/minimal/badges/release.svg")
resp = MakeRequest(t, req, http.StatusSeeOther)
assertBadge(t, resp, "release-Not%20found-crimson")
})
}