mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-07 18:59:19 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user