| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package trigger |
| 4 | |
| 5 | import ( |
| 6 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 7 | ) |
| 8 | |
| 9 | // Match reports whether the workflow's `on:` predicates accept the |
| 10 | // given event. Pure function: no I/O, no DB. Cheap to fuzz. |
| 11 | // |
| 12 | // The four event kinds: |
| 13 | // |
| 14 | // - push → on.push present, branch/tag classification |
| 15 | // passes the appropriate sub-filter, paths |
| 16 | // filter (when set) hits at least one |
| 17 | // changed path |
| 18 | // - pull_request → on.pull_request present, action is in the |
| 19 | // configured types: list (default ["opened", |
| 20 | // "synchronize", "reopened"]), base branch |
| 21 | // passes branches: filter, paths filter hits |
| 22 | // - schedule → on.schedule has any entry whose cron string |
| 23 | // equals event.Cron (the sweep tells us which |
| 24 | // cron fired; we just verify the workflow |
| 25 | // declared it) |
| 26 | // - workflow_dispatch → on.workflow_dispatch present |
| 27 | // |
| 28 | // Anything else returns false silently — strict-allowlist v1 posture. |
| 29 | // |
| 30 | // A workflow that doesn't declare the trigger kind at all (e.g., |
| 31 | // only `on: push` but the event is a pull_request) returns false. |
| 32 | func Match(w *workflow.Workflow, ev Event) bool { |
| 33 | if w == nil { |
| 34 | return false |
| 35 | } |
| 36 | switch ev.Kind { |
| 37 | case EventPush: |
| 38 | return matchPush(w.On.Push, ev) |
| 39 | case EventPullRequest: |
| 40 | return matchPullRequest(w.On.PullRequest, ev) |
| 41 | case EventSchedule: |
| 42 | return matchSchedule(w.On.Schedule, ev) |
| 43 | case EventWorkflowDispatch: |
| 44 | return w.On.WorkflowDispatch != nil |
| 45 | } |
| 46 | return false |
| 47 | } |
| 48 | |
| 49 | func matchPush(pt *workflow.PushTrigger, ev Event) bool { |
| 50 | if pt == nil { |
| 51 | return false |
| 52 | } |
| 53 | // Branch vs tag classification: |
| 54 | // - When event has a Branch set, only the branches: filter applies |
| 55 | // (a tags-only filter wouldn't accept a branch push, and vice |
| 56 | // versa). Match GHA semantics. |
| 57 | // - Same for Tag. |
| 58 | switch { |
| 59 | case ev.Branch != "": |
| 60 | // If neither branches nor tags is configured, match-all. |
| 61 | // If only tags is configured, this branch push doesn't match. |
| 62 | if len(pt.Branches) == 0 && len(pt.Tags) == 0 { |
| 63 | return matchPaths(pt.Paths, ev.ChangedPaths) |
| 64 | } |
| 65 | if len(pt.Branches) == 0 { |
| 66 | return false |
| 67 | } |
| 68 | if !matchAny(pt.Branches, ev.Branch) { |
| 69 | return false |
| 70 | } |
| 71 | case ev.Tag != "": |
| 72 | if len(pt.Branches) == 0 && len(pt.Tags) == 0 { |
| 73 | return matchPaths(pt.Paths, ev.ChangedPaths) |
| 74 | } |
| 75 | if len(pt.Tags) == 0 { |
| 76 | return false |
| 77 | } |
| 78 | if !matchAny(pt.Tags, ev.Tag) { |
| 79 | return false |
| 80 | } |
| 81 | default: |
| 82 | // Push to a non-branch, non-tag ref (e.g., refs/notes/*). v1 |
| 83 | // doesn't surface those. |
| 84 | return false |
| 85 | } |
| 86 | return matchPaths(pt.Paths, ev.ChangedPaths) |
| 87 | } |
| 88 | |
| 89 | func matchPullRequest(prt *workflow.PullRequestTrigger, ev Event) bool { |
| 90 | if prt == nil { |
| 91 | return false |
| 92 | } |
| 93 | // Default activity types per GHA when `types:` is omitted. |
| 94 | types := prt.Types |
| 95 | if len(types) == 0 { |
| 96 | types = defaultPRTypes |
| 97 | } |
| 98 | if !containsString(types, ev.Action) { |
| 99 | return false |
| 100 | } |
| 101 | // branches: filter applies to the BASE ref (the destination), per |
| 102 | // GHA docs — that's the branch the PR is targeting. |
| 103 | if len(prt.Branches) > 0 && !matchAny(prt.Branches, ev.BaseRef) { |
| 104 | return false |
| 105 | } |
| 106 | return matchPaths(prt.Paths, ev.ChangedPaths) |
| 107 | } |
| 108 | |
| 109 | func matchSchedule(entries []workflow.ScheduleTrigger, ev Event) bool { |
| 110 | if len(entries) == 0 { |
| 111 | return false |
| 112 | } |
| 113 | // We require an exact cron-expression match against the entry the |
| 114 | // sweep fired. The sweep is the source of truth for which cron is |
| 115 | // firing; we just gate on the workflow having declared that |
| 116 | // expression. Avoids interpreting cron semantics in two places. |
| 117 | for _, e := range entries { |
| 118 | if e.Cron == ev.Cron { |
| 119 | return true |
| 120 | } |
| 121 | } |
| 122 | return false |
| 123 | } |
| 124 | |
| 125 | // matchPaths returns true when paths is empty (no filter) or at least |
| 126 | // one changed path matches the filter list. Empty changed-paths + |
| 127 | // non-empty filter is a miss — we have a filter and nothing to match. |
| 128 | func matchPaths(filter, changed []string) bool { |
| 129 | if len(filter) == 0 { |
| 130 | return true |
| 131 | } |
| 132 | for _, c := range changed { |
| 133 | if matchAny(filter, c) { |
| 134 | return true |
| 135 | } |
| 136 | } |
| 137 | return false |
| 138 | } |
| 139 | |
| 140 | func containsString(haystack []string, needle string) bool { |
| 141 | for _, s := range haystack { |
| 142 | if s == needle { |
| 143 | return true |
| 144 | } |
| 145 | } |
| 146 | return false |
| 147 | } |
| 148 | |
| 149 | // defaultPRTypes mirrors GHA's default for on.pull_request when no |
| 150 | // types: list is given. Workflow authors who specify types: opt out |
| 151 | // of this default. |
| 152 | var defaultPRTypes = []string{"opened", "synchronize", "reopened"} |
| 153 |