mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-14 03:29:55 +00:00
enhance(actions): Make Summary UI more beautiful with more infos (#37824)
## Summary - Redesign the Actions run summary header to follow GitHub Actions layout: trigger info on the left, Status / Total duration / Artifacts columns inline on the right - Expose trigger user avatar, pull request link, and PR head branch info from the run view API - Update the workflow graph header to show the workflow filename (linked to the run workflow file) and `on: <event>`, while keeping the jobs/dependencies/success stats line - Remove the redundant commit/workflow metadata row below the run title; that information now lives in the summary bar New: <img width="1564" height="639" src="https://github.com/user-attachments/assets/e6bc1623-c5fc-4e97-abc9-fde7f3c6aef9" /> Old: <img width="2038" height="1038" src="https://github.com/user-attachments/assets/0857f19a-8d3a-4da2-82fd-e9ebeb200062" /> Replaces https://github.com/go-gitea/gitea/pull/36721 --------- Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
@@ -87,29 +87,39 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
|
||||
resp.State.Run.CanDeleteArtifact = true
|
||||
resp.State.Run.WorkflowID = "workflow-id"
|
||||
resp.State.Run.WorkflowLink = "./workflow-link"
|
||||
resp.State.Run.WorkflowID = "workflow-id.yml"
|
||||
resp.State.Run.TriggerEvent = "push"
|
||||
user2, _ := user_model.GetUserByID(ctx, 2)
|
||||
if user2 == nil {
|
||||
user2 = &user_model.User{Name: "user2"}
|
||||
}
|
||||
user3, _ := user_model.GetUserByID(ctx, 3)
|
||||
if user3 == nil {
|
||||
user3 = &user_model.User{Name: "user3"}
|
||||
}
|
||||
resp.State.Run.Commit = actions.ViewCommit{
|
||||
ShortSha: "ccccdddd",
|
||||
Link: "./commit-link",
|
||||
Pusher: actions.ViewUser{
|
||||
DisplayName: "pusher user",
|
||||
Link: "./pusher-link",
|
||||
DisplayName: user2.GetDisplayName(),
|
||||
Link: user2.HomeLink(),
|
||||
AvatarLink: user2.AvatarLinkWithSize(ctx, 16),
|
||||
},
|
||||
Branch: actions.ViewBranch{
|
||||
Name: "commit-branch",
|
||||
Name: "user2:commit-branch",
|
||||
Link: "./branch-link",
|
||||
IsDeleted: false,
|
||||
},
|
||||
}
|
||||
resp.State.Run.PullRequest = &actions.ViewPullRequest{
|
||||
Index: "#37658",
|
||||
Link: "./pull/37658",
|
||||
}
|
||||
now := time.Now()
|
||||
currentAttemptNum := int64(1)
|
||||
if attemptID > 0 {
|
||||
currentAttemptNum = attemptID
|
||||
}
|
||||
user2 := &user_model.User{Name: "user2"}
|
||||
user3 := &user_model.User{Name: "user3"}
|
||||
attempts := []*actions_model.ActionRunAttempt{{
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
@@ -168,15 +178,16 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{
|
||||
Attempt: attempt.Attempt,
|
||||
Status: attempt.Status.String(),
|
||||
Done: attempt.Status.IsDone(),
|
||||
Link: link,
|
||||
Current: current,
|
||||
Latest: attempt.Attempt == latestAttempt.Attempt,
|
||||
TriggeredAt: attempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: attempt.TriggerUser.HomeLink(),
|
||||
Attempt: attempt.Attempt,
|
||||
Status: attempt.Status.String(),
|
||||
Done: attempt.Status.IsDone(),
|
||||
Link: link,
|
||||
Current: current,
|
||||
Latest: attempt.Attempt == latestAttempt.Attempt,
|
||||
TriggeredAt: attempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: attempt.TriggerUser.HomeLink(),
|
||||
TriggerUserAvatar: attempt.TriggerUser.AvatarLinkWithSize(ctx, 16),
|
||||
})
|
||||
}
|
||||
isLatestAttempt := currentAttemptNum == latestAttempt.Attempt
|
||||
|
||||
@@ -20,14 +20,18 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/storage"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -306,6 +310,7 @@ type ViewResponse struct {
|
||||
Attempts []*ViewRunAttempt `json:"attempts"`
|
||||
Jobs []*ViewJob `json:"jobs"`
|
||||
Commit ViewCommit `json:"commit"`
|
||||
PullRequest *ViewPullRequest `json:"pullRequest,omitempty"`
|
||||
// Summary view: run duration and trigger time/event
|
||||
Duration string `json:"duration"`
|
||||
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
|
||||
@@ -340,15 +345,21 @@ type ViewJob struct {
|
||||
}
|
||||
|
||||
type ViewRunAttempt struct {
|
||||
Attempt int64 `json:"attempt"`
|
||||
Status string `json:"status"`
|
||||
Done bool `json:"done"`
|
||||
Link string `json:"link"`
|
||||
Current bool `json:"current"`
|
||||
Latest bool `json:"latest"`
|
||||
TriggeredAt int64 `json:"triggeredAt"`
|
||||
TriggerUserName string `json:"triggerUserName"`
|
||||
TriggerUserLink string `json:"triggerUserLink"`
|
||||
Attempt int64 `json:"attempt"`
|
||||
Status string `json:"status"`
|
||||
Done bool `json:"done"`
|
||||
Link string `json:"link"`
|
||||
Current bool `json:"current"`
|
||||
Latest bool `json:"latest"`
|
||||
TriggeredAt int64 `json:"triggeredAt"`
|
||||
TriggerUserName string `json:"triggerUserName"`
|
||||
TriggerUserLink string `json:"triggerUserLink"`
|
||||
TriggerUserAvatar string `json:"triggerUserAvatar"`
|
||||
}
|
||||
|
||||
type ViewPullRequest struct {
|
||||
Index string `json:"index"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
type ViewCommit struct {
|
||||
@@ -361,6 +372,7 @@ type ViewCommit struct {
|
||||
type ViewUser struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Link string `json:"link"`
|
||||
AvatarLink string `json:"avatarLink,omitempty"`
|
||||
}
|
||||
|
||||
type ViewBranch struct {
|
||||
@@ -388,6 +400,132 @@ type ViewStepLogLine struct {
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func viewPullRequestFromRun(ctx context.Context, run *actions_model.ActionRun, prPayload *api.PullRequestPayload) *ViewPullRequest {
|
||||
if run.Repo == nil {
|
||||
return nil
|
||||
}
|
||||
refName := git.RefName(run.Ref)
|
||||
if refName.IsPull() {
|
||||
return &ViewPullRequest{
|
||||
Index: "#" + refName.ShortName(),
|
||||
Link: run.RefLink(),
|
||||
}
|
||||
}
|
||||
if prPayload != nil && prPayload.Index > 0 {
|
||||
return &ViewPullRequest{
|
||||
Index: fmt.Sprintf("#%d", prPayload.Index),
|
||||
Link: fmt.Sprintf("%s/pulls/%d", run.Repo.Link(), prPayload.Index),
|
||||
}
|
||||
}
|
||||
// Push-triggered run: surface an open PR whose head matches this branch so
|
||||
// users coming from a PR's check details can navigate back to it.
|
||||
if refName.IsBranch() {
|
||||
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, run.RepoID, refName.ShortName())
|
||||
if err != nil {
|
||||
log.Error("GetUnmergedPullRequestsByHeadInfo: %v", err)
|
||||
} else if len(prs) == 1 {
|
||||
pr := prs[0]
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("LoadBaseRepo: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &ViewPullRequest{
|
||||
Index: fmt.Sprintf("#%d", pr.Index),
|
||||
Link: fmt.Sprintf("%s/pulls/%d", pr.BaseRepo.Link(), pr.Index),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewSummaryBranchFromRun(ctx context.Context, run *actions_model.ActionRun, prPayload *api.PullRequestPayload) ViewBranch {
|
||||
refName := git.RefName(run.Ref)
|
||||
if prPayload != nil && prPayload.PullRequest != nil && prPayload.PullRequest.Head != nil {
|
||||
head := prPayload.PullRequest.Head
|
||||
name := head.Name
|
||||
if name == "" {
|
||||
name = git.RefName(head.Ref).ShortName()
|
||||
}
|
||||
if head.Repository != nil && run.Repo != nil && head.RepoID > 0 && head.RepoID != run.Repo.ID {
|
||||
ownerName := ""
|
||||
if head.Repository.Owner != nil {
|
||||
ownerName = head.Repository.Owner.UserName
|
||||
} else if head.Repository.FullName != "" {
|
||||
ownerName, _, _ = strings.Cut(head.Repository.FullName, "/")
|
||||
}
|
||||
if ownerName != "" && !strings.Contains(name, ":") {
|
||||
name = ownerName + ":" + name
|
||||
}
|
||||
}
|
||||
link := ""
|
||||
if head.Repository != nil && head.Ref != "" {
|
||||
repoLink := head.Repository.Link
|
||||
if repoLink == "" {
|
||||
repoLink = head.Repository.HTMLURL
|
||||
}
|
||||
if repoLink != "" {
|
||||
link = repoLink + "/src/" + git.RefName(head.Ref).RefWebLinkPath()
|
||||
}
|
||||
}
|
||||
return ViewBranch{Name: name, Link: link}
|
||||
}
|
||||
|
||||
branch := ViewBranch{
|
||||
Name: run.PrettyRef(),
|
||||
Link: run.RefLink(),
|
||||
}
|
||||
if refName.IsBranch() {
|
||||
b, err := git_model.GetBranch(ctx, run.RepoID, refName.ShortName())
|
||||
if err != nil && !git_model.IsErrBranchNotExist(err) {
|
||||
log.Error("GetBranch: %v", err)
|
||||
} else if git_model.IsErrBranchNotExist(err) || (b != nil && b.IsDeleted) {
|
||||
branch.IsDeleted = true
|
||||
}
|
||||
}
|
||||
return branch
|
||||
}
|
||||
|
||||
// actionsSummaryRefCacheTTL bounds how long the resolved PR/branch summary is
|
||||
// cached. ViewPost is polled every second, but this metadata is stable for a
|
||||
// run, so a short TTL collapses the repeated DB lookups while staying fresh
|
||||
// enough for the navigation links.
|
||||
const actionsSummaryRefCacheTTL = 10 // seconds
|
||||
|
||||
type viewSummaryRefInfo struct {
|
||||
PullRequest *ViewPullRequest `json:"pullRequest"`
|
||||
Branch ViewBranch `json:"branch"`
|
||||
}
|
||||
|
||||
// getViewSummaryRefInfo resolves the run's pull request and head branch summary,
|
||||
// caching the result briefly so the per-second poll does not hit the database on
|
||||
// every request (GetUnmergedPullRequestsByHeadInfo / GetBranch).
|
||||
func getViewSummaryRefInfo(ctx context.Context, run *actions_model.ActionRun) viewSummaryRefInfo {
|
||||
compute := func() viewSummaryRefInfo {
|
||||
// parse the event payload once and share it between both resolvers
|
||||
prPayload, _ := run.GetPullRequestEventPayload() // nil unless this is a pull request event
|
||||
return viewSummaryRefInfo{
|
||||
PullRequest: viewPullRequestFromRun(ctx, run, prPayload),
|
||||
Branch: viewSummaryBranchFromRun(ctx, run, prPayload),
|
||||
}
|
||||
}
|
||||
c := cache.GetCache()
|
||||
if c == nil {
|
||||
return compute()
|
||||
}
|
||||
cacheKey := fmt.Sprintf("actions_run_summary_ref:%d", run.ID)
|
||||
if cached, ok := c.Get(cacheKey); ok && cached != "" {
|
||||
var info viewSummaryRefInfo
|
||||
if err := json.Unmarshal([]byte(cached), &info); err == nil {
|
||||
return info
|
||||
}
|
||||
}
|
||||
info := compute()
|
||||
if data, err := json.Marshal(info); err == nil {
|
||||
_ = c.Put(cacheKey, string(data), actionsSummaryRefCacheTTL)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func ViewPost(ctx *context_module.Context) {
|
||||
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
@@ -482,42 +620,33 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
}
|
||||
for _, runAttempt := range attempts {
|
||||
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &ViewRunAttempt{
|
||||
Attempt: runAttempt.Attempt,
|
||||
Status: runAttempt.Status.String(),
|
||||
Done: runAttempt.Status.IsDone(),
|
||||
Link: getRunViewLink(run, runAttempt),
|
||||
Current: runAttempt.ID == attempt.ID,
|
||||
Latest: runAttempt.ID == run.LatestAttemptID,
|
||||
TriggeredAt: runAttempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: runAttempt.TriggerUser.HomeLink(),
|
||||
Attempt: runAttempt.Attempt,
|
||||
Status: runAttempt.Status.String(),
|
||||
Done: runAttempt.Status.IsDone(),
|
||||
Link: getRunViewLink(run, runAttempt),
|
||||
Current: runAttempt.ID == attempt.ID,
|
||||
Latest: runAttempt.ID == run.LatestAttemptID,
|
||||
TriggeredAt: runAttempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: runAttempt.TriggerUser.HomeLink(),
|
||||
TriggerUserAvatar: runAttempt.TriggerUser.AvatarLinkWithSize(ctx, 16),
|
||||
})
|
||||
}
|
||||
|
||||
pusher := ViewUser{
|
||||
DisplayName: run.TriggerUser.GetDisplayName(),
|
||||
Link: run.TriggerUser.HomeLink(),
|
||||
}
|
||||
branch := ViewBranch{
|
||||
Name: run.PrettyRef(),
|
||||
Link: run.RefLink(),
|
||||
}
|
||||
refName := git.RefName(run.Ref)
|
||||
if refName.IsBranch() {
|
||||
b, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, refName.ShortName())
|
||||
if err != nil && !git_model.IsErrBranchNotExist(err) {
|
||||
log.Error("GetBranch: %v", err)
|
||||
} else if git_model.IsErrBranchNotExist(err) || (b != nil && b.IsDeleted) {
|
||||
branch.IsDeleted = true
|
||||
}
|
||||
AvatarLink: run.TriggerUser.AvatarLinkWithSize(ctx, 16),
|
||||
}
|
||||
|
||||
refInfo := getViewSummaryRefInfo(ctx, run)
|
||||
resp.State.Run.Commit = ViewCommit{
|
||||
ShortSha: base.ShortSha(run.CommitSHA),
|
||||
Link: fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA),
|
||||
Pusher: pusher,
|
||||
Branch: branch,
|
||||
Branch: refInfo.Branch,
|
||||
}
|
||||
resp.State.Run.PullRequest = refInfo.PullRequest
|
||||
resp.State.Run.TriggerEvent = run.TriggerEvent
|
||||
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0,
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
@@ -14,6 +16,66 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestViewPullRequestFromRun(t *testing.T) {
|
||||
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
|
||||
|
||||
t.Run("pull ref", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/123/head"}
|
||||
assert.Equal(t, &ViewPullRequest{Index: "#123", Link: "/owner/repo/pulls/123"}, viewPullRequestFromRun(t.Context(), run, nil))
|
||||
})
|
||||
|
||||
t.Run("pull request event payload", func(t *testing.T) {
|
||||
// a non-pull ref forces the payload branch instead of the ref branch
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/heads/feature"}
|
||||
payload := &api.PullRequestPayload{Index: 42}
|
||||
assert.Equal(t, &ViewPullRequest{Index: "#42", Link: "/owner/repo/pulls/42"}, viewPullRequestFromRun(t.Context(), run, payload))
|
||||
})
|
||||
|
||||
t.Run("nil repo", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Ref: "refs/pull/1/head"}
|
||||
assert.Nil(t, viewPullRequestFromRun(t.Context(), run, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestViewSummaryBranchFromRun(t *testing.T) {
|
||||
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
|
||||
|
||||
t.Run("pull request event same repo", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
|
||||
payload := &api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
|
||||
Name: "feature",
|
||||
Ref: "refs/heads/feature",
|
||||
RepoID: 1,
|
||||
Repository: &api.Repository{Link: "/owner/repo"},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, ViewBranch{Name: "feature", Link: "/owner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
|
||||
})
|
||||
|
||||
t.Run("pull request event from fork prefixes owner", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
|
||||
payload := &api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
|
||||
Name: "feature",
|
||||
Ref: "refs/heads/feature",
|
||||
RepoID: 2,
|
||||
Repository: &api.Repository{
|
||||
Link: "/forkowner/repo",
|
||||
Owner: &api.User{UserName: "forkowner"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, ViewBranch{Name: "forkowner:feature", Link: "/forkowner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
|
||||
})
|
||||
|
||||
t.Run("push to tag does not query branch", func(t *testing.T) {
|
||||
// a tag ref is not a branch, so no GetBranch DB lookup happens
|
||||
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/tags/v1.0.0"}
|
||||
assert.Equal(t, ViewBranch{Name: "v1.0.0", Link: "/owner/repo/src/tag/v1.0.0"}, viewSummaryBranchFromRun(t.Context(), run, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertToViewModel(t *testing.T) {
|
||||
task := &actions_model.ActionTask{
|
||||
Status: actions_model.StatusSuccess,
|
||||
|
||||
Reference in New Issue
Block a user