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