diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f6e71cccb3e..45b2892481f 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3833,6 +3833,11 @@ "actions.workflow.enable_success": "Workflow '%s' enabled successfully.", "actions.workflow.disabled": "Workflow is disabled.", "actions.workflow.run": "Run Workflow", + "actions.workflow.create_status_badge": "Create status badge", + "actions.workflow.status_badge": "Status Badge", + "actions.workflow.status_badge_url": "Badge URL", + "actions.workflow.status_badge_markdown": "Markdown", + "actions.workflow.status_badge_html": "HTML", "actions.workflow.not_found": "Workflow '%s' not found.", "actions.workflow.run_success": "Workflow '%s' run successfully.", "actions.workflow.from_ref": "Use workflow from", diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 9c5e1664de0..45c94fea5c2 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -8,6 +8,7 @@ import ( stdCtx "context" "errors" "fmt" + "html" "net/http" "net/url" "slices" @@ -47,6 +48,15 @@ type WorkflowInfo struct { Workflow *act_model.Workflow } +type workflowBadge struct { + URL string + WorkflowURL string + Markdown string + MarkdownAltText string + HTML string + HTMLAltText string +} + // DisplayName returns the workflow name from the YAML file if present, otherwise the filename. func (w WorkflowInfo) DisplayName() string { if w.Workflow != nil && w.Workflow.Name != "" { @@ -403,10 +413,16 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo ctx.Data["Runs"] = runs workflowNames := make(map[string]string, len(workflows)) + workflowDisplayName := workflowID for _, wf := range workflows { - workflowNames[wf.Entry.Name()] = wf.DisplayName() + displayName := wf.DisplayName() + workflowNames[wf.Entry.Name()] = displayName + if wf.Entry.Name() == workflowID { + workflowDisplayName = displayName + } } ctx.Data["WorkflowNames"] = workflowNames + prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName) actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) if err != nil { @@ -432,6 +448,32 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions) } +func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) { + if workflowID == "" { + return + } + if displayName == "" { + displayName = workflowID + } + + repoURL := ctx.Repo.Repository.HTMLURL(ctx) + badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repoURL, util.PathEscapeSegments(workflowID), url.QueryEscape(ctx.Repo.Repository.DefaultBranch)) + workflowURL := fmt.Sprintf("%s/actions?workflow=%s", repoURL, url.QueryEscape(workflowID)) + + ctx.Data["WorkflowBadge"] = workflowBadge{ + URL: badgeURL, + WorkflowURL: workflowURL, + Markdown: fmt.Sprintf("[![%s](%s)](%s)", escapeMarkdownImageAltText(displayName), badgeURL, workflowURL), + MarkdownAltText: escapeMarkdownImageAltText(displayName), + HTML: fmt.Sprintf(`%s`, html.EscapeString(workflowURL), html.EscapeString(badgeURL), html.EscapeString(displayName)), + HTMLAltText: displayName, + } +} + +func escapeMarkdownImageAltText(s string) string { + return strings.NewReplacer(`\`, `\\`, `[`, `\[`, `]`, `\]`).Replace(s) +} + // loadIsRefDeleted loads the IsRefDeleted field for each run in the list. // TODO: move this function to models/actions/run_list.go but now it will result in a circular import. func loadIsRefDeleted(ctx stdCtx.Context, repoID int64, runs actions_model.RunList) error { diff --git a/routers/web/repo/actions/actions_test.go b/routers/web/repo/actions/actions_test.go index 1f1040738bb..5997baae5c0 100644 --- a/routers/web/repo/actions/actions_test.go +++ b/routers/web/repo/actions/actions_test.go @@ -4,12 +4,18 @@ package actions import ( + "net/http" + "net/http/httptest" "strings" "testing" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" unittest "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + web_context "gitea.dev/services/context" act_model "gitea.com/gitea/runner/act/model" "github.com/stretchr/testify/assert" @@ -176,3 +182,48 @@ func Test_loadIsRefDeleted(t *testing.T) { assert.True(t, run.IsRefDeleted) } } + +func TestPrepareWorkflowBadgeTemplate(t *testing.T) { + defer test.MockVariableValue(&setting.IsInTesting, true)() + defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/")() + defer test.MockVariableValue(&setting.AppSubURL, "")() + defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)() + + t.Run("no workflow selected", func(t *testing.T) { + ctx := newWorkflowBadgeTestContext(t) + + prepareWorkflowBadgeTemplate(ctx, "", "ignored") + + assert.NotContains(t, ctx.Data, "WorkflowBadge") + }) + + t.Run("selected workflow", func(t *testing.T) { + ctx := newWorkflowBadgeTestContext(t) + + prepareWorkflowBadgeTemplate(ctx, "build/test workflow.yml", `CI [prod]\build "fast" `) + + assert.Equal(t, workflowBadge{ + URL: "https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix", + WorkflowURL: "https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml", + Markdown: `[![CI \[prod\]\\build "fast" ](https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix)]` + + `(https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml)`, + MarkdownAltText: `CI \[prod\]\\build "fast" `, + HTML: `CI [prod]\build "fast" <ok>`, + HTMLAltText: `CI [prod]\build "fast" `, + }, ctx.Data["WorkflowBadge"]) + }) +} + +func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil) + resp := httptest.NewRecorder() + ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(resp, req), nil, nil) + ctx.Repo.Repository = &repo_model.Repository{ + OwnerName: "user1", + Name: "repo1", + DefaultBranch: "release/1.0 & hotfix", + } + return ctx +} diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index bf2a1db0a7d..472953a34d2 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -106,13 +106,20 @@ - {{if and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow}} + {{if or .WorkflowBadge (and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow)}} {{end}} @@ -126,6 +133,49 @@
{{template "repo/actions/runs_list" .}}
+ {{if .WorkflowBadge}} + + {{end}} {{else}} diff --git a/web_src/js/features/repo-actions.test.ts b/web_src/js/features/repo-actions.test.ts new file mode 100644 index 00000000000..bcb2063fb7a --- /dev/null +++ b/web_src/js/features/repo-actions.test.ts @@ -0,0 +1,31 @@ +import {updateWorkflowBadgeFields} from './repo-actions.ts'; + +test('updateWorkflowBadgeFields updates badge snippets for selected branch', () => { + document.body.innerHTML = ` +
+ + + + +
+ `; + + const form = document.querySelector('[data-badge-url]')!; + + updateWorkflowBadgeFields(form, 'release/1.0 & hotfix'); + + const badgeURL = 'https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix'; + expect(form.querySelector('[data-workflow-badge-image]')!.src).toBe(badgeURL); + expect(form.querySelector('#workflow-badge-url')!.value).toBe(badgeURL); + expect(form.querySelector('#workflow-badge-markdown')!.value).toBe( + `[![CI \\[prod\\]\\\\build "fast" ](${badgeURL})](https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml)`, + ); + expect(form.querySelector('#workflow-badge-html')!.value).toBe( + `CI [prod]\\build "fast" <ok>`, + ); +}); diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 3d4bd49a4f3..f7106197122 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -1,7 +1,36 @@ import {createApp} from 'vue'; import RepoActionView from '../components/RepoActionView.vue'; +import {queryElems} from '../utils/dom.ts'; + +function escapeHTMLAttribute(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); +} + +export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { + const badgeURL = new URL(form.getAttribute('data-badge-url')!); + badgeURL.searchParams.set('branch', branch); + + const badgeURLString = badgeURL.href; + const workflowURL = form.getAttribute('data-workflow-url')!; + const markdownAltText = form.getAttribute('data-markdown-alt-text')!; + const htmlAltText = form.getAttribute('data-html-alt-text')!; + + form.querySelector('[data-workflow-badge-image]')!.src = badgeURLString; + form.querySelector('#workflow-badge-url')!.value = badgeURLString; + form.querySelector('#workflow-badge-markdown')!.value = `[![${markdownAltText}](${badgeURLString})](${workflowURL})`; + form.querySelector('#workflow-badge-html')!.value = `${escapeHTMLAttribute(htmlAltText)}`; +} + +function initWorkflowBadgeBranchSelection(): void { + queryElems(document, '[data-workflow-badge-form]', (form) => { + const branchInput = form.querySelector('[data-workflow-badge-branch]')!; + branchInput.addEventListener('change', () => updateWorkflowBadgeFields(form, branchInput.value)); + }); +} export function initRepositoryActionView() { + initWorkflowBadgeBranchSelection(); + const el = document.querySelector('#repo-action-view'); if (!el) return;