fix: improve actions status icons and texts (#37206)

Action runs, jobs and steps have 8 statuses but the UI only showed 5
(from the commit status api) for the latter two. Align all 8 to GitHub
as closely as possible:

  - waiting — `octicon-circle` (hollow circle), gray
  - blocked — `octicon-blocked` (slashed circle), yellow
  - running — `gitea-running` (rotating spinner), yellow
  - cancelled — `octicon-stop` (gray), was `octicon-x` (red)

Descriptions also aligned with GitHub:

  - "Has started running" → "In progress"
  - "Has been cancelled" → "Cancelled after {dur}"
  - "Has been skipped" → "Skipped"

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

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
This commit is contained in:
silverwind
2026-05-09 09:24:08 +02:00
committed by GitHub
parent a5d81d9ce2
commit ce089f498b
22 changed files with 248 additions and 70 deletions
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"net/http"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/services/context"
)
// IsArtifactV4 detects whether the artifact is likely from v4.
// V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash
// (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend.
func IsArtifactV4(art *actions_model.ActionArtifact) bool {
return strings.Contains(art.ContentEncodingOrType, "/")
}
func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) {
contentType := art.ContentEncodingOrType
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ContentType: contentType})
if err != nil {
return "", err
}
return u.String(), nil
}
func DownloadArtifactV4ServeDirect(ctx *context.Base, art *actions_model.ActionArtifact) bool {
if !setting.Actions.ArtifactStorage.ServeDirect() {
return false
}
u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method)
if err != nil {
log.Error("GetArtifactV4ServeDirectURL: %v", err)
return false
}
ctx.Redirect(u, http.StatusFound)
return true
}
func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionArtifact) error {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return err
}
defer f.Close()
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{
Filename: art.ArtifactPath,
ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type
})
return nil
}
func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error {
if DownloadArtifactV4ServeDirect(ctx, art) {
return nil
}
return DownloadArtifactV4ReadStorage(ctx, art)
}
+3 -3
View File
@@ -181,11 +181,11 @@ func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
case actions_model.StatusFailure:
return fmt.Sprintf("Failing after %s", job.Duration())
case actions_model.StatusCancelled:
return "Has been cancelled"
return fmt.Sprintf("Cancelled after %s", job.Duration())
case actions_model.StatusSkipped:
return "Has been skipped"
return "Skipped"
case actions_model.StatusRunning:
return "Has started running"
return "In progress"
case actions_model.StatusWaiting:
return "Waiting to run"
case actions_model.StatusBlocked:
+71 -1
View File
@@ -11,13 +11,36 @@ import (
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommitStatusDescription(t *testing.T) {
cases := []struct {
status actions_model.Status
started, stopped timeutil.TimeStamp
want string
}{
{actions_model.StatusSuccess, 100, 102, "Successful in 2s"},
{actions_model.StatusFailure, 100, 130, "Failing after 30s"},
{actions_model.StatusCancelled, 100, 145, "Cancelled after 45s"},
{actions_model.StatusSkipped, 0, 0, "Skipped"},
{actions_model.StatusRunning, 0, 0, "In progress"},
{actions_model.StatusWaiting, 0, 0, "Waiting to run"},
{actions_model.StatusBlocked, 0, 0, "Blocked by required conditions"},
{actions_model.StatusUnknown, 0, 0, "Unknown status: 0"},
}
for _, tc := range cases {
job := &actions_model.ActionRunJob{Status: tc.status, Started: tc.started, Stopped: tc.stopped}
assert.Equal(t, tc.want, toCommitStatusDescription(job), tc.status.String())
}
}
func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@@ -61,7 +84,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
require.Len(t, statuses, 2)
assert.Equal(t, "Waiting to run", statuses[0].Description)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[1].State)
assert.Equal(t, "Has started running", statuses[1].Description)
assert.Equal(t, "In progress", statuses[1].Description)
assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
@@ -75,6 +98,53 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
}
func TestGetCommitActionsStatusMap(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
run := &actions_model.ActionRun{
RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: "test.yaml", CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
cases := []struct {
jobName string
status actions_model.Status
}{
{"running-job", actions_model.StatusRunning},
{"waiting-job", actions_model.StatusWaiting},
{"unknown-job", actions_model.StatusUnknown},
}
for _, tc := range cases {
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, Name: tc.jobName, Status: tc.status,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
info := actions_module.GetCommitActionsStatusMap(t.Context(), statuses)
got := map[string]string{}
for _, s := range statuses {
got[s.Context] = info.IconStatus(s)
}
for _, tc := range cases {
key := "test.yaml / " + tc.jobName + " (push)"
want := tc.status.String()
assert.Equal(t, want, got[key], "icon status for %s", tc.jobName)
}
// Nil receiver returns "" without panicking — used by callers that skip enrichment.
var nilInfo actions_module.CommitActionsStatusMap
assert.Empty(t, nilInfo.IconStatus(statuses[0]))
}
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
t.Helper()
+1
View File
@@ -104,6 +104,7 @@ func NewTemplateContextForWeb(ctx reqctx.RequestContext, req *http.Request, loca
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
tmplCtx["MiscUtils"] = templates.NewMiscUtils(ctx)
tmplCtx["ActionsUtils"] = templates.NewActionsUtils(ctx)
tmplCtx["RootData"] = ctx.GetData()
tmplCtx["Consts"] = map[string]any{
"RepoUnitTypeCode": unit.TypeCode,