0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-10-21 06:24:42 -04:00

Add quick approve button on PR page (#35678)

This PR adds a quick approve button on PR page to allow reviewers to
approve all pending checks. Only users with write permission to the 
Actions unit can approve.

---------

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Zettat123
2025-10-20 04:46:37 -06:00
committed by GitHub
parent 66ee8f3553
commit 897e48dde3
10 changed files with 358 additions and 48 deletions

View File

@@ -30,17 +30,21 @@ import (
// CommitStatus holds a single Status of a single Commit // CommitStatus holds a single Status of a single Commit
type CommitStatus struct { type CommitStatus struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
Repo *repo_model.Repository `xorm:"-"` Repo *repo_model.Repository `xorm:"-"`
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
TargetURL string `xorm:"TEXT"`
Description string `xorm:"TEXT"` // TargetURL points to the commit status page reported by a CI system
ContextHash string `xorm:"VARCHAR(64) index"` // If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}"
Context string `xorm:"TEXT"` TargetURL string `xorm:"TEXT"`
Creator *user_model.User `xorm:"-"`
Description string `xorm:"TEXT"`
ContextHash string `xorm:"VARCHAR(64) index"`
Context string `xorm:"TEXT"`
Creator *user_model.User `xorm:"-"`
CreatorID int64 CreatorID int64
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` 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 // HideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions
func (status *CommitStatus) HideActionsURL(ctx context.Context) { 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 { if status.RepoID == 0 {
return return "", false
} }
if status.Repo == nil { if status.Repo == nil {
if err := status.loadRepository(ctx); err != nil { if err := status.loadRepository(ctx); err != nil {
log.Error("loadRepository: %v", err) log.Error("loadRepository: %v", err)
return return "", false
} }
} }
prefix := status.Repo.Link() + "/actions" prefix := status.Repo.Link() + "/actions"
if strings.HasPrefix(status.TargetURL, prefix) { return strings.CutPrefix(status.TargetURL, prefix)
status.TargetURL = "" }
// 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 // CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc

View File

@@ -1969,6 +1969,9 @@ pulls.status_checks_requested = Required
pulls.status_checks_details = Details pulls.status_checks_details = Details
pulls.status_checks_hide_all = Hide all checks pulls.status_checks_hide_all = Hide all checks
pulls.status_checks_show_all = Show 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 = Update branch by merge
pulls.update_branch_rebase = Update branch by rebase pulls.update_branch_rebase = Update branch by rebase
pulls.update_branch_success = Branch update was successful 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. 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. need_approval_desc = Need approval to run workflows for fork pull request.
approve_all_success = All workflow runs are approved successfully.
variables = Variables variables = Variables
variables.management = Variables Management variables.management = Variables Management

View File

@@ -606,33 +606,53 @@ func Cancel(ctx *context_module.Context) {
func Approve(ctx *context_module.Context) { func Approve(ctx *context_module.Context) {
runIndex := getRunIndex(ctx) runIndex := getRunIndex(ctx)
current, jobs := getRunJobs(ctx, runIndex, -1) approveRuns(ctx, []int64{runIndex})
if ctx.Written() { if ctx.Written() {
return 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) { err := db.WithTx(ctx, func(ctx context.Context) (err error) {
run.NeedApproval = false for _, runIndex := range runIndexes {
run.ApprovedBy = doer.ID run, err := actions_model.GetRunByIndex(ctx, repo.ID, runIndex)
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)
if err != nil { if err != nil {
return err return err
} }
if job.Status == actions_model.StatusWaiting { runMap[run.ID] = run
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") 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 { if err != nil {
return err return err
} }
if n > 0 { if job.Status == actions_model.StatusWaiting {
updatedJobs = append(updatedJobs, job) 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 return
} }
actions_service.CreateCommitStatusForRunJobs(ctx, current.Run, jobs...) for runID, run := range runMap {
actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...)
}
if len(updatedJobs) > 0 { if len(updatedJobs) > 0 {
job := updatedJobs[0] job := updatedJobs[0]
@@ -654,8 +676,6 @@ func Approve(ctx *context_module.Context) {
_ = job.LoadAttributes(ctx) _ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
} }
ctx.JSONOK()
} }
func Delete(ctx *context_module.Context) { 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) { func DisableWorkflowFile(ctx *context_module.Context) {
disableOrEnableWorkflowFile(ctx, false) disableOrEnableWorkflowFile(ctx, false)
} }

View File

@@ -437,6 +437,9 @@ func ViewIssue(ctx *context.Context) {
func ViewPullMergeBox(ctx *context.Context) { func ViewPullMergeBox(ctx *context.Context) {
issue := prepareIssueViewLoad(ctx) issue := prepareIssueViewLoad(ctx)
if ctx.Written() {
return
}
if !issue.IsPull { if !issue.IsPull {
ctx.NotFound(nil) ctx.NotFound(nil)
return return

View File

@@ -38,6 +38,7 @@ import (
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/utils" "code.gitea.io/gitea/routers/utils"
shared_user "code.gitea.io/gitea/routers/web/shared/user" 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" asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/automerge"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
@@ -311,6 +312,14 @@ func prepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue)
return compareInfo 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 // prepareViewPullInfo show meta information for a pull request preview page
func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_service.CompareInfo { func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_service.CompareInfo {
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
@@ -456,6 +465,11 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_
return nil 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) commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll)
if err != nil { if err != nil {
ctx.ServerError("GetLatestCommitStatus", err) ctx.ServerError("GetLatestCommitStatus", err)
@@ -465,6 +479,20 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) 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 { if len(commitStatuses) > 0 {
ctx.Data["LatestCommitStatuses"] = commitStatuses ctx.Data["LatestCommitStatuses"] = commitStatuses
ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(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) 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 { for _, c := range pb.StatusCheckContexts {
if c == context { if c == context {
return true return true

View File

@@ -1459,6 +1459,7 @@ func registerWebRoutes(m *web.Router) {
m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile)
m.Post("/run", reqRepoActionsWriter, actions.Run) m.Post("/run", reqRepoActionsWriter, actions.Run)
m.Get("/workflow-dispatch-inputs", reqRepoActionsWriter, actions.WorkflowDispatchInputs) m.Get("/workflow-dispatch-inputs", reqRepoActionsWriter, actions.WorkflowDispatchInputs)
m.Post("/approve-all-checks", reqRepoActionsWriter, actions.ApproveAllChecks)
m.Group("/runs/{run}", func() { m.Group("/runs/{run}", func() {
m.Combo(""). m.Combo("").

View File

@@ -18,6 +18,7 @@ import (
actions_module "code.gitea.io/gitea/modules/actions" actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/commitstatus" "code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook" webhook_module "code.gitea.io/gitea/modules/webhook"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" 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) { func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, commitID string, _ error) {
switch run.Event { switch run.Event {
case webhook_module.HookEventPush: case webhook_module.HookEventPush:

View File

@@ -31,9 +31,8 @@
{{template "repo/pulls/status" (dict {{template "repo/pulls/status" (dict
"CommitStatus" .LatestCommitStatus "CommitStatus" .LatestCommitStatus
"CommitStatuses" .LatestCommitStatuses "CommitStatuses" .LatestCommitStatuses
"MissingRequiredChecks" .MissingRequiredChecks
"ShowHideChecks" true "ShowHideChecks" true
"is_context_required" .is_context_required "StatusCheckData" .StatusCheckData
)}} )}}
</div> </div>
{{end}} {{end}}

View File

@@ -1,11 +1,10 @@
{{/* Template Attributes: {{/* Template Attributes:
* CommitStatus: summary of all commit status state * CommitStatus: summary of all commit status state
* CommitStatuses: all commit status elements * 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 * 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}} {{if .CommitStatus}}
<div class="commit-status-panel"> <div class="commit-status-panel">
<div class="ui top attached header commit-status-header"> <div class="ui top attached header commit-status-header">
@@ -33,25 +32,43 @@
{{end}} {{end}}
</div> </div>
{{if and $statusCheckData $statusCheckData.RequireApprovalRunCount}}
<div class="ui attached segment flex-text-block tw-justify-between" id="approve-status-checks">
<div>
<strong>
{{ctx.Locale.Tr "repo.pulls.status_checks_need_approvals" $statusCheckData.RequireApprovalRunCount}}
</strong>
<p>{{ctx.Locale.Tr "repo.pulls.status_checks_need_approvals_helper"}}</p>
</div>
{{if $statusCheckData.CanApprove}}
<button class="ui basic button link-action" data-url="{{$statusCheckData.ApproveLink}}">
{{ctx.Locale.Tr "repo.pulls.status_checks_approve_all"}}
</button>
{{end}}
</div>
{{end}}
<div class="commit-status-list"> <div class="commit-status-list">
{{range .CommitStatuses}} {{range .CommitStatuses}}
<div class="commit-status-item"> <div class="commit-status-item">
{{template "repo/commit_status" .}} {{template "repo/commit_status" .}}
<div class="status-context gt-ellipsis">{{.Context}} <span class="text light-2">{{.Description}}</span></div> <div class="status-context gt-ellipsis">{{.Context}} <span class="text light-2">{{.Description}}</span></div>
<div class="ui status-details"> <div class="ui status-details">
{{if $.is_context_required}} {{if and $statusCheckData $statusCheckData.IsContextRequired}}
{{if (call $.is_context_required .Context)}}<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div>{{end}} {{if (call $statusCheckData.IsContextRequired .Context)}}<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div>{{end}}
{{end}} {{end}}
<span>{{if .TargetURL}}<a href="{{.TargetURL}}">{{ctx.Locale.Tr "repo.pulls.status_checks_details"}}</a>{{end}}</span> <span>{{if .TargetURL}}<a href="{{.TargetURL}}">{{ctx.Locale.Tr "repo.pulls.status_checks_details"}}</a>{{end}}</span>
</div> </div>
</div> </div>
{{end}} {{end}}
{{range .MissingRequiredChecks}} {{if $statusCheckData}}
<div class="commit-status-item"> {{range $statusCheckData.MissingRequiredChecks}}
{{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} <div class="commit-status-item">
<div class="status-context gt-ellipsis">{{.}}</div> {{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}}
<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div> <div class="status-context gt-ellipsis">{{.}}</div>
</div> <div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div>
</div>
{{end}}
{{end}} {{end}}
</div> </div>
</div> </div>

View File

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