Skip to content

Add support for shields.io-based badges #28585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,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]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
5 changes: 5 additions & 0 deletions docs/content/administration/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ The following configuration set `Content-Type: application/vnd.android.package-a
.apk=application/vnd.android.package-archive
```

## Badges (`badges`)

- `ENABLED`: **true**: Whether repository badges (via a generator like `shields.io`) are enabled.
- `GENERATOR_URL_TEMPLATE`: **https://img.shields.io/badge/{{.label}}-{{.text}}-{{.color}}**: The URL template used for the badge generator service.

## CORS (`cors`)

- `ENABLED`: **false**: enable cors headers (disabled by default)
Expand Down
15 changes: 15 additions & 0 deletions models/actions/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,21 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork
return commiter.Commit()
}

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) {
var run ActionRun
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
Expand Down
24 changes: 24 additions & 0 deletions modules/setting/badges.go
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions modules/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadUIFrom(cfg)
loadAdminFrom(cfg)
loadAPIFrom(cfg)
loadBadgesFrom(cfg)
loadMetricsFrom(cfg)
loadCamoFrom(cfg)
loadI18nFrom(cfg)
Expand Down
165 changes: 165 additions & 0 deletions routers/web/repo/badges/badges.go
Original file line number Diff line number Diff line change
@@ -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")
}
21 changes: 21 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
org_setting "code.gitea.io/gitea/routers/web/org/setting"
"code.gitea.io/gitea/routers/web/repo"
"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"
"code.gitea.io/gitea/routers/web/user"
user_setting "code.gitea.io/gitea/routers/web/user/setting"
Expand Down Expand Up @@ -1318,6 +1319,24 @@ func registerRoutes(m *web.Route) {
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.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
Expand Down Expand Up @@ -1366,6 +1385,8 @@ func registerRoutes(m *web.Route) {
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
})

m.Get("/workflows/{workflow_name}/badge.svg", badges.GetWorkflowBadge)
}, reqRepoActionsReader, actions.MustEnableActions)

m.Group("/wiki", func() {
Expand Down
Loading