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