Repository: ankitpokhrel/jira-cli
Pull request #35 (2020-12-22 12:21:21)

feat: Initial move/transition cmd impl
```
$ jira issue move -h
Move transitions an issue from one state to another.

Usage:
  jira issue move ISSUE_KEY STATE [flags]

Aliases:
  move, transition

Flags:
  -h, --help   help for move

Global Flags:
  -c, --config string    Config file (default is $HOME/.config/jira/.jira.yml)
      --debug            Turn on debug output
  -p, --project string   Jira project to look into (defaults to value from $HOME/.config/jira/.jira.yml)
```



Patch:
diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go
index 9dc5703..39a5c3a 100644
--- a/internal/cmd/issue/issue.go
+++ b/internal/cmd/issue/issue.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/create"
 	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/list"
+	"github.com/ankitpokhrel/jira-cli/internal/cmd/issue/move"
 )
 
 const helpText = `Issue manage issues in a given project. See available commands below.`
@@ -22,7 +23,7 @@ func NewCmdIssue() *cobra.Command {
 	lc := list.NewCmdList()
 	cc := create.NewCmdCreate()
 
-	cmd.AddCommand(lc, cc)
+	cmd.AddCommand(lc, cc, move.NewCmdMove())
 
 	list.SetFlags(lc)
 	create.SetFlags(cc)
diff --git a/internal/cmd/issue/move/move.go b/internal/cmd/issue/move/move.go
new file mode 100644
index 0000000..1dfab14
--- /dev/null
+++ b/internal/cmd/issue/move/move.go
@@ -0,0 +1,75 @@
+package move
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/ankitpokhrel/jira-cli/api"
+	"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
+	"github.com/ankitpokhrel/jira-cli/pkg/jira"
+)
+
+const helpText = `Move transitions an issue from one state to another.`
+
+// NewCmdMove is a move command.
+func NewCmdMove() *cobra.Command {
+	return &cobra.Command{
+		Use:     "move ISSUE_KEY STATE",
+		Short:   "Transition an issue to a given state",
+		Long:    helpText,
+		Args:    cobra.MinimumNArgs(2), //nolint:gomnd
+		Aliases: []string{"transition"},
+		Run:     move,
+	}
+}
+
+func move(cmd *cobra.Command, args []string) {
+	key, state := args[0], args[1]
+
+	debug, err := cmd.Flags().GetBool("debug")
+	cmdutil.ExitIfError(err)
+
+	client := api.Client(jira.Config{Debug: debug})
+
+	transitions := func() []*jira.Transition {
+		s := cmdutil.Info("Verifying transition...")
+		defer s.Stop()
+
+		transitions, err := client.Transitions(key)
+		cmdutil.ExitIfError(err)
+
+		return transitions
+	}()
+
+	tr := func() *jira.Transition {
+		st := strings.ToLower(state)
+		for _, t := range transitions {
+			if strings.ToLower(t.Name) == st {
+				return t
+			}
+		}
+		return nil
+	}()
+	if tr == nil {
+		cmdutil.PrintErrF("\u001B[0;31m✗\u001B[0m Unable to transition issue to state \"%s\"", state)
+		return
+	}
+	if !tr.IsAvailable {
+		cmdutil.PrintErrF("\u001B[0;31m✗\u001B[0m Transition state \"%s\" for issue \"%s\" is not available", tr.Name, key)
+		return
+	}
+
+	func() {
+		s := cmdutil.Info(fmt.Sprintf("Transitioning issue to \"%s\"...", tr.Name))
+		defer s.Stop()
+
+		_, err := client.Transition(key, &jira.TransitionRequest{
+			Transition: &jira.TransitionRequestData{ID: tr.ID.String(), Name: tr.Name},
+		})
+		cmdutil.ExitIfError(err)
+	}()
+
+	fmt.Printf("\u001B[0;32m✓\u001B[0m Issue transitioned to state \"%s\"\n", tr.Name)
+}
diff --git a/pkg/jira/transition.go b/pkg/jira/transition.go
new file mode 100644
index 0000000..7d99d2e
--- /dev/null
+++ b/pkg/jira/transition.go
@@ -0,0 +1,80 @@
+package jira
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+)
+
+// TransitionRequest struct holds request data for transition request.
+type TransitionRequest struct {
+	Transition *TransitionRequestData `json:"transition"`
+}
+
+// TransitionRequestData is a transition request data.
+type TransitionRequestData struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+type transitionResponse struct {
+	Expand      string        `json:"expand"`
+	Transitions []*Transition `json:"transitions"`
+}
+
+// Transitions fetches valid transitions for an issue from GET /issue/{key}/transitions endpoint.
+func (c *Client) Transitions(key string) ([]*Transition, error) {
+	path := fmt.Sprintf("/issue/%s/transitions", key)
+
+	res, err := c.Get(context.Background(), path, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	if res == nil {
+		return nil, ErrEmptyResponse
+	}
+
+	defer func() { _ = res.Body.Close() }()
+
+	if res.StatusCode != http.StatusOK {
+		return nil, ErrUnexpectedStatusCode
+	}
+
+	var out transitionResponse
+
+	err = json.NewDecoder(res.Body).Decode(&out)
+
+	return out.Transitions, err
+}
+
+// Transition moves issue from one state to another using POST /issue/{key}/transitions endpoint.
+func (c *Client) Transition(key string, data *TransitionRequest) (int, error) {
+	body, err := json.Marshal(&data)
+	if err != nil {
+		return 0, err
+	}
+
+	path := fmt.Sprintf("/issue/%s/transitions", key)
+
+	res, err := c.Post(context.Background(), path, body, Header{
+		"Accept":       "application/json",
+		"Content-Type": "application/json",
+	})
+	if err != nil {
+		return 0, err
+	}
+
+	if res == nil {
+		return 0, ErrEmptyResponse
+	}
+
+	defer func() { _ = res.Body.Close() }()
+
+	if res.StatusCode != http.StatusNoContent {
+		return res.StatusCode, ErrUnexpectedStatusCode
+	}
+
+	return res.StatusCode, nil
+}
diff --git a/pkg/jira/types.go b/pkg/jira/types.go
index 06d5ef2..e9aae03 100644
--- a/pkg/jira/types.go
+++ b/pkg/jira/types.go
@@ -1,5 +1,7 @@
 package jira
 
+import "encoding/json"
+
 // Project holds project info.
 type Project struct {
 	Key  string `json:"key"`
@@ -63,6 +65,13 @@ type Sprint struct {
 	BoardID      int    `json:"originBoardId,omitempty"`
 }
 
+// Transition holds issue transition info.
+type Transition struct {
+	ID          json.Number `json:"id"`
+	Name        string      `json:"name"`
+	IsAvailable bool        `json:"isAvailable"`
+}
+
 // ADF is an Atlassian document format.
 // See https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
 type ADF struct {


Test patch:
diff --git a/pkg/jira/testdata/transitions.json b/pkg/jira/testdata/transitions.json
new file mode 100644
index 0000000..fcfd004
--- /dev/null
+++ b/pkg/jira/testdata/transitions.json
@@ -0,0 +1,20 @@
+{
+  "expand": "transitions",
+  "transitions": [
+    {
+      "id": "11",
+      "name": "To Do",
+      "isAvailable": true
+    },
+    {
+      "id": "21",
+      "name": "In Progress",
+      "isAvailable": true
+    },
+    {
+      "id": "31",
+      "name": "Done",
+      "isAvailable": false
+    }
+  ]
+}
diff --git a/pkg/jira/transition_test.go b/pkg/jira/transition_test.go
new file mode 100644
index 0000000..f74e9f7
--- /dev/null
+++ b/pkg/jira/transition_test.go
@@ -0,0 +1,97 @@
+package jira
+
+import (
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTransitions(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/3/issue/TEST/transitions", r.URL.Path)
+		assert.Equal(t, "GET", r.Method)
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			resp, err := ioutil.ReadFile("./testdata/transitions.json")
+			assert.NoError(t, err)
+
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(200)
+			_, _ = w.Write(resp)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+
+	actual, err := client.Transitions("TEST")
+	assert.NoError(t, err)
+
+	expected := []*Transition{
+		{
+			ID:          "11",
+			Name:        "To Do",
+			IsAvailable: true,
+		},
+		{
+			ID:          "21",
+			Name:        "In Progress",
+			IsAvailable: true,
+		},
+		{
+			ID:          "31",
+			Name:        "Done",
+			IsAvailable: false,
+		},
+	}
+
+	assert.Equal(t, expected, actual)
+
+	unexpectedStatusCode = true
+
+	_, err = client.Transitions("TEST")
+	assert.Error(t, ErrUnexpectedStatusCode, err)
+}
+
+func TestTransition(t *testing.T) {
+	var unexpectedStatusCode bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "/rest/api/3/issue/TEST/transitions", r.URL.Path)
+		assert.Equal(t, "POST", r.Method)
+		assert.Equal(t, "application/json", r.Header.Get("Accept"))
+		assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
+
+		actualBody := new(strings.Builder)
+		_, _ = io.Copy(actualBody, r.Body)
+
+		expectedBody := `{"transition":{"id":"31","name":"Done"}}`
+		assert.Equal(t, expectedBody, actualBody.String())
+
+		if unexpectedStatusCode {
+			w.WriteHeader(400)
+		} else {
+			w.WriteHeader(204)
+		}
+	}))
+	defer server.Close()
+
+	client := NewClient(Config{Server: server.URL}, WithTimeout(3))
+
+	requestData := TransitionRequest{Transition: &TransitionRequestData{
+		ID:   "31",
+		Name: "Done",
+	}}
+	code, err := client.Transition("TEST", &requestData)
+	assert.NoError(t, err)
+	assert.Equal(t, code, 204)
+}