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