mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-31 19:55:02 +00:00
Runs triggered by pull request reviews took their ref and commit from the review's `commit_id` instead of the pull request. They now use the pull request head like other pull request events, and their commit status is set on the commit that ran.
361 lines
15 KiB
Go
361 lines
15 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
|
|
actions_model "gitea.dev/models/actions"
|
|
"gitea.dev/models/db"
|
|
git_model "gitea.dev/models/git"
|
|
repo_model "gitea.dev/models/repo"
|
|
user_model "gitea.dev/models/user"
|
|
actions_module "gitea.dev/modules/actions"
|
|
"gitea.dev/modules/actions/jobparser"
|
|
"gitea.dev/modules/commitstatus"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/glob"
|
|
"gitea.dev/modules/log"
|
|
api "gitea.dev/modules/structs"
|
|
webhook_module "gitea.dev/modules/webhook"
|
|
pull_service "gitea.dev/services/pull"
|
|
commitstatus_service "gitea.dev/services/repository/commitstatus"
|
|
)
|
|
|
|
// CreateCommitStatusForRunJobs creates a commit status for the given job if it has a supported event and related commit.
|
|
// It won't return an error failed, but will log it, because it's not critical.
|
|
func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.ActionRun, jobs ...*actions_model.ActionRunJob) {
|
|
// don't create commit status for cron job
|
|
if run.ScheduleID != 0 {
|
|
return
|
|
}
|
|
|
|
event, commitID, err := getCommitStatusEventNameAndCommitID(run)
|
|
if err != nil {
|
|
log.Error("getCommitStatusEventNameAndCommitID: %v", err)
|
|
}
|
|
if event == "" || commitID == "" {
|
|
return // unsupported event, or no commit id, or error occurs, do nothing
|
|
}
|
|
|
|
if err = run.LoadAttributes(ctx); err != nil {
|
|
log.Error("run.LoadAttributes: %v", err)
|
|
return
|
|
}
|
|
|
|
// Compute the scoped source-repo prefix once per run; it is identical for every job.
|
|
var scopedPrefix string
|
|
if run.IsScopedRun {
|
|
scopedPrefix = actions_model.ScopedStatusContextPrefix(ctx, run.WorkflowRepoID)
|
|
}
|
|
|
|
var pendingFilter *pendingJobFilter
|
|
if slices.ContainsFunc(jobs, func(job *actions_model.ActionRunJob) bool { return job.Status.IsPending() && !job.IsMatrixDeferred }) {
|
|
pendingFilter = newPendingJobFilter(ctx, run)
|
|
}
|
|
for _, job := range jobs {
|
|
// A deferred-matrix placeholder's name changes when it expands, so a status created while it
|
|
// waits would be orphaned. The emitter reloads the jobs after expanding and creates them
|
|
// then. A placeholder that reached a final status (skipped by its `if:`, cancelled with the
|
|
// run, or failed to expand) never expands, so it keeps its own name and still deserves a status.
|
|
if job.IsMatrixDeferred && !job.Status.IsDone() {
|
|
continue
|
|
}
|
|
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job, pendingFilter); err != nil {
|
|
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func GetRunsFromCommitStatuses(ctx context.Context, repoID int64, statuses []*git_model.CommitStatus) (runs []*actions_model.ActionRun, _ error) {
|
|
runIDs := container.Set[int64]{}
|
|
for _, status := range statuses {
|
|
runID, _, ok := status.ParseGiteaActionsTargetURL(ctx)
|
|
if ok {
|
|
runIDs.Add(runID)
|
|
}
|
|
}
|
|
return actions_model.GetRunsByRepoAndID(ctx, repoID, runIDs.Values())
|
|
}
|
|
|
|
func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, commitID string, _ error) {
|
|
switch run.Event {
|
|
case webhook_module.HookEventPush:
|
|
event = "push"
|
|
payload, err := run.GetPushEventPayload()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("GetPushEventPayload: %w", err)
|
|
}
|
|
if payload.HeadCommit == nil {
|
|
return "", "", errors.New("head commit is missing in event payload")
|
|
}
|
|
commitID = payload.HeadCommit.ID
|
|
case // pull_request
|
|
webhook_module.HookEventPullRequest,
|
|
webhook_module.HookEventPullRequestSync,
|
|
webhook_module.HookEventPullRequestAssign,
|
|
webhook_module.HookEventPullRequestLabel,
|
|
webhook_module.HookEventPullRequestReviewRequest,
|
|
webhook_module.HookEventPullRequestMilestone:
|
|
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
|
|
event = "pull_request_target"
|
|
} else {
|
|
event = "pull_request"
|
|
}
|
|
payload, err := run.GetPullRequestEventPayload()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("GetPullRequestEventPayload: %w", err)
|
|
}
|
|
if payload.PullRequest == nil {
|
|
return "", "", errors.New("pull request is missing in event payload")
|
|
} else if payload.PullRequest.Head == nil {
|
|
return "", "", errors.New("head of pull request is missing in event payload")
|
|
}
|
|
commitID = payload.PullRequest.Head.Sha
|
|
case webhook_module.HookEventPullRequestReviewApproved,
|
|
webhook_module.HookEventPullRequestReviewRejected,
|
|
webhook_module.HookEventPullRequestReviewComment:
|
|
event = run.TriggerEvent
|
|
commitID = run.CommitSHA
|
|
case webhook_module.HookEventRelease:
|
|
event = string(run.Event)
|
|
commitID = run.CommitSHA
|
|
default: // do nothing, return empty
|
|
}
|
|
return event, commitID, nil
|
|
}
|
|
|
|
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID, scopedPrefix string, run *actions_model.ActionRun, job *actions_model.ActionRunJob, pendingFilter *pendingJobFilter) error {
|
|
displayName := actions_module.WorkflowDisplayName(run.WorkflowID, job.WorkflowPayload)
|
|
ctxName := actions_module.WorkflowStatusContextName(displayName, job.Name, event) // git_model.NewCommitStatus also trims spaces
|
|
if run.IsScopedRun {
|
|
// A scoped run is prefixed with its source repo (set off by a colon) so it stays distinct from a same-named repo-level workflow.
|
|
// scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks.
|
|
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
|
|
}
|
|
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
|
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job), pendingFilter.onlyReplace(job, ctxName))
|
|
}
|
|
|
|
// pendingJobFilter keeps optional Pending jobs from posting new statuses
|
|
type pendingJobFilter struct {
|
|
requiredGlobs []glob.Glob
|
|
}
|
|
|
|
func newPendingJobFilter(ctx context.Context, run *actions_model.ActionRun) *pendingJobFilter {
|
|
rules, err := git_model.FindRepoProtectedBranchRules(ctx, run.RepoID)
|
|
if err != nil {
|
|
log.Error("FindRepoProtectedBranchRules: %v", err)
|
|
return nil
|
|
}
|
|
if slices.ContainsFunc(rules, func(rule *git_model.ProtectedBranch) bool {
|
|
return rule.EnableStatusCheck && len(rule.StatusCheckContexts) == 0
|
|
}) {
|
|
return nil
|
|
}
|
|
requiredGlobs, err := requiredStatusContextGlobs(ctx, run.Repo, rules)
|
|
if err != nil {
|
|
log.Error("requiredStatusContextGlobs: %v", err)
|
|
return nil
|
|
}
|
|
return &pendingJobFilter{requiredGlobs: requiredGlobs}
|
|
}
|
|
|
|
func (f *pendingJobFilter) onlyReplace(job *actions_model.ActionRunJob, ctxName string) bool {
|
|
return f != nil && job.Status.IsPending() && !slices.ContainsFunc(f.requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) })
|
|
}
|
|
|
|
// getAllRequiredStatusContextGlobs returns the compiled globs of every status-check context required in the repo:
|
|
// its protected branch rules' contexts and its required scoped workflows' patterns (see EffectiveRequiredContexts).
|
|
func getAllRequiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository) ([]glob.Glob, error) {
|
|
rules, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("FindRepoProtectedBranchRules: %w", err)
|
|
}
|
|
return requiredStatusContextGlobs(ctx, repo, rules)
|
|
}
|
|
|
|
func requiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository, rules git_model.ProtectedBranchRules) ([]glob.Glob, error) {
|
|
required, err := pull_service.EffectiveRequiredContexts(ctx, repo, rules...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("EffectiveRequiredContexts: %w", err)
|
|
}
|
|
globs := make([]glob.Glob, 0, len(required))
|
|
for _, c := range required {
|
|
if gp, err := glob.Compile(c); err != nil {
|
|
log.Error("glob.Compile %q: %v", c, err)
|
|
} else {
|
|
globs = append(globs, gp)
|
|
}
|
|
}
|
|
return globs, nil
|
|
}
|
|
|
|
// CreateSkippedCommitStatusForFilteredWorkflow posts a skipped commit status for each job of a
|
|
// workflow that matched the triggering event but was excluded by a branch/paths filter.
|
|
// This lets a required status check tied to that context be satisfied without the workflow running.
|
|
// Only contexts matching requiredGlobs are posted; a non-required context gets no skipped status.
|
|
// No ActionRun is created, so the status has no target URL (there is no run/job to link to).
|
|
// A non-empty scopedPrefix prefixes each context with its source repo, matching scoped runs.
|
|
//
|
|
// TODO: requiredGlobs over-approximates by including every protected branch rule's required contexts.
|
|
// If possible, the set should be narrowed to the required contexts of a specific branch protection rule (and the contexts from required scoped workflows).
|
|
// Currently, a `push` event must keep the repo-wide union since its future pull request base branch is unknown.
|
|
func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *repo_model.Repository, event webhook_module.HookEventType, triggerEvent, workflowID string, content []byte, payload api.Payloader, scopedPrefix string, requiredGlobs []glob.Glob) error {
|
|
if len(requiredGlobs) == 0 {
|
|
return nil // nothing is required, so no skipped status is needed
|
|
}
|
|
|
|
// Derive the status event name and target commit from the payload.
|
|
// TODO: this mirrors getCommitStatusEventNameAndCommitID, which derives the same from a persisted run. Should merge the logic if possible.
|
|
var statusEvent, commitID string
|
|
switch event {
|
|
case webhook_module.HookEventPush:
|
|
if p, ok := payload.(*api.PushPayload); ok && p.HeadCommit != nil {
|
|
statusEvent, commitID = "push", p.HeadCommit.ID
|
|
}
|
|
case webhook_module.HookEventPullRequest,
|
|
webhook_module.HookEventPullRequestSync,
|
|
webhook_module.HookEventPullRequestAssign,
|
|
webhook_module.HookEventPullRequestLabel,
|
|
webhook_module.HookEventPullRequestReviewRequest,
|
|
webhook_module.HookEventPullRequestMilestone:
|
|
if p, ok := payload.(*api.PullRequestPayload); ok && p.PullRequest != nil && p.PullRequest.Head != nil {
|
|
statusEvent, commitID = "pull_request", p.PullRequest.Head.Sha
|
|
if triggerEvent == actions_module.GithubEventPullRequestTarget {
|
|
statusEvent = "pull_request_target"
|
|
}
|
|
}
|
|
}
|
|
if statusEvent == "" || commitID == "" {
|
|
return nil // unsupported event or missing commit id, nothing to post
|
|
}
|
|
|
|
workflows, err := jobparser.Parse(content)
|
|
if err != nil {
|
|
return fmt.Errorf("jobparser.Parse: %w", err)
|
|
}
|
|
|
|
displayName := actions_module.WorkflowDisplayName(workflowID, content)
|
|
for _, sw := range workflows {
|
|
_, job := sw.Job()
|
|
if job == nil {
|
|
continue
|
|
}
|
|
jobName := job.DisplayName()
|
|
ctxName := actions_module.WorkflowStatusContextName(displayName, jobName, statusEvent)
|
|
if scopedPrefix != "" {
|
|
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, jobName, statusEvent)
|
|
}
|
|
// Only a required context needs the skipped status.
|
|
if !slices.ContainsFunc(requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) }) {
|
|
continue
|
|
}
|
|
// "Skipped" mirrors toCommitStatusDescription for StatusSkipped.
|
|
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped", false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createWorkflowCommitStatus posts the commit status for one workflow-job context.
|
|
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string, onlyReplace bool) error {
|
|
// Mix the workflow file path into the hash so two workflow files that
|
|
// share the same `name:` and job name produce distinct commit statuses
|
|
// even though they render identically — matching GitHub's behavior
|
|
// (issue #35699).
|
|
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
|
|
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
|
|
// the legacy hash is still the "latest" for this SHA, reuse that hash so
|
|
// the new row supersedes it; otherwise the old pending status would stay
|
|
// stuck forever (it lives in its own dedupe group). Only relevant for
|
|
// in-flight workflows at upgrade time.
|
|
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
|
|
|
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
|
|
if err != nil {
|
|
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
|
}
|
|
for _, v := range statuses {
|
|
// Only adopt the legacy (Context-only) hash from a row the Actions user itself created before the
|
|
// #35699 fix, i.e. a pre-upgrade in-flight run. Adopting it from an external integration or the API
|
|
// would collapse two same-named workflows back into a single check.
|
|
if v.ContextHash == legacyHash && v.Context == ctxName && v.CreatorID == user_model.ActionsUserID {
|
|
ctxHash = legacyHash
|
|
break
|
|
}
|
|
}
|
|
hasPrevious := false
|
|
for _, v := range statuses {
|
|
if v.ContextHash == ctxHash {
|
|
if v.State == state && v.TargetURL == targetURL && v.Description == description {
|
|
return nil
|
|
}
|
|
hasPrevious = true
|
|
break
|
|
}
|
|
}
|
|
if onlyReplace && !hasPrevious {
|
|
return nil
|
|
}
|
|
|
|
creator := user_model.NewActionsUser()
|
|
status := git_model.CommitStatus{
|
|
SHA: commitID,
|
|
TargetURL: targetURL,
|
|
Description: description,
|
|
Context: ctxName,
|
|
ContextHash: ctxHash,
|
|
State: state,
|
|
CreatorID: creator.ID,
|
|
}
|
|
|
|
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status)
|
|
}
|
|
|
|
func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
|
|
switch job.Status {
|
|
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
|
|
case actions_model.StatusSuccess:
|
|
return fmt.Sprintf("Successful in %s", job.Duration())
|
|
case actions_model.StatusFailure:
|
|
return fmt.Sprintf("Failing after %s", job.Duration())
|
|
case actions_model.StatusCancelled:
|
|
return fmt.Sprintf("Canceled after %s", job.Duration())
|
|
case actions_model.StatusSkipped:
|
|
return "Skipped"
|
|
case actions_model.StatusRunning:
|
|
return "In progress"
|
|
case actions_model.StatusCancelling:
|
|
return "Canceling"
|
|
case actions_model.StatusWaiting:
|
|
return "Waiting to run"
|
|
case actions_model.StatusBlocked:
|
|
return "Blocked by required conditions"
|
|
case actions_model.StatusPending:
|
|
return "Waiting for needed jobs"
|
|
default:
|
|
return fmt.Sprintf("Unknown status: %d", job.Status)
|
|
}
|
|
}
|
|
|
|
func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState {
|
|
switch status {
|
|
case actions_model.StatusSuccess:
|
|
return commitstatus.CommitStatusSuccess
|
|
case actions_model.StatusFailure, actions_model.StatusCancelled:
|
|
return commitstatus.CommitStatusFailure
|
|
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusPending, actions_model.StatusRunning, actions_model.StatusCancelling:
|
|
return commitstatus.CommitStatusPending
|
|
case actions_model.StatusSkipped:
|
|
return commitstatus.CommitStatusSkipped
|
|
default:
|
|
return commitstatus.CommitStatusError
|
|
}
|
|
}
|