0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-26 16:33:25 +02:00

feat(actions): add branch filters to run list (#37826)

## Summary

- Add a Branch filter dropdown to the repo Actions run list web UI
- Wire `?branch=` query param through the web handler, matching the
existing REST API filter behavior
- Source the Branch dropdown from the indexed `branch` table (filtering
out deleted branches) instead of scanning `action_run.ref`, addressing
review feedback about unindexed columns

The Event filter was dropped after review: a static list of supported
events was noisy as UX, and querying distinct values from
`action_run.trigger_event` is slow because the column is not indexed.
`FindRunOptions.TriggerEvent` is kept for the REST API.

Closes #25042

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-05-26 11:08:05 +02:00 committed by GitHub
parent 4a6db5a7c2
commit a03e0364eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 140 additions and 13 deletions

View File

@ -132,6 +132,20 @@ func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInf
return statusInfoList
}
// GetRunBranches returns branch names for the run-list "Branch" filter.
// Sourced from the `branch` table (indexed by repo_id) rather than DISTINCT-ing
// `action_run.ref`, which is wildcard-matched and slow on large repos; as a side
// effect the list reflects existing branches, not only ones that produced a run.
func GetRunBranches(ctx context.Context, repoID int64) ([]string, error) {
branches := make([]string, 0, 10)
return branches, db.GetEngine(ctx).Table("branch").
Where("repo_id = ?", repoID).
And("is_deleted = ?", false).
Cols("name").
OrderBy("name ASC").
Find(&branches)
}
// GetRunWorkflowIDs returns all distinct WorkflowIDs that have at least
// one ActionRun in the given repo.
func GetRunWorkflowIDs(ctx context.Context, repoID int64) ([]string, error) {

View File

@ -3776,6 +3776,8 @@
"actions.runs.status": "Status",
"actions.runs.actors_no_select": "All actors",
"actions.runs.status_no_select": "All status",
"actions.runs.branch": "Branch",
"actions.runs.branches_no_select": "All branches",
"actions.runs.no_results": "No results matched.",
"actions.runs.no_workflows": "There are no workflows yet.",
"actions.runs.no_workflows.quick_start": "Don't know how to start with Gitea Actions? See <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the quick start guide</a>.",

View File

@ -7,7 +7,9 @@ import (
"bytes"
stdCtx "context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
@ -286,6 +288,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
actorID := ctx.FormInt64("actor")
status := ctx.FormInt("status")
workflowID := ctx.FormString("workflow")
branch := ctx.FormString("branch")
page := ctx.FormInt("page")
if page <= 0 {
page = 1
@ -295,7 +298,8 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
// they will be 0 by default, which indicates get all status or actors
ctx.Data["CurActor"] = actorID
ctx.Data["CurStatus"] = status
if actorID > 0 || status > int(actions_model.StatusUnknown) {
ctx.Data["CurBranch"] = branch
if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" {
ctx.Data["IsFiltered"] = true
}
@ -313,6 +317,9 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
if actions_model.Status(status) != actions_model.StatusUnknown {
opts.Status = []actions_model.Status{actions_model.Status(status)}
}
if branch != "" {
opts.Ref = string(git.RefNameFromBranch(branch))
}
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
@ -393,6 +400,13 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRunBranches", err)
return
}
ctx.Data["RunBranches"] = runBranches
pager := context.NewPagination(total, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
@ -509,3 +523,13 @@ func decodeNode(node yaml.Node, out any) bool {
}
return true
}
func actionsListRedirectURL(repoLink, workflow, actor, status, branch string) string {
return fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s&branch=%s",
repoLink,
url.QueryEscape(workflow),
url.QueryEscape(actor),
url.QueryEscape(status),
url.QueryEscape(branch),
)
}

View File

@ -1103,14 +1103,14 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow))
}
redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(workflow),
url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status")))
redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, workflow,
ctx.FormString("actor"), ctx.FormString("status"), ctx.FormString("branch"))
ctx.JSONRedirect(redirectURL)
}
func Run(ctx *context_module.Context) {
redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(ctx.FormString("workflow")),
url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status")))
redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, ctx.FormString("workflow"),
ctx.FormString("actor"), ctx.FormString("status"), ctx.FormString("branch"))
workflowID := ctx.FormString("workflow")
if len(workflowID) == 0 {

View File

@ -8,9 +8,9 @@
<div class="flex-container">
<div class="flex-container-nav">
<div class="ui fluid vertical menu">
<a class="item {{if not $.CurWorkflow}}active{{end}}" href="?actor={{$.CurActor}}&status={{$.CurStatus}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
<a class="item {{if not $.CurWorkflow}}active{{end}}" href="?actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
{{range .workflows}}
<a class="item flex-text-block {{if eq .Entry.Name $.CurWorkflow}}active{{end}}" href="?workflow={{.Entry.Name}}&actor={{$.CurActor}}&status={{$.CurStatus}}">
<a class="item flex-text-block {{if eq .Entry.Name $.CurWorkflow}}active{{end}}" href="?workflow={{.Entry.Name}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<span class="gt-ellipsis">{{.DisplayName}}</span>
{{if .ErrMsg}}
@ -54,11 +54,11 @@
<i class="icon">{{svg "octicon-search"}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "actions.runs.actor"}}">
</div>
<a class="item{{if not $.CurActor}} active{{end}}" href="?workflow={{$.CurWorkflow}}&status={{$.CurStatus}}&actor=0">
<a class="item{{if not $.CurActor}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&status={{$.CurStatus}}&branch={{$.CurBranch}}&actor=0">
{{ctx.Locale.Tr "actions.runs.actors_no_select"}}
</a>
{{range .Actors}}
<a class="item{{if eq .ID $.CurActor}} active{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{.ID}}&status={{$.CurStatus}}">
<a class="item{{if eq .ID $.CurActor}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{.ID}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
{{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}}
</a>
{{end}}
@ -73,22 +73,41 @@
<i class="icon">{{svg "octicon-search"}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "actions.runs.status"}}">
</div>
<a class="item{{if not $.CurStatus}} active{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status=0">
<a class="item{{if not $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&branch={{$.CurBranch}}&status=0">
{{ctx.Locale.Tr "actions.runs.status_no_select"}}
</a>
{{range .StatusInfoList}}
<a class="item{{if eq .Status $.CurStatus}} active{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}">
<a class="item{{if eq .Status $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}&branch={{$.CurBranch}}">
{{.DisplayedStatus}}
</a>
{{end}}
</div>
</div>
<!-- Branch -->
<div class="ui{{if not .RunBranches}} disabled{{end}} dropdown jump item" data-test-id="filter-branch">
<span class="text">{{ctx.Locale.Tr "actions.runs.branch"}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search"}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "actions.runs.branch"}}">
</div>
<a class="item{{if not $.CurBranch}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{$.CurStatus}}">
{{ctx.Locale.Tr "actions.runs.branches_no_select"}}
</a>
{{range .RunBranches}}
<a class="item{{if eq . $.CurBranch}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{.}}">
{{.}}
</a>
{{end}}
</div>
</div>
{{if and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow}}
<button class="ui jump dropdown btn interact-bg tw-p-2">
{{svg "octicon-kebab-horizontal"}}
<div class="menu">
<a class="item link-action" data-url="{{$.Link}}/{{if .CurWorkflowDisabled}}enable{{else}}disable{{end}}?workflow={{$.CurWorkflow}}&actor={{.CurActor}}&status={{$.CurStatus}}">
<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>
</div>

View File

@ -7,7 +7,7 @@
</div>
<div id="runWorkflowDispatchModal" class="ui tiny modal">
<div class="content">
<form id="runWorkflowDispatchForm" class="ui form ignore-dirty" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}" method="post">
<form id="runWorkflowDispatchForm" class="ui form ignore-dirty" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}" method="post">
<div class="ui inline field required tw-flex tw-items-center">
<span class="ui inline required field">
<label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label>

View File

@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsListFilters(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, user5.Name)
actionsURL := fmt.Sprintf("/%s/%s/actions", user5.Name, repo.Name)
t.Run("BranchDropdownListsBranches", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
var labels []string
htmlDoc.doc.Find(`[data-test-id="filter-branch"] .menu a.item`).Each(func(_ int, a *goquery.Selection) {
labels = append(labels, strings.TrimSpace(a.Text()))
})
assert.Contains(t, labels, "master")
})
t.Run("FilterByBranch", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL+"?branch=master")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
refs := htmlDoc.doc.Find(".run-list .run-list-ref")
assert.Positive(t, refs.Length(), "filtered run list should not be empty")
refs.Each(func(_ int, sel *goquery.Selection) {
assert.Equal(t, "master", strings.TrimSpace(sel.Text()))
})
})
t.Run("PaginationPreservesFilters", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL+"?branch=master&limit=1")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
pageLinks := htmlDoc.doc.Find(".pagination a[href]")
assert.Positive(t, pageLinks.Length(), "pagination should be rendered")
pageLinks.Each(func(_ int, a *goquery.Selection) {
u, err := url.Parse(a.AttrOr("href", ""))
require.NoError(t, err)
assert.Equal(t, "master", u.Query().Get("branch"), "pagination link must preserve branch filter")
})
})
}