diff --git a/models/git/commit_status.go b/models/git/commit_status.go index e255bca5d0..2ae5937a3d 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -30,17 +30,21 @@ import ( // CommitStatus holds a single Status of a single Commit type CommitStatus struct { - ID int64 `xorm:"pk autoincr"` - Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - Repo *repo_model.Repository `xorm:"-"` - State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` - SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` - TargetURL string `xorm:"TEXT"` - Description string `xorm:"TEXT"` - ContextHash string `xorm:"VARCHAR(64) index"` - Context string `xorm:"TEXT"` - Creator *user_model.User `xorm:"-"` + ID int64 `xorm:"pk autoincr"` + Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + Repo *repo_model.Repository `xorm:"-"` + State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` + SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` + + // TargetURL points to the commit status page reported by a CI system + // If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}" + TargetURL string `xorm:"TEXT"` + + Description string `xorm:"TEXT"` + ContextHash string `xorm:"VARCHAR(64) index"` + Context string `xorm:"TEXT"` + Creator *user_model.User `xorm:"-"` CreatorID int64 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` @@ -211,21 +215,45 @@ func (status *CommitStatus) LocaleString(lang translation.Locale) string { // HideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions func (status *CommitStatus) HideActionsURL(ctx context.Context) { + if _, ok := status.cutTargetURLGiteaActionsPrefix(ctx); ok { + status.TargetURL = "" + } +} + +func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context) (string, bool) { if status.RepoID == 0 { - return + return "", false } if status.Repo == nil { if err := status.loadRepository(ctx); err != nil { log.Error("loadRepository: %v", err) - return + return "", false } } prefix := status.Repo.Link() + "/actions" - if strings.HasPrefix(status.TargetURL, prefix) { - status.TargetURL = "" + return strings.CutPrefix(status.TargetURL, prefix) +} + +// ParseGiteaActionsTargetURL parses the commit status target URL as Gitea Actions link +func (status *CommitStatus) ParseGiteaActionsTargetURL(ctx context.Context) (runID, jobID int64, ok bool) { + s, ok := status.cutTargetURLGiteaActionsPrefix(ctx) + if !ok { + return 0, 0, false } + + parts := strings.Split(s, "/") // expect: /runs/{runID}/jobs/{jobID} + if len(parts) < 5 || parts[1] != "runs" || parts[3] != "jobs" { + return 0, 0, false + } + + runID, err1 := strconv.ParseInt(parts[2], 10, 64) + jobID, err2 := strconv.ParseInt(parts[4], 10, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return runID, jobID, true } // CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 03017ce674..46fdf06022 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1969,6 +1969,9 @@ pulls.status_checks_requested = Required pulls.status_checks_details = Details pulls.status_checks_hide_all = Hide all checks pulls.status_checks_show_all = Show all checks +pulls.status_checks_approve_all = Approve all workflows +pulls.status_checks_need_approvals = %d workflow awaiting approval +pulls.status_checks_need_approvals_helper = The workflow will only run after approval from the repository maintainer. pulls.update_branch = Update branch by merge pulls.update_branch_rebase = Update branch by rebase pulls.update_branch_success = Branch update was successful @@ -3890,6 +3893,7 @@ workflow.has_workflow_dispatch = This workflow has a workflow_dispatch event tri workflow.has_no_workflow_dispatch = Workflow '%s' has no workflow_dispatch event trigger. need_approval_desc = Need approval to run workflows for fork pull request. +approve_all_success = All workflow runs are approved successfully. variables = Variables variables.management = Variables Management diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 4250e6ff77..013dab4acf 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -606,33 +606,53 @@ func Cancel(ctx *context_module.Context) { func Approve(ctx *context_module.Context) { runIndex := getRunIndex(ctx) - current, jobs := getRunJobs(ctx, runIndex, -1) + approveRuns(ctx, []int64{runIndex}) if ctx.Written() { return } - run := current.Run - doer := ctx.Doer - var updatedJobs []*actions_model.ActionRunJob + ctx.JSONOK() +} + +func approveRuns(ctx *context_module.Context, runIndexes []int64) { + doer := ctx.Doer + repo := ctx.Repo.Repository + + updatedJobs := make([]*actions_model.ActionRunJob, 0) + runMap := make(map[int64]*actions_model.ActionRun, len(runIndexes)) + runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIndexes)) err := db.WithTx(ctx, func(ctx context.Context) (err error) { - run.NeedApproval = false - run.ApprovedBy = doer.ID - if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { - return err - } - for _, job := range jobs { - job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) + for _, runIndex := range runIndexes { + run, err := actions_model.GetRunByIndex(ctx, repo.ID, runIndex) if err != nil { return err } - if job.Status == actions_model.StatusWaiting { - n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + runMap[run.ID] = run + run.Repo = repo + run.NeedApproval = false + run.ApprovedBy = doer.ID + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return err + } + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return err + } + runJobs[run.ID] = jobs + for _, job := range jobs { + job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) if err != nil { return err } - if n > 0 { - updatedJobs = append(updatedJobs, job) + if job.Status == actions_model.StatusWaiting { + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + if n > 0 { + updatedJobs = append(updatedJobs, job) + } } } } @@ -643,7 +663,9 @@ func Approve(ctx *context_module.Context) { return } - actions_service.CreateCommitStatusForRunJobs(ctx, current.Run, jobs...) + for runID, run := range runMap { + actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...) + } if len(updatedJobs) > 0 { job := updatedJobs[0] @@ -654,8 +676,6 @@ func Approve(ctx *context_module.Context) { _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } - - ctx.JSONOK() } func Delete(ctx *context_module.Context) { @@ -818,6 +838,42 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } } +func ApproveAllChecks(ctx *context_module.Context) { + repo := ctx.Repo.Repository + commitID := ctx.FormString("commit_id") + + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return + } + runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses) + if err != nil { + ctx.ServerError("GetRunsFromCommitStatuses", err) + return + } + + runIndexes := make([]int64, 0, len(runs)) + for _, run := range runs { + if run.NeedApproval { + runIndexes = append(runIndexes, run.Index) + } + } + + if len(runIndexes) == 0 { + ctx.JSONOK() + return + } + + approveRuns(ctx, runIndexes) + if ctx.Written() { + return + } + + ctx.Flash.Success(ctx.Tr("actions.approve_all_success")) + ctx.JSONOK() +} + func DisableWorkflowFile(ctx *context_module.Context) { disableOrEnableWorkflowFile(ctx, false) } diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index d9f6c33e3f..5866ddc402 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -437,6 +437,9 @@ func ViewIssue(ctx *context.Context) { func ViewPullMergeBox(ctx *context.Context) { issue := prepareIssueViewLoad(ctx) + if ctx.Written() { + return + } if !issue.IsPull { ctx.NotFound(nil) return diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index cfe9a7ae02..c8da025b15 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -38,6 +38,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" shared_user "code.gitea.io/gitea/routers/web/shared/user" + actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/context" @@ -311,6 +312,14 @@ func prepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) return compareInfo } +type pullCommitStatusCheckData struct { + MissingRequiredChecks []string // list of missing required checks + IsContextRequired func(string) bool // function to check whether a context is required + RequireApprovalRunCount int // number of workflow runs that require approval + CanApprove bool // whether the user can approve workflow runs + ApproveLink string // link to approve all checks +} + // prepareViewPullInfo show meta information for a pull request preview page func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_service.CompareInfo { ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes @@ -456,6 +465,11 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_ return nil } + statusCheckData := &pullCommitStatusCheckData{ + ApproveLink: fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", repo.Link(), sha), + } + ctx.Data["StatusCheckData"] = statusCheckData + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) @@ -465,6 +479,20 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_ git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) } + runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses) + if err != nil { + ctx.ServerError("GetRunsFromCommitStatuses", err) + return nil + } + for _, run := range runs { + if run.NeedApproval { + statusCheckData.RequireApprovalRunCount++ + } + } + if statusCheckData.RequireApprovalRunCount > 0 { + statusCheckData.CanApprove = ctx.Repo.CanWrite(unit.TypeActions) + } + if len(commitStatuses) > 0 { ctx.Data["LatestCommitStatuses"] = commitStatuses ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) @@ -486,9 +514,9 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_ missingRequiredChecks = append(missingRequiredChecks, requiredContext) } } - ctx.Data["MissingRequiredChecks"] = missingRequiredChecks + statusCheckData.MissingRequiredChecks = missingRequiredChecks - ctx.Data["is_context_required"] = func(context string) bool { + statusCheckData.IsContextRequired = func(context string) bool { for _, c := range pb.StatusCheckContexts { if c == context { return true diff --git a/routers/web/web.go b/routers/web/web.go index 5ee211b576..9b3cfb6d16 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1459,6 +1459,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) m.Post("/run", reqRepoActionsWriter, actions.Run) m.Get("/workflow-dispatch-inputs", reqRepoActionsWriter, actions.WorkflowDispatchInputs) + m.Post("/approve-all-checks", reqRepoActionsWriter, actions.ApproveAllChecks) m.Group("/runs/{run}", func() { m.Combo(""). diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index d3f2b0f3cc..089dfeb634 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -18,6 +18,7 @@ import ( actions_module "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/commitstatus" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" @@ -52,6 +53,33 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action } } +func GetRunsFromCommitStatuses(ctx context.Context, statuses []*git_model.CommitStatus) ([]*actions_model.ActionRun, error) { + runMap := make(map[int64]*actions_model.ActionRun) + for _, status := range statuses { + runIndex, _, ok := status.ParseGiteaActionsTargetURL(ctx) + if !ok { + continue + } + _, ok = runMap[runIndex] + if !ok { + run, err := actions_model.GetRunByIndex(ctx, status.RepoID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + // the run may be deleted manually, just skip it + continue + } + return nil, fmt.Errorf("GetRunByIndex: %w", err) + } + runMap[runIndex] = run + } + } + runs := make([]*actions_model.ActionRun, 0, len(runMap)) + for _, run := range runMap { + runs = append(runs, run) + } + return runs, nil +} + func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, commitID string, _ error) { switch run.Event { case webhook_module.HookEventPush: diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index b0ac24c9f6..c0d717e854 100644 --- a/templates/repo/issue/view_content/pull_merge_box.tmpl +++ b/templates/repo/issue/view_content/pull_merge_box.tmpl @@ -31,9 +31,8 @@ {{template "repo/pulls/status" (dict "CommitStatus" .LatestCommitStatus "CommitStatuses" .LatestCommitStatuses - "MissingRequiredChecks" .MissingRequiredChecks "ShowHideChecks" true - "is_context_required" .is_context_required + "StatusCheckData" .StatusCheckData )}} {{end}} diff --git a/templates/repo/pulls/status.tmpl b/templates/repo/pulls/status.tmpl index 96030f9422..f3c1973c2b 100644 --- a/templates/repo/pulls/status.tmpl +++ b/templates/repo/pulls/status.tmpl @@ -1,11 +1,10 @@ {{/* Template Attributes: * CommitStatus: summary of all commit status state * CommitStatuses: all commit status elements -* MissingRequiredChecks: commit check contexts that are required by branch protection but not present * ShowHideChecks: whether use a button to show/hide the checks -* is_context_required: Used in pull request commit status check table +* StatusCheckData: additional status check data, see backend pullCommitStatusCheckData struct */}} - +{{$statusCheckData := .StatusCheckData}} {{if .CommitStatus}}
@@ -33,25 +32,43 @@ {{end}}
+ {{if and $statusCheckData $statusCheckData.RequireApprovalRunCount}} +
+
+ + {{ctx.Locale.Tr "repo.pulls.status_checks_need_approvals" $statusCheckData.RequireApprovalRunCount}} + +

{{ctx.Locale.Tr "repo.pulls.status_checks_need_approvals_helper"}}

+
+ {{if $statusCheckData.CanApprove}} + + {{end}} +
+ {{end}} +
{{range .CommitStatuses}}
{{template "repo/commit_status" .}}
{{.Context}} {{.Description}}
- {{if $.is_context_required}} - {{if (call $.is_context_required .Context)}}
{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
{{end}} + {{if and $statusCheckData $statusCheckData.IsContextRequired}} + {{if (call $statusCheckData.IsContextRequired .Context)}}
{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
{{end}} {{end}} {{if .TargetURL}}{{ctx.Locale.Tr "repo.pulls.status_checks_details"}}{{end}}
{{end}} - {{range .MissingRequiredChecks}} -
- {{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} -
{{.}}
-
{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
-
+ {{if $statusCheckData}} + {{range $statusCheckData.MissingRequiredChecks}} +
+ {{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} +
{{.}}
+
{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}
+
+ {{end}} {{end}}
diff --git a/tests/integration/actions_approve_test.go b/tests/integration/actions_approve_test.go new file mode 100644 index 0000000000..04b8bcb715 --- /dev/null +++ b/tests/integration/actions_approve_test.go @@ -0,0 +1,146 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" +) + +func TestApproveAllRunsOnPullRequestPage(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // user2 is the owner of the base repo + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + // user4 is the owner of the fork repo + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user4Session := loginUser(t, user4.Name) + user4Token := getTokenForLoggedInUser(t, loginUser(t, user4.Name), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiBaseRepo := createActionsTestRepo(t, user2Token, "approve-all-runs", false) + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID}) + user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user2APICtx)(t) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false) + + // init workflows + wf1TreePath := ".gitea/workflows/pull_1.yml" + wf1FileContent := `name: Pull 1 +on: pull_request +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - run: echo unit-test +` + opts1 := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create %s"+wf1TreePath, wf1FileContent) + createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wf1TreePath, opts1) + wf2TreePath := ".gitea/workflows/pull_2.yml" + wf2FileContent := `name: Pull 2 +on: pull_request +jobs: + integration-test: + runs-on: ubuntu-latest + steps: + - run: echo integration-test +` + opts2 := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create %s"+wf2TreePath, wf2FileContent) + createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wf2TreePath, opts2) + + // user4 forks the repo + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name), + &api.CreateForkOption{ + Name: util.ToPointer("approve-all-runs-fork"), + }).AddTokenAuth(user4Token) + resp := MakeRequest(t, req, http.StatusAccepted) + var apiForkRepo api.Repository + DecodeJSON(t, resp, &apiForkRepo) + forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID}) + user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user4APICtx)(t) + + // user4 creates a pull request from branch "bugfix/user4" + doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + NewBranchName: "bugfix/user4", + Message: "create user4-fix.txt", + Author: api.Identity{ + Name: user4.Name, + Email: user4.Email, + }, + Committer: api.Identity{ + Name: user4.Name, + Email: user4.Email, + }, + Dates: api.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix")), + })(t) + apiPull, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":bugfix/user4")(t) + assert.NoError(t, err) + + // check runs + run1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, WorkflowID: "pull_1.yml"}) + assert.True(t, run1.NeedApproval) + assert.Equal(t, actions_model.StatusBlocked, run1.Status) + run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, WorkflowID: "pull_2.yml"}) + assert.True(t, run2.NeedApproval) + assert.Equal(t, actions_model.StatusBlocked, run2.Status) + + // user4 cannot see the approve button + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", baseRepo.OwnerName, baseRepo.Name, apiPull.Index)) + resp = user4Session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Zero(t, htmlDoc.doc.Find("#approve-status-checks button.link-action").Length()) + + // user2 can see the approve button + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", baseRepo.OwnerName, baseRepo.Name, apiPull.Index)) + resp = user2Session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + dataURL, exist := htmlDoc.doc.Find("#approve-status-checks button.link-action").Attr("data-url") + assert.True(t, exist) + assert.Equal(t, + fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", + baseRepo.Link(), apiPull.Head.Sha), + dataURL, + ) + + // user2 approves all runs + req = NewRequestWithValues(t, "POST", dataURL, + map[string]string{ + "_csrf": GetUserCSRFToken(t, user2Session), + }) + user2Session.MakeRequest(t, req, http.StatusOK) + + // check runs + run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run1.ID}) + assert.False(t, run1.NeedApproval) + assert.Equal(t, user2.ID, run1.ApprovedBy) + assert.Equal(t, actions_model.StatusWaiting, run1.Status) + run2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID}) + assert.False(t, run2.NeedApproval) + assert.Equal(t, user2.ID, run2.ApprovedBy) + assert.Equal(t, actions_model.StatusWaiting, run2.Status) + }) +}