diff --git a/cli/admin/user/user_list.go b/cli/admin/user/user_list.go index baa436861..eed59beca 100644 --- a/cli/admin/user/user_list.go +++ b/cli/admin/user/user_list.go @@ -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 { diff --git a/cli/admin/user/user_show.go b/cli/admin/user/user_show.go index 9f45d5a13..3c6b82a34 100644 --- a/cli/admin/user/user_show.go +++ b/cli/admin/user/user_show.go @@ -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 { diff --git a/cli/common/flags.go b/cli/common/flags.go index 5cada2671..65b872c76 100644 --- a/cli/common/flags.go +++ b/cli/common/flags.go @@ -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, } diff --git a/cli/output/table.go b/cli/output/table.go index 9fff467df..21096c7ce 100644 --- a/cli/output/table.go +++ b/cli/output/table.go @@ -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 } diff --git a/cli/output/table_test.go b/cli/output/table_test.go index aa5fb3347..ffd70ed6d 100644 --- a/cli/output/table_test.go +++ b/cli/output/table_test.go @@ -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()) } diff --git a/cli/pipeline/deploy/deploy.go b/cli/pipeline/deploy/deploy.go index 110c675c5..f83f7cc28 100644 --- a/cli/pipeline/deploy/deploy.go +++ b/cli/pipeline/deploy/deploy.go @@ -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", diff --git a/cli/pipeline/pipeline.go b/cli/pipeline/pipeline.go index efcae8553..5f5821fee 100644 --- a/cli/pipeline/pipeline.go +++ b/cli/pipeline/pipeline.go @@ -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 { diff --git a/cli/pipeline/ps.go b/cli/pipeline/ps.go index f2836d7c3..81f2de1fd 100644 --- a/cli/pipeline/ps.go +++ b/cli/pipeline/ps.go @@ -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 { diff --git a/cli/pipeline/queue.go b/cli/pipeline/queue.go index 239179456..6f32538b5 100644 --- a/cli/pipeline/queue.go +++ b/cli/pipeline/queue.go @@ -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 { diff --git a/cli/repo/repo.go b/cli/repo/repo.go index f64f6a280..61d68771b 100644 --- a/cli/repo/repo.go +++ b/cli/repo/repo.go @@ -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 +} diff --git a/cli/repo/repo_list.go b/cli/repo/repo_list.go index 92e5b73d5..cf80b511f 100644 --- a/cli/repo/repo_list.go +++ b/cli/repo/repo_list.go @@ -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 }})" diff --git a/cli/repo/repo_show.go b/cli/repo/repo_show.go index c9c9ce87c..4a47a6ca6 100644 --- a/cli/repo/repo_show.go +++ b/cli/repo/repo_show.go @@ -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 }} -` diff --git a/cli/repo/repo_show_test.go b/cli/repo/repo_show_test.go new file mode 100644 index 000000000..29ae822e0 --- /dev/null +++ b/cli/repo/repo_show_test.go @@ -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) + }) + } +} diff --git a/cli/repo/repo_sync.go b/cli/repo/repo_sync.go index d7802c92e..0a21b5433 100644 --- a/cli/repo/repo_sync.go +++ b/cli/repo/repo_sync.go @@ -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 }})" diff --git a/cli/repo/repo_test.go b/cli/repo/repo_test.go new file mode 100644 index 000000000..82fc616a0 --- /dev/null +++ b/cli/repo/repo_test.go @@ -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) + }) + } +}