diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2e329ec..0c7fa37 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,11 +30,12 @@ jobs: with: go-version: 1.18 - run: make test - name: Run Tests + name: Run tests and generate coverage report shell: bash - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENAI_ORG_ID: ${{ secrets.OPENAI_ORG_ID }} + - uses: codecov/codecov-action@v3 + with: + files: ./cover.out + fail_ci_if_error: true - run: go vet -v ./... build-all: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 47780b3..da6acea 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ last-processed.json .copilot-ops.local.yaml # development files -bin/ \ No newline at end of file +bin/ + +# coverage files should be generated within CI +cover.out \ No newline at end of file diff --git a/Makefile b/Makefile index 2133fd1..637af39 100644 --- a/Makefile +++ b/Makefile @@ -5,16 +5,6 @@ build: @ echo ./copilot-ops -h "# run me!" .PHONY: build -test: build lint - @ echo ▶️ go test - go clean -testcache ./... - go test -v ./... - @ echo ✅ go test - @ echo ▶️ go vet - go vet ./... - @ echo ✅ go vet -.PHONY: test - ##@ Development @@ -24,8 +14,11 @@ lint: golangci-lint ## Lint source code $(GOLANGCILINT) run ./... @ echo "✅ golangci-lint run" -# .PHONY: test -# test: lint ginkgo ## Run tests. +.PHONY: test +test: lint ginkgo ## Run tests. + @ echo "▶️ ginkgo test" + $(GINKGO) --coverprofile "cover.out" ./... + @ echo "✅ ginkgo test" ##@ Build Dependencies @@ -42,5 +35,22 @@ GOLANGCILINT := $(LOCALBIN)/golangci-lint GOLANGCI_URL := https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh golangci-lint: $(GOLANGCILINT) ## Download golangci-lint $(GOLANGCILINT): $(LOCALBIN) + @ echo "▶️ Downloading golangci-lint" curl -sSfL $(GOLANGCI_URL) | sh -s -- -b $(LOCALBIN) $(GOLANGCI_VERSION) + @ echo "✅ Downloading golangci-lint" + +.PHONY: ginkgo +GINKGO := $(LOCALBIN)/ginkgo +ginkgo: $(GINKGO) ## Download ginkgo +$(GINKGO): $(LOCALBIN) + @ echo "▶️ Downloading ginkgo@v2" + GOBIN=$(LOCALBIN) go install -mod=mod github.com/onsi/ginkgo/v2/ginkgo + @ echo "✅ Downloaded ginkgo" + +##@ Build Dependencies +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + @ echo "▶️ Local binary directory not present, creating..." + mkdir -p $(LOCALBIN) + @ echo "✅ Local binary directory created" \ No newline at end of file diff --git a/README.md b/README.md index 79c2b4e..798a0d8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # copilot-ops +[![Go Report Card](https://goreportcard.com/badge/github.com/redhat-et/copilot-ops)](https://goreportcard.com/report/github.com/redhat-et/copilot-ops) + `copilot-ops` is a CLI tool that boosts up any "devops repo" to a ninja level of *Artificially Intelligent Ops Repo*. ## Requirements diff --git a/go.mod b/go.mod index 911fc5d..3f9aa28 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/redhat-et/copilot-ops go 1.18 require ( + github.com/onsi/ginkgo/v2 v2.1.4 + github.com/onsi/gomega v1.19.0 github.com/spf13/cobra v1.4.0 github.com/spf13/viper v1.11.0 ) @@ -20,6 +22,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.2.0 // indirect + golang.org/x/net v0.0.0-20220412020605-290c469a71a5 // indirect golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/ini.v1 v1.66.4 // indirect diff --git a/go.sum b/go.sum index ef693a8..f8119a5 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -136,6 +137,10 @@ github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamh github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/onsi/ginkgo/v2 v2.1.4 h1:GNapqRSid3zijZ9H77KrgVG4/8KqiyRsxcSxe+7ApXY= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU= @@ -249,6 +254,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -456,6 +463,7 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 40f5f1f..e692310 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -22,12 +22,10 @@ func NewRootCmd() *cobra.Command { // the root command shows the available subcommands cmd := &cobra.Command{ Use: "copilot-ops", - Long: `copilot-ops is a workflow automation tool that proposes an intelligent patches on a repo, using natural language AI engines (openai.com codex bring-your-own-token), and can be used to implement github bots, editor extensions, and more. `, - Example: ` copilot-ops generate --help`, // Usage on every error is too noisy and makes it harder diff --git a/pkg/cmd/cmd_suite_test.go b/pkg/cmd/cmd_suite_test.go new file mode 100644 index 0000000..2f3788e --- /dev/null +++ b/pkg/cmd/cmd_suite_test.go @@ -0,0 +1,68 @@ +package cmd_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/redhat-et/copilot-ops/pkg/openai" +) + +func TestCmd(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Cmd Suite") +} + +// OpenAITestServer Creates a mocked OpenAI server which can pretend to handle requests during testing. +func OpenAITestServer() *httptest.Server { + return httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var resBytes []byte + switch r.URL.Path { + case "/v1/edits": + res := openai.EditResponse{ + Response: openai.Response{ + Choices: []openai.Choice{ + { + Text: ` +# @path/to/kubernetes.yaml +apiVersion: v1 +kind: Pod +metadata: + name: cute-cats +spec: + priority: high +`, + Index: 0, + }, + }, + }, + } + resBytes, _ = json.Marshal(res) + fmt.Fprint(w, string(resBytes)) + return + case "/v1/completions": + res := openai.CompletionResponse{ + ID: "completion id", + Response: openai.Response{ + Choices: []openai.Choice{ + { + Text: "choice 1", + Index: 0, + }, + }, + }, + } + resBytes, _ = json.Marshal(res) + fmt.Fprintln(w, string(resBytes)) + return + default: + // the endpoint doesn't exist + http.Error(w, "the resource path doesn't exist", http.StatusNotFound) + return + } + })) +} diff --git a/pkg/cmd/cmd_test.go b/pkg/cmd/cmd_test.go new file mode 100644 index 0000000..7acb9b6 --- /dev/null +++ b/pkg/cmd/cmd_test.go @@ -0,0 +1,24 @@ +package cmd_test + +import ( + "log" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + cmd "github.com/redhat-et/copilot-ops/pkg/cmd" +) + +var _ = Describe("Root command", func() { + When("root command is created", func() { + It("contains edit and generate", func() { + rootCmd := cmd.NewRootCmd() + Expect(rootCmd).NotTo(BeNil()) + }) + }) + Describe("Edit command", func() { + It("hello world", func() { + log.Println("hello") + }) + }) +}) diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index 67a58d1..e510c24 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -48,7 +48,7 @@ func (c *Config) Load() error { viper.SetConfigName(".copilot-ops") // name of config file (without extension) if err := viper.MergeInConfig(); err != nil { - var configFileNotFound *viper.ConfigFileNotFoundError + var configFileNotFound viper.ConfigFileNotFoundError if ok := errors.As(err, &configFileNotFound); !ok { return err // allow no config file } @@ -60,7 +60,7 @@ func (c *Config) Load() error { viper.SetConfigName(".copilot-ops.local") if err := viper.MergeInConfig(); err != nil { - var configFileNotFound *viper.ConfigFileNotFoundError + var configFileNotFound viper.ConfigFileNotFoundError if ok := errors.As(err, &configFileNotFound); !ok { return err // allow no config file } @@ -75,6 +75,8 @@ func (c *Config) Load() error { return nil } +// FindFileset Returns a fileset with the matching name, +// or nil if none exists. func (c *Config) FindFileset(name string) *ConfigFilesets { for _, fileset := range c.Filesets { if fileset.Name == name { diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go new file mode 100644 index 0000000..193e76a --- /dev/null +++ b/pkg/cmd/config_test.go @@ -0,0 +1,42 @@ +package cmd_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + cmd "github.com/redhat-et/copilot-ops/pkg/cmd" +) + +var _ = Describe("Config", func() { + var config *cmd.Config + When("config is being loaded", func() { + BeforeEach(func() { + // generate an empty config + config = &cmd.Config{} + }) + When("filesets are provided", func() { + BeforeEach(func() { + config.Filesets = []cmd.ConfigFilesets{ + { + Name: "test", + Files: []string{"test.txt"}, + }, + } + }) + + It("finds the correct filesets", func() { + // config should find a fileset named "test" + Expect(config.FindFileset("test")).NotTo(BeNil()) + // config should not find a fileset named "test2" + Expect(config.FindFileset("test2")).To(BeNil()) + }) + + It("is case sensitive", func() { + // config should not find a fileset named "test" + Expect(config.FindFileset("test")).NotTo(BeNil()) + // config should find a fileset named "TEST" + Expect(config.FindFileset("TEST")).To(BeNil()) + }) + }) + }) +}) diff --git a/pkg/cmd/constants.go b/pkg/cmd/constants.go index 8d0b458..6d6ed8b 100644 --- a/pkg/cmd/constants.go +++ b/pkg/cmd/constants.go @@ -2,14 +2,24 @@ package cmd // Define the names of flags used in commands. const ( - FlagRequest = "request" - FlagWrite = "write" - FlagPath = "path" - FlagFiles = "file" - FlagFilesets = "fileset" - FlagNTokens = "ntokens" - FlagNCompletions = "ncompletions" - FlagOutputType = "output" + FlagRequestFull = "request" + FlagRequestShort = "r" + FlagWriteFull = "write" + FlagWriteShort = "w" + FlagPathFull = "path" + FlagPathShort = "p" + FlagFilesFull = "file" + FlagFilesShort = "f" + FlagFilesetsFull = "fileset" + FlagFilesetsShort = "s" + FlagNTokensFull = "ntokens" + FlagNTokensShort = "n" + FlagNCompletionsFull = "ncompletions" + FlagNCompletionsShort = "c" + FlagOpenAIURLFull = "openai-url" + FlagOpenAIURLShort = "d" + FlagOutputTypeFull = "output" + FlagOutputTypeShort = "o" ) // COMMAND Constants which define the names of commands used in the CLI. diff --git a/pkg/cmd/edit.go b/pkg/cmd/edit.go index b7edad5..57f3888 100644 --- a/pkg/cmd/edit.go +++ b/pkg/cmd/edit.go @@ -28,7 +28,7 @@ func NewEditCmd() *cobra.Command { // flag to add a file cmd.Flags().StringP( - FlagFiles, "f", "", + FlagFilesFull, FlagFilesShort, "", "File path to the document which should be edited.", ) diff --git a/pkg/cmd/edit_test.go b/pkg/cmd/edit_test.go new file mode 100644 index 0000000..153cc40 --- /dev/null +++ b/pkg/cmd/edit_test.go @@ -0,0 +1,43 @@ +package cmd_test + +import ( + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/redhat-et/copilot-ops/pkg/cmd" + "github.com/spf13/cobra" +) + +var _ = Describe("Edit", func() { + var c *cobra.Command + + BeforeEach(func() { + // create command + c = cmd.NewEditCmd() + Expect(c).NotTo(BeNil()) + }) + + When("OpenAI server exists", func() { + var ts *httptest.Server + BeforeEach(func() { + ts = OpenAITestServer() + }) + + JustBeforeEach(func() { + ts.Start() + err := c.Flags().Set(cmd.FlagOpenAIURLFull, ts.URL) + Expect(err).To(BeNil()) + }) + + AfterEach(func() { + defer ts.Close() + }) + + It("works", func() { + err := cmd.RunEdit(c, []string{}) + Expect(err).To(BeNil()) + }) + + }) +}) diff --git a/pkg/cmd/generate.go b/pkg/cmd/generate.go index 8ae5260..3a2a8b8 100644 --- a/pkg/cmd/generate.go +++ b/pkg/cmd/generate.go @@ -33,44 +33,44 @@ func NewGenerateCmd() *cobra.Command { // generate-specific flags cmd.Flags().StringArrayP( - FlagFiles, "f", []string{}, + FlagFilesFull, FlagFilesShort, []string{}, "File paths (glob) to be considered for the patch (can be specified multiple times)", ) cmd.Flags().StringArrayP( - FlagFilesets, "s", []string{}, + FlagFilesetsFull, FlagFilesetsShort, []string{}, "Fileset names (defined in "+ConfigFile+") to be considered for the patch (can be specified multiple times)", ) cmd.Flags().Int32P( - FlagNTokens, "n", DefaultTokens, + FlagNTokensFull, FlagNTokensShort, DefaultTokens, "Max number of tokens to generate", ) cmd.Flags().Int32P( - FlagNCompletions, "c", DefaultCompletions, + FlagNCompletionsFull, FlagNCompletionsShort, DefaultCompletions, "Number of completions to generate", ) return cmd } -// RunGenerate is the implementation of the `copilot-ops patch` command. +// RunGenerate is the implementation of the `copilot-ops generate` command. func RunGenerate(cmd *cobra.Command, args []string) error { r, err := PrepareRequest(cmd, openai.OpenAICodeDavinciV2) if err != nil { return err } - input := PrepareGenerateInput(r.UserRequest, r.FilemapText) log.Printf("requesting generate from OpenAI: %s", input) // generate a response from OpenAI output, err := r.OpenAI.GenerateCode(input) if err != nil { - return err + return fmt.Errorf("got error from OpenAI: %w", err) } + // decode the response r.Filemap = filemap.NewFilemap() log.Printf("decoding output") for _, s := range output { @@ -78,16 +78,15 @@ func RunGenerate(cmd *cobra.Command, args []string) error { err = r.Filemap.DecodeFromOutput(s) } } + if err == nil { return PrintOrWriteOut(r) } // HACK: try other way to decode the output to a fileset log.Printf("decoding failed, got error: %s", err) - log.Printf("trying fallback") - // fallback - generate new files and put the content inside - r = generateNewFiles(r, output) + generateNewFiles(r, output) return PrintOrWriteOut(r) } @@ -178,23 +177,23 @@ func callToActionSequence(request string, encodedFiles string) string { return prompt } -// Create a new file for every requested completion and store them in the "generated-by-copilot-ops" directory. -func generateNewFiles(req *Request, sepOutput []string) *Request { +// generateNewFiles Creates a new file for every requested completion, +// and stores them in the "generated-by-copilot-ops" directory. +func generateNewFiles(req *Request, sepOutput []string) { var i int32 newMap := make(map[string]filemap.File) for i = 0; i < req.OpenAI.NCompletions; i++ { + // set file name + path here newFileName := "generated-by-copilot-ops" + fmt.Sprint(i+1) + ".yaml" newFilePath := path.Join("generated-by-copilot-ops", newFileName) + var newFile filemap.File newFile.Content = sepOutput[i] newFile.Path = newFilePath - newFile.Tag = newFilePath newFile.Name = newFileName newMap[newFilePath] = newFile } req.Filemap.Files = newMap - - return req } diff --git a/pkg/cmd/generate_test.go b/pkg/cmd/generate_test.go new file mode 100644 index 0000000..a04b12a --- /dev/null +++ b/pkg/cmd/generate_test.go @@ -0,0 +1,59 @@ +package cmd_test + +import ( + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "github.com/redhat-et/copilot-ops/pkg/cmd" +) + +var _ = Describe("Generate command", func() { + var c *cobra.Command + var ts *httptest.Server + + BeforeEach(func() { + c = cmd.NewGenerateCmd() + }) + + When("server is created", func() { + BeforeEach(func() { + ts = OpenAITestServer() + + Expect(c).NotTo(BeNil()) + err := c.Flags().Set(cmd.FlagNTokensFull, "1") + Expect(err).To(BeNil()) + }) + + JustBeforeEach(func() { + ts.Start() + err := c.Flags().Set(cmd.FlagOpenAIURLFull, ts.URL) + Expect(err).To(BeNil()) + }) + AfterEach(func() { + ts.Close() + }) + + It("executes properly", func() { + err := cmd.RunGenerate(c, []string{}) + // use the minimum amount of tokens from OpenAI + Expect(err).To(BeNil()) + }) + // TODO: add more tests for expected success + }) + + When("OpenAI server is down", func() { + BeforeEach(func() { + // set a port that isn't taken + err := c.Flags().Set(cmd.FlagOpenAIURLFull, "http://localhost:23423") + Expect(err).To(BeNil()) + }) + It("fails", func() { + err := cmd.RunGenerate(c, []string{}) + Expect(err).To(HaveOccurred()) + }) + // TODO: add more cases that should fail + }) +}) diff --git a/pkg/cmd/utils.go b/pkg/cmd/utils.go index 6ea5f81..1b4ef42 100644 --- a/pkg/cmd/utils.go +++ b/pkg/cmd/utils.go @@ -20,40 +20,47 @@ type Request struct { IsWrite bool OpenAI *openai.Client OutputType string + OpenAIURL string } -func BuildOpenAIClient(conf Config, nTokens int32, nCompletions int32, engine string) *openai.Client { +// BuildOpenAIClient Creates and configures an OpenAI client based on the given parameters. +func BuildOpenAIClient(conf Config, nTokens int32, nCompletions int32, engine string, openAIURL string) *openai.Client { // create OpenAI client - openAIClient := openai.CreateOpenAIClient(conf.OpenAI.APIKey, conf.OpenAI.OrgID, engine) + openAIClient := openai.CreateOpenAIClient() openAIClient.NTokens = nTokens openAIClient.NCompletions = nCompletions openAIClient.Engine = engine + // allow override of OpenAI URL for testing + openAIClient.APIUrl = openAIURL + openai.OpenAIEndpointV1 return openAIClient } +// PrepareRequest Processes the user input along with provided environment variables, +// creating a Request object which is used for context in further requests. func PrepareRequest(cmd *cobra.Command, engine string) (*Request, error) { - request, _ := cmd.Flags().GetString(FlagRequest) - write, _ := cmd.Flags().GetBool(FlagWrite) - path, _ := cmd.Flags().GetString(FlagPath) - files, _ := cmd.Flags().GetStringArray(FlagFiles) + request, _ := cmd.Flags().GetString(FlagRequestFull) + write, _ := cmd.Flags().GetBool(FlagWriteFull) + path, _ := cmd.Flags().GetString(FlagPathFull) + files, _ := cmd.Flags().GetStringArray(FlagFilesFull) if cmd.Name() == CommandEdit { - file, _ := cmd.Flags().GetString(FlagFiles) + file, _ := cmd.Flags().GetString(FlagFilesFull) files = append(files, file) } - filesets, _ := cmd.Flags().GetStringArray(FlagFilesets) - nTokens, _ := cmd.Flags().GetInt32(FlagNTokens) - nCompletions, _ := cmd.Flags().GetInt32(FlagNCompletions) - outputType, _ := cmd.Flags().GetString(FlagOutputType) + filesets, _ := cmd.Flags().GetStringArray(FlagFilesetsFull) + nTokens, _ := cmd.Flags().GetInt32(FlagNTokensFull) + nCompletions, _ := cmd.Flags().GetInt32(FlagNCompletionsFull) + outputType, _ := cmd.Flags().GetString(FlagOutputTypeFull) + openAIURL, _ := cmd.Flags().GetString(FlagOpenAIURLFull) log.Printf("flags:\n") - log.Printf(" - %-8s: %v\n", FlagRequest, request) - log.Printf(" - %-8s: %v\n", FlagWrite, write) - log.Printf(" - %-8s: %v\n", FlagPath, path) - log.Printf(" - %-8s: %v\n", FlagFiles, files) - log.Printf(" - %-8s: %v\n", FlagFilesets, filesets) - log.Printf(" - %-8s: %v\n", FlagNTokens, nTokens) - log.Printf(" - %-8s: %v\n", FlagNCompletions, nCompletions) - log.Printf(" - %-8s: %v\n", FlagOutputType, outputType) + log.Printf(" - %-8s: %v\n", FlagRequestFull, request) + log.Printf(" - %-8s: %v\n", FlagWriteFull, write) + log.Printf(" - %-8s: %v\n", FlagPathFull, path) + log.Printf(" - %-8s: %v\n", FlagFilesFull, files) + log.Printf(" - %-8s: %v\n", FlagFilesetsFull, filesets) + log.Printf(" - %-8s: %v\n", FlagNTokensFull, nTokens) + log.Printf(" - %-8s: %v\n", FlagNCompletionsFull, nCompletions) + log.Printf(" - %-8s: %v\n", FlagOutputTypeFull, outputType) // Handle --path by changing the working directory // so that every file name we refer to is relative to path @@ -67,8 +74,7 @@ func PrepareRequest(cmd *cobra.Command, engine string) (*Request, error) { // we'll just use the defaults and continue without error. // Errors here might return if the file exists but is invalid. conf := Config{} - err := conf.Load() - if err != nil { + if err := conf.Load(); err != nil { return nil, err } @@ -101,14 +107,10 @@ func PrepareRequest(cmd *cobra.Command, engine string) (*Request, error) { } fm.LogDump() - - filemapText, err := fm.EncodeToInputText() - if err != nil { - return nil, err - } + filemapText := fm.EncodeToInputText() // create OpenAI client - openAIClient := BuildOpenAIClient(conf, nTokens, nCompletions, engine) + openAIClient := BuildOpenAIClient(conf, nTokens, nCompletions, engine, openAIURL) log.Printf("Model in use: " + openAIClient.Engine) r := Request{ @@ -127,29 +129,21 @@ func PrepareRequest(cmd *cobra.Command, engine string) (*Request, error) { // PrintOrWriteOut Accepts a request object and writes the contents of the filemap // to the disk if specified, otherwise it prints to STDOUT. func PrintOrWriteOut(r *Request) error { - // dump the state of the FileMap - r.Filemap.LogDump() - if r.IsWrite { - log.Printf("updating files ...\n") err := r.Filemap.WriteUpdatesToFiles() if err != nil { return err } - } else { - // TODO: Add output formatting control - // just encode the output and print it to stdout - // TODO: print as redirectable / pipeable write stream - fmOutput, err := r.Filemap.EncodeToInputTextFullPaths(r.OutputType) - if err != nil { - return err - } - - stringOut := strings.ReplaceAll(fmOutput, "\\n", "\n") + return nil + } - log.Printf("\n%s\n", stringOut) - log.Printf("use --write to actually update files\n") + // TODO: print as redirectable / pipeable write stream + fmOutput, err := r.Filemap.EncodeToInputTextFullPaths(r.OutputType) + if err != nil { + return err } + stringOut := strings.ReplaceAll(fmOutput, "\\n", "\n") + log.Printf("\n%s\n", stringOut) return nil } @@ -157,22 +151,29 @@ func PrintOrWriteOut(r *Request) error { // AddRequestFlags Appends flags to the given command which are then used at the command-line. func AddRequestFlags(cmd *cobra.Command) { cmd.Flags().StringP( - FlagRequest, "r", "", + FlagRequestFull, FlagRequestShort, "", "Requested changes in natural language (empty request will surprise you!)", ) cmd.Flags().BoolP( - FlagWrite, "w", false, + FlagWriteFull, FlagWriteShort, false, "Write changes to the repo files (if not set the patch is printed to stdout)", ) cmd.Flags().StringP( - FlagPath, "p", ".", + FlagPathFull, FlagPathShort, ".", "Path to the root of the repo", ) cmd.Flags().StringP( - FlagOutputType, "o", "json", + FlagOutputTypeFull, FlagOutputTypeShort, "json", "How to format output", ) + + _ = cmd.Flags().StringP( + FlagOpenAIURLFull, + FlagOpenAIURLShort, + openai.OpenAIURL, + "Domain of the OpenAI API", + ) } diff --git a/pkg/cmd/utils_test.go b/pkg/cmd/utils_test.go new file mode 100644 index 0000000..220a8d2 --- /dev/null +++ b/pkg/cmd/utils_test.go @@ -0,0 +1,12 @@ +package cmd_test + +// import ( +// . "github.com/onsi/ginkgo/v2" +// . "github.com/onsi/gomega" + +// "github.com/redhat-et/copilot-ops/pkg/cmd" +// ) + +// var _ = Describe("Utils", func() { + +// }) diff --git a/pkg/filemap/filemap.go b/pkg/filemap/filemap.go index 1d9d113..9be86fe 100644 --- a/pkg/filemap/filemap.go +++ b/pkg/filemap/filemap.go @@ -8,7 +8,6 @@ import ( "log" "os" "path/filepath" - "regexp" "strings" ) @@ -32,8 +31,6 @@ type File struct { Name string `json:"name"` // Path is the path to the file. Path string `json:"path"` - // Tag is the tagname of the file. - Tag string `json:"tag"` // Content is the content of the file. Content string `json:"content"` } @@ -43,21 +40,24 @@ type Filemap struct { Files map[string]File `json:"files"` } +// NewFilemap Builds and returns a new filemap. func NewFilemap() *Filemap { return &Filemap{ Files: make(map[string]File), } } + +// LogDump Displays the contents of the filemap to the log. func (fm *Filemap) LogDump() { maxShown := 30 log.Printf("filemap: len %d\n", len(fm.Files)) - for _, f := range fm.Files { + for name, f := range fm.Files { l := len(f.Content) if l > maxShown { l = maxShown } short := strings.ReplaceAll(f.Content[:l], "\n", " ") - log.Printf(" - tag: %-10q: path: %-20q [%s ...] len %d\n", f.Tag, f.Path, short, len(f.Content)) + log.Printf(" - tag: %-10q: path: %-20q [%s ...] len %d\n", name, f.Path, short, len(f.Content)) } } @@ -77,7 +77,6 @@ func (fm *Filemap) LoadFile(path string) error { fm.Files[tag] = File{ Path: path, Content: string(bytes), - Tag: tag, } return nil } @@ -100,14 +99,14 @@ func (fm *Filemap) LoadFilesFromGlob(glob string) error { // WriteUpdatesToFiles Writes the updated contents of each file to the directory. func (fm *Filemap) WriteUpdatesToFiles() error { - for _, file := range fm.Files { + for name, file := range fm.Files { // add extension if necessary, assume this is YAML for the time being // HACK: classify the relevant extension (e.g. .yaml, .yml, .json) // fileName := file.Tag // if len(strings.Split(file.Tag, ".")) == 1 { // fileName += ".yaml" // } - log.Printf("path: %q, tag: %q\n", file.Path, file.Tag) + log.Printf("path: %q, tag: %q\n", file.Path, name) // locate the base directory of filePath dirPath := filepath.Dir(file.Path) // create the directory if it does not exist @@ -136,7 +135,7 @@ func (fm *Filemap) WriteUpdatesToFiles() error { // EncodeToInputText Encodes the filemap into a string which can be used as input to the OpenAI CLI. // If there was some issue or problem encoding the filemap, an error will be returned. -func (fm *Filemap) EncodeToInputText() (string, error) { +func (fm *Filemap) EncodeToInputText() string { /* This function will encode the file contents as a string, with each file prepended by a hashtag, followed by its tagname. @@ -171,7 +170,7 @@ func (fm *Filemap) EncodeToInputText() (string, error) { } i++ } - return input, nil + return input } // EncodeToInputTextFullPaths Encodes the filemap into a string using each file's full path as its tagname. @@ -216,33 +215,11 @@ func GenerateJSON(input []File) (string, error) { return string(jsonOutput), err } -// extractTagName Extracts the tagname from the given content, -// providing its line number in the content, or an error if it doesn't exist. -func extractTagName(content string) (string, int32, error) { - // The tagname would be on a line in the format of: "# {FILE_TAG_PREFIX}tagname\n" - // We can split the line by the '#' character and then trim the leading and trailing whitespace. - lines := strings.Split(content, "\n") - - // search content for regex of the following pattern: /#\s*\{FILE_TAG_PREFIX}(.+)/g - // if found, return the tagname - // if not found, return an error - pattern := fmt.Sprintf(`#\s*\%s(.+)`, FileTagPrefix) - regexPattern, err := regexp.Compile(pattern) - if err != nil { - return "", 0, err - } - - for i, line := range lines { - // find the first line that matches the pattern - if match := regexPattern.FindStringSubmatch(line); match != nil { - return strings.TrimSpace(match[1]), int32(i), nil - } - } - return "", -1, fmt.Errorf("no tagname found in content") -} - // ConcatenateAfterLineNum Concatenates all of the content following the given lineNum. // If the lineNum exceeds the number of lines in the content, an error will be returned. +// +// The line numbers are zero-indexed, so passing -1 will concatenate all of the content, +// whereas 0 will exclude the first line. func ConcatenateAfterLineNum(content string, lineNum int32) (string, error) { lines := strings.Split(content, "\n") if lineNum >= int32(len(lines)) { @@ -269,7 +246,6 @@ func (fm *Filemap) AddContentByTag(tagname string, content string) { // TODO: infer path Path: "", Content: content, - Tag: tagname, } } } @@ -301,6 +277,11 @@ func (fm *Filemap) DecodeFromOutput(content string) error { if err != nil { return err } + // ignore empty files + if strings.TrimSpace(concatenatedContent) == "" { + continue + } + // add to the filemap fm.AddContentByTag(tagName, concatenatedContent) } diff --git a/pkg/filemap/filemap_suite_test.go b/pkg/filemap/filemap_suite_test.go new file mode 100644 index 0000000..9fae84a --- /dev/null +++ b/pkg/filemap/filemap_suite_test.go @@ -0,0 +1,13 @@ +package filemap_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFilemap(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Filemap Suite") +} diff --git a/pkg/filemap/filemap_test.go b/pkg/filemap/filemap_test.go new file mode 100644 index 0000000..f1fb260 --- /dev/null +++ b/pkg/filemap/filemap_test.go @@ -0,0 +1,184 @@ +package filemap_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/redhat-et/copilot-ops/pkg/filemap" +) + +var _ = Describe("Filemap", func() { + var filemap *Filemap + BeforeEach(func() { + // create the filemap object + filemap = NewFilemap() + Expect(filemap).NotTo(BeNil()) + }) + + When("multiple files are loaded", func() { + const ( + content1 = `--- +kind: FortniteVod +` + content2 = `--- +kind: CatVideos +` + ) + + BeforeEach(func() { + filemap.Files = map[string]File{ + "fortnite_vods": { + Path: "./testdata/fortnite_vods", + Content: content1, + }, + "cat_videos": { + Path: "./testdata/cat_videos", + Content: content2, + }, + } + }) + + It("contains the encoded files", func() { + // make sure both files are contained in the filemap + encoding := filemap.EncodeToInputText() + Expect(encoding).To(ContainSubstring("kind: FortniteVod")) + Expect(encoding).To(ContainSubstring("kind: CatVideos")) + + // files should be delimited by a delimiting string + Expect(encoding).To(ContainSubstring(FileDelimeter)) + }) + + It("adds new files", func() { + // make sure the new content is added and is encoded + filemap.AddContentByTag("new_tag", "new_content") + encoding := filemap.EncodeToInputText() + Expect(encoding).To(ContainSubstring("new_content")) + Expect(encoding).To(ContainSubstring(FileDelimeter)) + }) + + It("updates existing files by their tagname", func() { + // update the fortnite vods file with content + filemap.AddContentByTag("fortnite_vods", "new-fortnite-content") + Expect(filemap.Files["fortnite_vods"].Content).To(ContainSubstring("new-fortnite-content")) + + // make sure that content with new tags are simply appended to the filemap + filemap.AddContentByTag("new_tag", "new_content") + Expect(filemap.Files["new_tag"].Content).To(ContainSubstring("new_content")) + }) + + When("the filemap is encoded to output text", func() { + It("encodes files using their full paths", func() { + output, err := filemap.EncodeToInputTextFullPaths(OutputPlain) + Expect(err).NotTo(HaveOccurred()) + Expect(output).To(ContainSubstring("# @./testdata/fortnite_vods")) + + output, err = filemap.EncodeToInputTextFullPaths(OutputJSON) + Expect(err).NotTo(HaveOccurred()) + Expect(output).To(ContainSubstring("\"path\": \"./testdata/fortnite_vods\"")) + Expect(output).To(ContainSubstring("\"path\": \"./testdata/cat_videos\"")) + Expect(output).To(ContainSubstring("kind: FortniteVod")) + Expect(output).To(ContainSubstring("kind: CatVideos")) + }) + + It("encodes all formats except unknown", func() { + // create an anonymous struct & iterate through to make sure that only + // known formats are encoded + var formats = []struct { + Format string `json:"format"` + ShouldEncode bool `json:"should_encode"` + }{ + {Format: OutputPlain, ShouldEncode: true}, + {Format: OutputJSON, ShouldEncode: true}, + {Format: "unknown format", ShouldEncode: false}, + } + for _, format := range formats { + _, err := filemap.EncodeToInputTextFullPaths(format.Format) + if format.ShouldEncode { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + } + }) + }) + }) + + It("concatenates after a line number", func() { + const content = `1 +2 +3 +4 +5` + // check that concatenate after line number 0 includes the whole file + cat, err := ConcatenateAfterLineNum(content, -1) + Expect(err).NotTo(HaveOccurred()) + Expect(cat).To(ContainSubstring(content)) + + // after lin num should exclude it. + cat, err = ConcatenateAfterLineNum(content, 0) + Expect(err).NotTo(HaveOccurred()) + Expect(cat).To(ContainSubstring(content[2:])) + + // line 5 should return an error + _, err = ConcatenateAfterLineNum(content, 5) + Expect(err).To(HaveOccurred()) + }) + + When("decoding from an output", func() { + const responseTemplate = ` +# %sfortnite-stats +--- +kind: Deployment +metadata: + name: fortnite-stats +%s +--- +# %sviva-pinata-server +kind: Deployment +apiVersion: apps/v1 +metadata: + name: viva-pinata-server +` + + It("updates the filemap from the given yamls", func() { + var response = fmt.Sprintf(responseTemplate, FileTagPrefix, FileDelimeter, FileTagPrefix) + err := filemap.DecodeFromOutput(response) + Expect(err).NotTo(HaveOccurred()) + Expect(filemap.Files).To(HaveLen(2)) + Expect(filemap.Files["fortnite-stats"].Content).To(ContainSubstring("kind: Deployment")) + Expect(filemap.Files["viva-pinata-server"].Content).To(ContainSubstring("kind: Deployment")) + // delimeter should not be included in the content + Expect(filemap.Files["fortnite-stats"].Content).NotTo(ContainSubstring(FileDelimeter)) + Expect(filemap.Files["viva-pinata-server"].Content).NotTo(ContainSubstring(FileDelimeter)) + }) + + It("doesn't decode without tagname", func() { + var response = fmt.Sprintf(responseTemplate, "", FileDelimeter, FileTagPrefix) + err := filemap.DecodeFromOutput(response) + Expect(err).To(HaveOccurred()) + + response = fmt.Sprintf(responseTemplate, FileTagPrefix, FileDelimeter, "") + err = filemap.DecodeFromOutput(response) + Expect(err).To(HaveOccurred()) + }) + + It("doesn't add empty outputs", func() { + response := fmt.Sprintf(`# %semtpy-file +%s +# %snot-empty +kind: NotEmtpy`, FileTagPrefix, FileDelimeter, FileTagPrefix) + err := filemap.DecodeFromOutput(response) + Expect(err).NotTo(HaveOccurred()) + Expect(filemap.Files).To(HaveLen(1)) + // empty-file should not be in filemap + _, ok := filemap.Files["empty-file"] + Expect(ok).To(BeFalse()) + // not-empty should be in filemap + listing, ok := filemap.Files["not-empty"] + Expect(ok).To(BeTrue()) + Expect(listing.Content).To(ContainSubstring("kind: NotEmtpy")) + }) + }) +}) diff --git a/pkg/filemap/utils.go b/pkg/filemap/utils.go new file mode 100644 index 0000000..73fd39a --- /dev/null +++ b/pkg/filemap/utils.go @@ -0,0 +1,32 @@ +package filemap + +import ( + "fmt" + "regexp" + "strings" +) + +// extractTagName Extracts the tagname from the given content, +// providing its line number in the content, or an error if it doesn't exist. +func extractTagName(content string) (string, int32, error) { + // The tagname would be on a line in the format of: "# {FILE_TAG_PREFIX}tagname\n" + // We can split the line by the '#' character and then trim the leading and trailing whitespace. + lines := strings.Split(content, "\n") + + // search content for regex of the following pattern: /#\s*\{FILE_TAG_PREFIX}(.+)/g + // if found, return the tagname + // if not found, return an error + pattern := fmt.Sprintf(`#\s*\%s(.+)`, FileTagPrefix) + regexPattern, err := regexp.Compile(pattern) + if err != nil { + return "", 0, err + } + + for i, line := range lines { + // find the first line that matches the pattern + if match := regexPattern.FindStringSubmatch(line); match != nil { + return strings.TrimSpace(match[1]), int32(i), nil + } + } + return "", -1, fmt.Errorf("no tagname found in content") +} diff --git a/pkg/openai/constants.go b/pkg/openai/constants.go index dcd587c..76bc690 100644 --- a/pkg/openai/constants.go +++ b/pkg/openai/constants.go @@ -1,10 +1,12 @@ package openai const ( - EditEndpoint string = "edits" - CompletionEndpoint string = "completions" - SearchEndpoint string = "search" - OpenAIEndpointV1 string = "https://api.openai.com/v1" + EditEndpoint string = "edits" + CompletionEndpoint string = "completions" + SearchEndpoint string = "search" + OpenAIURL string = "https://api.openai.com" + // Maybe the OpenAIEndpoint should be a part of the URL string? + OpenAIEndpointV1 string = "/v1" OpenAICodeDavinciEditV1 string = "code-davinci-edit-001" OpenAICodeDavinciV2 string = "code-davinci-002" CompletionEndOfSequence string = "EOF" diff --git a/pkg/openai/openai.go b/pkg/openai/openai.go index 5ba0770..3060dba 100644 --- a/pkg/openai/openai.go +++ b/pkg/openai/openai.go @@ -13,15 +13,11 @@ import ( // CreateOpenAIClient Creates a client to perform requests to OpenAI based on the provided // API token and organization ID. -func CreateOpenAIClient(authToken string, organizationID string, engine string) *Client { +func CreateOpenAIClient() *Client { return &Client{ Client: &http.Client{ Timeout: time.Minute, }, - APIUrl: OpenAIEndpointV1, - Engine: engine, - AuthToken: authToken, - OrganizationID: organizationID, } } diff --git a/pkg/openai/types.go b/pkg/openai/types.go index daaba0b..ad95ed1 100644 --- a/pkg/openai/types.go +++ b/pkg/openai/types.go @@ -8,15 +8,20 @@ type EditResponse struct { Response } +// Choice is a single choice returned by OpenAI. +type Choice struct { + Text string `json:"text"` + Index int `json:"index"` +} + +// Response Defines a response object from OpenAI. type Response struct { - Object string `json:"object"` - Created uint64 `json:"created"` - Choices []struct { - Text string `json:"text"` - Index int `json:"index"` - } `json:"choices"` + Object string `json:"object"` + Created uint64 `json:"created"` + Choices []Choice `json:"choices"` } +// BodyParameters Define a set of parameters for the body of OpenAI requests. type BodyParameters struct { // Model is the model used by OpenAI to generate completions Model string `json:"model"` diff --git a/pkg/tests/cmd_test.go b/pkg/tests/cmd_test.go deleted file mode 100644 index 030a53a..0000000 --- a/pkg/tests/cmd_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package test_test - -import ( - "bytes" - "io/ioutil" - "os" - "testing" - - "github.com/redhat-et/copilot-ops/pkg/cmd" -) - -func TestGeneratePodForPVC(t *testing.T) { - t.Log(os.Getwd()) - Run(t, []string{ - "generate", - "--file", - "examples/app1/mysql-pvc.yaml", - "--request", - "Generate a pod that mounts the PVC. Set the pod resources requests and limits to 4 cpus and 5 Gig of memory.", - }) -} - -// TestEditPVCSize Tests that the edit command can successfully change the size of a PVC YAML -// to be 100Gi. -func TestEditPVCSize(t *testing.T) { - Run(t, []string{ - "edit", - "--file", - "examples/app1/mysql-pvc.yaml", - "--request", - "Increase the size of the PVC to 100Gi.", - }) -} - -func Run(t *testing.T, args []string) string { - cmd := cmd.NewRootCmd() - buf := bytes.NewBufferString("") - cmd.SetOut(buf) - cmd.SetArgs(args) - if err := cmd.Execute(); err != nil { - t.Fatal(err) - } - - bytes, err := ioutil.ReadAll(buf) - if err != nil { - t.Fatal(err) - } - out := string(bytes) - t.Logf("out: %+v\n", out) - return out -}