code.gitea.io/gitea@v1.22.3/services/actions/notifier_helper.go (about)

     1  // Copyright 2022 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package actions
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"fmt"
    10  	"slices"
    11  	"strings"
    12  
    13  	actions_model "code.gitea.io/gitea/models/actions"
    14  	"code.gitea.io/gitea/models/db"
    15  	issues_model "code.gitea.io/gitea/models/issues"
    16  	packages_model "code.gitea.io/gitea/models/packages"
    17  	access_model "code.gitea.io/gitea/models/perm/access"
    18  	repo_model "code.gitea.io/gitea/models/repo"
    19  	unit_model "code.gitea.io/gitea/models/unit"
    20  	user_model "code.gitea.io/gitea/models/user"
    21  	actions_module "code.gitea.io/gitea/modules/actions"
    22  	"code.gitea.io/gitea/modules/git"
    23  	"code.gitea.io/gitea/modules/gitrepo"
    24  	"code.gitea.io/gitea/modules/json"
    25  	"code.gitea.io/gitea/modules/log"
    26  	"code.gitea.io/gitea/modules/setting"
    27  	api "code.gitea.io/gitea/modules/structs"
    28  	webhook_module "code.gitea.io/gitea/modules/webhook"
    29  	"code.gitea.io/gitea/services/convert"
    30  
    31  	"github.com/nektos/act/pkg/jobparser"
    32  	"github.com/nektos/act/pkg/model"
    33  )
    34  
    35  var methodCtxKey struct{}
    36  
    37  // withMethod sets the notification method that this context currently executes.
    38  // Used for debugging/ troubleshooting purposes.
    39  func withMethod(ctx context.Context, method string) context.Context {
    40  	// don't overwrite
    41  	if v := ctx.Value(methodCtxKey); v != nil {
    42  		if _, ok := v.(string); ok {
    43  			return ctx
    44  		}
    45  	}
    46  	return context.WithValue(ctx, methodCtxKey, method)
    47  }
    48  
    49  // getMethod gets the notification method that this context currently executes.
    50  // Default: "notify"
    51  // Used for debugging/ troubleshooting purposes.
    52  func getMethod(ctx context.Context) string {
    53  	if v := ctx.Value(methodCtxKey); v != nil {
    54  		if s, ok := v.(string); ok {
    55  			return s
    56  		}
    57  	}
    58  	return "notify"
    59  }
    60  
    61  type notifyInput struct {
    62  	// required
    63  	Repo  *repo_model.Repository
    64  	Doer  *user_model.User
    65  	Event webhook_module.HookEventType
    66  
    67  	// optional
    68  	Ref         git.RefName
    69  	Payload     api.Payloader
    70  	PullRequest *issues_model.PullRequest
    71  }
    72  
    73  func newNotifyInput(repo *repo_model.Repository, doer *user_model.User, event webhook_module.HookEventType) *notifyInput {
    74  	return &notifyInput{
    75  		Repo:  repo,
    76  		Doer:  doer,
    77  		Event: event,
    78  	}
    79  }
    80  
    81  func newNotifyInputForSchedules(repo *repo_model.Repository) *notifyInput {
    82  	// the doer here will be ignored as we force using action user when handling schedules
    83  	return newNotifyInput(repo, user_model.NewActionsUser(), webhook_module.HookEventSchedule)
    84  }
    85  
    86  func (input *notifyInput) WithDoer(doer *user_model.User) *notifyInput {
    87  	input.Doer = doer
    88  	return input
    89  }
    90  
    91  func (input *notifyInput) WithRef(ref string) *notifyInput {
    92  	input.Ref = git.RefName(ref)
    93  	return input
    94  }
    95  
    96  func (input *notifyInput) WithPayload(payload api.Payloader) *notifyInput {
    97  	input.Payload = payload
    98  	return input
    99  }
   100  
   101  func (input *notifyInput) WithPullRequest(pr *issues_model.PullRequest) *notifyInput {
   102  	input.PullRequest = pr
   103  	if input.Ref == "" {
   104  		input.Ref = git.RefName(pr.GetGitRefName())
   105  	}
   106  	return input
   107  }
   108  
   109  func (input *notifyInput) Notify(ctx context.Context) {
   110  	log.Trace("execute %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name)
   111  
   112  	if err := notify(ctx, input); err != nil {
   113  		log.Error("an error occurred while executing the %s actions method: %v", getMethod(ctx), err)
   114  	}
   115  }
   116  
   117  func notify(ctx context.Context, input *notifyInput) error {
   118  	if input.Doer.IsActions() {
   119  		// avoiding triggering cyclically, for example:
   120  		// a comment of an issue will trigger the runner to add a new comment as reply,
   121  		// and the new comment will trigger the runner again.
   122  		log.Debug("ignore executing %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name)
   123  		return nil
   124  	}
   125  	if input.Repo.IsEmpty || input.Repo.IsArchived {
   126  		return nil
   127  	}
   128  	if unit_model.TypeActions.UnitGlobalDisabled() {
   129  		if err := actions_model.CleanRepoScheduleTasks(ctx, input.Repo); err != nil {
   130  			log.Error("CleanRepoScheduleTasks: %v", err)
   131  		}
   132  		return nil
   133  	}
   134  	if err := input.Repo.LoadUnits(ctx); err != nil {
   135  		return fmt.Errorf("repo.LoadUnits: %w", err)
   136  	} else if !input.Repo.UnitEnabled(ctx, unit_model.TypeActions) {
   137  		return nil
   138  	}
   139  
   140  	gitRepo, err := gitrepo.OpenRepository(context.Background(), input.Repo)
   141  	if err != nil {
   142  		return fmt.Errorf("git.OpenRepository: %w", err)
   143  	}
   144  	defer gitRepo.Close()
   145  
   146  	ref := input.Ref
   147  	if ref.BranchName() != input.Repo.DefaultBranch && actions_module.IsDefaultBranchWorkflow(input.Event) {
   148  		if ref != "" {
   149  			log.Warn("Event %q should only trigger workflows on the default branch, but its ref is %q. Will fall back to the default branch",
   150  				input.Event, ref)
   151  		}
   152  		ref = git.RefNameFromBranch(input.Repo.DefaultBranch)
   153  	}
   154  	if ref == "" {
   155  		log.Warn("Ref of event %q is empty, will fall back to the default branch", input.Event)
   156  		ref = git.RefNameFromBranch(input.Repo.DefaultBranch)
   157  	}
   158  
   159  	commitID, err := gitRepo.GetRefCommitID(ref.String())
   160  	if err != nil {
   161  		return fmt.Errorf("gitRepo.GetRefCommitID: %w", err)
   162  	}
   163  
   164  	// Get the commit object for the ref
   165  	commit, err := gitRepo.GetCommit(commitID)
   166  	if err != nil {
   167  		return fmt.Errorf("gitRepo.GetCommit: %w", err)
   168  	}
   169  
   170  	if skipWorkflows(input, commit) {
   171  		return nil
   172  	}
   173  
   174  	var detectedWorkflows []*actions_module.DetectedWorkflow
   175  	actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
   176  	shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
   177  	workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
   178  		input.Event,
   179  		input.Payload,
   180  		shouldDetectSchedules,
   181  	)
   182  	if err != nil {
   183  		return fmt.Errorf("DetectWorkflows: %w", err)
   184  	}
   185  
   186  	log.Trace("repo %s with commit %s event %s find %d workflows and %d schedules",
   187  		input.Repo.RepoPath(),
   188  		commit.ID,
   189  		input.Event,
   190  		len(workflows),
   191  		len(schedules),
   192  	)
   193  
   194  	for _, wf := range workflows {
   195  		if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
   196  			log.Trace("repo %s has disable workflows %s", input.Repo.RepoPath(), wf.EntryName)
   197  			continue
   198  		}
   199  
   200  		if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
   201  			detectedWorkflows = append(detectedWorkflows, wf)
   202  		}
   203  	}
   204  
   205  	if input.PullRequest != nil {
   206  		// detect pull_request_target workflows
   207  		baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
   208  		baseCommit, err := gitRepo.GetCommit(baseRef)
   209  		if err != nil {
   210  			return fmt.Errorf("gitRepo.GetCommit: %w", err)
   211  		}
   212  		baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
   213  		if err != nil {
   214  			return fmt.Errorf("DetectWorkflows: %w", err)
   215  		}
   216  		if len(baseWorkflows) == 0 {
   217  			log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RepoPath(), baseCommit.ID)
   218  		} else {
   219  			for _, wf := range baseWorkflows {
   220  				if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
   221  					detectedWorkflows = append(detectedWorkflows, wf)
   222  				}
   223  			}
   224  		}
   225  	}
   226  
   227  	if shouldDetectSchedules {
   228  		if err := handleSchedules(ctx, schedules, commit, input, ref.String()); err != nil {
   229  			return err
   230  		}
   231  	}
   232  
   233  	return handleWorkflows(ctx, detectedWorkflows, commit, input, ref.String())
   234  }
   235  
   236  func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
   237  	// skip workflow runs with a configured skip-ci string in commit message or pr title if the event is push or pull_request(_sync)
   238  	// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
   239  	skipWorkflowEvents := []webhook_module.HookEventType{
   240  		webhook_module.HookEventPush,
   241  		webhook_module.HookEventPullRequest,
   242  		webhook_module.HookEventPullRequestSync,
   243  	}
   244  	if slices.Contains(skipWorkflowEvents, input.Event) {
   245  		for _, s := range setting.Actions.SkipWorkflowStrings {
   246  			if input.PullRequest != nil && strings.Contains(input.PullRequest.Issue.Title, s) {
   247  				log.Debug("repo %s: skipped run for pr %v because of %s string", input.Repo.RepoPath(), input.PullRequest.Issue.ID, s)
   248  				return true
   249  			}
   250  			if strings.Contains(commit.CommitMessage, s) {
   251  				log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RepoPath(), commit.ID, s)
   252  				return true
   253  			}
   254  		}
   255  	}
   256  	return false
   257  }
   258  
   259  func handleWorkflows(
   260  	ctx context.Context,
   261  	detectedWorkflows []*actions_module.DetectedWorkflow,
   262  	commit *git.Commit,
   263  	input *notifyInput,
   264  	ref string,
   265  ) error {
   266  	if len(detectedWorkflows) == 0 {
   267  		log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RepoPath(), commit.ID)
   268  		return nil
   269  	}
   270  
   271  	p, err := json.Marshal(input.Payload)
   272  	if err != nil {
   273  		return fmt.Errorf("json.Marshal: %w", err)
   274  	}
   275  
   276  	isForkPullRequest := false
   277  	if pr := input.PullRequest; pr != nil {
   278  		switch pr.Flow {
   279  		case issues_model.PullRequestFlowGithub:
   280  			isForkPullRequest = pr.IsFromFork()
   281  		case issues_model.PullRequestFlowAGit:
   282  			// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
   283  			// So we can treat it as a fork pull request because it may be from an untrusted user
   284  			isForkPullRequest = true
   285  		default:
   286  			// unknown flow, assume it's a fork pull request to be safe
   287  			isForkPullRequest = true
   288  		}
   289  	}
   290  
   291  	for _, dwf := range detectedWorkflows {
   292  		run := &actions_model.ActionRun{
   293  			Title:             strings.SplitN(commit.CommitMessage, "\n", 2)[0],
   294  			RepoID:            input.Repo.ID,
   295  			OwnerID:           input.Repo.OwnerID,
   296  			WorkflowID:        dwf.EntryName,
   297  			TriggerUserID:     input.Doer.ID,
   298  			Ref:               ref,
   299  			CommitSHA:         commit.ID.String(),
   300  			IsForkPullRequest: isForkPullRequest,
   301  			Event:             input.Event,
   302  			EventPayload:      string(p),
   303  			TriggerEvent:      dwf.TriggerEvent.Name,
   304  			Status:            actions_model.StatusWaiting,
   305  		}
   306  
   307  		need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
   308  		if err != nil {
   309  			log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err)
   310  			continue
   311  		}
   312  
   313  		run.NeedApproval = need
   314  
   315  		if err := run.LoadAttributes(ctx); err != nil {
   316  			log.Error("LoadAttributes: %v", err)
   317  			continue
   318  		}
   319  
   320  		vars, err := actions_model.GetVariablesOfRun(ctx, run)
   321  		if err != nil {
   322  			log.Error("GetVariablesOfRun: %v", err)
   323  			continue
   324  		}
   325  
   326  		jobs, err := jobparser.Parse(dwf.Content, jobparser.WithVars(vars))
   327  		if err != nil {
   328  			log.Error("jobparser.Parse: %v", err)
   329  			continue
   330  		}
   331  
   332  		// cancel running jobs if the event is push or pull_request_sync
   333  		if run.Event == webhook_module.HookEventPush ||
   334  			run.Event == webhook_module.HookEventPullRequestSync {
   335  			if err := actions_model.CancelPreviousJobs(
   336  				ctx,
   337  				run.RepoID,
   338  				run.Ref,
   339  				run.WorkflowID,
   340  				run.Event,
   341  			); err != nil {
   342  				log.Error("CancelPreviousJobs: %v", err)
   343  			}
   344  		}
   345  
   346  		if err := actions_model.InsertRun(ctx, run, jobs); err != nil {
   347  			log.Error("InsertRun: %v", err)
   348  			continue
   349  		}
   350  
   351  		alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
   352  		if err != nil {
   353  			log.Error("FindRunJobs: %v", err)
   354  			continue
   355  		}
   356  		CreateCommitStatus(ctx, alljobs...)
   357  	}
   358  	return nil
   359  }
   360  
   361  func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
   362  	return newNotifyInput(issue.Repo, issue.Poster, event)
   363  }
   364  
   365  func notifyRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release, action api.HookReleaseAction) {
   366  	if err := rel.LoadAttributes(ctx); err != nil {
   367  		log.Error("LoadAttributes: %v", err)
   368  		return
   369  	}
   370  
   371  	permission, _ := access_model.GetUserRepoPermission(ctx, rel.Repo, doer)
   372  
   373  	newNotifyInput(rel.Repo, doer, webhook_module.HookEventRelease).
   374  		WithRef(git.RefNameFromTag(rel.TagName).String()).
   375  		WithPayload(&api.ReleasePayload{
   376  			Action:     action,
   377  			Release:    convert.ToAPIRelease(ctx, rel.Repo, rel),
   378  			Repository: convert.ToRepo(ctx, rel.Repo, permission),
   379  			Sender:     convert.ToUser(ctx, doer, nil),
   380  		}).
   381  		Notify(ctx)
   382  }
   383  
   384  func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_model.PackageDescriptor, action api.HookPackageAction) {
   385  	if pd.Repository == nil {
   386  		// When a package is uploaded to an organization, it could trigger an event to notify.
   387  		// So the repository could be nil, however, actions can't support that yet.
   388  		// See https://github.com/go-gitea/gitea/pull/17940
   389  		return
   390  	}
   391  
   392  	apiPackage, err := convert.ToPackage(ctx, pd, sender)
   393  	if err != nil {
   394  		log.Error("Error converting package: %v", err)
   395  		return
   396  	}
   397  
   398  	newNotifyInput(pd.Repository, sender, webhook_module.HookEventPackage).
   399  		WithPayload(&api.PackagePayload{
   400  			Action:  action,
   401  			Package: apiPackage,
   402  			Sender:  convert.ToUser(ctx, sender, nil),
   403  		}).
   404  		Notify(ctx)
   405  }
   406  
   407  func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
   408  	// 1. don't need approval if it's not a fork PR
   409  	// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
   410  	// 		see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
   411  	if !run.IsForkPullRequest || run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
   412  		return false, nil
   413  	}
   414  
   415  	// always need approval if the user is restricted
   416  	if user.IsRestricted {
   417  		log.Trace("need approval because user %d is restricted", user.ID)
   418  		return true, nil
   419  	}
   420  
   421  	// don't need approval if the user can write
   422  	if perm, err := access_model.GetUserRepoPermission(ctx, repo, user); err != nil {
   423  		return false, fmt.Errorf("GetUserRepoPermission: %w", err)
   424  	} else if perm.CanWrite(unit_model.TypeActions) {
   425  		log.Trace("do not need approval because user %d can write", user.ID)
   426  		return false, nil
   427  	}
   428  
   429  	// don't need approval if the user has been approved before
   430  	if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
   431  		RepoID:        repo.ID,
   432  		TriggerUserID: user.ID,
   433  		Approved:      true,
   434  	}); err != nil {
   435  		return false, fmt.Errorf("CountRuns: %w", err)
   436  	} else if count > 0 {
   437  		log.Trace("do not need approval because user %d has been approved before", user.ID)
   438  		return false, nil
   439  	}
   440  
   441  	// otherwise, need approval
   442  	log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
   443  	return true, nil
   444  }
   445  
   446  func handleSchedules(
   447  	ctx context.Context,
   448  	detectedWorkflows []*actions_module.DetectedWorkflow,
   449  	commit *git.Commit,
   450  	input *notifyInput,
   451  	ref string,
   452  ) error {
   453  	branch, err := commit.GetBranchName()
   454  	if err != nil {
   455  		return err
   456  	}
   457  	if branch != input.Repo.DefaultBranch {
   458  		log.Trace("commit branch is not default branch in repo")
   459  		return nil
   460  	}
   461  
   462  	if count, err := db.Count[actions_model.ActionSchedule](ctx, actions_model.FindScheduleOptions{RepoID: input.Repo.ID}); err != nil {
   463  		log.Error("CountSchedules: %v", err)
   464  		return err
   465  	} else if count > 0 {
   466  		if err := actions_model.CleanRepoScheduleTasks(ctx, input.Repo); err != nil {
   467  			log.Error("CleanRepoScheduleTasks: %v", err)
   468  		}
   469  	}
   470  
   471  	if len(detectedWorkflows) == 0 {
   472  		log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RepoPath(), commit.ID)
   473  		return nil
   474  	}
   475  
   476  	p, err := json.Marshal(input.Payload)
   477  	if err != nil {
   478  		return fmt.Errorf("json.Marshal: %w", err)
   479  	}
   480  
   481  	crons := make([]*actions_model.ActionSchedule, 0, len(detectedWorkflows))
   482  	for _, dwf := range detectedWorkflows {
   483  		// Check cron job condition. Only working in default branch
   484  		workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content))
   485  		if err != nil {
   486  			log.Error("ReadWorkflow: %v", err)
   487  			continue
   488  		}
   489  		schedules := workflow.OnSchedule()
   490  		if len(schedules) == 0 {
   491  			log.Warn("no schedule event")
   492  			continue
   493  		}
   494  
   495  		run := &actions_model.ActionSchedule{
   496  			Title:         strings.SplitN(commit.CommitMessage, "\n", 2)[0],
   497  			RepoID:        input.Repo.ID,
   498  			OwnerID:       input.Repo.OwnerID,
   499  			WorkflowID:    dwf.EntryName,
   500  			TriggerUserID: user_model.ActionsUserID,
   501  			Ref:           ref,
   502  			CommitSHA:     commit.ID.String(),
   503  			Event:         input.Event,
   504  			EventPayload:  string(p),
   505  			Specs:         schedules,
   506  			Content:       dwf.Content,
   507  		}
   508  		crons = append(crons, run)
   509  	}
   510  
   511  	return actions_model.CreateScheduleTask(ctx, crons)
   512  }
   513  
   514  // DetectAndHandleSchedules detects the schedule workflows on the default branch and create schedule tasks
   515  func DetectAndHandleSchedules(ctx context.Context, repo *repo_model.Repository) error {
   516  	if repo.IsEmpty || repo.IsArchived {
   517  		return nil
   518  	}
   519  
   520  	gitRepo, err := gitrepo.OpenRepository(context.Background(), repo)
   521  	if err != nil {
   522  		return fmt.Errorf("git.OpenRepository: %w", err)
   523  	}
   524  	defer gitRepo.Close()
   525  
   526  	// Only detect schedule workflows on the default branch
   527  	commit, err := gitRepo.GetCommit(repo.DefaultBranch)
   528  	if err != nil {
   529  		return fmt.Errorf("gitRepo.GetCommit: %w", err)
   530  	}
   531  	scheduleWorkflows, err := actions_module.DetectScheduledWorkflows(gitRepo, commit)
   532  	if err != nil {
   533  		return fmt.Errorf("detect schedule workflows: %w", err)
   534  	}
   535  	if len(scheduleWorkflows) == 0 {
   536  		return nil
   537  	}
   538  
   539  	// We need a notifyInput to call handleSchedules
   540  	// if repo is a mirror, commit author maybe an external user,
   541  	// so we use action user as the Doer of the notifyInput
   542  	notifyInput := newNotifyInputForSchedules(repo)
   543  
   544  	return handleSchedules(ctx, scheduleWorkflows, commit, notifyInput, repo.DefaultBranch)
   545  }