diff --git a/workflow/def.go b/workflow/def.go new file mode 100644 index 0000000..4004765 --- /dev/null +++ b/workflow/def.go @@ -0,0 +1,195 @@ +package workflow + +import ( + "errors" + "fmt" + "slices" + + "tangled.sh/tangled.sh/core/api/tangled" + + "github.com/go-git/go-git/v5/plumbing" + "gopkg.in/yaml.v3" +) + +// - when a repo is modified, it results in the trigger of a "Pipeline" +// - a repo could consist of several workflow files +// * .tangled/workflows/test.yml +// * .tangled/workflows/lint.yml +// - therefore a pipeline consists of several workflows, these execute in parallel +// - each workflow consists of some execution steps, these execute serially + +type ( + Pipeline []Workflow + + // this is simply a structural representation of the workflow file + Workflow struct { + Name string `yaml:"-"` // name of the workflow file + When []Constraint `yaml:"when"` + Dependencies Dependencies `yaml:"dependencies"` + Steps []Step `yaml:"steps"` + Environment map[string]string `yaml:"environment"` + CloneOpts CloneOpts `yaml:"clone"` + } + + Constraint struct { + Event StringList `yaml:"event"` + Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events + } + + Dependencies map[string][]string + + CloneOpts struct { + Skip bool `yaml:"skip"` + Depth int `yaml:"depth"` + IncludeSubmodules bool `yaml:"submodules"` + } + + Step struct { + Name string `yaml:"name"` + Command string `yaml:"command"` + } + + StringList []string +) + +const ( + TriggerKindPush string = "push" + TriggerKindPullRequest string = "pull_request" + TriggerKindManual string = "manual" +) + +func FromFile(name string, contents []byte) (Workflow, error) { + var wf Workflow + + err := yaml.Unmarshal(contents, &wf) + if err != nil { + return wf, err + } + + wf.Name = name + + return wf, nil +} + +// if any of the constraints on a workflow is true, return true +func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { + // manual triggers always run the workflow + if trigger.Manual != nil { + return true + } + + // if not manual, run through the constraint list and see if any one matches + for _, c := range w.When { + if c.Match(trigger) { + return true + } + } + + // no constraints, always run this workflow + if len(w.When) == 0 { + return true + } + + return false +} + +func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { + match := true + + // manual triggers always pass this constraint + if trigger.Manual != nil { + return true + } + + // apply event constraints + match = match && c.MatchEvent(trigger.Kind) + + // apply branch constraints for PRs + if trigger.PullRequest != nil { + match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) + } + + // apply ref constraints for pushes + if trigger.Push != nil { + match = match && c.MatchRef(trigger.Push.Ref) + } + + return match +} + +func (c *Constraint) MatchBranch(branch string) bool { + return slices.Contains(c.Branch, branch) +} + +func (c *Constraint) MatchRef(ref string) bool { + refName := plumbing.ReferenceName(ref) + if refName.IsBranch() { + return slices.Contains(c.Branch, refName.Short()) + } + fmt.Println("no", c.Branch, refName.Short()) + + return false +} + +func (c *Constraint) MatchEvent(event string) bool { + return slices.Contains(c.Event, event) +} + +// Custom unmarshaller for StringList +func (s *StringList) UnmarshalYAML(unmarshal func(any) error) error { + var stringType string + if err := unmarshal(&stringType); err == nil { + *s = []string{stringType} + return nil + } + + var sliceType []any + if err := unmarshal(&sliceType); err == nil { + + if sliceType == nil { + *s = nil + return nil + } + + parts := make([]string, len(sliceType)) + for k, v := range sliceType { + if sv, ok := v.(string); ok { + parts[k] = sv + } else { + return fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v) + } + } + + *s = parts + return nil + } + + return errors.New("failed to unmarshal StringOrSlice") +} + +// conversion utilities to atproto records +func (d Dependencies) AsRecord() []tangled.Pipeline_Dependencies_Elem { + var deps []tangled.Pipeline_Dependencies_Elem + for registry, packages := range d { + deps = append(deps, tangled.Pipeline_Dependencies_Elem{ + Registry: registry, + Packages: packages, + }) + } + return deps +} + +func (s Step) AsRecord() tangled.Pipeline_Step { + return tangled.Pipeline_Step{ + Command: s.Command, + Name: s.Name, + } +} + +func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts { + return tangled.Pipeline_CloneOpts{ + Depth: int64(c.Depth), + Skip: c.Skip, + Submodules: c.IncludeSubmodules, + } +} diff --git a/workflow/def_test.go b/workflow/def_test.go new file mode 100644 index 0000000..01661f7 --- /dev/null +++ b/workflow/def_test.go @@ -0,0 +1,116 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnmarshalWorkflow(t *testing.T) { + yamlData := ` +when: + - event: ["push", "pull_request"] + branch: ["main", "develop"] + +dependencies: + nixpkgs: + - go + - git + - curl + +steps: + - name: "Test" + command: | + go test ./...` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err, "YAML should unmarshal without error") + + assert.Len(t, wf.When, 1, "Should have one constraint") + assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) + assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) + + assert.Len(t, wf.Steps, 1) + assert.Equal(t, "Test", wf.Steps[0].Name) + assert.Equal(t, "go test ./...", wf.Steps[0].Command) + + pkgs, ok := wf.Dependencies["nixpkgs"] + assert.True(t, ok, "`nixpkgs` should be present in dependencies") + assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) + + assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") +} + +func TestUnmarshalCustomRegistry(t *testing.T) { + yamlData := ` +when: + - event: push + branch: main + +dependencies: + git+https://tangled.sh/@oppi.li/tbsp: + - tbsp + git+https://git.peppe.rs/languages/statix: + - statix + +steps: + - name: "Check" + command: | + statix check` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err, "YAML should unmarshal without error") + + assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) + assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) + + assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) + assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) +} + +func TestUnmarshalCloneFalse(t *testing.T) { + yamlData := ` +when: + - event: pull_request_close + +clone: + skip: true + +dependencies: + nixpkgs: + - python3 + +steps: + - name: Notify + command: | + python3 ./notify.py +` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err) + + assert.ElementsMatch(t, []string{"pull_request_close"}, wf.When[0].Event) + + assert.True(t, wf.CloneOpts.Skip, "Skip should be false") +} + +func TestUnmarshalEnv(t *testing.T) { + yamlData := ` +when: + - event: ["pull_request_close"] + +clone: + skip: false + +environment: + HOME: /home/foo bar/baz + CGO_ENABLED: 1 +` + + wf, err := FromFile("test.yml", []byte(yamlData)) + assert.NoError(t, err) + + assert.Len(t, wf.Environment, 2) + assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) + assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) +}