Repository: ankitpokhrel/jira-cli
Pull request #720 (2024-03-14 14:59:17)

feat(718): issue view: raw response option
https://github.com/ankitpokhrel/jira-cli/issues/718

Adding `--json` flag to the `jira issue view <KEY>` command so that the tool prints the issue contents as plain JSON if the flag is set. This feature allows users to process the issue information with their programs to support various automation scenarios.

I've added an intermediate struct `printableIssue` to ensure correct null/empty values in the produced JSON, to avoid zero-values like 
```
{
...
  "assignee": {
    "displayName": ""
  }
...
}
```

![Screenshot 2024-03-14 at 16 29 36](https://github.com/ankitpokhrel/jira-cli/assets/93705561/e52f2a22-34cf-40a0-8801-3a47d52a1beb)


mpalahuta: Hey @ankitpokhrel 
Thanks a lot for looking into this PR and for the green light on the JSON output - this will be very helpful!

Reg. raw response, sounds great, thanks! I will update this PR on Mon to do exactly that!
mpalahuta: hey @ankitpokhrel 
I've updated the PR to follow your suggestion of printing the raw API response. Verified on my machine - works as expected.
Please take a look whenever you have a chance, thanks!

![Screenshot 2024-03-19 at 18 01 24](https://github.com/ankitpokhrel/jira-cli/assets/93705561/3c72ef63-2391-464b-9971-19be312f96eb)

valters: hi @ankitpokhrel, I am also interested in this improvement. Having this will enable new build/release automation scenarios for us.

Patch:
diff --git a/api/client.go b/api/client.go
index 2d44485..f8ff619 100644
--- a/api/client.go
+++ b/api/client.go
@@ -95,6 +95,15 @@ func ProxyCreate(c *jira.Client, cr *jira.CreateRequest) (*jira.CreateResponse,
 	return resp, err
 }
 
+// ProxyGetIssueRaw executes the same request as ProxyGetIssue but returns raw API response body string.
+func ProxyGetIssueRaw(c *jira.Client, key string) (string, error) {
+	it := viper.GetString("installation")
+	if it == jira.InstallationTypeLocal {
+		return c.GetIssueV2Raw(key)
+	}
+	return c.GetIssueRaw(key)
+}
+
 // ProxyGetIssue uses either a v2 or v3 version of the Jira GET /issue/{key}
 // endpoint to fetch the issue details based on configured installation type.
 // Defaults to v3 if installation type is not defined in the config.
diff --git a/internal/cmd/issue/view/view.go b/internal/cmd/issue/view/view.go
index 1e6f161..c73bfb4 100644
--- a/internal/cmd/issue/view/view.go
+++ b/internal/cmd/issue/view/view.go
@@ -1,6 +1,8 @@
 package view
 
 import (
+	"fmt"
+
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 
@@ -16,7 +18,20 @@ const (
 	examples = `$ jira issue view ISSUE-1
 
 # Show 5 recent comments when viewing the issue
-$ jira issue view ISSUE-1 --comments 5`
+$ jira issue view ISSUE-1 --comments 5
+
+# Get the raw JSON data
+$ jira issue view ISSUE-1 --raw`
+
+	flagRaw      = "raw"
+	flagDebug    = "debug"
+	flagComments = "comments"
+	flagPlain    = "plain"
+
+	configProject = "project.key"
+	configServer  = "server"
+
+	messageFetchingData = "Fetching issue details..."
 )
 
 // NewCmdView is a view command.
@@ -34,22 +49,52 @@ func NewCmdView() *cobra.Command {
 		Run:  view,
 	}
 
-	cmd.Flags().Uint("comments", 1, "Show N comments")
-	cmd.Flags().Bool("plain", false, "Display output in plain mode")
+	cmd.Flags().Uint(flagComments, 1, "Show N comments")
+	cmd.Flags().Bool(flagPlain, false, "Display output in plain mode")
+	cmd.Flags().Bool(flagRaw, false, "Print raw Jira API response. Set this flag if you want to process the issue contents using a program")
 
 	return &cmd
 }
 
 func view(cmd *cobra.Command, args []string) {
-	debug, err := cmd.Flags().GetBool("debug")
+	raw, err := cmd.Flags().GetBool(flagRaw)
+	cmdutil.ExitIfError(err)
+
+	if raw {
+		viewRaw(cmd, args)
+		return
+	}
+	viewPretty(cmd, args)
+}
+
+func viewRaw(cmd *cobra.Command, args []string) {
+	debug, err := cmd.Flags().GetBool(flagDebug)
+	cmdutil.ExitIfError(err)
+
+	key := cmdutil.GetJiraIssueKey(viper.GetString(configProject), args[0])
+
+	apiResp, err := func() (string, error) {
+		s := cmdutil.Info(messageFetchingData)
+		defer s.Stop()
+
+		client := api.DefaultClient(debug)
+		return api.ProxyGetIssueRaw(client, key)
+	}()
+	cmdutil.ExitIfError(err)
+
+	fmt.Println(apiResp)
+}
+
+func viewPretty(cmd *cobra.Command, args []string) {
+	debug, err := cmd.Flags().GetBool(flagDebug)
 	cmdutil.ExitIfError(err)
 
-	comments, err := cmd.Flags().GetUint("comments")
+	comments, err := cmd.Flags().GetUint(flagComments)
 	cmdutil.ExitIfError(err)
 
-	key := cmdutil.GetJiraIssueKey(viper.GetString("project.key"), args[0])
+	key := cmdutil.GetJiraIssueKey(viper.GetString(configProject), args[0])
 	iss, err := func() (*jira.Issue, error) {
-		s := cmdutil.Info("Fetching issue details...")
+		s := cmdutil.Info(messageFetchingData)
 		defer s.Stop()
 
 		client := api.DefaultClient(debug)
@@ -57,11 +102,11 @@ func view(cmd *cobra.Command, args []string) {
 	}()
 	cmdutil.ExitIfError(err)
 
-	plain, err := cmd.Flags().GetBool("plain")
+	plain, err := cmd.Flags().GetBool(flagPlain)
 	cmdutil.ExitIfError(err)
 
 	v := tuiView.Issue{
-		Server:  viper.GetString("server"),
+		Server:  viper.GetString(configServer),
 		Data:    iss,
 		Display: tuiView.DisplayFormat{Plain: plain},
 		Options: tuiView.IssueOption{NumComments: comments},
diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go
index ab98602..61702df 100644
--- a/pkg/jira/issue.go
+++ b/pkg/jira/issue.go
@@ -4,7 +4,9 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
+	"strings"
 
 	"github.com/ankitpokhrel/jira-cli/pkg/jira/filter/issue"
 
@@ -26,15 +28,55 @@ const (
 
 // GetIssue fetches issue details using GET /issue/{key} endpoint.
 func (c *Client) GetIssue(key string, opts ...filter.Filter) (*Issue, error) {
-	return c.getIssue(key, apiVersion3, opts)
+	iss, err := c.getIssue(key, apiVersion3)
+	if err != nil {
+		return nil, err
+	}
+
+	iss.Fields.Description = ifaceToADF(iss.Fields.Description)
+
+	total := iss.Fields.Comment.Total
+	limit := filter.Collection(opts).GetInt(issue.KeyIssueNumComments)
+	if limit > total {
+		limit = total
+	}
+	for i := total - 1; i >= total-limit; i-- {
+		body := iss.Fields.Comment.Comments[i].Body
+		iss.Fields.Comment.Comments[i].Body = ifaceToADF(body)
+	}
+	return iss, nil
 }
 
 // GetIssueV2 fetches issue details using v2 version of Jira GET /issue/{key} endpoint.
-func (c *Client) GetIssueV2(key string, opts ...filter.Filter) (*Issue, error) {
-	return c.getIssue(key, apiVersion2, opts)
+func (c *Client) GetIssueV2(key string, _ ...filter.Filter) (*Issue, error) {
+	return c.getIssue(key, apiVersion2)
+}
+
+func (c *Client) getIssue(key, ver string) (*Issue, error) {
+	rawOut, err := c.getIssueRaw(key, ver)
+	if err != nil {
+		return nil, err
+	}
+
+	var iss Issue
+	err = json.Unmarshal([]byte(rawOut), &iss)
+	if err != nil {
+		return nil, err
+	}
+	return &iss, nil
+}
+
+// GetIssueRaw fetches issue details same as GetIssue but returns the raw API response body string.
+func (c *Client) GetIssueRaw(key string) (string, error) {
+	return c.getIssueRaw(key, apiVersion3)
+}
+
+// GetIssueV2Raw fetches issue details same as GetIssueV2 but returns the raw API response body string.
+func (c *Client) GetIssueV2Raw(key string) (string, error) {
+	return c.getIssueRaw(key, apiVersion2)
 }
 
-func (c *Client) getIssue(key, ver string, opts filter.Collection) (*Issue, error) {
+func (c *Client) getIssueRaw(key, ver string) (string, error) {
 	path := fmt.Sprintf("/issue/%s", key)
 
 	var (
@@ -50,37 +92,23 @@ func (c *Client) getIssue(key, ver string, opts filter.Collection) (*Issue, erro
 	}
 
 	if err != nil {
-		return nil, err
+		return "", err
 	}
 	if res == nil {
-		return nil, ErrEmptyResponse
+		return "", ErrEmptyResponse
 	}
 	defer func() { _ = res.Body.Close() }()
 
 	if res.StatusCode != http.StatusOK {
-		return nil, formatUnexpectedResponse(res)
+		return "", formatUnexpectedResponse(res)
 	}
 
-	var out Issue
-	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
-		return nil, err
-	}
-
-	if ver == apiVersion3 {
-		out.Fields.Description = ifaceToADF(out.Fields.Description)
-
-		total := out.Fields.Comment.Total
-		limit := opts.GetInt(issue.KeyIssueNumComments)
-		if limit > total {
-			limit = total
-		}
-		for i := total - 1; i >= total-limit; i-- {
-			body := out.Fields.Comment.Comments[i].Body
-			out.Fields.Comment.Comments[i].Body = ifaceToADF(body)
-		}
+	var b strings.Builder
+	_, err = io.Copy(&b, res.Body)
+	if err != nil {
+		return "", err
 	}
-
-	return &out, nil
+	return b.String(), nil
 }
 
 // AssignIssue assigns issue to the user using v3 version of the PUT /issue/{key}/assignee endpoint.


Test patch:
diff --git a/pkg/jira/issue_test.go b/pkg/jira/issue_test.go
index fa8c63a..2ec6861 100644
--- a/pkg/jira/issue_test.go
+++ b/pkg/jira/issue_test.go
@@ -14,6 +14,11 @@ import (
 	"github.com/ankitpokhrel/jira-cli/pkg/adf"
 )
 
+const (
+	_testdataPathIssue   = "./testdata/issue.json"
+	_testdataPathIssueV2 = "./testdata/issue-2.json"
+)
+
 func TestGetIssue(t *testing.T) {
 	var unexpectedStatusCode bool
 
@@ -23,7 +28,7 @@ func TestGetIssue(t *testing.T) {
 		if unexpectedStatusCode {
 			w.WriteHeader(400)
 		} else {
-			resp, err := os.ReadFile("./testdata/issue.json")
+			resp, err := os.ReadFile(_testdataPathIssue)
 			assert.NoError(t, err)
 
 			w.Header().Set("Content-Type", "application/json")
@@ -155,7 +160,7 @@ func TestGetIssueV2(t *testing.T) {
 		if unexpectedStatusCode {
 			w.WriteHeader(400)
 		} else {
-			resp, err := os.ReadFile("./testdata/issue-2.json")
+			resp, err := os.ReadFile(_testdataPathIssueV2)
 			assert.NoError(t, err)
 
 			w.Header().Set("Content-Type", "application/json")
@@ -202,6 +207,146 @@ func TestGetIssueV2(t *testing.T) {
 	assert.Error(t, &ErrUnexpectedResponse{}, err)
 }
 
+func TestGetIssueRaw(t *testing.T) {
+	cases := []struct {
+		title              string
+		givePayloadFile    string
+		giveClientCallFunc func(c *Client) (string, error)
+		wantReqURL         string
+		wantOut            string
+	}{
+		{
+			title:           "v3",
+			givePayloadFile: _testdataPathIssue,
+			giveClientCallFunc: func(c *Client) (string, error) {
+				return c.GetIssueRaw("KAN-1")
+			},
+			wantReqURL: "/rest/api/3/issue/KAN-1",
+			wantOut: `{
+  "key": "TEST-1",
+  "fields": {
+    "issuetype": {
+      "name": "Bug"
+    },
+    "resolution": null,
+    "created": "2020-12-03T14:05:20.974+0100",
+    "priority": {
+      "name": "Medium"
+    },
+    "labels": [],
+    "assignee": null,
+    "updated": "2020-12-03T14:05:20.974+0100",
+    "status": {
+      "name": "To Do"
+    },
+    "summary": "Bug summary",
+    "description": {
+      "version": 1,
+      "type": "doc",
+      "content": [
+        {
+          "type": "paragraph",
+          "content": [
+            {
+              "type": "text",
+              "text": "Test description"
+            }
+          ]
+        }
+      ]
+    },
+    "issuelinks": [
+      {
+        "id": "10001",
+        "outwardIssue": {
+          "key": "TEST-2"
+        }
+      },
+      {
+        "id": "10002",
+        "outwardIssue": {}
+      }
+    ],
+    "reporter": {
+      "displayName": "Person A"
+    },
+    "watches": {
+      "watchCount": 1,
+      "isWatching": true
+    }
+  }
+}
+`,
+		},
+		{
+			title:           "v2",
+			givePayloadFile: _testdataPathIssueV2,
+			giveClientCallFunc: func(c *Client) (string, error) {
+				return c.GetIssueV2Raw("KAN-1")
+			},
+			wantReqURL: "/rest/api/2/issue/KAN-1",
+			wantOut: `{
+  "key": "TEST-1",
+  "fields": {
+    "issuetype": {
+      "name": "Bug"
+    },
+    "resolution": null,
+    "created": "2020-12-03T14:05:20.974+0100",
+    "priority": {
+      "name": "Medium"
+    },
+    "labels": [],
+    "assignee": null,
+    "updated": "2020-12-03T14:05:20.974+0100",
+    "status": {
+      "name": "To Do"
+    },
+    "summary": "Bug summary",
+    "description": "Test description",
+    "reporter": {
+      "displayName": "Person A"
+    },
+    "watches": {
+      "watchCount": 1,
+      "isWatching": true
+    }
+  }
+}
+`,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.title, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				assert.Equal(t, c.wantReqURL, r.URL.Path)
+
+				respContent, err := os.ReadFile(c.givePayloadFile)
+				if !assert.NoError(t, err) {
+					return
+				}
+
+				w.Header().Set("Content-Type", "application/json")
+				_, err = w.Write(respContent)
+				if !assert.NoError(t, err) {
+					return
+				}
+				w.WriteHeader(http.StatusOK)
+			}))
+			defer server.Close()
+
+			client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
+			out, err := c.giveClientCallFunc(client)
+			if !assert.NoError(t, err) {
+				return
+			}
+
+			assert.Equal(t, c.wantOut, out)
+		})
+	}
+}
+
 func TestAssignIssue(t *testing.T) {
 	var (
 		apiVersion2          bool
@@ -352,7 +497,7 @@ func TestGetLinkID(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.Path)
 
-		resp, err := os.ReadFile("./testdata/issue.json")
+		resp, err := os.ReadFile(_testdataPathIssue)
 		assert.NoError(t, err)
 
 		w.Header().Set("Content-Type", "application/json")