diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 8f93e9354a..85797d225b 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/models/actions/run.go b/models/actions/run.go index e84552682b..977eaae166 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -319,6 +319,21 @@ func GetLatestRun(ctx context.Context, repoID int64) (*ActionRun, error) { 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) { 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 0000000000..e0c1cb55ec --- /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 ebfd3b27be..c0d8d0ee23 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -147,6 +147,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 0000000000..8fe99c7fc1 --- /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 69540d1d64..27ca610790 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" @@ -1314,6 +1315,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) @@ -1365,6 +1384,8 @@ func registerRoutes(m *web.Route) { 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 0000000000..5e0cd8beed --- /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") + }) +}