From 1d1d9197ee1397cfc2064c4d3ddaee6069ebae44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Riccardo=20F=C3=B6rster?= Date: Sat, 29 Nov 2025 05:05:30 +0000 Subject: [PATCH] feat(issue): Add JSON output and file redirection (#841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change enhances the 'issue' command functionality by enabling structured JSON output for single issue views and introducing a method for output redirection. **Changes Implemented:** 1. Enables the existing `--output json` flag for single issue commands (e.g., 'tea issue 17'). This flag was previously ignored in this context. 2. Introduces the new `--out ` flag, which redirects the marshaled JSON output from stdout to the specified file. Feeback more then welcome. Co-authored-by: Jonas Toth Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/841 Reviewed-by: TheFox0x7 Reviewed-by: Lunny Xiao Co-authored-by: Riccardo Förster Co-committed-by: Riccardo Förster --- cmd/issues.go | 98 ++++++++++++++++ cmd/issues_test.go | 270 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 cmd/issues_test.go diff --git a/cmd/issues.go b/cmd/issues.go index 68dfa59..05ce2f0 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -5,8 +5,12 @@ package cmd import ( stdctx "context" + "encoding/json" "fmt" + "time" + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/issues" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/interact" @@ -16,6 +20,34 @@ import ( "github.com/urfave/cli/v3" ) +type labelData struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type issueData struct { + ID int64 `json:"id"` + Index int64 `json:"index"` + Title string `json:"title"` + State gitea.StateType `json:"state"` + Created time.Time `json:"created"` + Labels []labelData `json:"labels"` + User string `json:"user"` + Body string `json:"body"` + Assignees []string `json:"assignees"` + URL string `json:"url"` + ClosedAt *time.Time `json:"closedAt"` + Comments []commentData `json:"comments"` +} + +type commentData struct { + ID int64 `json:"id"` + Author string `json:"author"` + Created time.Time `json:"created"` + Body string `json:"body"` +} + // CmdIssues represents to login a gitea server. var CmdIssues = cli.Command{ Name: "issues", @@ -64,6 +96,14 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { if err != nil { return err } + + if ctx.IsSet("output") { + switch ctx.String("output") { + case "json": + return runIssueDetailAsJSON(ctx, issue) + } + } + print.IssueDetails(issue, reactions) if issue.Comments > 0 { @@ -75,3 +115,61 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { return nil } + +func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error { + c := ctx.Login.Client() + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} + + labelSlice := make([]labelData, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labelSlice = append(labelSlice, labelData{label.Name, label.Color, label.Description}) + } + + assigneesSlice := make([]string, 0, len(issue.Assignees)) + for _, assignee := range issue.Assignees { + assigneesSlice = append(assigneesSlice, assignee.UserName) + } + + issueSlice := issueData{ + ID: issue.ID, + Index: issue.Index, + Title: issue.Title, + State: issue.State, + Created: issue.Created, + User: issue.Poster.UserName, + Body: issue.Body, + Labels: labelSlice, + Assignees: assigneesSlice, + URL: issue.HTMLURL, + ClosedAt: issue.Closed, + Comments: make([]commentData, 0), + } + + if ctx.Bool("comments") { + comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts) + issueSlice.Comments = make([]commentData, 0, len(comments)) + + if err != nil { + return err + } + + for _, comment := range comments { + issueSlice.Comments = append(issueSlice.Comments, commentData{ + ID: comment.ID, + Author: comment.Poster.UserName, + Body: comment.Body, // Selected Field + Created: comment.Created, + }) + } + + } + + jsonData, err := json.MarshalIndent(issueSlice, "", "\t") + if err != nil { + return err + } + + _, err = fmt.Fprintf(ctx.Writer, "%s\n", jsonData) + + return err +} diff --git a/cmd/issues_test.go b/cmd/issues_test.go new file mode 100644 index 0000000..9aa162b --- /dev/null +++ b/cmd/issues_test.go @@ -0,0 +1,270 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +const ( + testOwner = "testOwner" + testRepo = "testRepo" +) + +func createTestIssue(comments int, isClosed bool) gitea.Issue { + var issue = gitea.Issue{ + ID: 42, + Index: 1, + Title: "Test issue", + State: gitea.StateOpen, + Body: "This is a test", + Created: time.Date(2025, 31, 10, 23, 59, 59, 999999999, time.UTC), + Updated: time.Date(2025, 1, 11, 0, 0, 0, 0, time.UTC), + Labels: []*gitea.Label{ + { + Name: "example/Label1", + Color: "very red", + Description: "This is an example label", + }, + { + Name: "example/Label2", + Color: "hardly red", + Description: "This is another example label", + }, + }, + Comments: comments, + Poster: &gitea.User{ + UserName: "testUser", + }, + Assignees: []*gitea.User{ + {UserName: "testUser"}, + {UserName: "testUser3"}, + }, + HTMLURL: "", + Closed: nil, //2025-11-10T21:20:19Z + } + + if isClosed { + var closed = time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC) + issue.Closed = &closed + } + + if isClosed { + issue.State = gitea.StateClosed + } else { + issue.State = gitea.StateOpen + } + + return issue + +} + +func createTestIssueComments(comments int) []gitea.Comment { + baseID := 900 + var result []gitea.Comment + + for commentID := 0; commentID < comments; commentID++ { + result = append(result, gitea.Comment{ + ID: int64(baseID + commentID), + Poster: &gitea.User{ + UserName: "Freddy", + }, + Body: fmt.Sprintf("This is a test comment #%v", commentID), + Created: time.Date(2025, 11, 3, 12, 0, 0, 0, time.UTC). + Add(time.Duration(commentID) * time.Hour), + }) + } + + return result + +} + +func TestRunIssueDetailAsJSON(t *testing.T) { + type TestCase struct { + name string + issue gitea.Issue + comments []gitea.Comment + flagComments bool + flagOutput string + flagOut string + closed bool + } + + cmd := cli.Command{ + Name: "t", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "comments", + Value: false, + }, + &cli.StringFlag{ + Name: "output", + Value: "json", + }, + }, + } + + testContext := context.TeaContext{ + Owner: testOwner, + Repo: testRepo, + Login: &config.Login{ + Name: "testLogin", + URL: "http://127.0.0.1:8081", + }, + Command: &cmd, + } + + testCases := []TestCase{ + { + name: "Simple issue with no comments, no comments requested", + issue: createTestIssue(0, true), + comments: []gitea.Comment{}, + flagComments: false, + }, + { + name: "Simple issue with no comments, comments requested", + issue: createTestIssue(0, true), + comments: []gitea.Comment{}, + flagComments: true, + }, + { + name: "Simple issue with comments, no comments requested", + issue: createTestIssue(2, true), + comments: createTestIssueComments(2), + flagComments: false, + }, + { + name: "Simple issue with comments, comments requested", + issue: createTestIssue(2, true), + comments: createTestIssueComments(2), + flagComments: true, + }, + { + name: "Simple issue with comments, comments requested, not closed", + issue: createTestIssue(2, false), + comments: createTestIssueComments(2), + flagComments: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", testOwner, testRepo, testCase.issue.Index) { + jsonComments, err := json.Marshal(testCase.comments) + if err != nil { + require.NoError(t, err, "Testing setup failed: failed to marshal comments") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(jsonComments) + require.NoError(t, err, "Testing setup failed: failed to write out comments") + } else { + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + + testContext.Login.URL = server.URL + testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index) + + var outBuffer bytes.Buffer + testContext.Writer = &outBuffer + var errBuffer bytes.Buffer + testContext.ErrWriter = &errBuffer + + if testCase.flagComments { + _ = testContext.Command.Set("comments", "true") + } else { + _ = testContext.Command.Set("comments", "false") + } + + err := runIssueDetailAsJSON(&testContext, &testCase.issue) + + server.Close() + + require.NoError(t, err, "Failed to run issue detail as JSON") + + out := outBuffer.String() + + require.NotEmpty(t, out, "Unexpected empty output from runIssueDetailAsJSON") + + //setting expectations + + var expectedLabels []labelData + expectedLabels = []labelData{} + for _, l := range testCase.issue.Labels { + expectedLabels = append(expectedLabels, labelData{ + Name: l.Name, + Color: l.Color, + Description: l.Description, + }) + } + + var expectedAssignees []string + expectedAssignees = []string{} + for _, a := range testCase.issue.Assignees { + expectedAssignees = append(expectedAssignees, a.UserName) + } + + var expectedClosedAt *time.Time + if testCase.issue.Closed != nil { + expectedClosedAt = testCase.issue.Closed + } + + var expectedComments []commentData + expectedComments = []commentData{} + if testCase.flagComments { + for _, c := range testCase.comments { + expectedComments = append(expectedComments, commentData{ + ID: c.ID, + Author: c.Poster.UserName, + Body: c.Body, + Created: c.Created, + }) + } + } + + expected := issueData{ + ID: testCase.issue.ID, + Index: testCase.issue.Index, + Title: testCase.issue.Title, + State: testCase.issue.State, + Created: testCase.issue.Created, + User: testCase.issue.Poster.UserName, + Body: testCase.issue.Body, + URL: testCase.issue.HTMLURL, + ClosedAt: expectedClosedAt, + Labels: expectedLabels, + Assignees: expectedAssignees, + Comments: expectedComments, + } + + // validating reality + var actual issueData + dec := json.NewDecoder(bytes.NewReader(outBuffer.Bytes())) + dec.DisallowUnknownFields() + err = dec.Decode(&actual) + require.NoError(t, err, "Failed to unmarshal output into struct") + + assert.Equal(t, expected, actual, "Expected structs differ from expected one") + }) + } + +}