Skip to content

Commit

Permalink
Merge pull request #846 from threepipes/create-envvars
Browse files Browse the repository at this point in the history
Add a subcommand to create a project environment variable
  • Loading branch information
JulesFaucherre authored Mar 6, 2023
2 parents c899e41 + a9f9d15 commit 7602c67
Show file tree
Hide file tree
Showing 6 changed files with 537 additions and 5 deletions.
2 changes: 2 additions & 0 deletions api/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ type ProjectEnvironmentVariable struct {
// components.
type ProjectClient interface {
ListAllEnvironmentVariables(vcs, org, project string) ([]*ProjectEnvironmentVariable, error)
GetEnvironmentVariable(vcs, org, project, envName string) (*ProjectEnvironmentVariable, error)
CreateEnvironmentVariable(vcs, org, project string, v ProjectEnvironmentVariable) (*ProjectEnvironmentVariable, error)
}
53 changes: 53 additions & 0 deletions api/project/project_rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ type listAllProjectEnvVarsResponse struct {
NextPageToken string `json:"next_page_token"`
}

type createProjectEnvVarRequest struct {
Name string `json:"name"`
Value string `json:"value"`
}

// NewProjectRestClient returns a new projectRestClient satisfying the api.ProjectInterface
// interface via the REST API.
func NewProjectRestClient(config settings.Config) (*projectRestClient, error) {
Expand Down Expand Up @@ -102,3 +107,51 @@ func (c *projectRestClient) listEnvironmentVariables(params *listProjectEnvVarsP
}
return &resp, nil
}

// GetEnvironmentVariable retrieves and returns a variable with the given name.
// If the response status code is 404, nil is returned.
func (c *projectRestClient) GetEnvironmentVariable(vcs string, org string, project string, envName string) (*ProjectEnvironmentVariable, error) {
path := fmt.Sprintf("project/%s/%s/%s/envvar/%s", vcs, org, project, envName)
req, err := c.client.NewRequest("GET", &url.URL{Path: path}, nil)
if err != nil {
return nil, err
}

var resp projectEnvVarResponse
code, err := c.client.DoRequest(req, &resp)
if err != nil {
if code == 404 {
// Note: 404 may mean that the project isn't found.
// The cause can't be distinguished except by the response text.
return nil, nil
}
return nil, err
}
return &ProjectEnvironmentVariable{
Name: resp.Name,
Value: resp.Value,
}, nil
}

// CreateEnvironmentVariable creates a variable on the given project.
// This returns the variable if successfully created.
func (c *projectRestClient) CreateEnvironmentVariable(vcs string, org string, project string, v ProjectEnvironmentVariable) (*ProjectEnvironmentVariable, error) {
path := fmt.Sprintf("project/%s/%s/%s/envvar", vcs, org, project)
req, err := c.client.NewRequest("POST", &url.URL{Path: path}, &createProjectEnvVarRequest{
Name: v.Name,
Value: v.Value,
})
if err != nil {
return nil, err
}

var resp projectEnvVarResponse
_, err = c.client.DoRequest(req, &resp)
if err != nil {
return nil, err
}
return &ProjectEnvironmentVariable{
Name: resp.Name,
Value: resp.Value,
}, nil
}
163 changes: 163 additions & 0 deletions api/project/project_rest_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package project_test

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -158,3 +159,165 @@ func Test_projectRestClient_ListAllEnvironmentVariables(t *testing.T) {
})
}
}

func Test_projectRestClient_GetEnvironmentVariable(t *testing.T) {
const (
vcsType = "github"
orgName = "test-org"
projName = "test-proj"
)
tests := []struct {
name string
handler http.HandlerFunc
envName string
want *project.ProjectEnvironmentVariable
wantErr bool
}{
{
name: "Should handle a successful request",
handler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Header.Get("circle-token"), "token")
assert.Equal(t, r.Header.Get("accept"), "application/json")
assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())

assert.Equal(t, r.Method, "GET")
assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s/envvar/test1", vcsType, orgName, projName))

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`
{
"name": "foo",
"value": "xxxx1234"
}`))
assert.NilError(t, err)
},
envName: "test1",
want: &project.ProjectEnvironmentVariable{
Name: "foo",
Value: "xxxx1234",
},
},
{
name: "Should handle an error request",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte(`{"message": "error"}`))
assert.NilError(t, err)
},
wantErr: true,
},
{
name: "Should handle an 404 error as a valid request",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, err := w.Write([]byte(`{"message": "Environment variable not found."}`))
assert.NilError(t, err)
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(tt.handler)
defer server.Close()

p, err := getProjectRestClient(server)
assert.NilError(t, err)

got, err := p.GetEnvironmentVariable(vcsType, orgName, projName, tt.envName)
if (err != nil) != tt.wantErr {
t.Errorf("projectRestClient.GetEnvironmentVariable() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("projectRestClient.GetEnvironmentVariable() = %v, want %v", got, tt.want)
}
})
}
}

func Test_projectRestClient_CreateEnvironmentVariable(t *testing.T) {
const (
vcsType = "github"
orgName = "test-org"
projName = "test-proj"
)
tests := []struct {
name string
handler http.HandlerFunc
variable project.ProjectEnvironmentVariable
want *project.ProjectEnvironmentVariable
wantErr bool
}{
{
name: "Should handle a successful request",
handler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Header.Get("circle-token"), "token")
assert.Equal(t, r.Header.Get("accept"), "application/json")
assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())

assert.Equal(t, r.Method, "POST")
assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s/envvar", vcsType, orgName, projName))
var pv project.ProjectEnvironmentVariable
err := json.NewDecoder(r.Body).Decode(&pv)
assert.NilError(t, err)
assert.Equal(t, pv, project.ProjectEnvironmentVariable{
Name: "foo",
Value: "test1234",
})

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(`
{
"name": "foo",
"value": "xxxx1234"
}`))
assert.NilError(t, err)
},
variable: project.ProjectEnvironmentVariable{
Name: "foo",
Value: "test1234",
},
want: &project.ProjectEnvironmentVariable{
Name: "foo",
Value: "xxxx1234",
},
},
{
name: "Should handle an error request",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte(`{"message": "error"}`))
assert.NilError(t, err)
},
variable: project.ProjectEnvironmentVariable{
Name: "bar",
Value: "testbar",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(tt.handler)
defer server.Close()

p, err := getProjectRestClient(server)
assert.NilError(t, err)

got, err := p.CreateEnvironmentVariable(vcsType, orgName, projName, tt.variable)
if (err != nil) != tt.wantErr {
t.Errorf("projectRestClient.CreateEnvironmentVariable() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("projectRestClient.CreateEnvironmentVariable() = %v, want %v", got, tt.want)
}
})
}
}
59 changes: 59 additions & 0 deletions cmd/project/environment_variable.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package project

import (
"fmt"
"strings"

projectapi "github.com/CircleCI-Public/circleci-cli/api/project"
"github.com/CircleCI-Public/circleci-cli/cmd/validator"
"github.com/olekukonko/tablewriter"
Expand All @@ -23,7 +26,21 @@ func newProjectEnvironmentVariableCommand(ops *projectOpts, preRunE validator.Va
Args: cobra.ExactArgs(3),
}

var envValue string
createVarCommand := &cobra.Command{
Short: "Create an environment variable of a project. The value is read from stdin.",
Use: "create <vcs-type> <org-name> <project-name> <env-name>",
PreRunE: preRunE,
RunE: func(cmd *cobra.Command, args []string) error {
return createProjectEnvironmentVariable(cmd, ops.client, ops.reader, args[0], args[1], args[2], args[3], envValue)
},
Args: cobra.ExactArgs(4),
}

createVarCommand.Flags().StringVar(&envValue, "env-value", "", "An environment variable value to be created. You can also pass it by stdin without this option.")

cmd.AddCommand(listVarsCommand)
cmd.AddCommand(createVarCommand)
return cmd
}

Expand All @@ -44,3 +61,45 @@ func listProjectEnvironmentVariables(cmd *cobra.Command, client projectapi.Proje

return nil
}

func createProjectEnvironmentVariable(cmd *cobra.Command, client projectapi.ProjectClient, r UserInputReader, vcsType, orgName, projName, name, value string) error {
if value == "" {
val, err := r.ReadSecretString("Enter an environment variable value and press enter")
if err != nil {
return err
}
if val == "" {
return fmt.Errorf("the environment variable value must not be empty")
}
value = val
}
value = strings.Trim(value, "\r\n")

existV, err := client.GetEnvironmentVariable(vcsType, orgName, projName, name)
if err != nil {
return err
}
if existV != nil {
msg := fmt.Sprintf("The environment variable name=%s value=%s already exists. Do you overwrite it?", existV.Name, existV.Value)
if !r.AskConfirm(msg) {
fmt.Fprintln(cmd.OutOrStdout(), "Canceled")
return nil
}
}

v, err := client.CreateEnvironmentVariable(vcsType, orgName, projName, projectapi.ProjectEnvironmentVariable{
Name: name,
Value: value,
})
if err != nil {
return err
}

table := tablewriter.NewWriter(cmd.OutOrStdout())

table.SetHeader([]string{"Environment Variable", "Value"})
table.Append([]string{v.Name, v.Value})
table.Render()

return nil
}
49 changes: 45 additions & 4 deletions cmd/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,46 @@ package project
import (
projectapi "github.com/CircleCI-Public/circleci-cli/api/project"
"github.com/CircleCI-Public/circleci-cli/cmd/validator"
"github.com/CircleCI-Public/circleci-cli/prompt"

"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/spf13/cobra"
)

// UserInputReader displays a message and reads a user input value
type UserInputReader interface {
ReadSecretString(msg string) (string, error)
AskConfirm(msg string) bool
}

type projectOpts struct {
client projectapi.ProjectClient
reader UserInputReader
}

// ProjectOption configures a command created by NewProjectCommand
type ProjectOption interface {
apply(*projectOpts)
}

type promptReader struct{}

func (p promptReader) ReadSecretString(msg string) (string, error) {
return prompt.ReadSecretStringFromUser(msg)
}

func (p promptReader) AskConfirm(msg string) bool {
return prompt.AskUserToConfirm(msg)
}

// NewProjectCommand generates a cobra command for managing projects
func NewProjectCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command {
var opts projectOpts
func NewProjectCommand(config *settings.Config, preRunE validator.Validator, opts ...ProjectOption) *cobra.Command {
pos := projectOpts{
reader: &promptReader{},
}
for _, o := range opts {
o.apply(&pos)
}
command := &cobra.Command{
Use: "project",
Short: "Operate on projects",
Expand All @@ -23,12 +51,25 @@ func NewProjectCommand(config *settings.Config, preRunE validator.Validator) *co
if err != nil {
return err
}
opts.client = client
pos.client = client
return nil
},
}

command.AddCommand(newProjectEnvironmentVariableCommand(&opts, preRunE))
command.AddCommand(newProjectEnvironmentVariableCommand(&pos, preRunE))

return command
}

type customReaderProjectOption struct {
r UserInputReader
}

func (c customReaderProjectOption) apply(opts *projectOpts) {
opts.reader = c.r
}

// CustomReader returns a ProjectOption that sets a given UserInputReader to a project command
func CustomReader(r UserInputReader) ProjectOption {
return customReaderProjectOption{r}
}
Loading

0 comments on commit 7602c67

Please sign in to comment.