forkjo/services/actions/commit_status.go
Earl Warren d624a5edd6 fix: Actions PR workflows must update the commit status
When a workflow has

on:
  pull_request:
    types:
      - labeled
      - unlabeled

The outcome of the workflow (success or failure) must be associated
with the head sha commit status. Otherwise it cannot be used as a
requirement for merging the pull request (branch protections).

(cherry picked from commit 66c85b7d8b)
2024-11-04 14:10:27 +00:00

167 lines
5 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
"path"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/log"
api "code.gitea.io/gitea/modules/structs"
webhook_module "code.gitea.io/gitea/modules/webhook"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
"github.com/nektos/act/pkg/jobparser"
)
// CreateCommitStatus creates a commit status for the given job.
// It won't return an error failed, but will log it, because it's not critical.
func CreateCommitStatus(ctx context.Context, jobs ...*actions_model.ActionRunJob) {
for _, job := range jobs {
if err := createCommitStatus(ctx, job); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
}
}
func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) error {
if err := job.LoadAttributes(ctx); err != nil {
return fmt.Errorf("load run: %w", err)
}
run := job.Run
var (
sha string
event string
)
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 fmt.Errorf("head commit is missing in event payload")
}
sha = payload.HeadCommit.ID
case webhook_module.HookEventPullRequest, webhook_module.HookEventPullRequestSync, webhook_module.HookEventPullRequestLabel, webhook_module.HookEventPullRequestAssign, 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 fmt.Errorf("pull request is missing in event payload")
} else if payload.PullRequest.Head == nil {
return fmt.Errorf("head of pull request is missing in event payload")
}
sha = payload.PullRequest.Head.Sha
case webhook_module.HookEventRelease:
event = string(run.Event)
sha = run.CommitSHA
default:
return nil
}
repo := run.Repo
// 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)
state := toCommitStatus(job.Status)
if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, 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)
}
description := ""
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"
}
index, err := getIndexOfJob(ctx, job)
if err != nil {
return fmt.Errorf("getIndexOfJob: %w", err)
}
creator := user_model.NewActionsUser()
if err := commitstatus_service.CreateCommitStatus(ctx, repo, creator,
sha,
&git_model.CommitStatus{
SHA: sha,
TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), index),
Description: description,
Context: ctxname,
CreatorID: creator.ID,
State: state,
}); err != nil {
return fmt.Errorf("NewCommitStatus: %w", err)
}
return nil
}
func toCommitStatus(status actions_model.Status) api.CommitStatusState {
switch status {
case actions_model.StatusSuccess, actions_model.StatusSkipped:
return api.CommitStatusSuccess
case actions_model.StatusFailure, actions_model.StatusCancelled:
return api.CommitStatusFailure
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning:
return api.CommitStatusPending
default:
return api.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
}