diff --git a/atlasaction/action.go b/atlasaction/action.go index 873eff9..964051b 100644 --- a/atlasaction/action.go +++ b/atlasaction/action.go @@ -1240,23 +1240,28 @@ func RenderTemplate(name string, data any) (string, error) { return buf.String(), nil } -// toEnvName converts the given string to an environment variable name. -func toEnvName(s string) string { +// ToEnvName converts the given string to an environment variable name. +func ToEnvName(s string) string { return strings.ToUpper(strings.NewReplacer( " ", "_", "-", "_", "/", "_", ).Replace(s)) } -// toInputVarName converts the given string to an input variable name. -func toInputVarName(input string) string { - return fmt.Sprintf("ATLAS_INPUT_%s", toEnvName(input)) +// ToInputVarName converts the given string to an input variable name. +func ToInputVarName(input string) string { + return fmt.Sprintf("ATLAS_INPUT_%s", ToEnvName(input)) +} + +// ToInputVarName converts the given string to an input variable name. +func ToOutputVarName(action, output string) string { + return "ATLAS_OUTPUT_" + ToEnvName(action+"_"+output) } // toOutputVar converts the given values to an output variable. // The action and output are used to create the output variable name with the format: // ATLAS_OUTPUT__="" func toOutputVar(action, output, value string) string { - return fmt.Sprintf("ATLAS_OUTPUT_%s=%q", toEnvName(action+"_"+output), value) + return fmt.Sprintf("%s=%q", ToOutputVarName(action, output), value) } // fprintln writes the given values to the file using fmt.Fprintln. diff --git a/atlasaction/bitbucket.go b/atlasaction/bitbucket.go index d2add49..3fe7618 100644 --- a/atlasaction/bitbucket.go +++ b/atlasaction/bitbucket.go @@ -80,7 +80,7 @@ func (a *bbPipe) GetTriggerContext(context.Context) (*TriggerContext, error) { // GetInput implements the Action interface. func (a *bbPipe) GetInput(name string) string { - return strings.TrimSpace(a.getenv(toInputVarName(name))) + return strings.TrimSpace(a.getenv(ToInputVarName(name))) } // SetOutput implements Action. diff --git a/atlasaction/circleci_action.go b/atlasaction/circleci_action.go index 5184474..f18afe6 100644 --- a/atlasaction/circleci_action.go +++ b/atlasaction/circleci_action.go @@ -38,10 +38,10 @@ func (a *circleCIOrb) Getenv(key string) string { // GetInput implements the Action interface. func (a *circleCIOrb) GetInput(name string) string { - v := a.getenv(toInputVarName(name)) + v := a.getenv(ToInputVarName(name)) if v == "" { // TODO: Remove this fallback once all the actions are updated. - v = a.getenv(toEnvName("INPUT_" + name)) + v = a.getenv(ToEnvName("INPUT_" + name)) } return strings.TrimSpace(v) } diff --git a/atlasaction/gitlab_ci.go b/atlasaction/gitlab_ci.go index d490cd9..62d22c5 100644 --- a/atlasaction/gitlab_ci.go +++ b/atlasaction/gitlab_ci.go @@ -39,7 +39,7 @@ func (a *gitlabCI) Getenv(key string) string { // GetInput implements the Action interface. func (a *gitlabCI) GetInput(name string) string { - return strings.TrimSpace(a.getenv(toInputVarName(name))) + return strings.TrimSpace(a.getenv(ToInputVarName(name))) } // SetOutput implements the Action interface. diff --git a/go.mod b/go.mod index 6057a86..6968312 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module ariga.io/atlas-action -go 1.23 +go 1.24rc1 require ( ariga.io/atlas v0.21.2-0.20240418081819-02b3f6239b04 diff --git a/tools/gen/main.go b/tools/gen/main.go new file mode 100644 index 0000000..5f1abdf --- /dev/null +++ b/tools/gen/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "cmp" + "errors" + "io" + "io/fs" + "iter" + "maps" + "os" + "slices" + "strings" + + "gopkg.in/yaml.v3" +) + +func main() { + wd, err := os.Getwd() + if err != nil { + panic(err) + } + if err := process(wd); err != nil { + panic(err) + } +} + +func process(path string) error { + fsys := os.DirFS(path) + files, err := fs.Glob(fsys, "**/*/action.yml") + if err != nil { + return err + } + actions := make([]actionSpec, len(files)) + for i, f := range files { + if err := actions[i].fromPath(fsys, f); err != nil { + return err + } + } + slices.SortFunc(actions, func(i, j actionSpec) int { + return cmp.Compare(i.ID, j.ID) + }) + return readmeMarkdown(os.Stdout, actions) +} + +func readmeMarkdown(w io.Writer, actions []actionSpec) error { + return templates.ExecuteTemplate(w, "bitbucket.md", actions) +} + +type ( + actionSpec struct { + ID string `yaml:"-"` + Description string `yaml:"description"` + Name string `yaml:"name"` + Inputs map[string]actionInput `yaml:"inputs"` + Outputs map[string]actionOutput `yaml:"outputs"` + } + actionInput struct { + Description string `yaml:"description"` + Default string `yaml:"default"` + Required bool `yaml:"required"` + } + actionOutput struct { + Description string `yaml:"description"` + } +) + +func (a *actionSpec) fromPath(fsys fs.FS, path string) error { + f, err := fsys.Open(path) + if err != nil { + return err + } + defer f.Close() + if err = yaml.NewDecoder(f).Decode(a); err != nil { + return err + } + if id, ok := strings.CutSuffix(path, "/action.yml"); ok { + a.ID = strings.Trim(id, "/") + return nil + } + return errors.New("gen: invalid action.yml path") +} + +func (a *actionSpec) SortedInputs() iter.Seq2[string, actionInput] { + orders := []string{"working-directory", "config", "env", "vars", "dev-url"} + return func(yield func(string, actionInput) bool) { + keys := slices.SortedFunc(maps.Keys(a.Inputs), ComparePriority(orders)) + for _, k := range keys { + if !yield(k, a.Inputs[k]) { + return + } + } + } +} + +func (a *actionSpec) SortedOutputs() iter.Seq2[string, actionOutput] { + return func(yield func(string, actionOutput) bool) { + for _, k := range slices.Sorted(maps.Keys(a.Outputs)) { + if !yield(k, a.Outputs[k]) { + return + } + } + } +} + +func ComparePriority[T cmp.Ordered](ordered []T) func(T, T) int { + return func(x, y T) int { + switch xi, yi := slices.Index(ordered, x), slices.Index(ordered, y); { + case xi != -1 && yi != -1: + return cmp.Compare(xi, yi) + case yi != -1: + return -1 + case xi != -1: + return +1 + default: + // fallback to default comparison + return cmp.Compare(x, y) + } + } +} diff --git a/tools/gen/template/bitbucket.md b/tools/gen/template/bitbucket.md new file mode 100644 index 0000000..2c04fac --- /dev/null +++ b/tools/gen/template/bitbucket.md @@ -0,0 +1,81 @@ +--- +title: Bitbucket Pipes +id: bitbucket-pipes +slug: /integrations/bitbucket-pipes +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' + +// TODO: Write introduce + +{{ range . }} + +## `{{ .ID }}` + +{{ .Description }} + +{{ $tmpl := printf "usage/bitbucket/%s" .ID -}} +{{- if hasTemplate $tmpl -}} +{{- xtemplate $tmpl . -}} +{{- end -}} + +{{ if .Inputs }} +### Inputs + +* `ATLAS_ACTION` - (Required) always is `{{ .ID }}`. +* `ATLAS_TOKEN` - (Optional) to authenticate with Atlas Cloud. +* `BITBUCKET_ACTION_TOKEN` - (Optional) Bitbucket access token to post comment on the PR. +{{- range $name, $item := .SortedInputs }} +* `{{ $name | inputvar }}` - {{ if not $item.Required }}(Optional) {{end}}{{ $item.Description | trimnl | nl2sp }} +{{- end }} +{{ end -}} +{{ if .Outputs }} +### Outputs + +The outputs are written into the `.atlas-action/outputs.sh`, we can load it for the next step using the `source` command. +{{ $action := . }} +{{ range $name, $item := .SortedOutputs }} +* `{{ $name | outputvar $action.ID }}` - {{ $item.Description | trimnl | nl2sp }} +{{- end }} +{{- end }} +{{ end }} +{{- define "usage/bitbucket/schema/plan" -}} +### Usage + +Add `bitbucket-pipelines.yml` to your repo with the following contents: + + + +{{- $action := . -}} +{{- range $value, $item := dockers }} + + +```yaml +image: atlassian/default-image:3 +pipelines: + branches: + master: + - step: + name: "{{ $action.Description }}" + script: + - name: "{{ $action.Name }}" + pipe: docker://arigaio/atlas-action:master + variables: + ATLAS_ACTION: "{{ $action.ID }}" # Required + ATLAS_TOKEN: ${ATLAS_TOKEN} + BITBUCKET_ACCESS_TOKEN: ${BITBUCKET_ACCESS_TOKEN} + ATLAS_INPUT_DEV_URL: "{{ $item.DevURL }}" + - source .atlas-action/outputs.sh +``` + + +{{- end }} + +{{- end -}} \ No newline at end of file diff --git a/tools/gen/template/github.md b/tools/gen/template/github.md new file mode 100644 index 0000000..e21b882 --- /dev/null +++ b/tools/gen/template/github.md @@ -0,0 +1,19 @@ +{{ range . }} +### `ariga/atlas-action/{{ .ID }}` + +{{ .Description }} +{{ if .Inputs }} +#### Inputs + +All inputs are optional as they may be specified in the Atlas configuration file. +{{ range $name, $item := .SortedInputs }} +* `{{ $name }}` - {{ if not $item.Required }}(Optional) {{end}}{{ $item.Description | trimnl | nl2sp }} +{{- end }} +{{ end -}} +{{ if .Outputs }} +#### Outputs +{{ range $name, $item := .SortedOutputs }} +* `{{ $name }}` - {{ $item.Description | trimnl | nl2sp }} +{{- end }} +{{ end }} +{{ end }} diff --git a/tools/gen/templates.go b/tools/gen/templates.go new file mode 100644 index 0000000..4a38944 --- /dev/null +++ b/tools/gen/templates.go @@ -0,0 +1,113 @@ +package main + +import ( + "bytes" + "embed" + "io/fs" + "strings" + "text/template" + + "ariga.io/atlas-action/atlasaction" +) + +var ( + // templates holds the Go templates for the code generation. + templates *Template + //go:embed template/* + templateDir embed.FS + // Funcs are the predefined template + // functions used by the codegen. + Funcs = template.FuncMap{ + "xtemplate": xtemplate, + "hasTemplate": hasTemplate, + "trimnl": func(s string) string { + return strings.Trim(s, "\n") + }, + "nl2sp": func(s string) string { + return strings.ReplaceAll(s, "\n", " ") + }, + "env": atlasaction.ToEnvName, + "inputvar": atlasaction.ToInputVarName, + "outputvar": atlasaction.ToOutputVarName, + "dockers": func() map[string]map[string]string { + return map[string]map[string]string{ + "mysql": {"Label": "MySQL", "DevURL": "docker://mysql/8/dev"}, + "postgres": {"Label": "Postgres", "DevURL": "docker://postgres/15/dev?search_path=public"}, + "mariadb": {"Label": "MariaDB", "DevURL": "docker://maria/latest/schema"}, + "sqlserver": {"Label": "SQL Server", "DevURL": "docker://sqlserver/2022-latest?mode=schema"}, + "clickhouse": {"Label": "ClickHouse", "DevURL": "docker://clickhouse/23.11/dev"}, + "sqlite": {"Label": "SQLite", "DevURL": "sqlite://db?mode-memory"}, + } + }, + } +) + +type Template struct { + *template.Template + FuncMap template.FuncMap +} + +func init() { + templates = MustParse( + NewTemplate("templates"). + ParseFS(templateDir, + "template/*.md", + )) +} + +// MustParse is a helper that wraps a call to a function returning (*Template, error) +// and panics if the error is non-nil. +func MustParse(t *Template, err error) *Template { + if err != nil { + panic(err) + } + return t +} + +// NewTemplate creates an empty template with the standard codegen functions. +func NewTemplate(name string) *Template { + t := &Template{Template: template.New(name)} + return t.Funcs(Funcs) +} + +// ParseFS is like ParseFiles or ParseGlob but reads from the file system fsys +// instead of the host operating system's file system. +func (t *Template) ParseFS(fsys fs.FS, patterns ...string) (*Template, error) { + if _, err := t.Template.ParseFS(fsys, patterns...); err != nil { + return nil, err + } + return t, nil +} + +// Funcs merges the given funcMap with the template functions. +func (t *Template) Funcs(funcMap template.FuncMap) *Template { + t.Template.Funcs(funcMap) + if t.FuncMap == nil { + t.FuncMap = template.FuncMap{} + } + for name, f := range funcMap { + if _, ok := t.FuncMap[name]; !ok { + t.FuncMap[name] = f + } + } + return t +} + +// xtemplate dynamically executes templates by their names. +func xtemplate(name string, v any) (string, error) { + buf := bytes.NewBuffer(nil) + if err := templates.ExecuteTemplate(buf, name, v); err != nil { + return "", err + } + return buf.String(), nil +} + +// hasTemplate checks whether a template exists in the loaded templates. +func hasTemplate(name string) bool { + for _, t := range templates.Templates() { + if t.Name() == name { + return true + } + } + return false +}