summaryrefslogtreecommitdiffstats
path: root/modules/actions/workflows.go
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/actions/workflows.go702
1 files changed, 702 insertions, 0 deletions
diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
new file mode 100644
index 0000000..94c221e
--- /dev/null
+++ b/modules/actions/workflows.go
@@ -0,0 +1,702 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "bytes"
+ "io"
+ "strings"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ api "code.gitea.io/gitea/modules/structs"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+
+ "github.com/gobwas/glob"
+ "github.com/nektos/act/pkg/jobparser"
+ "github.com/nektos/act/pkg/model"
+ "github.com/nektos/act/pkg/workflowpattern"
+ "gopkg.in/yaml.v3"
+)
+
+type DetectedWorkflow struct {
+ EntryName string
+ TriggerEvent *jobparser.Event
+ Content []byte
+}
+
+func init() {
+ model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
+ // Log the error instead of panic or fatal.
+ // It will be a big job to refactor act/pkg/model to return decode error,
+ // so we just log the error and return empty value, and improve it later.
+ log.Error("Failed to decode node %v into %T: %v", node, out, err)
+ }
+}
+
+func IsWorkflow(path string) bool {
+ if (!strings.HasSuffix(path, ".yaml")) && (!strings.HasSuffix(path, ".yml")) {
+ return false
+ }
+
+ return strings.HasPrefix(path, ".forgejo/workflows") || strings.HasPrefix(path, ".gitea/workflows") || strings.HasPrefix(path, ".github/workflows")
+}
+
+func ListWorkflows(commit *git.Commit) (git.Entries, error) {
+ tree, err := commit.SubTree(".forgejo/workflows")
+ if _, ok := err.(git.ErrNotExist); ok {
+ tree, err = commit.SubTree(".gitea/workflows")
+ }
+ if _, ok := err.(git.ErrNotExist); ok {
+ tree, err = commit.SubTree(".github/workflows")
+ }
+ if _, ok := err.(git.ErrNotExist); ok {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ entries, err := tree.ListEntriesRecursiveFast()
+ if err != nil {
+ return nil, err
+ }
+
+ ret := make(git.Entries, 0, len(entries))
+ for _, entry := range entries {
+ if strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml") {
+ ret = append(ret, entry)
+ }
+ }
+ return ret, nil
+}
+
+func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) {
+ f, err := entry.Blob().DataAsync()
+ if err != nil {
+ return nil, err
+ }
+ content, err := io.ReadAll(f)
+ _ = f.Close()
+ if err != nil {
+ return nil, err
+ }
+ return content, nil
+}
+
+func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
+ workflow, err := model.ReadWorkflow(bytes.NewReader(content))
+ if err != nil {
+ return nil, err
+ }
+ events, err := jobparser.ParseRawOn(&workflow.RawOn)
+ if err != nil {
+ return nil, err
+ }
+
+ return events, nil
+}
+
+func DetectWorkflows(
+ gitRepo *git.Repository,
+ commit *git.Commit,
+ triggedEvent webhook_module.HookEventType,
+ payload api.Payloader,
+ detectSchedule bool,
+) ([]*DetectedWorkflow, []*DetectedWorkflow, error) {
+ entries, err := ListWorkflows(commit)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ workflows := make([]*DetectedWorkflow, 0, len(entries))
+ schedules := make([]*DetectedWorkflow, 0, len(entries))
+ for _, entry := range entries {
+ content, err := GetContentFromEntry(entry)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // one workflow may have multiple events
+ events, err := GetEventsFromContent(content)
+ if err != nil {
+ log.Warn("ignore invalid workflow %q: %v", entry.Name(), err)
+ continue
+ }
+ for _, evt := range events {
+ log.Trace("detect workflow %q for event %#v matching %q", entry.Name(), evt, triggedEvent)
+ if evt.IsSchedule() {
+ if detectSchedule {
+ dwf := &DetectedWorkflow{
+ EntryName: entry.Name(),
+ TriggerEvent: evt,
+ Content: content,
+ }
+ schedules = append(schedules, dwf)
+ }
+ } else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) {
+ dwf := &DetectedWorkflow{
+ EntryName: entry.Name(),
+ TriggerEvent: evt,
+ Content: content,
+ }
+ workflows = append(workflows, dwf)
+ }
+ }
+ }
+
+ return workflows, schedules, nil
+}
+
+func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
+ entries, err := ListWorkflows(commit)
+ if err != nil {
+ return nil, err
+ }
+
+ wfs := make([]*DetectedWorkflow, 0, len(entries))
+ for _, entry := range entries {
+ content, err := GetContentFromEntry(entry)
+ if err != nil {
+ return nil, err
+ }
+
+ // one workflow may have multiple events
+ events, err := GetEventsFromContent(content)
+ if err != nil {
+ log.Warn("ignore invalid workflow %q: %v", entry.Name(), err)
+ continue
+ }
+ for _, evt := range events {
+ if evt.IsSchedule() {
+ log.Trace("detect scheduled workflow: %q", entry.Name())
+ dwf := &DetectedWorkflow{
+ EntryName: entry.Name(),
+ TriggerEvent: evt,
+ Content: content,
+ }
+ wfs = append(wfs, dwf)
+ }
+ }
+ }
+
+ return wfs, nil
+}
+
+func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) bool {
+ if !canGithubEventMatch(evt.Name, triggedEvent) {
+ return false
+ }
+
+ switch triggedEvent {
+ case // events with no activity types
+ webhook_module.HookEventWorkflowDispatch,
+ webhook_module.HookEventCreate,
+ webhook_module.HookEventDelete,
+ webhook_module.HookEventFork,
+ webhook_module.HookEventWiki,
+ webhook_module.HookEventSchedule:
+ if len(evt.Acts()) != 0 {
+ log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
+ }
+ // no special filter parameters for these events, just return true if name matched
+ return true
+
+ case // push
+ webhook_module.HookEventPush:
+ return matchPushEvent(commit, payload.(*api.PushPayload), evt)
+
+ case // issues
+ webhook_module.HookEventIssues,
+ webhook_module.HookEventIssueAssign,
+ webhook_module.HookEventIssueLabel,
+ webhook_module.HookEventIssueMilestone:
+ return matchIssuesEvent(payload.(*api.IssuePayload), evt)
+
+ case // issue_comment
+ webhook_module.HookEventIssueComment,
+ // `pull_request_comment` is same as `issue_comment`
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
+ webhook_module.HookEventPullRequestComment:
+ return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt)
+
+ case // pull_request
+ webhook_module.HookEventPullRequest,
+ webhook_module.HookEventPullRequestSync,
+ webhook_module.HookEventPullRequestAssign,
+ webhook_module.HookEventPullRequestLabel,
+ webhook_module.HookEventPullRequestReviewRequest,
+ webhook_module.HookEventPullRequestMilestone:
+ return matchPullRequestEvent(gitRepo, commit, payload.(*api.PullRequestPayload), evt)
+
+ case // pull_request_review
+ webhook_module.HookEventPullRequestReviewApproved,
+ webhook_module.HookEventPullRequestReviewRejected:
+ return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt)
+
+ case // pull_request_review_comment
+ webhook_module.HookEventPullRequestReviewComment:
+ return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt)
+
+ case // release
+ webhook_module.HookEventRelease:
+ return matchReleaseEvent(payload.(*api.ReleasePayload), evt)
+
+ case // registry_package
+ webhook_module.HookEventPackage:
+ return matchPackageEvent(payload.(*api.PackagePayload), evt)
+
+ default:
+ log.Warn("unsupported event %q", triggedEvent)
+ return false
+ }
+}
+
+func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ hasBranchFilter := false
+ hasTagFilter := false
+ refName := git.RefName(pushPayload.Ref)
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "branches":
+ hasBranchFilter = true
+ if !refName.IsBranch() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "branches-ignore":
+ hasBranchFilter = true
+ if !refName.IsBranch() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "tags":
+ hasTagFilter = true
+ if !refName.IsTag() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "tags-ignore":
+ hasTagFilter = true
+ if !refName.IsTag() {
+ break
+ }
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "paths":
+ filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ case "paths-ignore":
+ filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ default:
+ log.Warn("push event unsupported condition %q", cond)
+ }
+ }
+ // if both branch and tag filter are defined in the workflow only one needs to match
+ if hasBranchFilter && hasTagFilter {
+ matchTimes++
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issues
+ // Actions with the same name:
+ // opened, edited, closed, reopened, assigned, unassigned, milestoned, demilestoned
+ // Actions need to be converted:
+ // label_updated -> labeled
+ // label_cleared -> unlabeled
+ // Unsupported activity types:
+ // deleted, transferred, pinned, unpinned, locked, unlocked
+
+ action := issuePayload.Action
+ switch action {
+ case api.HookIssueLabelUpdated:
+ action = "labeled"
+ case api.HookIssueLabelCleared:
+ action = "unlabeled"
+ }
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("issue event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
+ acts := evt.Acts()
+ activityTypeMatched := false
+ matchTimes := 0
+
+ if vals, ok := acts["types"]; !ok {
+ // defaultly, only pull request `opened`, `reopened` and `synchronized` will trigger workflow
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
+ activityTypeMatched = prPayload.Action == api.HookIssueSynchronized || prPayload.Action == api.HookIssueOpened || prPayload.Action == api.HookIssueReOpened
+ } else {
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
+ // Actions with the same name:
+ // opened, edited, closed, reopened, assigned, unassigned, review_requested, review_request_removed, milestoned, demilestoned
+ // Actions need to be converted:
+ // synchronized -> synchronize
+ // label_updated -> labeled
+ // label_cleared -> unlabeled
+ // Unsupported activity types:
+ // converted_to_draft, ready_for_review, locked, unlocked, auto_merge_enabled, auto_merge_disabled, enqueued, dequeued
+
+ action := prPayload.Action
+ switch action {
+ case api.HookIssueSynchronized:
+ action = "synchronize"
+ case api.HookIssueLabelUpdated:
+ action = "labeled"
+ case api.HookIssueLabelCleared:
+ action = "unlabeled"
+ }
+ log.Trace("matching pull_request %s with %v", action, vals)
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ activityTypeMatched = true
+ matchTimes++
+ break
+ }
+ }
+ }
+
+ var (
+ headCommit = commit
+ err error
+ )
+ if evt.Name == GithubEventPullRequestTarget && (len(acts["paths"]) > 0 || len(acts["paths-ignore"]) > 0) {
+ headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
+ if err != nil {
+ log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err)
+ return false
+ }
+ }
+
+ // all acts conditions should be satisfied
+ for cond, vals := range acts {
+ switch cond {
+ case "types":
+ // types have been checked
+ continue
+ case "branches":
+ refName := git.RefName(prPayload.PullRequest.Base.Ref)
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "branches-ignore":
+ refName := git.RefName(prPayload.PullRequest.Base.Ref)
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ case "paths":
+ filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.Base.Ref)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ case "paths-ignore":
+ filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.Base.Ref)
+ if err != nil {
+ log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
+ } else {
+ patterns, err := workflowpattern.CompilePatterns(vals...)
+ if err != nil {
+ break
+ }
+ if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) {
+ matchTimes++
+ }
+ }
+ default:
+ log.Warn("pull request event unsupported condition %q", cond)
+ }
+ }
+ return activityTypeMatched && matchTimes == len(evt.Acts())
+}
+
+func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issue_comment
+ // Actions with the same name:
+ // created, edited, deleted
+ // Actions need to be converted:
+ // NONE
+ // Unsupported activity types:
+ // NONE
+
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("issue comment event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review
+ // Activity types with the same name:
+ // NONE
+ // Activity types need to be converted:
+ // reviewed -> submitted
+ // reviewed -> edited
+ // Unsupported activity types:
+ // dismissed
+
+ actions := make([]string, 0)
+ if prPayload.Action == api.HookIssueReviewed {
+ // the `reviewed` HookIssueAction can match the two activity types: `submitted` and `edited`
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review
+ actions = append(actions, "submitted", "edited")
+ }
+
+ matched := false
+ for _, val := range vals {
+ for _, action := range actions {
+ if glob.MustCompile(val, '/').Match(action) {
+ matched = true
+ break
+ }
+ }
+ if matched {
+ break
+ }
+ }
+ if matched {
+ matchTimes++
+ }
+ default:
+ log.Warn("pull request review event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review_comment
+ // Activity types with the same name:
+ // NONE
+ // Activity types need to be converted:
+ // reviewed -> created
+ // reviewed -> edited
+ // Unsupported activity types:
+ // deleted
+
+ actions := make([]string, 0)
+ if prPayload.Action == api.HookIssueReviewed {
+ // the `reviewed` HookIssueAction can match the two activity types: `created` and `edited`
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_review_comment
+ actions = append(actions, "created", "edited")
+ }
+
+ matched := false
+ for _, val := range vals {
+ for _, action := range actions {
+ if glob.MustCompile(val, '/').Match(action) {
+ matched = true
+ break
+ }
+ }
+ if matched {
+ break
+ }
+ }
+ if matched {
+ matchTimes++
+ }
+ default:
+ log.Warn("pull request review comment event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release
+ // Activity types with the same name:
+ // published
+ // Activity types need to be converted:
+ // updated -> edited
+ // Unsupported activity types:
+ // unpublished, created, deleted, prereleased, released
+
+ action := payload.Action
+ if action == api.HookReleaseUpdated {
+ action = "edited"
+ }
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("release event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}
+
+func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
+ // with no special filter parameters
+ if len(evt.Acts()) == 0 {
+ return true
+ }
+
+ matchTimes := 0
+ // all acts conditions should be satisfied
+ for cond, vals := range evt.Acts() {
+ switch cond {
+ case "types":
+ // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#registry_package
+ // Activity types with the same name:
+ // NONE
+ // Activity types need to be converted:
+ // created -> published
+ // Unsupported activity types:
+ // updated
+
+ action := payload.Action
+ if action == api.HookPackageCreated {
+ action = "published"
+ }
+ for _, val := range vals {
+ if glob.MustCompile(val, '/').Match(string(action)) {
+ matchTimes++
+ break
+ }
+ }
+ default:
+ log.Warn("package event unsupported condition %q", cond)
+ }
+ }
+ return matchTimes == len(evt.Acts())
+}