feat(actions): add workflow status badge modal (#38196)

- Add a Create Status Badge button for selected Actions workflows.
- Show badge URL, Markdown, and HTML snippets backed by the existing
workflow badge route.

## Screenshots
<img width="553" height="470" alt="dyn-a5d565ab915b9ffb6c02ac68113494b0"
src="https://github.com/user-attachments/assets/43b4ceb9-bbd1-4024-b058-d85ec8325e88"
/>
<img width="349" height="156" alt="grafik"
src="https://github.com/user-attachments/assets/6eaec62d-ffb0-45c0-b63d-866a41a66005"
/>



Fixes https://github.com/go-gitea/gitea/issues/31462

---------

Signed-off-by: guanzi008 <245205080@qq.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
guanzi008
2026-06-28 07:36:45 +08:00
committed by GitHub
parent d392fb1438
commit 9540292596
6 changed files with 210 additions and 2 deletions

View File

@@ -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",

View File

@@ -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(`<a href="%s"><img src="%s" alt="%s"></a>`, 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 {

View File

@@ -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" <ok>`)
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" <ok>](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" <ok>`,
HTML: `<a href="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"><img src="https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix" alt="CI [prod]\build &#34;fast&#34; &lt;ok&gt;"></a>`,
HTMLAltText: `CI [prod]\build "fast" <ok>`,
}, 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
}

View File

@@ -106,13 +106,20 @@
</div>
</div>
{{if and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow}}
{{if or .WorkflowBadge (and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow)}}
<button class="ui jump dropdown btn interact-bg tw-p-2">
{{svg "octicon-kebab-horizontal"}}
<div class="menu">
{{if .WorkflowBadge}}
<div class="item show-modal" data-modal="#workflow-status-badge-modal">
{{ctx.Locale.Tr "actions.workflow.create_status_badge"}}
</div>
{{end}}
{{if and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow}}
<a class="item link-action" data-url="{{$.Link}}/{{if .CurWorkflowDisabled}}enable{{else}}disable{{end}}?workflow={{$.CurWorkflow}}&actor={{.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
{{if .CurWorkflowDisabled}}{{ctx.Locale.Tr "actions.workflow.enable"}}{{else}}{{ctx.Locale.Tr "actions.workflow.disable"}}{{end}}
</a>
{{end}}
</div>
</button>
{{end}}
@@ -126,6 +133,49 @@
<div class="ui attached segment">
{{template "repo/actions/runs_list" .}}
</div>
{{if .WorkflowBadge}}
<div id="workflow-status-badge-modal" class="ui tiny modal">
<div class="header">{{ctx.Locale.Tr "actions.workflow.status_badge"}}</div>
<div class="content">
<div class="ui form" data-workflow-badge-form data-badge-url="{{.WorkflowBadge.URL}}" data-workflow-url="{{.WorkflowBadge.WorkflowURL}}" data-markdown-alt-text="{{.WorkflowBadge.MarkdownAltText}}" data-html-alt-text="{{.WorkflowBadge.HTMLAltText}}">
<div class="field">
<img data-workflow-badge-image src="{{.WorkflowBadge.URL}}" alt="{{.CurWorkflow}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "actions.runs.branch"}}</label>
<div class="ui selection dropdown">
<input type="hidden" data-workflow-badge-branch value="{{.Repository.DefaultBranch}}">
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="text">{{.Repository.DefaultBranch}}</div>
<div class="menu">
<div class="item selected" data-value="{{.Repository.DefaultBranch}}">{{.Repository.DefaultBranch}}</div>
{{range .RunBranches}}
{{if ne . $.Repository.DefaultBranch}}
<div class="item" data-value="{{.}}">{{.}}</div>
{{end}}
{{end}}
</div>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "actions.workflow.status_badge_url"}}</label>
<div class="ui action input">
<input id="workflow-badge-url" readonly autofocus value="{{.WorkflowBadge.URL}}">
<button class="ui icon button" data-tooltip-content="{{ctx.Locale.Tr "copy"}}" data-clipboard-target="#workflow-badge-url">{{svg "octicon-copy" 14}}</button>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "actions.workflow.status_badge_markdown"}}</label>
<textarea id="workflow-badge-markdown" rows="2" readonly>{{.WorkflowBadge.Markdown}}</textarea>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "actions.workflow.status_badge_html"}}</label>
<textarea id="workflow-badge-html" rows="2" readonly>{{.WorkflowBadge.HTML}}</textarea>
</div>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{else}}

View File

@@ -0,0 +1,31 @@
import {updateWorkflowBadgeFields} from './repo-actions.ts';
test('updateWorkflowBadgeFields updates badge snippets for selected branch', () => {
document.body.innerHTML = `
<div
data-badge-url="https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=main"
data-workflow-url="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"
data-markdown-alt-text="CI \\[prod\\]\\\\build &quot;fast&quot; &lt;ok&gt;"
data-html-alt-text="CI [prod]\\build &quot;fast&quot; &lt;ok&gt;"
>
<img data-workflow-badge-image src="">
<input id="workflow-badge-url" readonly>
<textarea id="workflow-badge-markdown" readonly></textarea>
<textarea id="workflow-badge-html" readonly></textarea>
</div>
`;
const form = document.querySelector<HTMLElement>('[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<HTMLImageElement>('[data-workflow-badge-image]')!.src).toBe(badgeURL);
expect(form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value).toBe(badgeURL);
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value).toBe(
`[![CI \\[prod\\]\\\\build "fast" <ok>](${badgeURL})](https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml)`,
);
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value).toBe(
`<a href="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"><img src="${badgeURL}" alt="CI [prod]\\build &quot;fast&quot; &lt;ok&gt;"></a>`,
);
});

View File

@@ -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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
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<HTMLImageElement>('[data-workflow-badge-image]')!.src = badgeURLString;
form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value = badgeURLString;
form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value = `[![${markdownAltText}](${badgeURLString})](${workflowURL})`;
form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value = `<a href="${escapeHTMLAttribute(workflowURL)}"><img src="${escapeHTMLAttribute(badgeURLString)}" alt="${escapeHTMLAttribute(htmlAltText)}"></a>`;
}
function initWorkflowBadgeBranchSelection(): void {
queryElems(document, '[data-workflow-badge-form]', (form) => {
const branchInput = form.querySelector<HTMLInputElement>('[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;