Monorepo for Tangled
tangled.org
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 Tags *bool `yaml:"tags"`
47 }
48
49 StringList []string
50
51 TriggerKind string
52)
53
54const (
55 WorkflowDir = ".tangled/workflows"
56
57 TriggerKindPush TriggerKind = "push"
58 TriggerKindPullRequest TriggerKind = "pull_request"
59 TriggerKindManual TriggerKind = "manual"
60)
61
62func (t TriggerKind) String() string {
63 return strings.ReplaceAll(string(t), "_", " ")
64}
65
66// matchesPattern checks if a name matches any of the given patterns.
67// Patterns can be exact matches or glob patterns using * and **.
68// * matches any sequence of non-separator characters
69// ** matches any sequence of characters including separators
70func matchesPattern(name string, patterns []string) (bool, error) {
71 for _, pattern := range patterns {
72 matched, err := doublestar.Match(pattern, name)
73 if err != nil {
74 return false, err
75 }
76 if matched {
77 return true, nil
78 }
79 }
80 return false, nil
81}
82
83func FromFile(name string, contents []byte) (Workflow, error) {
84 var wf Workflow
85
86 err := yaml.Unmarshal(contents, &wf)
87 if err != nil {
88 return wf, err
89 }
90
91 wf.Name = name
92 wf.Raw = string(contents)
93
94 return wf, nil
95}
96
97// if any of the constraints on a workflow is true, return true
98func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) {
99 // manual triggers always run the workflow
100 if trigger.Manual != nil {
101 return true, nil
102 }
103
104 // if not manual, run through the constraint list and see if any one matches
105 for _, c := range w.When {
106 matched, err := c.Match(trigger, changedFiles)
107 if err != nil {
108 return false, err
109 }
110 if matched {
111 return true, nil
112 }
113 }
114
115 // no constraints, always run this workflow
116 if len(w.When) == 0 {
117 return true, nil
118 }
119
120 return false, nil
121}
122
123func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) {
124 match := true
125
126 // manual triggers always pass this constraint
127 if trigger.Manual != nil {
128 return true, nil
129 }
130
131 // apply event constraints
132 match = match && c.MatchEvent(trigger.Kind)
133
134 // apply branch constraints for PRs
135 if trigger.PullRequest != nil {
136 matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
137 if err != nil {
138 return false, err
139 }
140 match = match && matched
141 }
142
143 // apply ref constraints for pushes
144 if trigger.Push != nil {
145 matched, err := c.MatchRef(trigger.Push.Ref)
146 if err != nil {
147 return false, err
148 }
149 match = match && matched
150 }
151
152 // apply paths filter: if specified, at least one changed file must match
153 if len(c.Paths) > 0 {
154 matched, err := matchesAnyFile(changedFiles, c.Paths)
155 if err != nil {
156 return false, err
157 }
158 match = match && matched
159 }
160
161 return match, nil
162}
163
164// matchesAnyFile returns true if any file in files matches any of the glob patterns.
165func matchesAnyFile(files []string, patterns []string) (bool, error) {
166 for _, f := range files {
167 matched, err := matchesPattern(f, patterns)
168 if err != nil {
169 return false, err
170 }
171 if matched {
172 return true, nil
173 }
174 }
175 return false, nil
176}
177
178func (c *Constraint) MatchRef(ref string) (bool, error) {
179 refName := plumbing.ReferenceName(ref)
180 shortName := refName.Short()
181
182 if refName.IsBranch() {
183 return c.MatchBranch(shortName)
184 }
185
186 if refName.IsTag() {
187 return c.MatchTag(shortName)
188 }
189
190 return false, nil
191}
192
193func (c *Constraint) MatchBranch(branch string) (bool, error) {
194 return matchesPattern(branch, c.Branch)
195}
196
197func (c *Constraint) MatchTag(tag string) (bool, error) {
198 return matchesPattern(tag, c.Tag)
199}
200
201func (c *Constraint) MatchEvent(event string) bool {
202 return slices.Contains(c.Event, event)
203}
204
205// Custom unmarshaller for StringList
206func (s *StringList) UnmarshalYAML(unmarshal func(any) error) error {
207 var stringType string
208 if err := unmarshal(&stringType); err == nil {
209 *s = []string{stringType}
210 return nil
211 }
212
213 var sliceType []any
214 if err := unmarshal(&sliceType); err == nil {
215
216 if sliceType == nil {
217 *s = nil
218 return nil
219 }
220
221 parts := make([]string, len(sliceType))
222 for k, v := range sliceType {
223 if sv, ok := v.(string); ok {
224 parts[k] = sv
225 } else {
226 return fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v)
227 }
228 }
229
230 *s = parts
231 return nil
232 }
233
234 return errors.New("failed to unmarshal StringOrSlice")
235}
236
237func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
238 return tangled.Pipeline_CloneOpts{
239 Depth: int64(c.Depth),
240 Skip: c.Skip,
241 Submodules: c.IncludeSubmodules == nil || *c.IncludeSubmodules,
242 Tags: c.Tags == nil || *c.Tags,
243 }
244}