Add `tea issues --fields`, allow printing labels (#312)

generalize list printing with dynamic fields

refactor print.IssuesList to use tableFromItems()

preparatory refactor

print.IssuesList: allow printing labels

move formatters to formatters.go

expose more printable fields on issue

add generic flags.FieldsFlag

add fields flag to tea issues, tea ms issues

validate provided fields

add strict username, or formatted user fields

change default fields

tea issues -> replace updated with labels
tea ms issues -> replace author with labels, reorder

Validate provided fields

Co-authored-by: Norwin Roosen <git@nroo.de>
Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://gitea.com/gitea/tea/pulls/312
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: 6543 <6543@obermui.de>
Co-Authored-By: Norwin <noerw@noreply.gitea.io>
Co-Committed-By: Norwin <noerw@noreply.gitea.io>
This commit is contained in:
Norwin 2020-12-21 23:41:07 +08:00 committed by 6543
parent 8bb5c15745
commit 9efee7bf99
16 changed files with 343 additions and 222 deletions

View File

@ -5,6 +5,11 @@
package flags package flags
import ( import (
"fmt"
"strings"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -91,3 +96,30 @@ var IssuePRFlags = append([]cli.Flag{
&PaginationPageFlag, &PaginationPageFlag,
&PaginationLimitFlag, &PaginationLimitFlag,
}, AllDefaultFlags...) }, AllDefaultFlags...)
// FieldsFlag generates a flag selecting printable fields.
// To retrieve the value, use GetFields()
func FieldsFlag(availableFields, defaultFields []string) *cli.StringFlag {
return &cli.StringFlag{
Name: "fields",
Aliases: []string{"f"},
Usage: fmt.Sprintf(`Comma-separated list of fields to print. Available values:
%s
`, strings.Join(availableFields, ",")),
Value: strings.Join(defaultFields, ","),
}
}
// GetFields parses the values provided in a fields flag, and
// optionally validates against valid values.
func GetFields(ctx *cli.Context, validFields []string) ([]string, error) {
selection := strings.Split(ctx.String("fields"), ",")
if validFields != nil {
for _, field := range selection {
if !utils.Contains(validFields, field) {
return nil, fmt.Errorf("Invalid field '%s'", field)
}
}
}
return selection, nil
}

View File

@ -5,7 +5,6 @@
package cmd package cmd
import ( import (
"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/print" "code.gitea.io/tea/modules/print"
@ -29,7 +28,7 @@ var CmdIssues = cli.Command{
&issues.CmdIssuesReopen, &issues.CmdIssuesReopen,
&issues.CmdIssuesClose, &issues.CmdIssuesClose,
}, },
Flags: flags.IssuePRFlags, Flags: issues.CmdIssuesList.Flags,
} }
func runIssues(ctx *cli.Context) error { func runIssues(ctx *cli.Context) error {

View File

@ -20,7 +20,11 @@ var CmdIssuesList = cli.Command{
Usage: "List issues of the repository", Usage: "List issues of the repository",
Description: `List issues of the repository`, Description: `List issues of the repository`,
Action: RunIssuesList, Action: RunIssuesList,
Flags: flags.IssuePRFlags, Flags: append([]cli.Flag{
flags.FieldsFlag(print.IssueFields, []string{
"index", "title", "state", "author", "milestone", "labels",
}),
}, flags.IssuePRFlags...),
} }
// RunIssuesList list issues // RunIssuesList list issues
@ -48,6 +52,11 @@ func RunIssuesList(cmd *cli.Context) error {
return err return err
} }
print.IssuesList(issues, ctx.Output) fields, err := flags.GetFields(cmd, print.IssueFields)
if err != nil {
return err
}
print.IssuesPullsList(issues, ctx.Output, fields)
return nil return nil
} }

View File

@ -5,7 +5,6 @@
package cmd package cmd
import ( import (
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/milestones" "code.gitea.io/tea/cmd/milestones"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/print"
@ -30,7 +29,7 @@ var CmdMilestones = cli.Command{
&milestones.CmdMilestonesReopen, &milestones.CmdMilestonesReopen,
&milestones.CmdMilestonesIssues, &milestones.CmdMilestonesIssues,
}, },
Flags: flags.AllDefaultFlags, Flags: milestones.CmdMilestonesList.Flags,
} }
func runMilestones(ctx *cli.Context) error { func runMilestones(ctx *cli.Context) error {

View File

@ -40,6 +40,9 @@ var CmdMilestonesIssues = cli.Command{
}, },
&flags.PaginationPageFlag, &flags.PaginationPageFlag,
&flags.PaginationLimitFlag, &flags.PaginationLimitFlag,
flags.FieldsFlag(print.IssueFields, []string{
"index", "kind", "title", "state", "updated", "labels",
}),
}, flags.AllDefaultFlags...), }, flags.AllDefaultFlags...),
} }
@ -107,7 +110,11 @@ func runMilestoneIssueList(cmd *cli.Context) error {
return err return err
} }
print.IssuesPullsList(issues, ctx.Output) fields, err := flags.GetFields(cmd, print.IssueFields)
if err != nil {
return err
}
print.IssuesPullsList(issues, ctx.Output, fields)
return nil return nil
} }

View File

@ -6,28 +6,11 @@ package repos
import ( import (
"fmt" "fmt"
"strings"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
// printFieldsFlag provides a selection of fields to print
var printFieldsFlag = cli.StringFlag{
Name: "fields",
Aliases: []string{"f"},
Usage: fmt.Sprintf(`Comma-separated list of fields to print. Available values:
%s
`, strings.Join(print.RepoFields, ",")),
Value: "owner,name,type,ssh",
}
func getFields(ctx *cli.Context) []string {
return strings.Split(ctx.String("fields"), ",")
}
var typeFilterFlag = cli.StringFlag{ var typeFilterFlag = cli.StringFlag{
Name: "type", Name: "type",
Aliases: []string{"T"}, Aliases: []string{"T"},

View File

@ -27,7 +27,9 @@ var CmdReposListFlags = append([]cli.Flag{
Required: false, Required: false,
Usage: "List your starred repos instead", Usage: "List your starred repos instead",
}, },
&printFieldsFlag, flags.FieldsFlag(print.RepoFields, []string{
"owner", "name", "type", "ssh",
}),
&typeFilterFlag, &typeFilterFlag,
&flags.PaginationPageFlag, &flags.PaginationPageFlag,
&flags.PaginationLimitFlag, &flags.PaginationLimitFlag,
@ -80,7 +82,12 @@ func RunReposList(cmd *cli.Context) error {
reposFiltered = filterReposByType(rps, typeFilter) reposFiltered = filterReposByType(rps, typeFilter)
} }
print.ReposList(reposFiltered, ctx.Output, getFields(cmd)) fields, err := flags.GetFields(cmd, print.RepoFields)
if err != nil {
return err
}
print.ReposList(reposFiltered, ctx.Output, fields)
return nil return nil
} }

View File

@ -50,7 +50,9 @@ var CmdReposSearch = cli.Command{
Required: false, Required: false,
Usage: "Filter archived repos (true|false)", Usage: "Filter archived repos (true|false)",
}, },
&printFieldsFlag, flags.FieldsFlag(print.RepoFields, []string{
"owner", "name", "type", "ssh",
}),
&flags.PaginationPageFlag, &flags.PaginationPageFlag,
&flags.PaginationLimitFlag, &flags.PaginationLimitFlag,
}, flags.LoginOutputFlags...), }, flags.LoginOutputFlags...),
@ -123,6 +125,10 @@ func runReposSearch(cmd *cli.Context) error {
return err return err
} }
print.ReposList(rps, ctx.Output, getFields(cmd)) fields, err := flags.GetFields(cmd, nil)
if err != nil {
return err
}
print.ReposList(rps, ctx.Output, fields)
return nil return nil
} }

View File

@ -0,0 +1,74 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package print
import (
"fmt"
"time"
"code.gitea.io/sdk/gitea"
"github.com/muesli/termenv"
)
// formatSize get kb in int and return string
func formatSize(kb int64) string {
if kb < 1024 {
return fmt.Sprintf("%d Kb", kb)
}
mb := kb / 1024
if mb < 1024 {
return fmt.Sprintf("%d Mb", mb)
}
gb := mb / 1024
if gb < 1024 {
return fmt.Sprintf("%d Gb", gb)
}
return fmt.Sprintf("%d Tb", gb/1024)
}
// FormatTime give a date-time in local timezone if available
func FormatTime(t time.Time) string {
location, err := time.LoadLocation("Local")
if err != nil {
return t.Format("2006-01-02 15:04 UTC")
}
return t.In(location).Format("2006-01-02 15:04")
}
func formatDuration(seconds int64, outputType string) string {
if isMachineReadable(outputType) {
return fmt.Sprint(seconds)
}
return time.Duration(1e9 * seconds).String()
}
func formatLabel(label *gitea.Label, allowColor bool, text string) string {
colorProfile := termenv.Ascii
if allowColor {
colorProfile = termenv.EnvColorProfile()
}
if len(text) == 0 {
text = label.Name
}
styled := termenv.String(text)
styled = styled.Foreground(colorProfile.Color("#" + label.Color))
return fmt.Sprint(styled)
}
func formatPermission(p *gitea.Permission) string {
if p.Admin {
return "admin"
} else if p.Push {
return "write"
}
return "read"
}
func formatUserName(u *gitea.User) string {
if len(u.FullName) == 0 {
return u.UserName
}
return u.FullName
}

View File

@ -6,7 +6,7 @@ package print
import ( import (
"fmt" "fmt"
"strconv" "strings"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
) )
@ -24,68 +24,103 @@ func IssueDetails(issue *gitea.Issue) {
)) ))
} }
// IssuesList prints a listing of issues
func IssuesList(issues []*gitea.Issue, output string) {
t := tableWithHeader(
"Index",
"Title",
"State",
"Author",
"Milestone",
"Updated",
)
for _, issue := range issues {
author := issue.Poster.FullName
if len(author) == 0 {
author = issue.Poster.UserName
}
mile := ""
if issue.Milestone != nil {
mile = issue.Milestone.Title
}
t.addRow(
strconv.FormatInt(issue.Index, 10),
issue.Title,
string(issue.State),
author,
mile,
FormatTime(issue.Updated),
)
}
t.print(output)
}
// IssuesPullsList prints a listing of issues & pulls // IssuesPullsList prints a listing of issues & pulls
// TODO combine with IssuesList func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) {
func IssuesPullsList(issues []*gitea.Issue, output string) { printIssues(issues, output, fields)
t := tableWithHeader(
"Index",
"State",
"Kind",
"Author",
"Updated",
"Title",
)
for _, issue := range issues {
name := issue.Poster.FullName
if len(name) == 0 {
name = issue.Poster.UserName
}
kind := "Issue"
if issue.PullRequest != nil {
kind = "Pull"
}
t.addRow(
strconv.FormatInt(issue.Index, 10),
string(issue.State),
kind,
name,
FormatTime(issue.Updated),
issue.Title,
)
} }
// IssueFields are all available fields to print with IssuesList()
var IssueFields = []string{
"index",
"state",
"kind",
"author",
"author-id",
"url",
"title",
"body",
"created",
"updated",
"deadline",
"assignees",
"milestone",
"labels",
"comments",
}
func printIssues(issues []*gitea.Issue, output string, fields []string) {
labelMap := map[int64]string{}
var printables = make([]printable, len(issues))
for i, x := range issues {
// pre-serialize labels for performance
for _, label := range x.Labels {
if _, ok := labelMap[label.ID]; !ok {
labelMap[label.ID] = formatLabel(label, !isMachineReadable(output), "")
}
}
// store items with printable interface
printables[i] = &printableIssue{x, &labelMap}
}
t := tableFromItems(fields, printables)
t.print(output) t.print(output)
} }
type printableIssue struct {
*gitea.Issue
formattedLabels *map[int64]string
}
func (x printableIssue) FormatField(field string) string {
switch field {
case "index":
return fmt.Sprintf("%d", x.Index)
case "state":
return string(x.State)
case "kind":
if x.PullRequest != nil {
return "Pull"
}
return "Issue"
case "author":
return formatUserName(x.Poster)
case "author-id":
return x.Poster.UserName
case "url":
return x.HTMLURL
case "title":
return x.Title
case "body":
return x.Body
case "created":
return FormatTime(x.Created)
case "updated":
return FormatTime(x.Updated)
case "deadline":
return FormatTime(*x.Deadline)
case "milestone":
if x.Milestone != nil {
return x.Milestone.Title
}
return ""
case "labels":
var labels = make([]string, len(x.Labels))
for i, l := range x.Labels {
labels[i] = (*x.formattedLabels)[l.ID]
}
return strings.Join(labels, " ")
case "assignees":
var assignees = make([]string, len(x.Assignees))
for i, a := range x.Assignees {
assignees[i] = formatUserName(a)
}
return strings.Join(assignees, " ")
case "comments":
return fmt.Sprintf("%d", x.Comments)
}
return ""
}

View File

@ -5,11 +5,9 @@
package print package print
import ( import (
"fmt"
"strconv" "strconv"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/muesli/termenv"
) )
// LabelsList prints a listing of labels // LabelsList prints a listing of labels
@ -21,14 +19,10 @@ func LabelsList(labels []*gitea.Label, output string) {
"Description", "Description",
) )
p := termenv.ColorProfile()
for _, label := range labels { for _, label := range labels {
color := termenv.String(label.Color)
t.addRow( t.addRow(
strconv.FormatInt(label.ID, 10), strconv.FormatInt(label.ID, 10),
fmt.Sprint(color.Background(p.Color("#"+label.Color))), formatLabel(label, !isMachineReadable(output), label.Color),
label.Name, label.Name,
label.Description, label.Description,
) )

View File

@ -1,35 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package print
import (
"fmt"
"time"
)
// formatSize get kb in int and return string
func formatSize(kb int64) string {
if kb < 1024 {
return fmt.Sprintf("%d Kb", kb)
}
mb := kb / 1024
if mb < 1024 {
return fmt.Sprintf("%d Mb", mb)
}
gb := mb / 1024
if gb < 1024 {
return fmt.Sprintf("%d Gb", gb)
}
return fmt.Sprintf("%d Tb", gb/1024)
}
// FormatTime give a date-time in local timezone if available
func FormatTime(t time.Time) string {
location, err := time.LoadLocation("Local")
if err != nil {
return t.Format("2006-01-02 15:04 UTC")
}
return t.In(location).Format("2006-01-02 15:04")
}

View File

@ -6,91 +6,19 @@ package print
import ( import (
"fmt" "fmt"
"log"
"strings" "strings"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
) )
type rp = *gitea.Repository
type fieldFormatter = func(*gitea.Repository) string
var (
fieldFormatters map[string]fieldFormatter
// RepoFields are the available fields to print with ReposList()
RepoFields []string
)
func init() {
fieldFormatters = map[string]fieldFormatter{
"description": func(r rp) string { return r.Description },
"forks": func(r rp) string { return fmt.Sprintf("%d", r.Forks) },
"id": func(r rp) string { return r.FullName },
"name": func(r rp) string { return r.Name },
"owner": func(r rp) string { return r.Owner.UserName },
"stars": func(r rp) string { return fmt.Sprintf("%d", r.Stars) },
"ssh": func(r rp) string { return r.SSHURL },
"updated": func(r rp) string { return FormatTime(r.Updated) },
"url": func(r rp) string { return r.HTMLURL },
"permission": func(r rp) string {
if r.Permissions.Admin {
return "admin"
} else if r.Permissions.Push {
return "write"
}
return "read"
},
"type": func(r rp) string {
if r.Fork {
return "fork"
}
if r.Mirror {
return "mirror"
}
return "source"
},
}
for f := range fieldFormatters {
RepoFields = append(RepoFields, f)
}
}
// ReposList prints a listing of the repos // ReposList prints a listing of the repos
func ReposList(repos []*gitea.Repository, output string, fields []string) { func ReposList(repos []*gitea.Repository, output string, fields []string) {
if len(repos) == 0 { var printables = make([]printable, len(repos))
fmt.Println("No repositories found") for i, r := range repos {
return printables[i] = &printableRepo{r}
} }
t := tableFromItems(fields, printables)
if len(fields) == 0 {
fmt.Println("No fields to print")
return
}
formatters := make([]fieldFormatter, len(fields))
values := make([][]string, len(repos))
// find field format functions by header name
for i, f := range fields {
if formatter, ok := fieldFormatters[strings.ToLower(f)]; ok {
formatters[i] = formatter
} else {
log.Fatalf("invalid field '%s'", f)
}
}
// extract values from each repo and store them in 2D table
for i, repo := range repos {
values[i] = make([]string, len(formatters))
for j, format := range formatters {
values[i][j] = format(repo)
}
}
t := table{headers: fields, values: values}
t.print(output) t.print(output)
} }
@ -142,7 +70,7 @@ func RepoDetails(repo *gitea.Repository, topics []string) {
perm := fmt.Sprintf( perm := fmt.Sprintf(
"- Permission:\t%s\n", "- Permission:\t%s\n",
fieldFormatters["permission"](repo), formatPermission(repo.Permissions),
) )
var tops string var tops string
@ -161,3 +89,54 @@ func RepoDetails(repo *gitea.Repository, topics []string) {
tops, tops,
)) ))
} }
// RepoFields are the available fields to print with ReposList()
var RepoFields = []string{
"description",
"forks",
"id",
"name",
"owner",
"stars",
"ssh",
"updated",
"url",
"permission",
"type",
}
type printableRepo struct{ *gitea.Repository }
func (x printableRepo) FormatField(field string) string {
switch field {
case "description":
return x.Description
case "forks":
return fmt.Sprintf("%d", x.Forks)
case "id":
return x.FullName
case "name":
return x.Name
case "owner":
return x.Owner.UserName
case "stars":
return fmt.Sprintf("%d", x.Stars)
case "ssh":
return x.SSHURL
case "updated":
return FormatTime(x.Updated)
case "url":
return x.HTMLURL
case "permission":
return formatPermission(x.Permissions)
case "type":
if x.Fork {
return "fork"
}
if x.Mirror {
return "mirror"
}
return "source"
}
return ""
}

View File

@ -22,6 +22,24 @@ type table struct {
sortColumn uint // ↑ sortColumn uint // ↑
} }
// printable can be implemented for structs to put fields dynamically into a table
type printable interface {
FormatField(field string) string
}
// high level api to print a table of items with dynamic fields
func tableFromItems(fields []string, values []printable) table {
t := table{headers: fields}
for _, v := range values {
row := make([]string, len(fields))
for i, f := range fields {
row[i] = v.FormatField(f)
}
t.addRowSlice(row)
}
return t
}
func tableWithHeader(header ...string) table { func tableWithHeader(header ...string) table {
return table{headers: header} return table{headers: header}
} }
@ -54,16 +72,16 @@ func (t table) Less(i, j int) bool {
} }
func (t *table) print(output string) { func (t *table) print(output string) {
switch { switch output {
case output == "" || output == "table": case "", "table":
outputtable(t.headers, t.values) outputtable(t.headers, t.values)
case output == "csv": case "csv":
outputdsv(t.headers, t.values, ",") outputdsv(t.headers, t.values, ",")
case output == "simple": case "simple":
outputsimple(t.headers, t.values) outputsimple(t.headers, t.values)
case output == "tsv": case "tsv":
outputdsv(t.headers, t.values, "\t") outputdsv(t.headers, t.values, "\t")
case output == "yaml": case "yml", "yaml":
outputyaml(t.headers, t.values) outputyaml(t.headers, t.values)
default: default:
fmt.Printf("unknown output type '" + output + "', available types are:\n- csv: comma-separated values\n- simple: space-separated values\n- table: auto-aligned table format (default)\n- tsv: tab-separated values\n- yaml: YAML format\n") fmt.Printf("unknown output type '" + output + "', available types are:\n- csv: comma-separated values\n- simple: space-separated values\n- table: auto-aligned table format (default)\n- tsv: tab-separated values\n- yaml: YAML format\n")
@ -119,3 +137,11 @@ func outputyaml(headers []string, values [][]string) {
} }
} }
} }
func isMachineReadable(outputFormat string) bool {
switch outputFormat {
case "yml", "yaml", "csv":
return true
}
return false
}

View File

@ -5,22 +5,12 @@
package print package print
import ( import (
"fmt"
"strconv" "strconv"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
) )
func formatDuration(seconds int64, outputType string) string {
switch outputType {
case "yaml":
case "csv":
return fmt.Sprint(seconds)
}
return time.Duration(1e9 * seconds).String()
}
// TrackedTimesList print list of tracked times to stdout // TrackedTimesList print list of tracked times to stdout
func TrackedTimesList(times []*gitea.TrackedTime, outputType string, from, until time.Time, printTotal bool) { func TrackedTimesList(times []*gitea.TrackedTime, outputType string, from, until time.Time, printTotal bool) {
tab := tableWithHeader( tab := tableWithHeader(

16
modules/utils/utils.go Normal file
View File

@ -0,0 +1,16 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package utils
// Contains checks containment
func Contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}