mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 03:18:24 +00: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:
		| @@ -36,7 +36,11 @@ type CommitStatus struct { | |||||||
| 	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 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"` | 	TargetURL string `xorm:"TEXT"` | ||||||
|  |  | ||||||
| 	Description string           `xorm:"TEXT"` | 	Description string           `xorm:"TEXT"` | ||||||
| 	ContextHash string           `xorm:"VARCHAR(64) index"` | 	ContextHash string           `xorm:"VARCHAR(64) index"` | ||||||
| 	Context     string           `xorm:"TEXT"` | 	Context     string           `xorm:"TEXT"` | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -606,21 +606,40 @@ 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) { | ||||||
|  | 		for _, runIndex := range runIndexes { | ||||||
|  | 			run, err := actions_model.GetRunByIndex(ctx, repo.ID, runIndex) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 			runMap[run.ID] = run | ||||||
|  | 			run.Repo = repo | ||||||
| 			run.NeedApproval = false | 			run.NeedApproval = false | ||||||
| 			run.ApprovedBy = doer.ID | 			run.ApprovedBy = doer.ID | ||||||
| 			if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { | 			if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { | ||||||
| 				return err | 				return err | ||||||
| 			} | 			} | ||||||
|  | 			jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 			runJobs[run.ID] = jobs | ||||||
| 			for _, job := range jobs { | 			for _, job := range jobs { | ||||||
| 				job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) | 				job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| @@ -636,6 +655,7 @@ func Approve(ctx *context_module.Context) { | |||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
|  | 		} | ||||||
| 		return nil | 		return nil | ||||||
| 	}) | 	}) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -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) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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(""). | ||||||
|   | |||||||
| @@ -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: | ||||||
|   | |||||||
| @@ -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}} | ||||||
|   | |||||||
| @@ -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,26 +32,44 @@ | |||||||
| 		{{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}} | ||||||
|  | 			{{range $statusCheckData.MissingRequiredChecks}} | ||||||
| 				<div class="commit-status-item"> | 				<div class="commit-status-item"> | ||||||
| 					{{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} | 					{{svg "octicon-dot-fill" 18 "commit-status icon text yellow"}} | ||||||
| 					<div class="status-context gt-ellipsis">{{.}}</div> | 					<div class="status-context gt-ellipsis">{{.}}</div> | ||||||
| 					<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div> | 					<div class="ui label">{{ctx.Locale.Tr "repo.pulls.status_checks_requested"}}</div> | ||||||
| 				</div> | 				</div> | ||||||
| 			{{end}} | 			{{end}} | ||||||
|  | 		{{end}} | ||||||
| 	</div> | 	</div> | ||||||
| </div> | </div> | ||||||
| {{end}} | {{end}} | ||||||
|   | |||||||
							
								
								
									
										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) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user