Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cli output handlers #3660

Merged
merged 14 commits into from
May 2, 2024
15 changes: 15 additions & 0 deletions cli/common/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ func FormatFlag(tmpl string, hidden ...bool) *cli.StringFlag {
}
}

// OutputFlags returns a slice of cli.Flag containing output format options.
func OutputFlags(def string) []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "output",
Usage: "output format",
Value: def,
},
&cli.BoolFlag{
Name: "no-header",
xoxys marked this conversation as resolved.
Show resolved Hide resolved
Usage: "don't print headers",
},
}
}

var RepoFlag = &cli.StringFlag{
Name: "repository",
Aliases: []string{"repo"},
Expand Down
24 changes: 24 additions & 0 deletions cli/output/output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package output

import (
"errors"
"strings"
)

var ErrOutputOptionRequired = errors.New("output option required")

func ParseOutputOptions(out string) (string, []string) {
out, opt, found := strings.Cut(out, "=")

if !found {
return out, nil
}

var optList []string

if opt != "" {
optList = strings.Split(opt, ",")
}

return out, optList
}
203 changes: 203 additions & 0 deletions cli/output/table.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package output

import (
"fmt"
"io"
"reflect"
"sort"
"strings"
"text/tabwriter"
"unicode"

"github.com/mitchellh/mapstructure"
)

// NewTable creates a new Table.
func NewTable(out io.Writer) *Table {
padding := 2

return &Table{
w: tabwriter.NewWriter(out, 0, 0, padding, ' ', 0),
columns: map[string]bool{},
fieldMapping: map[string]FieldFn{},
fieldAlias: map[string]string{},
allowedFields: map[string]bool{},
}
}

type FieldFn func(obj any) string

type writerFlusher interface {
io.Writer
Flush() error
}

// Table is a generic way to format object as a table.
type Table struct {
w writerFlusher
columns map[string]bool
fieldMapping map[string]FieldFn
fieldAlias map[string]string
allowedFields map[string]bool
}

// Columns returns a list of known output columns.
func (o *Table) Columns() (cols []string) {
for c := range o.columns {
cols = append(cols, c)
}
sort.Strings(cols)
return
}

// AddFieldAlias overrides the field name to allow custom column headers.
func (o *Table) AddFieldAlias(field, alias string) *Table {
o.fieldAlias[field] = alias
return o
}

// AddFieldFn adds a function which handles the output of the specified field.
func (o *Table) AddFieldFn(field string, fn FieldFn) *Table {
o.fieldMapping[field] = fn
o.allowedFields[field] = true
o.columns[field] = true
return o
}

// AddAllowedFields reads all first level fieldnames of the struct and allows them to be used.
func (o *Table) AddAllowedFields(obj any) (*Table, error) {
v := reflect.ValueOf(obj)
if v.Kind() != reflect.Struct {
return o, fmt.Errorf("AddAllowedFields input must be a struct")
}
t := v.Type()
for i := 0; i < v.NumField(); i++ {
k := t.Field(i).Type.Kind()
if k != reflect.Bool &&
k != reflect.Float32 &&
k != reflect.Float64 &&
k != reflect.String &&
k != reflect.Int &&
k != reflect.Int64 {
// only allow simple values
// complex values need to be mapped via a FieldFn
continue
}
o.allowedFields[strings.ToLower(t.Field(i).Name)] = true
o.allowedFields[fieldName(t.Field(i).Name)] = true
o.columns[fieldName(t.Field(i).Name)] = true
}
return o, nil
}

// RemoveAllowedField removes fields from the allowed list.
func (o *Table) RemoveAllowedField(fields ...string) *Table {
for _, field := range fields {
delete(o.allowedFields, field)
delete(o.columns, field)
}
return o
}

// ValidateColumns returns an error if invalid columns are specified.
func (o *Table) ValidateColumns(cols []string) error {
var invalidCols []string
for _, col := range cols {
if _, ok := o.allowedFields[strings.ToLower(col)]; !ok {
invalidCols = append(invalidCols, col)
}
}
if len(invalidCols) > 0 {
return fmt.Errorf("invalid table columns: %s", strings.Join(invalidCols, ","))
}
return nil
}

// WriteHeader writes the table header.
func (o *Table) WriteHeader(columns []string) {
var header []string
for _, col := range columns {
if alias, ok := o.fieldAlias[col]; ok {
col = alias
}
header = append(header, strings.ReplaceAll(strings.ToUpper(col), "_", " "))
}
_, _ = fmt.Fprintln(o.w, strings.Join(header, "\t"))
}

func (o *Table) Flush() error {
return o.w.Flush()
}

// Write writes a table line.
func (o *Table) Write(columns []string, obj any) error {
var data map[string]any

if err := mapstructure.Decode(obj, &data); err != nil {
return fmt.Errorf("failed to decode object: %w", err)
}

dataL := map[string]any{}
for key, value := range data {
dataL[strings.ToLower(key)] = value
}

var out []string
for _, col := range columns {
colName := strings.ToLower(col)
if alias, ok := o.fieldAlias[colName]; ok {
if fn, ok := o.fieldMapping[alias]; ok {
out = append(out, fn(obj))
continue
}
}
if fn, ok := o.fieldMapping[colName]; ok {
out = append(out, fn(obj))
continue
}
if value, ok := dataL[strings.ReplaceAll(colName, "_", "")]; ok {
if value == nil {
out = append(out, NA(""))
continue
}
if b, ok := value.(bool); ok {
out = append(out, YesNo(b))
continue
}
if s, ok := value.(string); ok {
out = append(out, NA(s))
continue
}
out = append(out, fmt.Sprintf("%v", value))
}
}
_, _ = fmt.Fprintln(o.w, strings.Join(out, "\t"))

return nil
}

func NA(s string) string {
if s == "" {
return "-"
}
return s
}

func YesNo(b bool) string {
if b {
return "yes"
}
return "no"
}

func fieldName(name string) string {
r := []rune(name)
var out []rune
for i := range r {
if i > 0 && (unicode.IsUpper(r[i])) && (i+1 < len(r) && unicode.IsLower(r[i+1]) || unicode.IsLower(r[i-1])) {
out = append(out, '_')
}
out = append(out, unicode.ToLower(r[i]))
}
return string(out)
}
75 changes: 75 additions & 0 deletions cli/output/table_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package output

import (
"bytes"
"os"
"strings"
"testing"
)

type writerFlusherStub struct {
bytes.Buffer
}

func (s writerFlusherStub) Flush() error {
return nil
}

type testFieldsStruct struct {
Name string
Number int
}

func TestTableOutput(t *testing.T) {
var wfs writerFlusherStub
to := NewTable(os.Stdout)
to.w = &wfs

t.Run("AddAllowedFields", func(t *testing.T) {
_, _ = to.AddAllowedFields(testFieldsStruct{})
if _, ok := to.allowedFields["name"]; !ok {
t.Error("name should be a allowed field")
}
})
t.Run("AddFieldAlias", func(t *testing.T) {
to.AddFieldAlias("woodpecker_ci", "woodpecker ci")
if alias, ok := to.fieldAlias["woodpecker_ci"]; !ok || alias != "woodpecker ci" {
t.Errorf("woodpecker_ci alias should be 'woodpecker ci', is: %v", alias)
}
})
t.Run("AddFieldOutputFn", func(t *testing.T) {
to.AddFieldFn("woodpecker ci", FieldFn(func(_ any) string {
return "WOODPECKER CI!!!"
}))
if _, ok := to.fieldMapping["woodpecker ci"]; !ok {
t.Errorf("'woodpecker ci' field output fn should be set")
}
})
t.Run("ValidateColumns", func(t *testing.T) {
err := to.ValidateColumns([]string{"non-existent", "NAME"})
if err == nil ||
strings.Contains(err.Error(), "name") ||
!strings.Contains(err.Error(), "non-existent") {
t.Errorf("error should contain 'non-existent' but not 'name': %v", err)
}
})
t.Run("WriteHeader", func(t *testing.T) {
to.WriteHeader([]string{"woodpecker_ci", "name"})
if wfs.String() != "WOODPECKER CI\tNAME\n" {
t.Errorf("written header should be 'WOODPECKER CI\\tNAME\\n', is: %q", wfs.String())
}
wfs.Reset()
})
t.Run("WriteLine", func(t *testing.T) {
_ = to.Write([]string{"woodpecker_ci", "name", "number"}, &testFieldsStruct{"test123", 1000000000})
if wfs.String() != "WOODPECKER CI!!!\ttest123\t1000000000\n" {
t.Errorf("written line should be 'WOODPECKER CI!!!\\ttest123\\t1000000000\\n', is: %q", wfs.String())
}
wfs.Reset()
})
t.Run("Columns", func(t *testing.T) {
if len(to.Columns()) != 3 {
t.Errorf("unexpected number of columns: %v", to.Columns())
}
})
}
14 changes: 3 additions & 11 deletions cli/pipeline/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
package pipeline

import (
"os"
"strings"
"text/template"

"github.com/urfave/cli/v2"

Expand All @@ -31,8 +29,7 @@ var pipelineCreateCmd = &cli.Command{
Usage: "create new pipeline",
ArgsUsage: "<repo-id|repo-full-name>",
Action: pipelineCreate,
Flags: []cli.Flag{
common.FormatFlag(tmplPipelineList),
Flags: append(common.OutputFlags("table"), []cli.Flag{
&cli.StringFlag{
Name: "branch",
Usage: "branch to create pipeline from",
Expand All @@ -42,7 +39,7 @@ var pipelineCreateCmd = &cli.Command{
Name: "var",
Usage: "key=value",
},
},
}...),
}

func pipelineCreate(c *cli.Context) error {
Expand Down Expand Up @@ -76,10 +73,5 @@ func pipelineCreate(c *cli.Context) error {
return err
}

tmpl, err := template.New("_").Parse(c.String("format") + "\n")
if err != nil {
return err
}

return tmpl.Execute(os.Stdout, pipeline)
return pipelineOutput(c, []woodpecker.Pipeline{*pipeline})
anbraten marked this conversation as resolved.
Show resolved Hide resolved
}
Loading