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 +}