Repository: Shopify/themekit (go)

Issue:
Feature request: publish a theme
I think it would be nice to be able to publish a theme from themekit. It seems the only two ways are:

1. Shopify REST Admin Api -> online store -> theme -> PUT /admin/themes/#{theme_id}.json
2. Shopify Admin UI, on the version desired, click "publish".

My bet is that it was probably thought of but I don't know why it wouldn't be implemented.

Pull request description:
Adding a publish command to themekit
Rel: #583 

This adds a publish command to themekit so that a developer can publish a theme without opening admin.

It can be used by running 

```
> theme publish --env=production
```

### Warn Checklist
- [ ] This changes the interface and requires a Major/Minor version change.
- [ ] I have :tophat:'d these changes by using the commands I changed by hand.
- [ ] I have added a dependancy to the project.


Patch:
diff --git a/cmd/publish.go b/cmd/publish.go
new file mode 100644
index 0000000..1e1de12
--- /dev/null
+++ b/cmd/publish.go
@@ -0,0 +1,33 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	"github.com/Shopify/themekit/src/cmdutil"
+	"github.com/Shopify/themekit/src/colors"
+)
+
+var publishCmd = &cobra.Command{
+	Use:   "publish",
+	Short: "publish a theme",
+	Long: `Publish will update the theme to be your current publish theme. Select
+the theme you want to publish using the env flag.
+
+ For more documentation please see http://shopify.github.io/themekit/commands/#deploy
+ `,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return cmdutil.ForSingleClient(flags, args, publish)
+	},
+}
+
+func publish(ctx *cmdutil.Ctx) error {
+	err := ctx.Client.PublishTheme()
+	if err == nil {
+		ctx.Log.Printf(
+			"[%s] Successfully published theme %s",
+			colors.Green(ctx.Env.Name),
+			colors.Green(ctx.Env.ThemeID),
+		)
+	}
+	return err
+}
diff --git a/cmd/theme.go b/cmd/theme.go
index 83c52dd..8888303 100644
--- a/cmd/theme.go
+++ b/cmd/theme.go
@@ -109,5 +109,5 @@ func init() {
 	getCmd.Flags().BoolVarP(&flags.List, "list", "l", false, "list available themes.")
 	deployCmd.Flags().BoolVarP(&flags.NoDelete, "nodelete", "n", false, "do no delete file on shopify diring deploy.")
 
-	ThemeCmd.AddCommand(openCmd, versionCmd, bootstrapCmd, newCmd, configureCmd, downloadCmd, removeCmd, updateCmd, uploadCmd, replaceCmd, watchCmd, getCmd, deployCmd)
+	ThemeCmd.AddCommand(publishCmd, openCmd, versionCmd, bootstrapCmd, newCmd, configureCmd, downloadCmd, removeCmd, updateCmd, uploadCmd, replaceCmd, watchCmd, getCmd, deployCmd)
 }
diff --git a/src/cmdutil/_mocks/ShopifyClient.go b/src/cmdutil/_mocks/ShopifyClient.go
index f2e0e36..eccecc7 100644
--- a/src/cmdutil/_mocks/ShopifyClient.go
+++ b/src/cmdutil/_mocks/ShopifyClient.go
@@ -132,6 +132,11 @@ func (_m *ShopifyClient) GetInfo() (shopify.Theme, error) {
 	return r0, r1
 }
 
+// PublishTheme provides a mock function with given fields:
+func (_m *ShopifyClient) PublishTheme() error {
+	return _m.Called().Error(0)
+}
+
 // GetShop provides a mock function with given fields:
 func (_m *ShopifyClient) GetShop() (shopify.Shop, error) {
 	ret := _m.Called()
diff --git a/src/cmdutil/interfaces.go b/src/cmdutil/interfaces.go
index 71b2b5b..bff2342 100644
--- a/src/cmdutil/interfaces.go
+++ b/src/cmdutil/interfaces.go
@@ -9,6 +9,7 @@ type shopifyClient interface {
 	GetShop() (shopify.Shop, error)
 	CreateNewTheme(string, string) (shopify.Theme, error)
 	GetInfo() (shopify.Theme, error)
+	PublishTheme() error
 	Themes() ([]shopify.Theme, error)
 	GetAllAssets() ([]string, error)
 	GetAsset(string) (shopify.Asset, error)
diff --git a/src/shopify/theme_client.go b/src/shopify/theme_client.go
index cf91c9c..dbf2296 100644
--- a/src/shopify/theme_client.go
+++ b/src/shopify/theme_client.go
@@ -28,6 +28,8 @@ var (
 	ErrZipPathRequired = errors.New("theme zip path is required")
 	// ErrInfoWithoutThemeID will be returned if GetInfo is called on a live theme
 	ErrInfoWithoutThemeID = errors.New("cannot get info without a theme id")
+	// ErrPublishWithoutThemeID will be returned if PublishTheme is called on a live theme
+	ErrPublishWithoutThemeID = errors.New("cannot publish a theme without a theme id set")
 	// ErrThemeNotFound will be returned if trying to get a theme that does not exist
 	ErrThemeNotFound = errors.New("requested theme was not found")
 	// ErrShopDomainNotFound will be returned if you are getting shop info on an invalid domain
@@ -41,7 +43,7 @@ var (
 // Theme represents a shopify theme.
 type Theme struct {
 	ID          int64  `json:"id,omitempty"`
-	Name        string `json:"name"`
+	Name        string `json:"name,omitempty"`
 	Source      string `json:"src,omitempty"`
 	Role        string `json:"role,omitempty"`
 	Previewable bool   `json:"previewable,omitempty"`
@@ -199,6 +201,34 @@ func (c Client) GetInfo() (Theme, error) {
 	return r.Theme, nil
 }
 
+// PublishTheme will update the theme to be role main
+func (c Client) PublishTheme() error {
+	if c.themeID == "" {
+		return ErrPublishWithoutThemeID
+	}
+
+	resp, err := c.http.Put(
+		fmt.Sprintf("/admin/themes/%s.json", c.themeID),
+		map[string]Theme{"theme": {Role: "main"}},
+	)
+	if err != nil {
+		return err
+	} else if resp.StatusCode == 404 {
+		return ErrThemeNotFound
+	}
+
+	var r themeResponse
+	if err = unmarshalResponse(resp.Body, &r); err != nil {
+		return err
+	}
+
+	if len(r.Errors) > 0 {
+		return errors.New(toSentence(toMessages(r.Errors)))
+	}
+
+	return nil
+}
+
 // GetAllAssets will return a slice of remote assets from the shopify servers. The
 // assets are sorted and any ignored files based on your config are filtered out.
 // The assets returned will not have any data, only ID and filenames. This is because


Test patch:
diff --git a/cmd/publish_test.go b/cmd/publish_test.go
new file mode 100644
index 0000000..c0d30ec
--- /dev/null
+++ b/cmd/publish_test.go
@@ -0,0 +1,12 @@
+package cmd
+
+import (
+	"testing"
+)
+
+func TestPublish(t *testing.T) {
+	ctx, client, _, _, _ := createTestCtx()
+	client.On("PublishTheme").Return(nil)
+	publish(ctx)
+	client.AssertExpectations(t)
+}
diff --git a/src/shopify/theme_client_test.go b/src/shopify/theme_client_test.go
index 8ea8314..6ae6bd3 100644
--- a/src/shopify/theme_client_test.go
+++ b/src/shopify/theme_client_test.go
@@ -193,6 +193,49 @@ func TestThemeClient_GetInfo(t *testing.T) {
 	}
 }
 
+func TestThemeClient_PublishTheme(t *testing.T) {
+	testcases := []struct {
+		themeID, resp, resperr, err string
+		code                        int
+	}{
+		{err: ErrPublishWithoutThemeID.Error()},
+		{themeID: "nope", resp: `{"errors": "Not Found"}`, code: 200, err: "Not Found"},
+		{themeID: "123456", resperr: "(Client.Timeout exceeded while awaiting headers)", err: "(Client.Timeout exceeded while awaiting headers)"},
+		{themeID: "123456", resp: `{"theme":{"id": 123456,"name":"timberland","role":"main","previewable":true}}`, code: 200},
+		{themeID: "123456", resp: `{"errors":{"role":["cannot be set to main: missing required file layout/theme.liquid"]}}`, code: 422, err: "role cannot be set to main: missing required file layout/theme.liquid"},
+		{themeID: "123456", resp: "{}", code: 404, err: ErrThemeNotFound.Error()},
+	}
+
+	for i, testcase := range testcases {
+		m := new(mocks.HttpAdapter)
+		client, _ := NewClient(&env.Env{ThemeID: testcase.themeID})
+		client.http = m
+
+		expectation := m.On(
+			"Put",
+			fmt.Sprintf("/admin/themes/%s.json", testcase.themeID),
+			map[string]Theme{"theme": {Role: "main"}},
+		)
+		if testcase.resperr != "" {
+			expectation.Return(nil, errors.New(testcase.resperr))
+		} else {
+			expectation.Return(jsonResponse(testcase.resp, testcase.code), nil)
+		}
+
+		err := client.PublishTheme()
+
+		if testcase.err == "" {
+			assert.Nil(t, err, fmt.Sprintf("unexpected err in testcase: %d", i))
+		} else if assert.NotNil(t, err) {
+			assert.Contains(t, err.Error(), testcase.err)
+		}
+
+		if testcase.resp != "" || testcase.resperr != "" {
+			m.AssertExpectations(t)
+		}
+	}
+}
+
 func TestThemeClient_GetAllAssets(t *testing.T) {
 	testcases := []struct {
 		resp, resperr, err string