From e3262ea6846eaa64187fc423fac31dc1e26604ec Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Fri, 29 Dec 2023 19:47:36 +0100 Subject: [PATCH] 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 #5633 and #23688. Work sponsored by Codeberg e.V. Signed-off-by: Gergely Nagy --- custom/conf/app.example.ini | 8 + .../config-cheat-sheet.en-us.md | 5 + models/actions/run.go | 15 ++ modules/setting/badges.go | 24 ++ modules/setting/setting.go | 1 + routers/web/repo/badges/badges.go | 165 ++++++++++++ routers/web/web.go | 21 ++ tests/integration/repo_badges_test.go | 237 ++++++++++++++++++ 8 files changed, 476 insertions(+) create mode 100644 modules/setting/badges.go create mode 100644 routers/web/repo/badges/badges.go create mode 100644 tests/integration/repo_badges_test.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 08f2e0d63fc66..a8844c7d9c3e4 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index beaa8cfb30245..e4217acb3c61c 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -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) diff --git a/models/actions/run.go b/models/actions/run.go index 4656aa22a2933..c9a9c518a0f5f 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -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) diff --git a/modules/setting/badges.go b/modules/setting/badges.go new file mode 100644 index 0000000000000..e0c1cb55ec94a --- /dev/null +++ b/modules/setting/badges.go @@ -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)) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index d444d9a0175c6..5aad8132d8d80 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -145,6 +145,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadUIFrom(cfg) loadAdminFrom(cfg) loadAPIFrom(cfg) + loadBadgesFrom(cfg) loadMetricsFrom(cfg) loadCamoFrom(cfg) loadI18nFrom(cfg) diff --git a/routers/web/repo/badges/badges.go b/routers/web/repo/badges/badges.go new file mode 100644 index 0000000000000..8fe99c7fc1216 --- /dev/null +++ b/routers/web/repo/badges/badges.go @@ -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") +} diff --git a/routers/web/web.go b/routers/web/web.go index 164c137f2a3b6..98038f14e1f17 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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" @@ -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) @@ -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() { diff --git a/tests/integration/repo_badges_test.go b/tests/integration/repo_badges_test.go new file mode 100644 index 0000000000000..5e0cd8beedd83 --- /dev/null +++ b/tests/integration/repo_badges_test.go @@ -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") + }) +}