diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index d9139f17e3..a629f11adf 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -3804,7 +3804,10 @@
"actions.runs.latest": "Latest",
"actions.runs.latest_attempt": "Latest attempt",
"actions.runs.triggered_via": "Triggered via %s",
- "actions.runs.total_duration": "Total duration:",
+ "actions.runs.rerun_triggered": "Re-run triggered",
+ "actions.runs.back_to_pull_request": "Back to pull request",
+ "actions.runs.back_to_workflow": "Back to workflow",
+ "actions.runs.total_duration": "Total duration",
"actions.runs.workflow_dependencies": "Workflow Dependencies",
"actions.runs.graph_jobs_count_1": "%d job",
"actions.runs.graph_jobs_count_n": "%d jobs",
diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index 61f20a3ef4..185cdc8acb 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -87,29 +87,39 @@ func MockActionsRunsJobs(ctx *context.Context) {
resp.State.Run.TitleHTML = `mock run title link`
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
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index da99744449..9f8477d4c0 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -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,
diff --git a/routers/web/repo/actions/view_test.go b/routers/web/repo/actions/view_test.go
index 737ac5e1b3..020930eb38 100644
--- a/routers/web/repo/actions/view_test.go
+++ b/routers/web/repo/actions/view_test.go
@@ -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,
diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl
index 2ed47ad9df..8b8a6dfeff 100644
--- a/templates/repo/actions/view_component.tmpl
+++ b/templates/repo/actions/view_component.tmpl
@@ -18,6 +18,10 @@
data-locale-expand-caller-jobs="{{ctx.Locale.Tr "actions.runs.expand_caller_jobs"}}"
data-locale-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}"
data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
+ data-locale-rerun-triggered="{{ctx.Locale.Tr "actions.runs.rerun_triggered"}}"
+ data-locale-back-to-pull-request="{{ctx.Locale.Tr "actions.runs.back_to_pull_request"}}"
+ data-locale-back-to-workflow="{{ctx.Locale.Tr "actions.runs.back_to_workflow"}}"
+ data-locale-status-label="{{ctx.Locale.Tr "actions.runs.status"}}"
data-locale-total-duration="{{ctx.Locale.Tr "actions.runs.total_duration"}}"
data-locale-run-details="{{ctx.Locale.Tr "actions.runs.run_details"}}"
data-locale-workflow-file="{{ctx.Locale.Tr "actions.runs.workflow_file"}}"
diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue
index 17b7e2802e..e3813e9e17 100644
--- a/web_src/js/components/ActionRunSummaryView.vue
+++ b/web_src/js/components/ActionRunSummaryView.vue
@@ -1,5 +1,4 @@