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 @@