From 9efee7bf99ffedad2ed8677f0191d465b10dad4d Mon Sep 17 00:00:00 2001 From: Norwin Date: Mon, 21 Dec 2020 23:41:07 +0800 Subject: [PATCH] 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 Co-authored-by: 6543 <6543@obermui.de> Reviewed-on: https://gitea.com/gitea/tea/pulls/312 Reviewed-by: Lunny Xiao Reviewed-by: 6543 <6543@obermui.de> Co-Authored-By: Norwin Co-Committed-By: Norwin --- cmd/flags/flags.go | 32 ++++++++ cmd/issues.go | 3 +- cmd/issues/list.go | 13 ++- cmd/milestones.go | 3 +- cmd/milestones/issues.go | 9 ++- cmd/repos/flags.go | 17 ---- cmd/repos/list.go | 11 ++- cmd/repos/search.go | 10 ++- modules/print/formatters.go | 74 +++++++++++++++++ modules/print/issue.go | 153 ++++++++++++++++++++++-------------- modules/print/label.go | 8 +- modules/print/print.go | 35 --------- modules/print/repo.go | 133 +++++++++++++------------------ modules/print/table.go | 38 +++++++-- modules/print/times.go | 10 --- modules/utils/utils.go | 16 ++++ 16 files changed, 343 insertions(+), 222 deletions(-) create mode 100644 modules/print/formatters.go delete mode 100644 modules/print/print.go create mode 100644 modules/utils/utils.go diff --git a/cmd/flags/flags.go b/cmd/flags/flags.go index d9fe526..57b8ae8 100644 --- a/cmd/flags/flags.go +++ b/cmd/flags/flags.go @@ -5,6 +5,11 @@ package flags import ( + "fmt" + "strings" + + "code.gitea.io/tea/modules/utils" + "github.com/urfave/cli/v2" ) @@ -91,3 +96,30 @@ var IssuePRFlags = append([]cli.Flag{ &PaginationPageFlag, &PaginationLimitFlag, }, 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 +} diff --git a/cmd/issues.go b/cmd/issues.go index e24b738..6fbf63b 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -5,7 +5,6 @@ package cmd import ( - "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/issues" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/print" @@ -29,7 +28,7 @@ var CmdIssues = cli.Command{ &issues.CmdIssuesReopen, &issues.CmdIssuesClose, }, - Flags: flags.IssuePRFlags, + Flags: issues.CmdIssuesList.Flags, } func runIssues(ctx *cli.Context) error { diff --git a/cmd/issues/list.go b/cmd/issues/list.go index b2b17bf..d9ec8ab 100644 --- a/cmd/issues/list.go +++ b/cmd/issues/list.go @@ -20,7 +20,11 @@ var CmdIssuesList = cli.Command{ Usage: "List issues of the repository", Description: `List issues of the repository`, 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 @@ -48,6 +52,11 @@ func RunIssuesList(cmd *cli.Context) error { 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 } diff --git a/cmd/milestones.go b/cmd/milestones.go index 88b3509..79c0b54 100644 --- a/cmd/milestones.go +++ b/cmd/milestones.go @@ -5,7 +5,6 @@ package cmd import ( - "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/milestones" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/print" @@ -30,7 +29,7 @@ var CmdMilestones = cli.Command{ &milestones.CmdMilestonesReopen, &milestones.CmdMilestonesIssues, }, - Flags: flags.AllDefaultFlags, + Flags: milestones.CmdMilestonesList.Flags, } func runMilestones(ctx *cli.Context) error { diff --git a/cmd/milestones/issues.go b/cmd/milestones/issues.go index 93d3920..11a353d 100644 --- a/cmd/milestones/issues.go +++ b/cmd/milestones/issues.go @@ -40,6 +40,9 @@ var CmdMilestonesIssues = cli.Command{ }, &flags.PaginationPageFlag, &flags.PaginationLimitFlag, + flags.FieldsFlag(print.IssueFields, []string{ + "index", "kind", "title", "state", "updated", "labels", + }), }, flags.AllDefaultFlags...), } @@ -107,7 +110,11 @@ func runMilestoneIssueList(cmd *cli.Context) error { 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 } diff --git a/cmd/repos/flags.go b/cmd/repos/flags.go index 7803730..daa329e 100644 --- a/cmd/repos/flags.go +++ b/cmd/repos/flags.go @@ -6,28 +6,11 @@ package repos import ( "fmt" - "strings" - - "code.gitea.io/tea/modules/print" "code.gitea.io/sdk/gitea" "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{ Name: "type", Aliases: []string{"T"}, diff --git a/cmd/repos/list.go b/cmd/repos/list.go index d2e6511..78ed1e4 100644 --- a/cmd/repos/list.go +++ b/cmd/repos/list.go @@ -27,7 +27,9 @@ var CmdReposListFlags = append([]cli.Flag{ Required: false, Usage: "List your starred repos instead", }, - &printFieldsFlag, + flags.FieldsFlag(print.RepoFields, []string{ + "owner", "name", "type", "ssh", + }), &typeFilterFlag, &flags.PaginationPageFlag, &flags.PaginationLimitFlag, @@ -80,7 +82,12 @@ func RunReposList(cmd *cli.Context) error { 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 } diff --git a/cmd/repos/search.go b/cmd/repos/search.go index 1d97ce5..e9264fc 100644 --- a/cmd/repos/search.go +++ b/cmd/repos/search.go @@ -50,7 +50,9 @@ var CmdReposSearch = cli.Command{ Required: false, Usage: "Filter archived repos (true|false)", }, - &printFieldsFlag, + flags.FieldsFlag(print.RepoFields, []string{ + "owner", "name", "type", "ssh", + }), &flags.PaginationPageFlag, &flags.PaginationLimitFlag, }, flags.LoginOutputFlags...), @@ -123,6 +125,10 @@ func runReposSearch(cmd *cli.Context) error { 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 } diff --git a/modules/print/formatters.go b/modules/print/formatters.go new file mode 100644 index 0000000..e5a6d88 --- /dev/null +++ b/modules/print/formatters.go @@ -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 +} diff --git a/modules/print/issue.go b/modules/print/issue.go index df8856f..62a6e1a 100644 --- a/modules/print/issue.go +++ b/modules/print/issue.go @@ -6,7 +6,7 @@ package print import ( "fmt" - "strconv" + "strings" "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 -// TODO combine with IssuesList -func IssuesPullsList(issues []*gitea.Issue, output string) { - t := tableWithHeader( - "Index", - "State", - "Kind", - "Author", - "Updated", - "Title", - ) +func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) { + printIssues(issues, output, fields) +} - for _, issue := range issues { - name := issue.Poster.FullName - if len(name) == 0 { - name = issue.Poster.UserName +// 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), "") + } } - 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, - ) + // store items with printable interface + printables[i] = &printableIssue{x, &labelMap} } + t := tableFromItems(fields, printables) 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 "" +} diff --git a/modules/print/label.go b/modules/print/label.go index ad6d96d..4a83ec2 100644 --- a/modules/print/label.go +++ b/modules/print/label.go @@ -5,11 +5,9 @@ package print import ( - "fmt" "strconv" "code.gitea.io/sdk/gitea" - "github.com/muesli/termenv" ) // LabelsList prints a listing of labels @@ -21,14 +19,10 @@ func LabelsList(labels []*gitea.Label, output string) { "Description", ) - p := termenv.ColorProfile() - for _, label := range labels { - color := termenv.String(label.Color) - t.addRow( strconv.FormatInt(label.ID, 10), - fmt.Sprint(color.Background(p.Color("#"+label.Color))), + formatLabel(label, !isMachineReadable(output), label.Color), label.Name, label.Description, ) diff --git a/modules/print/print.go b/modules/print/print.go deleted file mode 100644 index 1473d82..0000000 --- a/modules/print/print.go +++ /dev/null @@ -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") -} diff --git a/modules/print/repo.go b/modules/print/repo.go index cbd4951..2e02266 100644 --- a/modules/print/repo.go +++ b/modules/print/repo.go @@ -6,91 +6,19 @@ package print import ( "fmt" - "log" "strings" "time" "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 func ReposList(repos []*gitea.Repository, output string, fields []string) { - if len(repos) == 0 { - fmt.Println("No repositories found") - return + var printables = make([]printable, len(repos)) + for i, r := range repos { + printables[i] = &printableRepo{r} } - - 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 := tableFromItems(fields, printables) t.print(output) } @@ -142,7 +70,7 @@ func RepoDetails(repo *gitea.Repository, topics []string) { perm := fmt.Sprintf( "- Permission:\t%s\n", - fieldFormatters["permission"](repo), + formatPermission(repo.Permissions), ) var tops string @@ -161,3 +89,54 @@ func RepoDetails(repo *gitea.Repository, topics []string) { 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 "" +} diff --git a/modules/print/table.go b/modules/print/table.go index 920eb37..498fe58 100644 --- a/modules/print/table.go +++ b/modules/print/table.go @@ -22,6 +22,24 @@ type table struct { 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 { return table{headers: header} } @@ -54,16 +72,16 @@ func (t table) Less(i, j int) bool { } func (t *table) print(output string) { - switch { - case output == "" || output == "table": + switch output { + case "", "table": outputtable(t.headers, t.values) - case output == "csv": + case "csv": outputdsv(t.headers, t.values, ",") - case output == "simple": + case "simple": outputsimple(t.headers, t.values) - case output == "tsv": + case "tsv": outputdsv(t.headers, t.values, "\t") - case output == "yaml": + case "yml", "yaml": outputyaml(t.headers, t.values) 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") @@ -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 +} diff --git a/modules/print/times.go b/modules/print/times.go index 9c60f8e..9f3c787 100644 --- a/modules/print/times.go +++ b/modules/print/times.go @@ -5,22 +5,12 @@ package print import ( - "fmt" "strconv" "time" "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 func TrackedTimesList(times []*gitea.TrackedTime, outputType string, from, until time.Time, printTotal bool) { tab := tableWithHeader( diff --git a/modules/utils/utils.go b/modules/utils/utils.go new file mode 100644 index 0000000..88c4514 --- /dev/null +++ b/modules/utils/utils.go @@ -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 +}