forked from
tangled.org/core
Monorepo for Tangled
1package workflow
2
3import (
4 "errors"
5 "fmt"
6 "slices"
7 "strings"
8
9 "tangled.org/core/api/tangled"
10
11 "github.com/bmatcuk/doublestar/v4"
12 "github.com/go-git/go-git/v5/plumbing"
13 "gopkg.in/yaml.v3"
14)
15
16// - when a repo is modified, it results in the trigger of a "Pipeline"
17// - a repo could consist of several workflow files
18// * .tangled/workflows/test.yml
19// * .tangled/workflows/lint.yml
20// - therefore a pipeline consists of several workflows, these execute in parallel
21// - each workflow consists of some execution steps, these execute serially
22
23type (
24 Pipeline []Workflow
25
26 // this is simply a structural representation of the workflow file
27 Workflow struct {
28 Name string `yaml:"-"` // name of the workflow file
29 Engine string `yaml:"engine"`
30 When []Constraint `yaml:"when"`
31 CloneOpts CloneOpts `yaml:"clone"`
32 Raw string `yaml:"-"`
33 }
34
35 Constraint struct {
36 Event StringList `yaml:"event"`
37 Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
38 Tag StringList `yaml:"tag"` // optional; only applies to push events
39 Paths StringList `yaml:"paths"` // optional; only run if any changed file matches a glob pattern
40 }
41
42 CloneOpts struct {
43 Skip bool `yaml:"skip"`
44 Depth int `yaml:"depth"`
45 IncludeSubmodules bool `yaml:"submodules"`
46 }
47
48 StringList []string
49
50 TriggerKind string
51)
52
53const (
54 WorkflowDir = ".tangled/workflows"
55
56 TriggerKindPush TriggerKind = "push"
57 TriggerKindPullRequest TriggerKind = "pull_request"
58 TriggerKindManual TriggerKind = "manual"
59)
60
61func (t TriggerKind) String() string {
62 return strings.ReplaceAll(string(t), "_", " ")
63}
64
65// matchesPattern checks if a name matches any of the given patterns.
66// Patterns can be exact matches or glob patterns using * and **.
67// * matches any sequence of non-separator characters
68// ** matches any sequence of characters including separators
69func matchesPattern(name string, patterns []string) (bool, error) {
70 for _, pattern := range patterns {
71 matched, err := doublestar.Match(pattern, name)
72 if err != nil {
73 return false, err
74 }
75 if matched {
76 return true, nil
77 }
78 }
79 return false, nil
80}
81
82func FromFile(name string, contents []byte) (Workflow, error) {
83 var wf Workflow
84
85 err := yaml.Unmarshal(contents, &wf)
86 if err != nil {
87 return wf, err
88 }
89
90 wf.Name = name
91 wf.Raw = string(contents)
92
93 return wf, nil
94}
95
96// if any of the constraints on a workflow is true, return true
97func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) {
98 // manual triggers always run the workflow
99 if trigger.Manual != nil {
100 return true, nil
101 }
102
103 // if not manual, run through the constraint list and see if any one matches
104 for _, c := range w.When {
105 matched, err := c.Match(trigger, changedFiles)
106 if err != nil {
107 return false, err
108 }
109 if matched {
110 return true, nil
111 }
112 }
113
114 // no constraints, always run this workflow
115 if len(w.When) == 0 {
116 return true, nil
117 }
118
119 return false, nil
120}
121
122func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) {
123 match := true
124
125 // manual triggers always pass this constraint
126 if trigger.Manual != nil {
127 return true, nil
128 }
129
130 // apply event constraints
131 match = match && c.MatchEvent(trigger.Kind)
132
133 // apply branch constraints for PRs
134 if trigger.PullRequest != nil {
135 matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
136 if err != nil {
137 return false, err
138 }
139 match = match && matched
140 }
141
142 // apply ref constraints for pushes
143 if trigger.Push != nil {
144 matched, err := c.MatchRef(trigger.Push.Ref)
145 if err != nil {
146 return false, err
147 }
148 match = match && matched
149 }
150
151 // apply paths filter: if specified, at least one changed file must match
152 if len(c.Paths) > 0 {
153 matched, err := matchesAnyFile(changedFiles, c.Paths)
154 if err != nil {
155 return false, err
156 }
157 match = match && matched
158 }
159
160 return match, nil
161}
162
163// matchesAnyFile returns true if any file in files matches any of the glob patterns.
164func matchesAnyFile(files []string, patterns []string) (bool, error) {
165 for _, f := range files {
166 matched, err := matchesPattern(f, patterns)
167 if err != nil {
168 return false, err
169 }
170 if matched {
171 return true, nil
172 }
173 }
174 return false, nil
175}
176
177func (c *Constraint) MatchRef(ref string) (bool, error) {
178 refName := plumbing.ReferenceName(ref)
179 shortName := refName.Short()
180
181 if refName.IsBranch() {
182 return c.MatchBranch(shortName)
183 }
184
185 if refName.IsTag() {
186 return c.MatchTag(shortName)
187 }
188
189 return false, nil
190}
191
192func (c *Constraint) MatchBranch(branch string) (bool, error) {
193 return matchesPattern(branch, c.Branch)
194}
195
196func (c *Constraint) MatchTag(tag string) (bool, error) {
197 return matchesPattern(tag, c.Tag)
198}
199
200func (c *Constraint) MatchEvent(event string) bool {
201 return slices.Contains(c.Event, event)
202}
203
204// Custom unmarshaller for StringList
205func (s *StringList) UnmarshalYAML(unmarshal func(any) error) error {
206 var stringType string
207 if err := unmarshal(&stringType); err == nil {
208 *s = []string{stringType}
209 return nil
210 }
211
212 var sliceType []any
213 if err := unmarshal(&sliceType); err == nil {
214
215 if sliceType == nil {
216 *s = nil
217 return nil
218 }
219
220 parts := make([]string, len(sliceType))
221 for k, v := range sliceType {
222 if sv, ok := v.(string); ok {
223 parts[k] = sv
224 } else {
225 return fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v)
226 }
227 }
228
229 *s = parts
230 return nil
231 }
232
233 return errors.New("failed to unmarshal StringOrSlice")
234}
235
236func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
237 return tangled.Pipeline_CloneOpts{
238 Depth: int64(c.Depth),
239 Skip: c.Skip,
240 Submodules: c.IncludeSubmodules,
241 }
242}