Migrate repo output format to customizable output (#4888)

This commit is contained in:
Robert Kaussow 2025-03-05 14:28:54 +01:00 committed by GitHub
parent a1dbcc90a5
commit 9a2b13341e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 316 additions and 85 deletions

View file

@ -31,7 +31,7 @@ var userListCmd = &cli.Command{
Usage: "list all users",
ArgsUsage: " ",
Action: userList,
Flags: []cli.Flag{common.FormatFlag(tmplUserList)},
Flags: []cli.Flag{common.FormatFlag(tmplUserList, false)},
}
func userList(ctx context.Context, c *cli.Command) error {

View file

@ -31,7 +31,7 @@ var userShowCmd = &cli.Command{
Usage: "show user information",
ArgsUsage: "<username>",
Action: userShow,
Flags: []cli.Flag{common.FormatFlag(tmplUserInfo)},
Flags: []cli.Flag{common.FormatFlag(tmplUserInfo, false)},
}
func userShow(ctx context.Context, c *cli.Command) error {

View file

@ -15,6 +15,8 @@
package common
import (
"fmt"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/woodpecker/v3/shared/logger"
@ -63,10 +65,15 @@ var GlobalFlags = append([]cli.Flag{
// FormatFlag return format flag with value set based on template
// if hidden value is set, flag will be hidden.
func FormatFlag(tmpl string, hidden ...bool) *cli.StringFlag {
func FormatFlag(tmpl string, deprecated bool, hidden ...bool) *cli.StringFlag {
usage := "format output"
if deprecated {
usage = fmt.Sprintf("%s (deprecated)", usage)
}
return &cli.StringFlag{
Name: "format",
Usage: "format output",
Usage: usage,
Value: tmpl,
Hidden: len(hidden) != 0,
}

View file

@ -52,15 +52,15 @@ func (o *Table) Columns() (cols []string) {
// AddFieldAlias overrides the field name to allow custom column headers.
func (o *Table) AddFieldAlias(field, alias string) *Table {
o.fieldAlias[field] = alias
o.fieldAlias[strings.ToLower(alias)] = field
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
o.fieldMapping[strings.ToLower(field)] = fn
o.allowedFields[strings.ToLower(field)] = true
o.columns[strings.ToLower(field)] = true
return o
}
@ -117,9 +117,6 @@ func (o *Table) ValidateColumns(cols []string) error {
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"))
@ -146,12 +143,9 @@ func (o *Table) Write(columns []string, obj any) error {
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, sanitizeString(fn(obj)))
continue
}
colName = strings.ToLower(alias)
}
if fn, ok := o.fieldMapping[colName]; ok {
if fn, ok := o.fieldMapping[strings.ReplaceAll(colName, "_", "")]; ok {
out = append(out, sanitizeString(fn(obj)))
continue
}

View file

@ -32,17 +32,17 @@ func TestTableOutput(t *testing.T) {
}
})
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)
to.AddFieldAlias("WoodpeckerCI", "wp")
if alias, ok := to.fieldAlias["wp"]; !ok || alias != "WoodpeckerCI" {
t.Errorf("'wp' alias should resolve to 'WoodpeckerCI', is: %v", alias)
}
})
t.Run("AddFieldOutputFn", func(t *testing.T) {
to.AddFieldFn("woodpecker ci", FieldFn(func(_ any) string {
to.AddFieldFn("WoodpeckerCI", FieldFn(func(_ any) string {
return "WOODPECKER CI!!!"
}))
if _, ok := to.fieldMapping["woodpecker ci"]; !ok {
t.Errorf("'woodpecker ci' field output fn should be set")
if _, ok := to.fieldMapping["woodpeckerci"]; !ok {
t.Errorf("'WoodpeckerCI' field output fn should be set")
}
})
t.Run("ValidateColumns", func(t *testing.T) {
@ -54,14 +54,14 @@ func TestTableOutput(t *testing.T) {
}
})
t.Run("WriteHeader", func(t *testing.T) {
to.WriteHeader([]string{"woodpecker_ci", "name"})
if wfs.String() != "WOODPECKER CI\tNAME\n" {
to.WriteHeader([]string{"wp", "name"})
if wfs.String() != "WP\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})
_ = to.Write([]string{"wp", "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())
}

View file

@ -35,7 +35,7 @@ var Command = &cli.Command{
ArgsUsage: "<repo-id|repo-full-name> <pipeline> <environment>",
Action: deploy,
Flags: []cli.Flag{
common.FormatFlag(tmplDeployInfo),
common.FormatFlag(tmplDeployInfo, false),
&cli.StringFlag{
Name: "branch",
Usage: "branch filter",

View file

@ -55,13 +55,9 @@ func pipelineOutput(c *cli.Command, pipelines []*woodpecker.Pipeline, fd ...io.W
noHeader := c.Bool("output-no-headers")
var out io.Writer
switch len(fd) {
case 0:
out = os.Stdout
case 1:
out = os.Stdout
if len(fd) > 0 {
out = fd[0]
default:
out = os.Stdout
}
switch outFmt {

View file

@ -33,7 +33,7 @@ var pipelinePsCmd = &cli.Command{
Usage: "show pipeline steps",
ArgsUsage: "<repo-id|repo-full-name> <pipeline>",
Action: pipelinePs,
Flags: []cli.Flag{common.FormatFlag(tmplPipelinePs)},
Flags: []cli.Flag{common.FormatFlag(tmplPipelinePs, false)},
}
func pipelinePs(ctx context.Context, c *cli.Command) error {

View file

@ -31,7 +31,7 @@ var pipelineQueueCmd = &cli.Command{
Usage: "show pipeline queue",
ArgsUsage: " ",
Action: pipelineQueue,
Flags: []cli.Flag{common.FormatFlag(tmplPipelineQueue)},
Flags: []cli.Flag{common.FormatFlag(tmplPipelineQueue, false)},
}
func pipelineQueue(ctx context.Context, c *cli.Command) error {

View file

@ -15,11 +15,19 @@
package repo
import (
"fmt"
"io"
"os"
"text/template"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/woodpecker/v3/cli/output"
"go.woodpecker-ci.org/woodpecker/v3/cli/repo/cron"
"go.woodpecker-ci.org/woodpecker/v3/cli/repo/registry"
"go.woodpecker-ci.org/woodpecker/v3/cli/repo/secret"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
// Command exports the repository command.
@ -40,3 +48,84 @@ var Command = &cli.Command{
repoUpdateCmd,
},
}
func repoOutput(c *cli.Command, repos []*woodpecker.Repo, fd ...io.Writer) error {
outFmt, outOpt := output.ParseOutputOptions(c.String("output"))
noHeader := c.Bool("output-no-headers")
legacyFmt := c.String("format")
if legacyFmt != "" {
log.Warn().Msgf("the --format flag is deprecated, please use --output instead")
outFmt = "go-template"
outOpt = []string{legacyFmt}
}
var out io.Writer
out = os.Stdout
if len(fd) > 0 {
out = fd[0]
}
switch outFmt {
case "go-template":
if len(outOpt) < 1 {
return fmt.Errorf("%w: missing template", output.ErrOutputOptionRequired)
}
tmpl, err := template.New("_").Parse(outOpt[0] + "\n")
if err != nil {
return err
}
if err := tmpl.Execute(out, repos); err != nil {
return err
}
case "table":
fallthrough
default:
table := output.NewTable(out)
// Add custom field mapping for nested Trusted fields
table.AddFieldFn("TrustedNetwork", func(obj any) string {
repo, ok := obj.(*woodpecker.Repo)
if !ok {
return ""
}
return output.YesNo(repo.Trusted.Network)
})
table.AddFieldFn("TrustedSecurity", func(obj any) string {
repo, ok := obj.(*woodpecker.Repo)
if !ok {
return ""
}
return output.YesNo(repo.Trusted.Security)
})
table.AddFieldFn("TrustedVolume", func(obj any) string {
repo, ok := obj.(*woodpecker.Repo)
if !ok {
return ""
}
return output.YesNo(repo.Trusted.Volumes)
})
table.AddFieldAlias("Is_Active", "Active")
table.AddFieldAlias("Is_SCM_Private", "SCM_Private")
cols := []string{"Full_Name", "Branch", "Forge_URL", "Visibility", "SCM_Private", "Active", "Allow_Pull"}
if len(outOpt) > 0 {
cols = outOpt
}
if !noHeader {
table.WriteHeader(cols)
}
for _, resource := range repos {
if err := table.Write(cols, resource); err != nil {
return err
}
}
table.Flush()
}
return nil
}

View file

@ -16,8 +16,6 @@ package repo
import (
"context"
"os"
"text/template"
"github.com/urfave/cli/v3"
@ -30,9 +28,9 @@ var repoListCmd = &cli.Command{
Name: "ls",
Usage: "list all repos",
ArgsUsage: " ",
Action: repoList,
Flags: []cli.Flag{
common.FormatFlag(tmplRepoList),
Action: List,
Flags: append(common.OutputFlags("table"), []cli.Flag{
common.FormatFlag("", true),
&cli.StringFlag{
Name: "org",
Usage: "filter by organization",
@ -41,40 +39,38 @@ var repoListCmd = &cli.Command{
Name: "all",
Usage: "query all repos, including inactive ones",
},
},
}...),
}
func repoList(ctx context.Context, c *cli.Command) error {
func List(ctx context.Context, c *cli.Command) error {
client, err := internal.NewClient(ctx, c)
if err != nil {
return err
}
repos, err := repoList(c, client)
if err != nil {
return err
}
return repoOutput(c, repos)
}
func repoList(c *cli.Command, client woodpecker.Client) ([]*woodpecker.Repo, error) {
repos := make([]*woodpecker.Repo, 0)
opt := woodpecker.RepoListOptions{
All: c.Bool("all"),
}
repos, err := client.RepoList(opt)
if err != nil || len(repos) == 0 {
return err
}
tmpl, err := template.New("_").Parse(c.String("format") + "\n")
if err != nil {
return err
raw, err := client.RepoList(opt)
if err != nil || len(raw) == 0 {
return nil, err
}
org := c.String("org")
for _, repo := range repos {
for _, repo := range raw {
if org != "" && org != repo.Owner {
continue
}
if err := tmpl.Execute(os.Stdout, repo); err != nil {
return err
}
repos = append(repos, repo)
}
return nil
return repos, nil
}
// Template for repository list items.
var tmplRepoList = "\x1b[33m{{ .FullName }}\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }})"

View file

@ -16,56 +16,45 @@ package repo
import (
"context"
"os"
"text/template"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/woodpecker/v3/cli/common"
"go.woodpecker-ci.org/woodpecker/v3/cli/internal"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
var repoShowCmd = &cli.Command{
Name: "show",
Usage: "show repository information",
ArgsUsage: "<repo-id|repo-full-name>",
Action: repoShow,
Flags: []cli.Flag{common.FormatFlag(tmplRepoInfo)},
Action: Show,
Flags: common.OutputFlags("table"),
}
func repoShow(ctx context.Context, c *cli.Command) error {
repoIDOrFullName := c.Args().First()
func Show(ctx context.Context, c *cli.Command) error {
client, err := internal.NewClient(ctx, c)
if err != nil {
return err
}
repoID, err := internal.ParseRepo(client, repoIDOrFullName)
repo, err := repoShow(c, client)
if err != nil {
return err
}
return repoOutput(c, []*woodpecker.Repo{repo})
}
func repoShow(c *cli.Command, client woodpecker.Client) (*woodpecker.Repo, error) {
repoIDOrFullName := c.Args().First()
repoID, err := internal.ParseRepo(client, repoIDOrFullName)
if err != nil {
return nil, err
}
repo, err := client.Repo(repoID)
if err != nil {
return err
return nil, err
}
tmpl, err := template.New("_").Parse(c.String("format"))
if err != nil {
return err
}
return tmpl.Execute(os.Stdout, repo)
return repo, nil
}
// tTemplate for repo information.
var tmplRepoInfo = `Owner: {{ .Owner }}
Repo: {{ .Name }}
URL: {{ .ForgeURL }}
Config path: {{ .Config }}
Visibility: {{ .Visibility }}
Private: {{ .IsSCMPrivate }}
Trusted: {{ .IsTrusted }}
Gated: {{ .IsGated }}
Require approval for: {{ .RequireApproval }}
Clone url: {{ .Clone }}
Allow pull-requests: {{ .AllowPullRequests }}
`

View file

@ -0,0 +1,72 @@
package repo
import (
"context"
"errors"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/mocks"
)
func TestRepoShow(t *testing.T) {
tests := []struct {
name string
repoID int64
mockRepo *woodpecker.Repo
mockError error
expectedError bool
expected *woodpecker.Repo
args []string
}{
{
name: "valid repo by ID",
repoID: 123,
mockRepo: &woodpecker.Repo{Name: "test-repo"},
expected: &woodpecker.Repo{Name: "test-repo"},
args: []string{"show", "123"},
},
{
name: "valid repo by full name",
repoID: 456,
mockRepo: &woodpecker.Repo{ID: 456, Name: "repo", Owner: "owner"},
expected: &woodpecker.Repo{ID: 456, Name: "repo", Owner: "owner"},
args: []string{"show", "owner/repo"},
},
{
name: "invalid repo ID",
repoID: 999,
expectedError: true,
args: []string{"show", "invalid"},
mockError: errors.New("repo not found"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := mocks.NewClient(t)
mockClient.On("Repo", tt.repoID).Return(tt.mockRepo, tt.mockError).Maybe()
mockClient.On("RepoLookup", "owner/repo").Return(tt.mockRepo, nil).Maybe()
command := repoShowCmd
command.Writer = io.Discard
command.Action = func(_ context.Context, c *cli.Command) error {
output, err := repoShow(c, mockClient)
if tt.expectedError {
assert.Error(t, err)
return nil
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, output)
return nil
}
_ = command.Run(context.Background(), tt.args)
})
}
}

View file

@ -31,7 +31,7 @@ var repoSyncCmd = &cli.Command{
Usage: "synchronize the repository list",
ArgsUsage: " ",
Action: repoSync,
Flags: []cli.Flag{common.FormatFlag(tmplRepoList)},
Flags: []cli.Flag{common.FormatFlag(tmplRepoList, false)},
}
// TODO: remove this and add an option to the list cmd as we do not store the remote repo list anymore
@ -66,3 +66,6 @@ func repoSync(ctx context.Context, c *cli.Command) error {
}
return nil
}
// Template for repository list items.
var tmplRepoList = "\x1b[33m{{ .FullName }}\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }})"

85
cli/repo/repo_test.go Normal file
View file

@ -0,0 +1,85 @@
package repo
import (
"bytes"
"context"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/woodpecker/v3/cli/common"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
func TestRepoOutput(t *testing.T) {
tests := []struct {
name string
args []string
expected string
wantErr bool
}{
{
name: "table output with default columns",
args: []string{},
expected: "FULL NAME BRANCH FORGE URL VISIBILITY SCM PRIVATE ACTIVE ALLOW PULL\norg/repo1 main git.example.com public no yes yes\n",
},
{
name: "table output with custom columns",
args: []string{"output", "--output", "table=Name,Forge_URL,Trusted_Network"},
expected: "NAME FORGE URL TRUSTED NETWORK\nrepo1 git.example.com yes\n",
},
{
name: "table output with no header",
args: []string{"output", "--output-no-headers"},
expected: "org/repo1 main git.example.com public no yes yes\n",
},
{
name: "go-template output",
args: []string{"output", "--output", "go-template={{range . }}{{.Name}} {{.ForgeURL}} {{.Trusted.Network}}{{end}}"},
expected: "repo1 git.example.com true\n",
},
}
repos := []*woodpecker.Repo{
{
Name: "repo1",
FullName: "org/repo1",
ForgeURL: "git.example.com",
Branch: "main",
Visibility: "public",
IsActive: true,
AllowPull: true,
Trusted: woodpecker.TrustedConfiguration{
Network: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
command := &cli.Command{
Writer: io.Discard,
Name: "output",
Flags: common.OutputFlags("table"),
Action: func(_ context.Context, c *cli.Command) error {
var buf bytes.Buffer
err := repoOutput(c, repos, &buf)
if tt.wantErr {
assert.Error(t, err)
return nil
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, buf.String())
return nil
},
}
_ = command.Run(context.Background(), tt.args)
})
}
}