gitmote / internal / ci / triggers.go · history · branches
4457 bytes · raw
package ci
import (
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// pushTrigger is the parsed push-event filter of a workflow's `on:` — the subset
// gitmote evaluates to decide whether a branch push starts a run. reacts is false
// when the workflow doesn't react to push at all (e.g. workflow_dispatch only),
// so a push never triggers it. branches / branchesIgnore are GitHub's branch
// globs; empty means "no branch constraint".
type pushTrigger struct {
reacts bool
branches []string
branchesIgnore []string
}
// workflowOn decodes just a workflow's `on:` key. The explicit tag matters: YAML
// resolves a bare `on` scalar as the boolean true (YAML 1.1), so keying off a
// decoded map would miss it — a tagged struct field matches the literal `on`.
type workflowOn struct {
On yaml.Node `yaml:"on"`
}
// parsePushTrigger extracts the push filter from a workflow's YAML. A file that
// doesn't parse returns an error (the caller keeps it so the run still surfaces
// the failure). A well-formed file that doesn't react to push returns
// reacts=false with a nil error.
func parsePushTrigger(content []byte) (pushTrigger, error) {
var w workflowOn
if err := yaml.Unmarshal(content, &w); err != nil {
return pushTrigger{}, err
}
return interpretOn(&w.On), nil
}
// interpretOn reads the three shapes `on:` takes — a scalar (`on: push`), a
// sequence (`on: [push, …]`), or a mapping (`on: {push: {branches: […]}}`).
func interpretOn(on *yaml.Node) pushTrigger {
switch on.Kind {
case yaml.ScalarNode:
return pushTrigger{reacts: on.Value == "push"}
case yaml.SequenceNode:
for _, n := range on.Content {
if n.Kind == yaml.ScalarNode && n.Value == "push" {
return pushTrigger{reacts: true}
}
}
return pushTrigger{}
case yaml.MappingNode:
for i := 0; i+1 < len(on.Content); i += 2 {
if on.Content[i].Value != "push" {
continue
}
t := pushTrigger{reacts: true}
if v := on.Content[i+1]; v.Kind == yaml.MappingNode {
t.branches = seqStrings(mapValue(v, "branches"))
t.branchesIgnore = seqStrings(mapValue(v, "branches-ignore"))
}
return t
}
return pushTrigger{}
default:
return pushTrigger{} // null or absent `on:` — nothing to react to
}
}
// matches reports whether a push to branch should trigger this workflow: it must
// react to push, pass any `branches` allow-list, and clear any `branches-ignore`
// deny-list.
func (t pushTrigger) matches(branch string) bool {
if !t.reacts {
return false
}
if len(t.branches) > 0 && !anyGlob(t.branches, branch) {
return false
}
if len(t.branchesIgnore) > 0 && anyGlob(t.branchesIgnore, branch) {
return false
}
return true
}
// mapValue returns the value node for key in a mapping node, or nil.
func mapValue(m *yaml.Node, key string) *yaml.Node {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// seqStrings reads a scalar or a sequence of scalars into a string slice (a
// branch filter may be written `branches: main` or `branches: [main, dev]`).
func seqStrings(n *yaml.Node) []string {
if n == nil {
return nil
}
switch n.Kind {
case yaml.ScalarNode:
return []string{n.Value}
case yaml.SequenceNode:
out := make([]string, 0, len(n.Content))
for _, e := range n.Content {
if e.Kind == yaml.ScalarNode {
out = append(out, e.Value)
}
}
return out
default:
return nil
}
}
func anyGlob(patterns []string, name string) bool {
for _, p := range patterns {
if globMatch(p, name) {
return true
}
}
return false
}
// globMatch reports whether name matches a GitHub branch-filter glob. It models
// the two wildcards that cover real branch filters — `*` (a run of any character
// except `/`) and `**` (any character including `/`) — and treats everything
// else literally. GitHub's rarer pattern features (`?`, `+`, `[]`, `!`) are not
// modeled: a pattern using them matches only its literal form.
func globMatch(pattern, name string) bool {
var b strings.Builder
b.WriteByte('^')
for i := 0; i < len(pattern); i++ {
if pattern[i] == '*' {
if i+1 < len(pattern) && pattern[i+1] == '*' {
b.WriteString(".*")
i++
} else {
b.WriteString("[^/]*")
}
continue
}
b.WriteString(regexp.QuoteMeta(pattern[i : i+1]))
}
b.WriteByte('$')
re, err := regexp.Compile(b.String())
if err != nil {
return pattern == name
}
return re.MatchString(name)
}