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 ¬ifyInput{ 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 }