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