1package workflow
2
3import (
4 "errors"
5 "fmt"
6 "slices"
7
8 "tangled.sh/tangled.sh/core/api/tangled"
9
10 "github.com/go-git/go-git/v5/plumbing"
11 "gopkg.in/yaml.v3"
12)
13
14// - when a repo is modified, it results in the trigger of a "Pipeline"
15// - a repo could consist of several workflow files
16// * .tangled/workflows/test.yml
17// * .tangled/workflows/lint.yml
18// - therefore a pipeline consists of several workflows, these execute in parallel
19// - each workflow consists of some execution steps, these execute serially
20
21type (
22 Pipeline []Workflow
23
24 // this is simply a structural representation of the workflow file
25 Workflow struct {
26 Name string `yaml:"-"` // name of the workflow file
27 When []Constraint `yaml:"when"`
28 Dependencies Dependencies `yaml:"dependencies"`
29 Steps []Step `yaml:"steps"`
30 Environment map[string]string `yaml:"environment"`
31 CloneOpts CloneOpts `yaml:"clone"`
32 }
33
34 Constraint struct {
35 Event StringList `yaml:"event"`
36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
37 }
38
39 Dependencies map[string][]string
40
41 CloneOpts struct {
42 Skip bool `yaml:"skip"`
43 Depth int `yaml:"depth"`
44 IncludeSubmodules bool `yaml:"submodules"`
45 }
46
47 Step struct {
48 Name string `yaml:"name"`
49 Command string `yaml:"command"`
50 Environment map[string]string `yaml:"environment"`
51 }
52
53 StringList []string
54)
55
56const (
57 TriggerKindPush string = "push"
58 TriggerKindPullRequest string = "pull_request"
59 TriggerKindManual string = "manual"
60)
61
62func FromFile(name string, contents []byte) (Workflow, error) {
63 var wf Workflow
64
65 err := yaml.Unmarshal(contents, &wf)
66 if err != nil {
67 return wf, err
68 }
69
70 wf.Name = name
71
72 return wf, nil
73}
74
75// if any of the constraints on a workflow is true, return true
76func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
77 // manual triggers always run the workflow
78 if trigger.Manual != nil {
79 return true
80 }
81
82 // if not manual, run through the constraint list and see if any one matches
83 for _, c := range w.When {
84 if c.Match(trigger) {
85 return true
86 }
87 }
88
89 // no constraints, always run this workflow
90 if len(w.When) == 0 {
91 return true
92 }
93
94 return false
95}
96
97func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
98 match := true
99
100 // manual triggers always pass this constraint
101 if trigger.Manual != nil {
102 return true
103 }
104
105 // apply event constraints
106 match = match && c.MatchEvent(trigger.Kind)
107
108 // apply branch constraints for PRs
109 if trigger.PullRequest != nil {
110 match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
111 }
112
113 // apply ref constraints for pushes
114 if trigger.Push != nil {
115 match = match && c.MatchRef(trigger.Push.Ref)
116 }
117
118 return match
119}
120
121func (c *Constraint) MatchBranch(branch string) bool {
122 return slices.Contains(c.Branch, branch)
123}
124
125func (c *Constraint) MatchRef(ref string) bool {
126 refName := plumbing.ReferenceName(ref)
127 if refName.IsBranch() {
128 return slices.Contains(c.Branch, refName.Short())
129 }
130 return false
131}
132
133func (c *Constraint) MatchEvent(event string) bool {
134 return slices.Contains(c.Event, event)
135}
136
137// Custom unmarshaller for StringList
138func (s *StringList) UnmarshalYAML(unmarshal func(any) error) error {
139 var stringType string
140 if err := unmarshal(&stringType); err == nil {
141 *s = []string{stringType}
142 return nil
143 }
144
145 var sliceType []any
146 if err := unmarshal(&sliceType); err == nil {
147
148 if sliceType == nil {
149 *s = nil
150 return nil
151 }
152
153 parts := make([]string, len(sliceType))
154 for k, v := range sliceType {
155 if sv, ok := v.(string); ok {
156 parts[k] = sv
157 } else {
158 return fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v)
159 }
160 }
161
162 *s = parts
163 return nil
164 }
165
166 return errors.New("failed to unmarshal StringOrSlice")
167}
168
169// conversion utilities to atproto records
170func (d Dependencies) AsRecord() []tangled.Pipeline_Dependencies_Elem {
171 var deps []tangled.Pipeline_Dependencies_Elem
172 for registry, packages := range d {
173 deps = append(deps, tangled.Pipeline_Dependencies_Elem{
174 Registry: registry,
175 Packages: packages,
176 })
177 }
178 return deps
179}
180
181func (s Step) AsRecord() tangled.Pipeline_Step {
182 return tangled.Pipeline_Step{
183 Command: s.Command,
184 Name: s.Name,
185 }
186}
187
188func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
189 return tangled.Pipeline_CloneOpts{
190 Depth: int64(c.Depth),
191 Skip: c.Skip,
192 Submodules: c.IncludeSubmodules,
193 }
194}