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