diff --git a/modules/actions/commit_status_info.go b/modules/actions/commit_status_info.go new file mode 100644 index 0000000000..af06d35a2a --- /dev/null +++ b/modules/actions/commit_status_info.go @@ -0,0 +1,70 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "maps" + "slices" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" +) + +// CommitActionsStatusMap maps CommitStatus.ID to the live ActionRunJob status +// for Gitea Actions rows. +type CommitActionsStatusMap map[int64]actions_model.Status + +// IconStatus returns the action status name to route the icon through +// repo/icons/action_status, or "" when the row isn't from Gitea Actions. +func (m CommitActionsStatusMap) IconStatus(s *git_model.CommitStatus) string { + if status, ok := m[s.ID]; ok { + return status.String() + } + return "" +} + +// GetCommitActionsStatusMap resolves the live ActionRunJob.Status for every +// CommitStatus row backed by Gitea Actions. Rows from other sources (external +// CIs, API) are left untouched and rendered from their stored State. +func GetCommitActionsStatusMap(ctx context.Context, statuses []*git_model.CommitStatus) CommitActionsStatusMap { + if len(statuses) == 0 { + return nil + } + statusByJobID := make(map[int64]*git_model.CommitStatus) + repoByID := make(map[int64]*repo_model.Repository) + for _, status := range statuses { + if status == nil || status.TargetURL == "" { + continue + } + if status.Repo == nil { + status.Repo = repoByID[status.RepoID] + } + // ParseGiteaActionsTargetURL lazy-loads status.Repo on miss; cache the + // outcome so later entries with the same RepoID skip that load. + _, jobID, ok := status.ParseGiteaActionsTargetURL(ctx) + repoByID[status.RepoID] = status.Repo + if ok { + statusByJobID[jobID] = status + } + } + if len(statusByJobID) == 0 { + return nil + } + jobs := make(map[int64]*actions_model.ActionRunJob, len(statusByJobID)) + if err := db.GetEngine(ctx).In("id", slices.Collect(maps.Keys(statusByJobID))).Cols("id", "status").Find(&jobs); err != nil { + log.Error("db.Find: failed to find action run jobs: %v", err) + return nil + } + info := make(CommitActionsStatusMap, len(jobs)) + for jobID, status := range statusByJobID { + if job, ok := jobs[jobID]; ok { + info[status.ID] = job.Status + } + } + return info +} diff --git a/modules/templates/util_actions.go b/modules/templates/util_actions.go new file mode 100644 index 0000000000..ad7c695185 --- /dev/null +++ b/modules/templates/util_actions.go @@ -0,0 +1,23 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "context" + + git_model "code.gitea.io/gitea/models/git" + actions_module "code.gitea.io/gitea/modules/actions" +) + +type ActionsUtils struct { + ctx context.Context +} + +func NewActionsUtils(ctx context.Context) *ActionsUtils { + return &ActionsUtils{ctx: ctx} +} + +func (a *ActionsUtils) CommitStatusesToActionsStatuses(statuses []*git_model.CommitStatus) actions_module.CommitActionsStatusMap { + return actions_module.GetCommitActionsStatusMap(a.ctx, statuses) +} diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 8bd3fb7e2b..f1f33424ed 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -104,7 +104,6 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -112,6 +111,7 @@ import ( "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" "google.golang.org/protobuf/encoding/protojson" diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 09b0cc5b2d..d23fc849ac 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -21,7 +21,6 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" secret_model "code.gitea.io/gitea/models/secret" - "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" @@ -1877,7 +1876,7 @@ func GetArtifact(ctx *context.APIContext) { return } - if actions.IsArtifactV4(art) { + if actions_service.IsArtifactV4(art) { convertedArtifact, err := convert.ToActionArtifact(ctx.Repo.Repository, art) if err != nil { ctx.APIErrorInternal(err) @@ -1926,7 +1925,7 @@ func DeleteArtifact(ctx *context.APIContext) { return } - if actions.IsArtifactV4(art) { + if actions_service.IsArtifactV4(art) { if err := actions_model.SetArtifactNeedDeleteByID(ctx, art.ID); err != nil { ctx.APIErrorInternal(err) return @@ -1999,10 +1998,10 @@ func DownloadArtifact(ctx *context.APIContext) { return } - if actions.IsArtifactV4(art) { + if actions_service.IsArtifactV4(art) { // @actions/toolkit asserts that downloaded artifacts of a different runid return 302 // https://github.com/actions/toolkit/blob/44d43b5490b02998bd09b0c4ff369a4cc67876c2/packages/artifact/src/internal/download/download-artifact.ts#L203-L210 - if actions.DownloadArtifactV4ServeDirect(ctx.Base, art) { + if actions_service.DownloadArtifactV4ServeDirect(ctx.Base, art) { return } @@ -2054,8 +2053,8 @@ func DownloadArtifactRaw(ctx *context.APIContext) { ctx.APIError(http.StatusNotFound, "Artifact has expired") return } - if actions.IsArtifactV4(art) { - err := actions.DownloadArtifactV4(ctx.Base, art) + if actions_service.IsArtifactV4(art) { + err := actions_service.DownloadArtifactV4(ctx.Base, art) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 3ce4337fbc..726ab63e56 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -972,8 +972,8 @@ func ArtifactsDownloadView(ctx *context_module.Context) { // A v4 Artifact may only contain a single file // Multiple files are uploaded as a single file archive // All other cases fall back to the legacy v1–v3 zip handling below - if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { - err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) + if len(artifacts) == 1 && actions_service.IsArtifactV4(artifacts[0]) { + err := actions_service.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) return diff --git a/modules/actions/artifacts.go b/services/actions/artifacts.go similarity index 100% rename from modules/actions/artifacts.go rename to services/actions/artifacts.go diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 5a7f8f1f44..76c11da7cb 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -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: diff --git a/services/actions/commit_status_test.go b/services/actions/commit_status_test.go index 6ff9393318..fa95a46383 100644 --- a/services/actions/commit_status_test.go +++ b/services/actions/commit_status_test.go @@ -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() diff --git a/services/context/context.go b/services/context/context.go index e8b1663b22..b4e9904cd4 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -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, diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index f60d480f8a..e3a73c9739 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -8,7 +8,9 @@ {{range $run := .Runs}}
- {{template "repo/actions/status" (dict "status" $run.Status.String)}} + + {{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}} +
diff --git a/templates/repo/actions/status.tmpl b/templates/repo/actions/status.tmpl deleted file mode 100644 index f44245086e..0000000000 --- a/templates/repo/actions/status.tmpl +++ /dev/null @@ -1,23 +0,0 @@ - -{{- $size := Iif .size .size 16 -}} -{{- $className := Iif .className .className "" -}} - -{{if eq .status "success"}} - {{svg "octicon-check-circle-fill" $size (printf "tw-text-green %s" $className)}} -{{else if eq .status "skipped"}} - {{svg "octicon-skip" $size (printf "tw-text-text-light %s" $className)}} -{{else if eq .status "cancelled"}} - {{svg "octicon-stop" $size (printf "tw-text-text-light %s" $className)}} -{{else if eq .status "waiting"}} - {{svg "octicon-circle" $size (printf "tw-text-text-light %s" $className)}} -{{else if eq .status "blocked"}} - {{svg "octicon-blocked" $size (printf "tw-text-yellow %s" $className)}} -{{else if eq .status "running"}} - {{svg "gitea-running" $size (printf "tw-text-yellow rotate-clockwise %s" $className)}} -{{else}}{{/*failure, unknown*/}} - {{svg "octicon-x-circle-fill" $size (printf "tw-text-red %s" $className)}} -{{end}} - diff --git a/templates/repo/commit_statuses.tmpl b/templates/repo/commit_statuses.tmpl index 4575827ceb..fb1e6fbf9b 100644 --- a/templates/repo/commit_statuses.tmpl +++ b/templates/repo/commit_statuses.tmpl @@ -1,11 +1,11 @@ {{if .Statuses}} {{if and (eq (len .Statuses) 1) .Status.TargetURL}} - {{template "repo/commit_status" .Status}} + {{template "repo/icons/commit_status" .Status}} {{else}} - {{template "repo/commit_status" .Status}} + {{template "repo/icons/commit_status" .Status}} {{end}}
diff --git a/templates/repo/icons/action_status.tmpl b/templates/repo/icons/action_status.tmpl new file mode 100644 index 0000000000..4f381bdb0e --- /dev/null +++ b/templates/repo/icons/action_status.tmpl @@ -0,0 +1,28 @@ +{{/* Status icons used for runs, jobs and steps. + +Template Attributes: +* Status: one of success, skipped, waiting, blocked, running, failure, cancelled, unknown +* Size: icon size in pixels (default 16) +* ClassName: additional CSS classes +* IconVariant: "circle-fill" → octicon-check-circle-fill / octicon-x-circle-fill + +Keep this template in sync with web_src/js/components/ActionStatusIcon.vue. +*/}} +{{- $size := or .Size 16 -}} +{{- $className := or .ClassName "" -}} +{{- $circleFill := eq .IconVariant "circle-fill" -}} +{{if eq .Status "success"}} + {{svg (Iif $circleFill "octicon-check-circle-fill" "octicon-check") $size (printf "tw-text-green %s" $className)}} +{{else if eq .Status "skipped"}} + {{svg "octicon-skip" $size (printf "tw-text-text-light %s" $className)}} +{{else if eq .Status "cancelled"}} + {{svg "octicon-stop" $size (printf "tw-text-text-light %s" $className)}} +{{else if eq .Status "waiting"}} + {{svg "octicon-circle" $size (printf "tw-text-text-light %s" $className)}} +{{else if eq .Status "blocked"}} + {{svg "octicon-blocked" $size (printf "tw-text-yellow %s" $className)}} +{{else if eq .Status "running"}} + {{svg "gitea-running" $size (printf "tw-text-yellow rotate-clockwise %s" $className)}} +{{else}}{{/*failure, unknown*/}} + {{svg (Iif $circleFill "octicon-x-circle-fill" "octicon-x") $size (printf "tw-text-red %s" $className)}} +{{end}} diff --git a/templates/repo/commit_status.tmpl b/templates/repo/icons/commit_status.tmpl similarity index 100% rename from templates/repo/commit_status.tmpl rename to templates/repo/icons/commit_status.tmpl diff --git a/templates/repo/pulls/status_items.tmpl b/templates/repo/pulls/status_items.tmpl index b4f8307395..415f9a8a32 100644 --- a/templates/repo/pulls/status_items.tmpl +++ b/templates/repo/pulls/status_items.tmpl @@ -3,10 +3,16 @@ * StatusCheckData: optional, additional status check data, see backend pullCommitStatusCheckData struct */}} {{$statusCheckData := $.StatusCheckData}} +{{$commitActionsStatuses := ctx.ActionsUtils.CommitStatusesToActionsStatuses $.CommitStatuses}} {{range $cs := $.CommitStatuses}}
- {{template "repo/commit_status" $cs}} + {{$actionStatus := $commitActionsStatuses.IconStatus $cs}} + {{if $actionStatus}} + {{template "repo/icons/action_status" (dict "Status" $actionStatus "Size" 18 "ClassName" "commit-status icon")}} + {{else}} + {{template "repo/icons/commit_status" $cs}} + {{end}}
{{$cs.Context}} {{$cs.Description}}
diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index a78385b6b4..8bae33e71b 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -1,14 +1,14 @@ diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 4d20392dac..725a996028 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -1,6 +1,6 @@