0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-21 06:54:43 +01:00
gitea/services/actions/commit_status.go
Jörg Thalheim 3830d488d5
actions: report commit status for pull_request_review events (#36589)
Workflows triggered by pull_request_review events (approved, rejected,
comment) complete successfully but never create a commit status on the
PR. This makes them invisible in the merge checks UI, breaking any CI
gate that re-evaluates on review submission.

The commit status handler's switch statement was missing the three
review event types, so they fell through to the default case which
returned empty strings. Additionally, review events use
PullRequestPayload but IsPullRequest() returns false for them (Event()
returns "pull_request_approved" etc. instead of "pull_request"), so
GetPullRequestEventPayload() refuses to parse their payload.

Signed-off-by: Jörg Thalheim <joerg@thalheim.io>
Co-authored-by: silverwind <me@silverwind.io>
2026-02-20 16:12:22 +00:00

231 lines
7.5 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"path"
"strconv"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
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"
"github.com/nektos/act/pkg/jobparser"
)
// 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("GetCommitStatusEventNameAndSHA: %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
}
for _, job := range jobs {
if err = createCommitStatus(ctx, run.Repo, event, commitID, run, job); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
}
}
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:
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 // pull_request_review events share the same PullRequestPayload as pull_request
webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected,
webhook_module.HookEventPullRequestReviewComment:
event = run.TriggerEvent
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.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 string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
// TODO: store workflow name as a field in ActionRun to avoid parsing
runName := path.Base(run.WorkflowID)
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
runName = wfs[0].Name
}
ctxName := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)
ctxName = strings.TrimSpace(ctxName) // git_model.NewCommitStatus also trims spaces
state := toCommitStatus(job.Status)
if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll); err == nil {
for _, v := range statuses {
if v.Context == ctxName {
if v.State == state {
// no need to update
return nil
}
break
}
}
} else {
return fmt.Errorf("GetLatestCommitStatus: %w", err)
}
var description 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:
description = fmt.Sprintf("Successful in %s", job.Duration())
case actions_model.StatusFailure:
description = fmt.Sprintf("Failing after %s", job.Duration())
case actions_model.StatusCancelled:
description = "Has been cancelled"
case actions_model.StatusSkipped:
description = "Has been skipped"
case actions_model.StatusRunning:
description = "Has started running"
case actions_model.StatusWaiting:
description = "Waiting to run"
case actions_model.StatusBlocked:
description = "Blocked by required conditions"
default:
description = "Unknown status: " + strconv.Itoa(int(job.Status))
}
index, err := getIndexOfJob(ctx, job)
if err != nil {
return fmt.Errorf("getIndexOfJob: %w", err)
}
creator := user_model.NewActionsUser()
status := git_model.CommitStatus{
SHA: commitID,
TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), index),
Description: description,
Context: ctxName,
CreatorID: creator.ID,
State: state,
}
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &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.StatusRunning:
return commitstatus.CommitStatusPending
case actions_model.StatusSkipped:
return commitstatus.CommitStatusSkipped
default:
return commitstatus.CommitStatusError
}
}
func getIndexOfJob(ctx context.Context, job *actions_model.ActionRunJob) (int, error) {
// TODO: store job index as a field in ActionRunJob to avoid this
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
return 0, err
}
for i, v := range jobs {
if v.ID == job.ID {
return i, nil
}
}
return 0, nil
}