From 347a9d7db15b211b0a38403503488698f484e50c Mon Sep 17 00:00:00 2001 From: Mykhailo Palahuta Date: Wed, 13 Mar 2024 17:00:41 +0200 Subject: [PATCH] feat(718): view issue: json output option --- internal/cmd/issue/view/view.go | 15 +- internal/view/issue.go | 147 +++++++++++ internal/view/issue_test.go | 429 ++++++++++++++++++++++---------- 3 files changed, 463 insertions(+), 128 deletions(-) diff --git a/internal/cmd/issue/view/view.go b/internal/cmd/issue/view/view.go index 1e6f161a..d484b281 100644 --- a/internal/cmd/issue/view/view.go +++ b/internal/cmd/issue/view/view.go @@ -1,6 +1,8 @@ package view import ( + "os" + "github.com/spf13/cobra" "github.com/spf13/viper" @@ -16,7 +18,10 @@ 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 + +# Print the contents of the issue as plain JSON +$ jira issue view ISSUE-1 --json` ) // NewCmdView is a view command. @@ -36,6 +41,7 @@ func NewCmdView() *cobra.Command { cmd.Flags().Uint("comments", 1, "Show N comments") cmd.Flags().Bool("plain", false, "Display output in plain mode") + cmd.Flags().Bool("json", false, "Print the issue contents as JSON. Set this flag when you want to process the output with a program") return &cmd } @@ -60,11 +66,18 @@ func view(cmd *cobra.Command, args []string) { plain, err := cmd.Flags().GetBool("plain") cmdutil.ExitIfError(err) + printJson, err := cmd.Flags().GetBool("json") + cmdutil.ExitIfError(err) + v := tuiView.Issue{ Server: viper.GetString("server"), Data: iss, Display: tuiView.DisplayFormat{Plain: plain}, Options: tuiView.IssueOption{NumComments: comments}, } + if printJson { + cmdutil.ExitIfError(v.RenderJSON(os.Stdout)) + return + } cmdutil.ExitIfError(v.Render()) } diff --git a/internal/view/issue.go b/internal/view/issue.go index 80ed1993..af8ac6f5 100644 --- a/internal/view/issue.go +++ b/internal/view/issue.go @@ -1,6 +1,7 @@ package view import ( + "encoding/json" "fmt" "io" "os" @@ -457,3 +458,149 @@ func (i Issue) renderPlain(w io.Writer) error { _, err = fmt.Fprint(w, out) return err } + +// printableIssue is a jira.Issue that can be marshaled into a format of choice f.e. JSON and +// printed for other programs to consume. +type printableIssue struct { + Key string `json:"key"` + Fields printableIssueFields `json:"fields"` +} + +type printableIssueFields struct { + Summary string `json:"summary"` + Description interface{} `json:"description"` + Labels []string `json:"labels"` + Resolution *struct { + Name string `json:"name"` + } `json:"resolution"` + IssueType *jira.IssueType `json:"issueType"` + Parent *struct { + Key string `json:"key"` + } `json:"parent,omitempty"` + Assignee *struct { + Name string `json:"displayName"` + } `json:"assignee"` + Priority *struct { + Name string `json:"name"` + } `json:"priority"` + Reporter *struct { + Name string `json:"displayName"` + } `json:"reporter"` + Watches struct { + IsWatching bool `json:"isWatching"` + WatchCount int `json:"watchCount"` + } `json:"watches"` + Status *struct { + Name string `json:"name"` + } `json:"status"` + Components []struct { + Name string `json:"name"` + } `json:"components"` + FixVersions []struct { + Name string `json:"name"` + } `json:"fixVersions"` + AffectsVersions []struct { + Name string `json:"name"` + } `json:"versions"` + Comment struct { + Comments []struct { + ID string `json:"id"` + Author jira.User `json:"author"` + Body interface{} `json:"body"` + Created string `json:"created"` + } `json:"comments"` + Total int `json:"total"` + } `json:"comment"` + Subtasks []string + IssueLinks []printableIssueLink `json:"issueLinks"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +type printableIssueLink struct { + ID string `json:"id"` + LinkType struct { + Name string `json:"name"` + Inward string `json:"inward"` + Outward string `json:"outward"` + } `json:"type"` + InwardIssue string `json:"inwardIssue,omitempty"` + OutwardIssue string `json:"outwardIssue,omitempty"` +} + +func toPrintableIssue(issue jira.Issue) printableIssue { + pi := printableIssue{ + Key: issue.Key, + Fields: printableIssueFields{ + Summary: issue.Fields.Summary, + Description: issue.Fields.Description, + Labels: issue.Fields.Labels, + Parent: issue.Fields.Parent, + Watches: issue.Fields.Watches, + Components: issue.Fields.Components, + FixVersions: issue.Fields.FixVersions, + AffectsVersions: issue.Fields.AffectsVersions, + Comment: issue.Fields.Comment, + Created: issue.Fields.Created, + Updated: issue.Fields.Updated, + }, + } + if issue.Fields.Resolution.Name != "" { + pi.Fields.Resolution = &issue.Fields.Resolution + } + if issue.Fields.IssueType.ID != "" || issue.Fields.IssueType.Name != "" { + pi.Fields.IssueType = &issue.Fields.IssueType + } + if issue.Fields.Assignee.Name != "" { + pi.Fields.Assignee = &issue.Fields.Assignee + } + if issue.Fields.Priority.Name != "" { + pi.Fields.Priority = &issue.Fields.Priority + } + if issue.Fields.Reporter.Name != "" { + pi.Fields.Reporter = &issue.Fields.Reporter + } + if issue.Fields.Status.Name != "" { + pi.Fields.Status = &issue.Fields.Status + } + + if len(issue.Fields.Subtasks) > 0 { + var subKeys []string + for _, sub := range issue.Fields.Subtasks { + subKeys = append(subKeys, sub.Key) + } + pi.Fields.Subtasks = subKeys + } + if len(issue.Fields.IssueLinks) > 0 { + var piLinks []printableIssueLink + for _, il := range issue.Fields.IssueLinks { + pil := printableIssueLink{ + ID: il.ID, + LinkType: il.LinkType, + } + if il.InwardIssue != nil { + pil.InwardIssue = il.InwardIssue.Key + } + if il.OutwardIssue != nil { + pil.OutwardIssue = il.OutwardIssue.Key + } + piLinks = append(piLinks, pil) + } + pi.Fields.IssueLinks = piLinks + } + return pi +} + +// RenderJSON prints the issue data as a plain JSON. +func (i Issue) RenderJSON(w io.Writer) error { + if i.Data == nil { + return fmt.Errorf("no issue data available") + } + pi := toPrintableIssue(*i.Data) + + err := json.NewEncoder(w).Encode(pi) + if err != nil { + return fmt.Errorf("json encode: %w", err) + } + return nil +} diff --git a/internal/view/issue_test.go b/internal/view/issue_test.go index d8c4110c..fbb9b877 100644 --- a/internal/view/issue_test.go +++ b/internal/view/issue_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +12,138 @@ import ( "github.com/ankitpokhrel/jira-cli/pkg/tui" ) +var _testJiraIssue = &jira.Issue{ + Key: "TEST-1", + Fields: jira.IssueFields{ + Summary: "This is a test", + Resolution: struct { + Name string `json:"name"` + }{Name: "Fixed"}, + Description: "h1. Title\nh2. Subtitle\n\nThis is a *bold* and _italic_ text with [a link|https://ankit.pl] in between.", + IssueType: jira.IssueType{Name: "Bug"}, + Assignee: struct { + Name string `json:"displayName"` + }{Name: "Person A"}, + Priority: struct { + Name string `json:"name"` + }{Name: "High"}, + Reporter: struct { + Name string `json:"displayName"` + }{Name: "Person Z"}, + Status: struct { + Name string `json:"name"` + }{Name: "Done"}, + Labels: []string{"test-1"}, + Components: []struct { + Name string `json:"name"` + }{{Name: "BE"}, {Name: "FE"}}, + FixVersions: []struct { + Name string `json:"name"` + }{{Name: "fix-version-1"}}, + AffectsVersions: []struct { + Name string `json:"name"` + }{{Name: "v1.0.0"}}, + Comment: struct { + Comments []struct { + ID string `json:"id"` + Author jira.User `json:"author"` + Body interface{} `json:"body"` + Created string `json:"created"` + } `json:"comments"` + Total int `json:"total"` + }{ + Comments: []struct { + ID string `json:"id"` + Author jira.User `json:"author"` + Body interface{} `json:"body"` + Created string `json:"created"` + }{ + {ID: "10033", Author: jira.User{Name: "Person A"}, Body: "Test comment A", Created: "2021-11-22T23:44:13.782+0100"}, + {ID: "10034", Author: jira.User{Name: "Person B"}, Body: "Test comment B", Created: "2021-11-23T23:44:13.782+0100"}, + {ID: "10035", Author: jira.User{Name: "Person C"}, Body: "Test comment C", Created: "2021-11-24T23:44:13.782+0100"}, + }, + Total: 3, + }, + Subtasks: []jira.Issue{ + { + Key: "TEST-2", + Fields: jira.IssueFields{ + Summary: "Subtask 1", + Status: struct { + Name string `json:"name"` + }{Name: "TO DO"}, + Priority: struct { + Name string `json:"name"` + }{Name: "High"}, + }, + }, + { + Key: "TEST-3", + Fields: jira.IssueFields{ + Summary: "Subtask 2", + Status: struct { + Name string `json:"name"` + }{Name: "Done"}, + Priority: struct { + Name string `json:"name"` + }{Name: "Normal"}, + }, + }, + }, + IssueLinks: []struct { + ID string `json:"id"` + LinkType struct { + Name string `json:"name"` + Inward string `json:"inward"` + Outward string `json:"outward"` + } `json:"type"` + InwardIssue *jira.Issue `json:"inwardIssue,omitempty"` + OutwardIssue *jira.Issue `json:"outwardIssue,omitempty"` + }{ + { + LinkType: struct { + Name string `json:"name"` + Inward string `json:"inward"` + Outward string `json:"outward"` + }{Name: "blocks", Inward: "blocks", Outward: "is blocked by"}, + InwardIssue: &jira.Issue{ + Key: "TEST-2", + Fields: jira.IssueFields{ + Summary: "Something is broken", + IssueType: jira.IssueType{Name: "Bug"}, + Priority: struct { + Name string `json:"name"` + }{Name: "High"}, Status: struct { + Name string `json:"name"` + }{Name: "TO DO"}, + }, + }, + }, + { + LinkType: struct { + Name string `json:"name"` + Inward string `json:"inward"` + Outward string `json:"outward"` + }{Name: "relates", Inward: "relates", Outward: "relates to"}, + OutwardIssue: &jira.Issue{ + Key: "TEST-3", + Fields: jira.IssueFields{ + Summary: "Everything is on fire", + IssueType: jira.IssueType{Name: "Bug"}, + Priority: struct { + Name string `json:"name"` + }{Name: "Urgent"}, Status: struct { + Name string `json:"name"` + }{Name: "Done"}, + }, + }, + }, + }, + Created: "2020-12-13T14:05:20.974+0100", + Updated: "2020-12-13T14:07:20.974+0100", + }, +} + func TestIssueDetailsRenderInPlainView(t *testing.T) { t.Parallel() @@ -92,140 +225,15 @@ func TestIssueDetailsWithV2Description(t *testing.T) { var b bytes.Buffer - data := &jira.Issue{ - Key: "TEST-1", - Fields: jira.IssueFields{ - Summary: "This is a test", - Resolution: struct { - Name string `json:"name"` - }{Name: "Fixed"}, - Description: "h1. Title\nh2. Subtitle\n\nThis is a *bold* and _italic_ text with [a link|https://ankit.pl] in between.", - IssueType: jira.IssueType{Name: "Bug"}, - Assignee: struct { - Name string `json:"displayName"` - }{Name: "Person A"}, - Priority: struct { - Name string `json:"name"` - }{Name: "High"}, - Reporter: struct { - Name string `json:"displayName"` - }{Name: "Person Z"}, - Status: struct { - Name string `json:"name"` - }{Name: "Done"}, - Components: []struct { - Name string `json:"name"` - }{{Name: "BE"}, {Name: "FE"}}, - Comment: struct { - Comments []struct { - ID string `json:"id"` - Author jira.User `json:"author"` - Body interface{} `json:"body"` - Created string `json:"created"` - } `json:"comments"` - Total int `json:"total"` - }{ - Comments: []struct { - ID string `json:"id"` - Author jira.User `json:"author"` - Body interface{} `json:"body"` - Created string `json:"created"` - }{ - {ID: "10033", Author: jira.User{Name: "Person A"}, Body: "Test comment A", Created: "2021-11-22T23:44:13.782+0100"}, - {ID: "10034", Author: jira.User{Name: "Person B"}, Body: "Test comment B", Created: "2021-11-23T23:44:13.782+0100"}, - {ID: "10035", Author: jira.User{Name: "Person C"}, Body: "Test comment C", Created: "2021-11-24T23:44:13.782+0100"}, - }, - Total: 3, - }, - Subtasks: []jira.Issue{ - { - Key: "TEST-2", - Fields: jira.IssueFields{ - Summary: "Subtask 1", - Status: struct { - Name string `json:"name"` - }{Name: "TO DO"}, - Priority: struct { - Name string `json:"name"` - }{Name: "High"}, - }, - }, - { - Key: "TEST-3", - Fields: jira.IssueFields{ - Summary: "Subtask 2", - Status: struct { - Name string `json:"name"` - }{Name: "Done"}, - Priority: struct { - Name string `json:"name"` - }{Name: "Normal"}, - }, - }, - }, - IssueLinks: []struct { - ID string `json:"id"` - LinkType struct { - Name string `json:"name"` - Inward string `json:"inward"` - Outward string `json:"outward"` - } `json:"type"` - InwardIssue *jira.Issue `json:"inwardIssue,omitempty"` - OutwardIssue *jira.Issue `json:"outwardIssue,omitempty"` - }{ - { - LinkType: struct { - Name string `json:"name"` - Inward string `json:"inward"` - Outward string `json:"outward"` - }{Name: "blocks", Inward: "blocks", Outward: "is blocked by"}, - InwardIssue: &jira.Issue{ - Key: "TEST-2", - Fields: jira.IssueFields{ - Summary: "Something is broken", - IssueType: jira.IssueType{Name: "Bug"}, - Priority: struct { - Name string `json:"name"` - }{Name: "High"}, Status: struct { - Name string `json:"name"` - }{Name: "TO DO"}, - }, - }, - }, - { - LinkType: struct { - Name string `json:"name"` - Inward string `json:"inward"` - Outward string `json:"outward"` - }{Name: "relates", Inward: "relates", Outward: "relates to"}, - OutwardIssue: &jira.Issue{ - Key: "TEST-3", - Fields: jira.IssueFields{ - Summary: "Everything is on fire", - IssueType: jira.IssueType{Name: "Bug"}, - Priority: struct { - Name string `json:"name"` - }{Name: "Urgent"}, Status: struct { - Name string `json:"name"` - }{Name: "Done"}, - }, - }, - }, - }, - Created: "2020-12-13T14:05:20.974+0100", - Updated: "2020-12-13T14:07:20.974+0100", - }, - } - issue := Issue{ Server: "https://test.local", - Data: data, + Data: _testJiraIssue, Display: DisplayFormat{Plain: true}, Options: IssueOption{NumComments: 2}, } assert.NoError(t, issue.renderPlain(&b)) - expected := "šŸž Bug āœ… Done āŒ› Sun, 13 Dec 20 šŸ‘· Person A šŸ”‘ļø TEST-1 šŸ’­ 3 comments \U0001F9F5 2 linked\n# This is a test\nā±ļø Sun, 13 Dec 20 šŸ”Ž Person Z šŸš€ High šŸ“¦ BE, FE šŸ·ļø None šŸ‘€ 0 watchers\n\n------------------------ Description ------------------------\n\n# Title\n## Subtitle\nThis is a **bold** and _italic_ text with [a link](https://ankit.pl) in between.\n\n\n------------------------ 2 Subtasks ------------------------\n\n\n SUBTASKS\n\n TEST-2 Subtask 1 ā€¢ High ā€¢ TO DO\n TEST-3 Subtask 2 ā€¢ Normal ā€¢ Done \n\n\n\n------------------------ Linked Issues ------------------------\n\n\n BLOCKS\n\n TEST-2 Something is broken ā€¢ Bug ā€¢ High ā€¢ TO DO\n\n RELATES TO\n\n TEST-3 Everything is on fire ā€¢ Bug ā€¢ Urgent ā€¢ Done \n\n\n\n------------------------ 3 Comments ------------------------\n\n\n Person C ā€¢ Wed, 24 Nov 21 ā€¢ Latest comment\n\nTest comment C\n\n\n\n Person B ā€¢ Tue, 23 Nov 21\n\nTest comment B\n\n" + expected := "šŸž Bug āœ… Done āŒ› Sun, 13 Dec 20 šŸ‘· Person A šŸ”‘ļø TEST-1 šŸ’­ 3 comments \U0001F9F5 2 linked\n# This is a test\nā±ļø Sun, 13 Dec 20 šŸ”Ž Person Z šŸš€ High šŸ“¦ BE, FE šŸ·ļø test-1 šŸ‘€ 0 watchers\n\n------------------------ Description ------------------------\n\n# Title\n## Subtitle\nThis is a **bold** and _italic_ text with [a link](https://ankit.pl) in between.\n\n\n------------------------ 2 Subtasks ------------------------\n\n\n SUBTASKS\n\n TEST-2 Subtask 1 ā€¢ High ā€¢ TO DO\n TEST-3 Subtask 2 ā€¢ Normal ā€¢ Done \n\n\n\n------------------------ Linked Issues ------------------------\n\n\n BLOCKS\n\n TEST-2 Something is broken ā€¢ Bug ā€¢ High ā€¢ TO DO\n\n RELATES TO\n\n TEST-3 Everything is on fire ā€¢ Bug ā€¢ Urgent ā€¢ Done \n\n\n\n------------------------ 3 Comments ------------------------\n\n\n Person C ā€¢ Wed, 24 Nov 21 ā€¢ Latest comment\n\nTest comment C\n\n\n\n Person B ā€¢ Tue, 23 Nov 21\n\nTest comment B\n\n" if xterm256() { expected += "\x1b[38;5;242mUse --comments with `jira issue view` to load more comments\x1b[m\n\n" expected += "\x1b[38;5;242mView this issue on Jira: https://test.local/browse/TEST-1\x1b[m" @@ -310,3 +318,170 @@ func TestSeparator(t *testing.T) { }) } } + +func TestRenderJSON(t *testing.T) { + t.Parallel() + + issue := Issue{Data: _testJiraIssue} + var b strings.Builder + err := issue.RenderJSON(&b) + if !assert.NoError(t, err) { + return + } + + assert.JSONEq(t, + ` +{ + "key": "TEST-1", + "fields": { + "summary": "This is a test", + "description": "h1. Title\nh2. Subtitle\n\nThis is a *bold* and _italic_ text with [a link|https://ankit.pl] in between.", + "labels": ["test-1"], + "resolution": { + "name": "Fixed" + }, + "issueType": { + "id": "", + "name": "Bug", + "subtask": false + }, + "assignee": { + "displayName": "Person A" + }, + "priority": { + "name": "High" + }, + "reporter": { + "displayName": "Person Z" + }, + "watches": { + "isWatching": false, + "watchCount": 0 + }, + "status": { + "name": "Done" + }, + "components": [{"name": "BE"}, {"name": "FE"}], + "fixVersions": [ + { + "name": "fix-version-1" + } + ], + "versions": [ + { + "name": "v1.0.0" + } + ], + "comment": { + "comments": [ + { + "id": "10033", + "author": { + "emailAddress": "", + "name": "Person A", + "displayName": "", + "active": false + }, + "body": "Test comment A", + "created": "2021-11-22T23:44:13.782+0100" + }, + { + "id": "10034", + "author": { + "emailAddress": "", + "name": "Person B", + "displayName": "", + "active": false + }, + "body": "Test comment B", + "created": "2021-11-23T23:44:13.782+0100" + }, + { + "id": "10035", + "author": { + "emailAddress": "", + "name": "Person C", + "displayName": "", + "active": false + }, + "body": "Test comment C", + "created": "2021-11-24T23:44:13.782+0100" + } + ], + "total": 3 + }, + "Subtasks": ["TEST-2", "TEST-3"], + "issueLinks": [ + { + "id": "", + "type": { + "name": "blocks", + "inward": "blocks", + "outward": "is blocked by" + }, + "inwardIssue": "TEST-2" + }, + { + "id": "", + "type": { + "name": "relates", + "inward": "relates", + "outward": "relates to" + }, + "outwardIssue": "TEST-3" + } + ], + "created": "2020-12-13T14:05:20.974+0100", + "updated": "2020-12-13T14:07:20.974+0100" + } +}`, b.String()) +} + +func TestRenderJSON_NoData(t *testing.T) { + issue := Issue{} + var b strings.Builder + err := issue.RenderJSON(&b) + assert.Error(t, err) + assert.Equal(t, "no issue data available", err.Error()) +} + +func TestRenderJSON_OmitEmpty(t *testing.T) { + issue := Issue{Data: &jira.Issue{Key: "TEST-2", Fields: jira.IssueFields{}}} + + var b strings.Builder + err := issue.RenderJSON(&b) + if !assert.NoError(t, err) { + return + } + + assert.JSONEq(t, ` +{ + "key": "TEST-2", + "fields": { + "summary": "", + "description": null, + "labels": null, + "resolution": null, + "issueType": null, + "assignee": null, + "priority": null, + "reporter": null, + "watches": { + "isWatching": false, + "watchCount": 0 + }, + "status": null, + "components": null, + "fixVersions": null, + "versions": null, + "comment": { + "comments": null, + "total": 0 + }, + "Subtasks": null, + "issueLinks": null, + "created": "", + "updated": "" + } +}`, b.String()) +}