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