diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..40231c8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.go] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1c409f0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +on: + pull_request: + +name: Lint + +defaults: + run: + shell: bash + +permissions: + contents: read + pull-requests: read + +jobs: + lint: + name: Lint files + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + only-new-issues: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3561363 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +on: + push: + tags: + - 'v*' + +name: Latest Release + +defaults: + run: + shell: bash + +permissions: + contents: write + pull-requests: read + +jobs: + lint: + name: Lint files + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + only-new-issues: true + + release: + name: Create Release + runs-on: ubuntu-latest + needs: lint + + strategy: + matrix: + arch: + - darwin/amd64 + - darwin/arm64 + - linux/amd64 + - linux/arm + - linux/arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v3 + + - name: Get OS and arch info + run: | + GOOSARCH=${{matrix.arch}} + GOOS=${GOOSARCH%/*} + GOARCH=${GOOSARCH#*/} + BINARY_NAME=${{github.repository}}-$GOOS-$GOARCH + echo "BINARY_NAME=$BINARY_NAME" >> $GITHUB_ENV + echo "GOOS=$GOOS" >> $GITHUB_ENV + echo "GOARCH=$GOARCH" >> $GITHUB_ENV + + - name: Build + run: | + go build -o "$BINARY_NAME" -v + + - name: Release with Notes + uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true + files: ${{env.BINARY_NAME}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1b1a15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.log +.idea +coverage +dist +**/dist +*DS_store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3fa6800 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,28 @@ +issues: + exclude-use-default: true + max-same-issues: 0 + max-issues-per-linter: 0 + +linters: + disable-all: true + enable: + - bodyclose + - goimports + - gosimple + - govet + - ineffassign + - misspell + - revive + - staticcheck + - unconvert + - unused + +linters-settings: + misspell: + locale: US +output: + uniq-by-line: false + +run: + skip-dirs-use-default: false + timeout: 5m diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a876ac2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Gravitational, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 2e608e2..d0b01e7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,121 @@ -# gamma -An open source tool to compile a monorepo of Github actions into individual repos +# Gamma, by [Teleport](https://goteleport.com) + +_**G**ithub **A**ctions **M**onorepo **M**agic **A**utomation_ + +Gamma is a tool that sets out to solve a few shortcomings when it comes to managing and maintaining multiple GitHub actions. + +## What does it do? + +- 🚀 No more including the compiled source code in your commits +- 🚀 Automatically build all your actions into individual, publishable repos +- 🚀 Share schema definitions between actions +- 🚀 Version all actions separately + +Gamma allows you to have a monorepo of actions that are then built and deployed into individual repos. Having each action in its own repo allows for the action to be published on the Github Marketplace. + +Gamma also goes further when it comes to sharing common `action.yml` attributes between actions. Actions in your monorepo can extend upon other YAML files and bring in their `inputs`, `branding`, etc - reducing code duplication and making things easier to maintain. + +## How to use + +This assumes you're using `yarn` with workspaces. Each workspace is an action. + +Your root `package.json` should look like: + +```json +{ + "name": "actions-monorepo", + "private": true, + "workspaces": [ + "actions/*" + ] +} +``` + +Each action then lives under the `actions/` directory. + +Each action should be able to be built via `yarn build`. We recommend [ncc](https://github.com/vercel/ncc) for building your actions. The compiled source code should end up in a `dist` folder, relative to the action. You should add `dist/` to your `.gitignore`. + +`actions/example/package.json` + +```json +{ + "name": "example", + "version": "1.0.0", + "repository": "https://github.com/mono-actions/example.git", + "scripts": { + "build": "ncc build ./src/index.ts -o dist" + }, + "dependencies": { + "@actions/core": "^1.10.0" + }, + "devDependencies": { + "@types/node": "^18.8.2", + "@vercel/ncc": "^0.34.0", + "typescript": "^4.8.4" + } +} +``` + +The `repository` field is where the compiled action will deployed to. + +`actions/example/action.yml` + +This is where Gamma can really shine. You can define your `action.yml` as normal, whilst also extending on other YAML files for common attributes. + +```yaml +name: Example Action +description: This is an example action +extend: + - from: '@/shared/common.yml' + include: + - field: inputs + include: + - version + - field: runs + - field: author + - field: branding +``` + +`@/` refers to the root of the directory. `@/shared/common.yml` would resolve to `shared/common.yml`, which can look like this: + +`shared/common.yml` + +```yaml +author: Gravitational, Inc. +inputs: + version: + required: true + description: 'Specify the version without the preceding "v"' +branding: + icon: terminal + color: purple +runs: + using: node16 + main: dist/index.js +``` + +Gamma will compile this and publish the final `action.yml` to the correct repository. + +`github.com/mono-actions/example/action.yml` + +```yaml +name: Example Action +description: This is an example action +author: Gravitational Inc. +inputs: + version: + description: Specify the version without the preceding "v" + required: true +runs: + using: node16 + main: dist/index.js +branding: + icon: terminal + color: purple +``` + +The built source code will also be committed, so you end up with a publishable Github Action. + +## Use in GitHub actions + +You can use this in your GitHub action workflows via [setup-gamma](https://github.com/gravitational/setup-gamma). diff --git a/cmd/build/build.go b/cmd/build/build.go new file mode 100644 index 0000000..e99bde3 --- /dev/null +++ b/cmd/build/build.go @@ -0,0 +1,102 @@ +package build + +import ( + "os" + "strings" + "time" + + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + + "github.com/gravitational/gamma/internal/logger" + "github.com/gravitational/gamma/internal/utils" + "github.com/gravitational/gamma/internal/workspace" +) + +var outputDirectory string +var workingDirectory string + +var Command = &cobra.Command{ + Use: "build", + Short: "Builds all the actions in the monorepo", + Long: `Builds all the actions in the monorepo and puts them into the specified output directory, separated by repo.`, + Run: func(cmd *cobra.Command, args []string) { + started := time.Now() + + if workingDirectory == "the current working directory" { // this is the default value from the flag + wd, err := os.Getwd() + if err != nil { + logger.Fatalf("could not get current working directory: %v", err) + } + + workingDirectory = wd + } + + wd, od, err := utils.NormalizeDirectories(workingDirectory, outputDirectory) + if err != nil { + logger.Fatal(err) + } + + if err := os.RemoveAll(od); err != nil { + logger.Fatalf("could not remove output directory: %v", err) + } + + if err := os.Mkdir(od, 0755); err != nil { + logger.Fatalf("could not create output directory: %v", err) + } + + ws := workspace.New(wd, od) + + logger.Info("collecting actions") + + actions, err := ws.CollectActions() + if err != nil { + logger.Fatal(err) + } + + if len(actions) == 0 { + logger.Fatal("could not find any actions") + } + + var actionNames []string + for _, action := range actions { + actionNames = append(actionNames, action.Name()) + } + + logger.Infof("found actions [%s]", strings.Join(actionNames, ", ")) + + var hasError bool + + for _, action := range actions { + logger.Infof("action %s has changes, building", action.Name()) + + buildStarted := time.Now() + + if err := action.Build(); err != nil { + hasError = true + logger.Errorf("error building action %s: %v", action.Name(), err) + + continue + } + + buildTook := time.Since(buildStarted) + + logger.Successf("successfully built action %s in %.2fs", action.Name(), buildTook.Seconds()) + } + + bold := text.Colors{text.FgWhite, text.Bold} + + took := time.Since(started) + + if hasError { + logger.Fatal(bold.Sprintf("completed with errors in %.2fs", took.Seconds())) + } + + logger.Success(bold.Sprintf("done in %.2fs", took.Seconds())) + }, +} + +func init() { + Command.Flags().StringVarP(&outputDirectory, "output", "o", "build", "output directory") + Command.Flags().StringVarP(&workingDirectory, "directory", "d", "the current working directory", "directory containing the monorepo of actions") +} diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go new file mode 100644 index 0000000..0b7efeb --- /dev/null +++ b/cmd/deploy/deploy.go @@ -0,0 +1,154 @@ +package deploy + +import ( + "os" + "strings" + "time" + + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + + "github.com/gravitational/gamma/internal/action" + "github.com/gravitational/gamma/internal/git" + "github.com/gravitational/gamma/internal/logger" + "github.com/gravitational/gamma/internal/utils" + "github.com/gravitational/gamma/internal/workspace" +) + +var outputDirectory string +var workingDirectory string +var assetPaths []string + +var Command = &cobra.Command{ + Use: "deploy", + Short: "Builds and deploys actions", + Long: `Builds and deploys all the actions that have changes.`, + Run: func(cmd *cobra.Command, args []string) { + started := time.Now() + + if workingDirectory == "the current working directory" { // this is the default value from the flag + wd, err := os.Getwd() + if err != nil { + logger.Fatalf("could not get current working directory: %v", err) + } + + workingDirectory = wd + } + + wd, od, err := utils.NormalizeDirectories(workingDirectory, outputDirectory) + if err != nil { + logger.Fatal(err) + } + + if err := os.RemoveAll(od); err != nil { + logger.Fatalf("could not remove output directory: %v", err) + } + + if err := os.Mkdir(od, 0755); err != nil { + logger.Fatalf("could not create output directory: %v", err) + } + + repo, err := git.New(wd) + if err != nil { + logger.Fatal(err) + } + + logger.Info("collecting changed files") + + changed, err := repo.GetChangedFiles() + if err != nil { + logger.Fatal(err) + } + + logger.Infof("files changed [%s]", strings.Join(changed, ", ")) + + ws := workspace.New(wd, od) + + logger.Info("collecting actions") + + actions, err := ws.CollectActions() + if err != nil { + logger.Fatal(err) + } + + if len(actions) == 0 { + logger.Fatal("could not find any actions") + } + + var actionNames []string + for _, action := range actions { + actionNames = append(actionNames, action.Name()) + } + + logger.Infof("found actions [%s]", strings.Join(actionNames, ", ")) + + var actionsToBuild []action.Action + + outer: + for _, action := range actions { + for _, file := range changed { + if action.Contains(file) { + actionsToBuild = append(actionsToBuild, action) + + continue outer + } + } + } + + if len(actionsToBuild) == 0 { + logger.Warning("no actions need building, exiting") + + return + } + + var hasError bool + + for _, action := range actionsToBuild { + logger.Infof("action %s has changes, building", action.Name()) + + buildStarted := time.Now() + + if err := action.Build(); err != nil { + hasError = true + logger.Errorf("error building action %s: %v", action.Name(), err) + + continue + } + + buildTook := time.Since(buildStarted) + + logger.Successf("successfully built action %s in %.2fs", action.Name(), buildTook.Seconds()) + + logger.Infof("deploying action %s", action.Name()) + + deployStarted := time.Now() + + if err := repo.DeployAction(action); err != nil { + hasError = true + logger.Errorf("error deploying action %s: %v", action.Name(), err) + + continue + } + + deployTook := time.Since(deployStarted) + + logger.Successf("successfully deployed action %s in %.2fs", action.Name(), deployTook.Seconds()) + } + + bold := text.Colors{text.FgWhite, text.Bold} + + took := time.Since(started) + + if hasError { + logger.Fatal(bold.Sprintf("completed with errors in %.2fs", took.Seconds())) + } + + logger.Success(bold.Sprintf("done in %.2fs", took.Seconds())) + }, +} + +func init() { + Command.Flags().StringVarP(&outputDirectory, "output", "o", "build", "output directory") + Command.Flags().StringVarP(&workingDirectory, "directory", "d", "the current working directory", "directory containing the monorepo of actions") + Command.Flags().StringArrayVarP(&assetPaths, "asset", "a", []string{}, "copy over an asset to each action") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..38e6903 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/gravitational/gamma/cmd/build" + "github.com/gravitational/gamma/cmd/deploy" + "github.com/gravitational/gamma/internal/color" +) + +var rootCmd = &cobra.Command{ + Use: "gamma", + Short: "Gamma builds a monorepo of Github actions into individual repos", +} + +var gammaLogo = "\x1B[38;2;236;147;168m#\x1B[39m\x1B[38;2;226;142;179m#\x1B[39m\x1B[38;2;216;138;191m#\x1B[39m \x1B[38;2;206;133;202mG\x1B[39m\x1B[38;2;197;129;214ma\x1B[39m\x1B[38;2;187;124;225mm\x1B[39m\x1B[38;2;177;120;237mm\x1B[39m\x1B[38;2;167;115;248ma\x1B[39m \x1B[38;2;167;115;248mb\x1B[39m\x1B[38;2;160;109;244my\x1B[39m \x1B[38;2;153;104;240mT\x1B[39m\x1B[38;2;146;98;236me\x1B[39m\x1B[38;2;138;93;232ml\x1B[39m\x1B[38;2;131;87;228me\x1B[39m\x1B[38;2;124;82;225mp\x1B[39m\x1B[38;2;117;76;221mo\x1B[39m\x1B[38;2;110;70;217mr\x1B[39m\x1B[38;2;103;65;213mt\x1B[39m \x1B[38;2;95;59;209m#\x1B[39m\x1B[38;2;88;54;205m#\x1B[39m\x1B[38;2;81;48;201m#\x1B[39m" + +func Execute() error { + return rootCmd.Execute() +} +func logo() string { + return gammaLogo +} + +func init() { + cobra.AddTemplateFunc("emoji", emoji) + cobra.AddTemplateFunc("colorize", colorize) + cobra.AddTemplateFunc("green", color.Green) + cobra.AddTemplateFunc("logo", logo) + + rootCmd.AddCommand(deploy.Command) + rootCmd.AddCommand(deploy.Command) + + rootCmd.SetHelpTemplate(`{{ logo }} + +{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} + +{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`) + + rootCmd.SetUsageTemplate(`Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{green .CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{emoji .Name}} {{colorize .Name (rpad .Name .NamePadding) }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`) +} + +func colorize(s, name string) string { + switch s { + case build.Command.Name(): + return color.Magenta(name) + case deploy.Command.Name(): + return color.Teal(name) + case "help": + return color.Purple(name) + case "completion": + return color.Yellow(name) + } + + return s +} + +func emoji(s string) string { + switch s { + case build.Command.Name(): + return "🔧" + case deploy.Command.Name(): + return "🚀" + case "help": + return "❓" + case "completion": + return "⚡️" + } + + return s +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c8865a --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module github.com/gravitational/gamma + +go 1.19 + +require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.1.0 + github.com/go-git/go-git/v5 v5.4.2 + github.com/google/go-github/v48 v48.1.0 + github.com/jedib0t/go-pretty/v6 v6.4.2 + github.com/spf13/cobra v1.6.1 + golang.org/x/sync v0.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/Microsoft/go-winio v0.4.16 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect + github.com/acomagu/bufpipe v1.0.3 // indirect + github.com/emirpasic/gods v1.12.0 // indirect + github.com/go-git/gcfg v1.5.0 // indirect + github.com/go-git/go-billy/v5 v5.3.1 // indirect + github.com/golang-jwt/jwt/v4 v4.4.1 // indirect + github.com/google/go-github/v45 v45.2.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sergi/go-diff v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.8.1 // indirect + github.com/xanzy/ssh-agent v0.3.0 // indirect + golang.org/x/crypto v0.3.0 // indirect + golang.org/x/net v0.2.0 // indirect + golang.org/x/sys v0.2.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..61f7e93 --- /dev/null +++ b/go.sum @@ -0,0 +1,159 @@ +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= +github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= +github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bradleyfalzon/ghinstallation/v2 v2.1.0 h1:5+NghM1Zred9Z078QEZtm28G/kfDfZN/92gkDlLwGVA= +github.com/bradleyfalzon/ghinstallation/v2 v2.1.0/go.mod h1:Xg3xPRN5Mcq6GDqeUVhFbjEWMb4JHCyWEeeBGEYQoTU= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= +github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= +github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= +github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= +github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI= +github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28= +github.com/google/go-github/v48 v48.1.0 h1:nqPqq+0oRY2AMR/SRskGrrP4nnewPB7e/m2+kbT/UvM= +github.com/google/go-github/v48 v48.1.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jedib0t/go-pretty/v6 v6.4.2 h1:DcJNSNIb1E17Tvy9w9S7z+sExvWvvjNbFdyr6C+FUL0= +github.com/jedib0t/go-pretty/v6 v6.4.2/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= +github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= +golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/action/action.go b/internal/action/action.go new file mode 100644 index 0000000..8450aec --- /dev/null +++ b/internal/action/action.go @@ -0,0 +1,196 @@ +package action + +import ( + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "golang.org/x/sync/errgroup" + "gopkg.in/yaml.v3" + + "github.com/gravitational/gamma/internal/node" + "github.com/gravitational/gamma/internal/schema" +) + +type action struct { + name string + packageInfo *node.PackageInfo + outputDirectory string + workingDirectory string + owner string +} + +type Config struct { + Name string + WorkingDirectory string + OutputDirectory string + PackageInfo *node.PackageInfo +} + +type Action interface { + Build() error + Name() string + Owner() string + OutputDirectory() string + Contains(filename string) bool +} + +func New(config *Config) (Action, error) { + uri, err := url.Parse(config.PackageInfo.Repository) + if err != nil { + return nil, err + } + + parts := strings.Split(uri.Path[1:], "/") + + return &action{ + name: config.Name, + packageInfo: config.PackageInfo, + outputDirectory: config.OutputDirectory, + workingDirectory: config.WorkingDirectory, + owner: parts[0], + }, nil +} + +func (a *action) Name() string { + return a.packageInfo.Name +} + +func (a *action) OutputDirectory() string { + return a.outputDirectory +} + +func (a *action) Owner() string { + return a.owner +} + +func (a *action) Contains(filename string) bool { + normalizedPath, _ := filepath.Rel(a.workingDirectory, a.packageInfo.Path) + + return strings.HasPrefix(filename, normalizedPath+"/") +} + +func (a *action) buildPackage() error { + cmd := exec.Command("yarn", "build") + cmd.Dir = a.packageInfo.Path + + if err := cmd.Run(); err != nil { + return err + } + + return a.movePackage() +} + +func (a *action) movePackage() error { + dist := path.Join(a.packageInfo.Path, "dist") + destination := path.Join(a.outputDirectory, "dist") + + if err := os.Rename(dist, destination); err != nil { + return err + } + + return nil +} + +func (a *action) createActionYAML() error { + filename := path.Join(a.packageInfo.Path, "action.yml") + + definition, err := schema.GetConfig(a.workingDirectory, filename) + if err != nil { + return err + } + + bytes, err := yaml.Marshal(definition) + if err != nil { + return err + } + + output := path.Join(a.outputDirectory, "action.yml") + if err := os.WriteFile(output, bytes, 0644); err != nil { + return fmt.Errorf("could not create action.yml: %v", err) + } + + return nil +} + +func (a *action) copyFile(file string) error { + src := path.Join(a.packageInfo.Path, file) + dst := path.Join(a.outputDirectory, file) + + if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) { + return nil + } + + source, err := os.Open(src) + if err != nil { + return err + } + + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return fmt.Errorf("could not create file: %v", err) + } + + defer destination.Close() + + if _, err := io.Copy(destination, source); err != nil { + return err + } + + return nil +} + +func (a *action) copyFiles() error { + files := []string{ + "README.md", + } + + var eg errgroup.Group + + for _, file := range files { + f := file + eg.Go(func() error { + return a.copyFile(f) + }) + } + + if err := eg.Wait(); err != nil { + return err + } + + return nil +} + +func (a *action) createOutputDirectory() error { + if err := os.Mkdir(a.outputDirectory, 0755); err != nil { + return fmt.Errorf("could not create the output directory: %v", err) + } + + return nil +} + +func (a *action) Build() error { + if err := a.createOutputDirectory(); err != nil { + return fmt.Errorf("could not create output directory: %v", err) + } + + var eg errgroup.Group + + eg.Go(a.buildPackage) + eg.Go(a.createActionYAML) + eg.Go(a.copyFiles) + + if err := eg.Wait(); err != nil { + return err + } + + return nil +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..758e74d --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,38 @@ +package cache + +import ( + "sync" +) + +type cache[T any] struct { + mu sync.RWMutex + + values map[string]T +} + +type Cache[T any] interface { + Get(name string) (T, bool) + Set(name string, value T) +} + +func New[T any]() Cache[T] { + return &cache[T]{ + values: make(map[string]T), + } +} + +func (c *cache[T]) Set(name string, value T) { + c.mu.Lock() + defer c.mu.Unlock() + + c.values[name] = value +} + +func (c *cache[T]) Get(name string) (value T, ok bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + value, ok = c.values[name] + + return +} diff --git a/internal/color/color.go b/internal/color/color.go new file mode 100644 index 0000000..a8214a6 --- /dev/null +++ b/internal/color/color.go @@ -0,0 +1,35 @@ +package color + +import ( + "fmt" +) + +var ( + Red = Color("\033[1;31m%s\033[0m") + Green = Color("\033[1;32m%s\033[0m") + Yellow = Color("\033[1;33m%s\033[0m") + Purple = Color("\033[1;34m%s\033[0m") + Magenta = Color("\033[1;35m%s\033[0m") + Teal = Color("\033[1;36m%s\033[0m") + White = Color("\033[1;37m%s\033[0m") +) + +type Func = func(...interface{}) string + +var Colors = []Func{ + Red, + Green, + Yellow, + Purple, + Magenta, + Teal, + White, +} + +func Color(colorString string) Func { + sprint := func(args ...interface{}) string { + return fmt.Sprintf(colorString, + fmt.Sprint(args...)) + } + return sprint +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..a971466 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,229 @@ +package git + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/bradleyfalzon/ghinstallation/v2" + gogit "github.com/go-git/go-git/v5" + "github.com/google/go-github/v48/github" + + "github.com/gravitational/gamma/internal/action" +) + +type Git interface { + GetChangedFiles() ([]string, error) + DeployAction(a action.Action) error +} + +type git struct { + repo *gogit.Repository + gh *github.Client +} + +func New(wd string) (Git, error) { + repo, err := gogit.PlainOpen(wd) + if err != nil { + return nil, fmt.Errorf("the current directory is not a git repo: %v", err) + } + + gh, err := createGithubClient() + if err != nil { + return nil, err + } + + return &git{repo, gh}, nil +} + +func createGithubClient() (*github.Client, error) { + if os.Getenv("GITHUB_APP_PRIVATE_KEY") == "" { + return nil, errors.New("set your Github app's private key as GITHUB_APP_PRIVATE_KEY") + } + + privateKey := strings.ReplaceAll(os.Getenv("GITHUB_APP_PRIVATE_KEY"), "\\n", "\n") + + if os.Getenv("GITHUB_APP_ID") == "" { + return nil, errors.New("set your Github app's ID as GITHUB_APP_ID") + } + + appID, err := strconv.Atoi(os.Getenv("GITHUB_APP_ID")) + if err != nil { + return nil, errors.New("the Github app ID should be a number") + } + + if os.Getenv("GITHUB_APP_INSTALLATION_ID") == "" { + return nil, errors.New("set your Github app's installation ID as GITHUB_APPID") + } + + appInstallationID, err := strconv.Atoi(os.Getenv("GITHUB_APP_INSTALLATION_ID")) + if err != nil { + return nil, errors.New("the Github app installation ID should be a number") + } + + itr, err := ghinstallation.New(http.DefaultTransport, int64(appID), int64(appInstallationID), []byte(privateKey)) + if err != nil { + return nil, fmt.Errorf("could not authenticate with Github: %v", err) + } + + return github.NewClient(&http.Client{Transport: itr}), nil +} + +func (g *git) GetChangedFiles() ([]string, error) { + head, err := g.repo.Head() + if err != nil { + return nil, fmt.Errorf("could not get HEAD: %v", err) + } + + commit, err := g.repo.CommitObject(head.Hash()) + if err != nil { + return nil, fmt.Errorf("could not get the HEAD commit: %v", err) + } + + parentHash := commit.ParentHashes[0] + parent, err := g.repo.CommitObject(parentHash) + if err != nil { + return nil, fmt.Errorf("could not get the parent commit: %v", err) + } + + patch, err := parent.Patch(commit) + if err != nil { + return nil, fmt.Errorf("could not get the parent patch: %v", err) + } + + changedFiles := make(map[string]struct{}) + + for _, p := range patch.FilePatches() { + from, to := p.Files() + + if from != nil { + changedFiles[from.Path()] = struct{}{} + } + if to != nil { + changedFiles[to.Path()] = struct{}{} + } + } + + var files []string + for file := range changedFiles { + files = append(files, file) + } + + return files, nil +} + +func (g *git) DeployAction(a action.Action) error { + ref, err := g.getRef(context.Background(), a) + if err != nil { + return fmt.Errorf("could not create git ref: %v", err) + } + + tree, err := g.getTree(context.Background(), ref, a) + if err != nil { + return fmt.Errorf("could not create git tree: %v", err) + } + + if err := g.pushCommit(context.Background(), ref, tree, a); err != nil { + return fmt.Errorf("could not push changes: %v", err) + } + + return nil +} + +func (g *git) getTree(ctx context.Context, ref *github.Reference, a action.Action) (*github.Tree, error) { + var entries []*github.TreeEntry + + ferr := filepath.Walk(a.OutputDirectory(), + func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("could not read %s: %v", path, err) + } + + p, err := filepath.Rel(a.OutputDirectory(), path) + if err != nil { + return fmt.Errorf("could not resolve relative path between %s and %s: %v", a.OutputDirectory(), path, err) + } + + entry := &github.TreeEntry{ + Path: github.String(p), + Type: github.String("blob"), + Content: github.String(string(content)), + Mode: github.String("100644"), + } + + entries = append(entries, entry) + + return nil + }) + + if ferr != nil { + return nil, ferr + } + + tree, _, err := g.gh.Git.CreateTree(ctx, a.Owner(), a.Name(), *ref.Object.SHA, entries) + + return tree, err +} + +func (g *git) getRef(ctx context.Context, a action.Action) (*github.Reference, error) { + head, err := g.repo.Head() + if err != nil { + return nil, fmt.Errorf("could not get HEAD: %v", err) + } + + ref, _, err := g.gh.Git.GetRef(ctx, a.Owner(), a.Name(), head.Name().String()) + if err != nil { + return nil, err + } + + return ref, nil +} + +func (g *git) pushCommit(ctx context.Context, ref *github.Reference, tree *github.Tree, a action.Action) error { + parent, _, err := g.gh.Repositories.GetCommit(ctx, a.Owner(), a.Name(), *ref.Object.SHA, nil) + if err != nil { + return err + } + + parent.Commit.SHA = parent.SHA + + head, err := g.repo.Head() + if err != nil { + return fmt.Errorf("could not get HEAD: %v", err) + } + + c, err := g.repo.CommitObject(head.Hash()) + if err != nil { + return fmt.Errorf("could not get the HEAD commit: %v", err) + } + + commit := &github.Commit{ + Message: github.String(c.Message), + Tree: tree, + Parents: []*github.Commit{parent.Commit}, + } + + newCommit, _, err := g.gh.Git.CreateCommit(ctx, a.Owner(), a.Name(), commit) + if err != nil { + return err + } + + ref.Object.SHA = newCommit.SHA + _, _, err = g.gh.Git.UpdateRef(ctx, a.Owner(), a.Name(), ref, false) + + return err +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..4174834 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,65 @@ +package logger + +import ( + "fmt" + "os" + + "github.com/gravitational/gamma/internal/color" +) + +var ( + info = color.Magenta("ℹ") + success = color.Green("✔") + warning = color.Yellow("⚠") + error = color.Red("✖") +) + +func Info(message any) { + fmt.Printf("%s %s\n", info, message) +} + +func Infof(format string, a ...any) { + fmt.Printf("%s ", info) + fmt.Printf(format, a...) + fmt.Print("\n") +} + +func Success(message any) { + fmt.Printf("%s %s\n", success, message) +} + +func Successf(format string, a ...any) { + fmt.Printf("%s ", success) + fmt.Printf(format, a...) + fmt.Print("\n") +} + +func Warning(message any) { + fmt.Printf("%s %s\n", warning, message) +} + +func Warningf(format string, a ...any) { + fmt.Printf("%s ", warning) + fmt.Printf(format, a...) + fmt.Print("\n") +} + +func Error(message any) { + fmt.Printf("%s %s\n", error, message) +} + +func Errorf(format string, a ...any) { + fmt.Printf("%s ", error) + fmt.Printf(format, a...) + fmt.Print("\n") +} + +func Fatal(message any) { + fmt.Printf("%s %s\n", error, message) + os.Exit(1) +} + +func Fatalf(format string, a ...any) { + Errorf(format, a...) + os.Exit(1) +} diff --git a/internal/node/package.go b/internal/node/package.go new file mode 100644 index 0000000..49fe0d8 --- /dev/null +++ b/internal/node/package.go @@ -0,0 +1,107 @@ +package node + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" +) + +type Workspaces struct { + Value []string +} + +func (w *Workspaces) UnmarshalJSON(data []byte) error { + var obj struct { + Packages []string `json:"packages"` + } + + if err := json.Unmarshal(data, &obj); err == nil { + w.Value = obj.Packages + + return nil + } + var array []string + if err := json.Unmarshal(data, &array); err == nil { + w.Value = array + + return nil + } + + return errors.New("could not parse workspaces") +} + +type packageService struct { + RootPath string +} + +type PackageService interface { + ReadPackageInfo(filename string) (*PackageInfo, error) + GetWorkspaces(p *PackageInfo) ([]*PackageInfo, error) +} + +func NewPackageService(rootPath string) PackageService { + return &packageService{ + rootPath, + } +} + +func (s *packageService) ReadPackageInfo(filename string) (*PackageInfo, error) { + p := PackageInfo{ + Path: path.Dir(filename), + RootPath: s.RootPath, + } + + contents, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading package.json: %v [%s]", err, filename) + } + + if err := json.Unmarshal(contents, &p); err != nil { + return nil, fmt.Errorf("error parsing package.json: %v [%s]", err, filename) + } + + return &p, nil +} + +type PackageInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Repository string `json:"repository"` + Workspaces Workspaces `json:"workspaces"` + + Path string + RootPath string +} + +func (s *packageService) GetWorkspaces(p *PackageInfo) ([]*PackageInfo, error) { + if len(p.Workspaces.Value) == 0 { + return nil, errors.New("no workspaces specified") + } + + var workspaces []*PackageInfo + + dir := os.DirFS(p.Path) + for _, workspace := range p.Workspaces.Value { + matches, err := fs.Glob(dir, workspace) + + if err != nil { + return nil, err + } + + for _, match := range matches { + filename := path.Join(p.Path, match, "package.json") + + w, err := s.ReadPackageInfo(filename) + if err != nil { + return nil, err + } + + workspaces = append(workspaces, w) + } + } + + return workspaces, nil +} diff --git a/internal/schema/parse.go b/internal/schema/parse.go new file mode 100644 index 0000000..6545a5b --- /dev/null +++ b/internal/schema/parse.go @@ -0,0 +1,304 @@ +package schema + +import ( + "fmt" + "os" + "path" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/gravitational/gamma/internal/cache" +) + +var configCache = cache.New[*Config]() + +func GetConfig(root, filename string) (*Config, error) { + var config CustomConfig + + contents, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading %s: %v", filename, err) + } + + if err := yaml.Unmarshal(contents, &config); err != nil { + return nil, fmt.Errorf("error parsing %s: %v", filename, err) + } + + config.Path = filename + + return parseCustomConfig(root, filename, config) +} + +func parseCustomConfig(root, filename string, customConfig CustomConfig) (*Config, error) { + config := &Config{ + Path: customConfig.Path, + Name: customConfig.Name, + Author: customConfig.Author, + Description: customConfig.Description, + Inputs: customConfig.Inputs, + Outputs: customConfig.Outputs, + Runs: customConfig.Runs, + Branding: customConfig.Branding, + } + + if customConfig.Extend != nil { + for _, extension := range *customConfig.Extend { + file := extension.From + if strings.HasPrefix(file, "@/") { + file = strings.TrimPrefix(file, "@/") + file = path.Join(root, file) + } + if !path.IsAbs(file) { + file = path.Join(filename, file) + } + + var extensionConfig *Config + var ok bool + + extensionConfig, ok = configCache.Get(file) + if !ok { + def, err := GetConfig(root, file) + if err != nil { + return nil, err + } + + configCache.Set(file, def) + + extensionConfig = def + } + + if err := mergeConfigs(config, extensionConfig, extension.Include); err != nil { + return nil, err + } + } + } + + return config, nil +} + +func mergeConfigs(base, extension *Config, includes *[]ExtensionInclude) error { + if includes != nil { + for _, include := range *includes { + field := include.Field + + switch field { + case "inputs": + if err := mergeInputs(base, extension, &include); err != nil { + return err + } + case "outputs": + if err := mergeOutputs(base, extension, &include); err != nil { + return err + } + case "branding": + if err := mergeBranding(base, extension, &include); err != nil { + return err + } + case "author": + if err := mergeAuthor(base, extension); err != nil { + return err + } + case "runs": + if err := mergeRuns(base, extension); err != nil { + return err + } + } + } + + return nil + } + + _ = mergeInputs(base, extension, nil) + _ = mergeOutputs(base, extension, nil) + _ = mergeBranding(base, extension, nil) + _ = mergeAuthor(base, extension) + _ = mergeRuns(base, extension) + + return nil +} + +func mergeInputs(base, extension *Config, includes *ExtensionInclude) error { + if extension.Inputs == nil { + return fmt.Errorf("no inputs exist in %s", extension.Path) + } + + newInputs := make(InputMap) + + if base.Inputs != nil { + for key, value := range *base.Inputs { + newInputs[key] = value + } + } + + if includes != nil && includes.Include != nil { + for _, field := range *includes.Include { + + inputs := *extension.Inputs + + input, ok := inputs[field] + if !ok { + return fmt.Errorf("input %s does not exist in %s", field, extension.Path) + } + + if _, ok := newInputs[field]; ok { + return fmt.Errorf("conflicting input, %s exists in both %s and %s", field, base.Path, extension.Path) + } + + newInputs[field] = input + } + } else { + outer: + for key, input := range *extension.Inputs { + if includes != nil && includes.Exclude != nil { + for _, exclude := range *includes.Exclude { + if exclude == key { + continue outer + } + } + } + + newInputs[key] = input + } + } + + base.Inputs = &newInputs + + return nil +} + +func mergeOutputs(base, extension *Config, includes *ExtensionInclude) error { + if extension.Outputs == nil { + return fmt.Errorf("no outputs exist in %s", extension.Path) + } + + newOutputs := make(OutputMap) + + if base.Outputs != nil { + for key, value := range *base.Outputs { + newOutputs[key] = value + } + } + + if includes != nil && includes.Include != nil { + for _, field := range *includes.Include { + + outputs := *extension.Outputs + + output, ok := outputs[field] + if !ok { + return fmt.Errorf("output %s does not exist in %s", field, extension.Path) + } + + if _, ok := newOutputs[field]; ok { + return fmt.Errorf("conflicting output, %s exists in both %s and %s", field, base.Path, extension.Path) + } + + newOutputs[field] = output + } + } else { + outer: + for key, output := range *extension.Outputs { + if includes != nil && includes.Exclude != nil { + for _, exclude := range *includes.Exclude { + if exclude == key { + continue outer + } + } + } + + newOutputs[key] = output + } + } + + base.Outputs = &newOutputs + + return nil +} + +func mergeBranding(base, extension *Config, includes *ExtensionInclude) error { + if extension.Branding == nil { + return fmt.Errorf("no branding exists in %s", extension.Path) + } + + newBranding := &Branding{} + if base.Branding != nil { + newBranding = base.Branding + } + + if includes != nil && includes.Include != nil { + for _, field := range *includes.Include { + + switch field { + case "color": + newBranding.Color = extension.Branding.Color + case "icon": + newBranding.Icon = extension.Branding.Icon + } + } + } else { + var excludeColor bool + var excludeIcon bool + + if includes != nil && includes.Exclude != nil { + for _, exclude := range *includes.Exclude { + switch exclude { + case "color": + excludeColor = true + case "icon": + excludeIcon = true + } + } + } + + if !excludeColor { + newBranding.Color = extension.Branding.Color + } + + if !excludeIcon { + newBranding.Icon = extension.Branding.Icon + } + } + + base.Branding = newBranding + + return nil +} + +func mergeRuns(base, extension *Config) error { + if extension.Runs.JavascriptRun == nil && + extension.Runs.DockerRun == nil && + extension.Runs.CompositeRun == nil { + return fmt.Errorf("runs is empty in %s", extension.Path) + } + + if extension.Runs.JavascriptRun != nil { + base.Runs = Runs{ + JavascriptRun: extension.Runs.JavascriptRun, + } + } + + if extension.Runs.DockerRun != nil { + base.Runs = Runs{ + DockerRun: extension.Runs.DockerRun, + } + } + + if extension.Runs.CompositeRun != nil { + base.Runs = Runs{ + CompositeRun: extension.Runs.CompositeRun, + } + } + + return nil +} + +func mergeAuthor(base, extension *Config) error { + if extension.Author == nil { + return fmt.Errorf("author is empty in %s", extension.Path) + } + + base.Author = extension.Author + + return nil +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 0000000..9e80a12 --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,172 @@ +package schema + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Path string `yaml:"-"` + Name string `yaml:"name"` + Author *string `yaml:"author,omitempty"` + Description string `yaml:"description"` + Inputs *InputMap `yaml:"inputs,omitempty"` + Outputs *OutputMap `yaml:"output,omitempty"` + Runs Runs `yaml:"runs"` + Branding *Branding `yaml:"branding,omitempty"` +} + +type CustomConfig struct { + Path string `yaml:"-"` + Name string `yaml:"name"` + Author *string `yaml:"author,omitempty"` + Description string `yaml:"description"` + Inputs *InputMap `yaml:"inputs,omitempty"` + Outputs *OutputMap `yaml:"output,omitempty"` + Runs Runs `yaml:"runs"` + Branding *Branding `yaml:"branding,omitempty"` + Extend *[]Extension `yaml:"extend,omitempty"` +} + +type ExtensionInclude struct { + Field string `yaml:"field"` + Include *[]string `yaml:"include"` + Exclude *[]string `yaml:"exclude"` +} + +type Extension struct { + From string `yaml:"from"` + Include *[]ExtensionInclude `yaml:"include"` +} + +type Branding struct { + Color *string `json:"color"` + Icon *string `json:"icon"` +} + +type Input struct { + Description string `yaml:"description"` + Required *bool `yaml:"required,omitempty"` + Default *string `yaml:"default,omitempty"` + DeprecationMessage *string `yaml:"deprecationMessage,omitempty"` +} + +type InputMap = map[string]Input + +type Output struct { + Description string `yaml:"description"` + Value string `yaml:"value"` +} + +type OutputMap = map[string]Output + +type EnvMap = map[string]string +type WithMap = map[string]string + +type JavascriptRun struct { + Using string `yaml:"using"` + Main string `yaml:"main"` + Pre *string `yaml:"pre,omitempty"` + PreIf *string `yaml:"pre-if,omitempty"` + Post *string `yaml:"post,omitempty"` + PostIf *string `yaml:"post-if,omitempty"` +} + +type RunStep struct { + Run *string `yaml:"run,omitempty"` + Shell *string `yaml:"shell,omitempty"` + If *string `yaml:"if,omitempty"` + Name *string `yaml:"name,omitempty"` + ID *string `yaml:"id,omitempty"` + Env *EnvMap `yaml:"env,omitempty"` + WorkingDirectory *string `yaml:"working-directory,omitempty"` + Uses *string `yaml:"uses,omitempty"` + With *WithMap `yaml:"with,omitempty"` +} + +type CompositeRun struct { + Using string `yaml:"using"` + Steps []RunStep `yaml:"steps"` +} + +type DockerRun struct { + PreEntrypoint *string `yaml:"pre-entrypoint,omitempty"` + Image string `yaml:"image"` + Env *EnvMap `yaml:"env,omitempty"` + Entrypoint *string `yaml:"entrypoint,omitempty"` + PostEntrypoint *string `yaml:"post-entrypoint,omitempty"` + Args *[]string `yaml:"args,omitempty"` +} + +type Runs struct { + Using string `yaml:"using"` + + *CompositeRun + *JavascriptRun + *DockerRun +} + +func (r Runs) MarshalYAML() (interface{}, error) { + if r.JavascriptRun != nil { + return r.JavascriptRun, nil + } + + if r.DockerRun != nil { + return r.DockerRun, nil + } + + if r.CompositeRun != nil { + return r.CompositeRun, nil + } + + return nil, nil +} + +func (r *Runs) UnmarshalYAML(value *yaml.Node) error { + var obj struct { + Using string `yaml:"using"` + } + if err := value.Decode(&obj); err != nil { + return err + } + + r.Using = obj.Using + + switch obj.Using { + case "composite": + var compositeRun CompositeRun + + if err := value.Decode(&compositeRun); err != nil { + return err + } + + r.CompositeRun = &compositeRun + + return nil + + case "docker": + var dockerRun DockerRun + + if err := value.Decode(&dockerRun); err != nil { + return err + } + + r.DockerRun = &dockerRun + + return nil + + case "node12", "node16": + var javascriptRun JavascriptRun + + if err := value.Decode(&javascriptRun); err != nil { + return err + } + + r.JavascriptRun = &javascriptRun + + return nil + } + + return fmt.Errorf("unsupported runs.using value: %v, expected composite, docker, node12 or node16", obj.Using) +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..f107404 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,28 @@ +package utils + +import ( + "fmt" + "os" + "path" +) + +func NormalizeDirectories(workingDirectory, outputDirectory string) (string, string, error) { + wd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("could not get current working directory: %v", err) + } + + if workingDirectory == "" { + workingDirectory = wd + } else { + if !path.IsAbs(workingDirectory) { + workingDirectory = path.Join(wd, workingDirectory) + } + } + + if !path.IsAbs(outputDirectory) { + outputDirectory = path.Join(workingDirectory, outputDirectory) + } + + return workingDirectory, outputDirectory, nil +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..e5c1b55 --- /dev/null +++ b/internal/workspace/workspace.go @@ -0,0 +1,65 @@ +package workspace + +import ( + "path" + + "github.com/gravitational/gamma/internal/action" + "github.com/gravitational/gamma/internal/node" +) + +type Workspace interface { + CollectActions() ([]action.Action, error) +} + +type workspace struct { + workingDirectory string + outputDirectory string + packages node.PackageService +} + +func New(workingDirectory, outputDirectory string) Workspace { + return &workspace{ + workingDirectory, + outputDirectory, + node.NewPackageService(workingDirectory), + } +} + +func (w *workspace) CollectActions() ([]action.Action, error) { + rootPackage, err := w.readRootPackage() + if err != nil { + return nil, err + } + + workspaces, err := w.packages.GetWorkspaces(rootPackage) + if err != nil { + return nil, err + } + + var actions []action.Action + for _, ws := range workspaces { + outputDirectory := path.Join(w.outputDirectory, ws.Name) + + config := &action.Config{ + Name: ws.Name, + WorkingDirectory: w.workingDirectory, + OutputDirectory: outputDirectory, + PackageInfo: ws, + } + + action, err := action.New(config) + if err != nil { + return nil, err + } + + actions = append(actions, action) + } + + return actions, nil +} + +func (w *workspace) readRootPackage() (*node.PackageInfo, error) { + p := path.Join(w.workingDirectory, "package.json") + + return w.packages.ReadPackageInfo(p) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f620032 --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/gravitational/gamma/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +}