mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-27 05:55:21 +08: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:
		
							parent
							
								
									66ee8f3553
								
							
						
					
					
						commit
						897e48dde3
					
				| @ -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
 | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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) | ||||
| } | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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(""). | ||||
|  | ||||
| @ -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: | ||||
|  | ||||
| @ -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 | ||||
| 		)}} | ||||
| 		</div> | ||||
| 		{{end}} | ||||
|  | ||||
| @ -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}} | ||||
| <div class="commit-status-panel"> | ||||
| 	<div class="ui top attached header commit-status-header"> | ||||
| @ -33,25 +32,43 @@ | ||||
| 		{{end}} | ||||
| 	</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"> | ||||
| 		{{range .CommitStatuses}} | ||||
| 			<div class="commit-status-item"> | ||||
| 				{{template "repo/commit_status" .}} | ||||
| 				<div class="status-context gt-ellipsis">{{.Context}} <span class="text light-2">{{.Description}}</span></div> | ||||
| 				<div class="ui status-details"> | ||||
| 					{{if $.is_context_required}} | ||||
| 						{{if (call $.is_context_required .Context)}}<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div>{{end}} | ||||
| 					{{if and $statusCheckData $statusCheckData.IsContextRequired}} | ||||
| 						{{if (call $statusCheckData.IsContextRequired .Context)}}<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div>{{end}} | ||||
| 					{{end}} | ||||
| 					<span>{{if .TargetURL}}<a href="{{.TargetURL}}">{{ctx.Locale.Tr "repo.pulls.status_checks_details"}}</a>{{end}}</span> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		{{end}} | ||||
| 		{{range .MissingRequiredChecks}} | ||||
| 			<div class="commit-status-item"> | ||||
| 				{{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} | ||||
| 				<div class="status-context gt-ellipsis">{{.}}</div> | ||||
| 				<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div> | ||||
| 			</div> | ||||
| 		{{if $statusCheckData}} | ||||
| 			{{range $statusCheckData.MissingRequiredChecks}} | ||||
| 				<div class="commit-status-item"> | ||||
| 					{{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} | ||||
| 					<div class="status-context gt-ellipsis">{{.}}</div> | ||||
| 					<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div> | ||||
| 				</div> | ||||
| 			{{end}} | ||||
| 		{{end}} | ||||
| 	</div> | ||||
| </div> | ||||
|  | ||||
							
								
								
									
										146
									
								
								tests/integration/actions_approve_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								tests/integration/actions_approve_test.go
									
									
									
									
									
										Normal 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) | ||||
| 	}) | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 Zettat123
						Zettat123