feat(issue): Add JSON output and file redirection (#841)

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 <filename>` flag, which redirects the marshaled JSON output from stdout to the specified file.

Feeback more then welcome.

Co-authored-by: Jonas Toth <development@jonas-toth.eu>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/841
Reviewed-by: TheFox0x7 <thefox0x7@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Riccardo Förster <riccardo.foerster@sarad.de>
Co-committed-by: Riccardo Förster <riccardo.foerster@sarad.de>
This commit is contained in:
Riccardo Förster
2025-11-29 05:05:30 +00:00
committed by Lunny Xiao
parent f6d4b5fa4f
commit 1d1d9197ee
2 changed files with 368 additions and 0 deletions

View File

@@ -5,8 +5,12 @@ package cmd
import ( import (
stdctx "context" stdctx "context"
"encoding/json"
"fmt" "fmt"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/issues" "code.gitea.io/tea/cmd/issues"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/interact"
@@ -16,6 +20,34 @@ import (
"github.com/urfave/cli/v3" "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. // CmdIssues represents to login a gitea server.
var CmdIssues = cli.Command{ var CmdIssues = cli.Command{
Name: "issues", Name: "issues",
@@ -64,6 +96,14 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
if err != nil { if err != nil {
return err return err
} }
if ctx.IsSet("output") {
switch ctx.String("output") {
case "json":
return runIssueDetailAsJSON(ctx, issue)
}
}
print.IssueDetails(issue, reactions) print.IssueDetails(issue, reactions)
if issue.Comments > 0 { if issue.Comments > 0 {
@@ -75,3 +115,61 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
return nil 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
}

270
cmd/issues_test.go Normal file
View File

@@ -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: "<space holder>",
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")
})
}
}