mirror of
https://gitea.com/gitea/tea.git
synced 2026-04-05 16:03:32 +02:00
replace log.Fatal/os.Exit with error returns (#941)
* Use stdlib encoders * Reduce some duplication * Remove global pagination state * Dedupe JSON detail types * Bump golangci-lint Reviewed-on: https://gitea.com/gitea/tea/pulls/941 Co-authored-by: techknowlogick <techknowlogick@gitea.com> Co-committed-by: techknowlogick <techknowlogick@gitea.com>
This commit is contained in:
committed by
techknowlogick
parent
21881525a8
commit
b05e03416b
@@ -40,12 +40,21 @@ type LocalConfig struct {
|
||||
|
||||
var (
|
||||
// config contain if loaded local tea config
|
||||
config LocalConfig
|
||||
loadConfigOnce sync.Once
|
||||
config LocalConfig
|
||||
loadConfigOnce sync.Once
|
||||
configPathMu sync.Mutex
|
||||
configPathTestOverride string
|
||||
)
|
||||
|
||||
// GetConfigPath return path to tea config file
|
||||
func GetConfigPath() string {
|
||||
configPathMu.Lock()
|
||||
override := configPathTestOverride
|
||||
configPathMu.Unlock()
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
configFilePath, err := xdg.ConfigFile("tea/config.yml")
|
||||
|
||||
var exists bool
|
||||
@@ -71,6 +80,13 @@ func GetConfigPath() string {
|
||||
return configFilePath
|
||||
}
|
||||
|
||||
// SetConfigPathForTesting overrides the config path used by helpers in tests.
|
||||
func SetConfigPathForTesting(path string) {
|
||||
configPathMu.Lock()
|
||||
configPathTestOverride = path
|
||||
configPathMu.Unlock()
|
||||
}
|
||||
|
||||
// GetPreferences returns preferences based on the config file
|
||||
func GetPreferences() Preferences {
|
||||
_ = loadConfig()
|
||||
|
||||
@@ -72,28 +72,36 @@ func TestConfigLock_MutexProtection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func useTempConfigPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
SetConfigPathForTesting(configPath)
|
||||
t.Cleanup(func() {
|
||||
SetConfigPathForTesting("")
|
||||
})
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
func TestReloadConfigFromDisk(t *testing.T) {
|
||||
configPath := useTempConfigPath(t)
|
||||
|
||||
// Save original config state
|
||||
originalConfig := config
|
||||
|
||||
// Create a temp config file
|
||||
tmpDir, err := os.MkdirTemp("", "tea-reload-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
config = LocalConfig{Logins: []Login{{Name: "stale"}}}
|
||||
if err := os.WriteFile(configPath, []byte("logins:\n - name: test\n"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write temp config: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// We can't easily change GetConfigPath, so we test that reloadConfigFromDisk
|
||||
// handles a missing file gracefully (returns nil and resets config)
|
||||
config = LocalConfig{Logins: []Login{{Name: "test"}}}
|
||||
|
||||
// Call reload - since the actual config path likely exists or doesn't,
|
||||
// we just verify it doesn't panic and returns without error or with expected error
|
||||
err = reloadConfigFromDisk()
|
||||
// The function should either succeed or return an error, not panic
|
||||
err := reloadConfigFromDisk()
|
||||
if err != nil {
|
||||
// This is acceptable - config file might not exist in test environment
|
||||
t.Logf("reloadConfigFromDisk returned error (expected in test env): %v", err)
|
||||
t.Fatalf("reloadConfigFromDisk returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(config.Logins) != 1 || config.Logins[0].Name != "test" {
|
||||
t.Fatalf("expected config to reload test login, got %+v", config.Logins)
|
||||
}
|
||||
|
||||
// Restore original config
|
||||
@@ -101,6 +109,8 @@ func TestReloadConfigFromDisk(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWithConfigLock(t *testing.T) {
|
||||
useTempConfigPath(t)
|
||||
|
||||
executed := false
|
||||
err := withConfigLock(func() error {
|
||||
executed = true
|
||||
@@ -115,6 +125,8 @@ func TestWithConfigLock(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWithConfigLock_PropagatesError(t *testing.T) {
|
||||
useTempConfigPath(t)
|
||||
|
||||
expectedErr := fmt.Errorf("test error")
|
||||
err := withConfigLock(func() error {
|
||||
return expectedErr
|
||||
@@ -126,6 +138,8 @@ func TestWithConfigLock_PropagatesError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDoubleCheckedLocking_SimulatedRefresh(t *testing.T) {
|
||||
useTempConfigPath(t)
|
||||
|
||||
// This test simulates the double-checked locking pattern
|
||||
// by having multiple goroutines try to "refresh" simultaneously
|
||||
|
||||
|
||||
@@ -164,18 +164,17 @@ func SetDefaultLogin(name string) error {
|
||||
}
|
||||
|
||||
// GetLoginByName get login by name (case insensitive)
|
||||
func GetLoginByName(name string) *Login {
|
||||
err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
func GetLoginByName(name string) (*Login, error) {
|
||||
if err := loadConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, l := range config.Logins {
|
||||
if strings.EqualFold(l.Name, name) {
|
||||
return &l
|
||||
for i := range config.Logins {
|
||||
if strings.EqualFold(config.Logins[i].Name, name) {
|
||||
return &config.Logins[i], nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetLoginByToken get login by token
|
||||
|
||||
@@ -6,9 +6,8 @@ package context
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"code.gitea.io/tea/modules/git"
|
||||
@@ -23,6 +22,9 @@ import (
|
||||
|
||||
var errNotAGiteaRepo = errors.New("No Gitea login found. You might want to specify --repo (and --login) to work outside of a repository")
|
||||
|
||||
// ErrCommandCanceled is returned when the user explicitly cancels an interactive prompt.
|
||||
var ErrCommandCanceled = errors.New("command canceled")
|
||||
|
||||
// TeaContext contains all context derived during command initialization and wraps cli.Context
|
||||
type TeaContext struct {
|
||||
*cli.Command
|
||||
@@ -38,9 +40,11 @@ type TeaContext struct {
|
||||
|
||||
// GetRemoteRepoHTMLURL returns the web-ui url of the remote repo,
|
||||
// after ensuring a remote repo is present in the context.
|
||||
func (ctx *TeaContext) GetRemoteRepoHTMLURL() string {
|
||||
ctx.Ensure(CtxRequirement{RemoteRepo: true})
|
||||
return path.Join(ctx.Login.URL, ctx.Owner, ctx.Repo)
|
||||
func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) {
|
||||
if err := ctx.Ensure(CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(ctx.Login.URL, "/") + "/" + ctx.Owner + "/" + ctx.Repo, nil
|
||||
}
|
||||
|
||||
// IsInteractiveMode returns true if the command is running in interactive mode
|
||||
@@ -57,7 +61,7 @@ func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool {
|
||||
// available the repo slug. It does this by reading the config file for logins, parsing
|
||||
// the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from
|
||||
// command flags. If a local git repo can't be found, repo slug values are unset.
|
||||
func InitCommand(cmd *cli.Command) *TeaContext {
|
||||
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
||||
// these flags are used as overrides to the context detection via local git repo
|
||||
repoFlag := cmd.String("repo")
|
||||
loginFlag := cmd.String("login")
|
||||
@@ -75,7 +79,7 @@ func InitCommand(cmd *cli.Command) *TeaContext {
|
||||
// check if repoFlag can be interpreted as path to local repo.
|
||||
if len(repoFlag) != 0 {
|
||||
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if repoFlagPathExists {
|
||||
repoPath = repoFlag
|
||||
@@ -88,7 +92,7 @@ func InitCommand(cmd *cli.Command) *TeaContext {
|
||||
|
||||
if repoPath == "" {
|
||||
if repoPath, err = os.Getwd(); err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +101,7 @@ func InitCommand(cmd *cli.Command) *TeaContext {
|
||||
envLogin := GetLoginByEnvVar()
|
||||
if envLogin != nil {
|
||||
if _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "", false, "", ""); err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
extraLogins = append(extraLogins, *envLogin)
|
||||
}
|
||||
@@ -108,7 +112,7 @@ func InitCommand(cmd *cli.Command) *TeaContext {
|
||||
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
|
||||
// we can deal with that, commands needing the optional values use ctx.Ensure()
|
||||
} else {
|
||||
log.Fatal(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,20 +129,20 @@ func InitCommand(cmd *cli.Command) *TeaContext {
|
||||
|
||||
// override login from flag, or use default login if repo based detection failed
|
||||
if len(loginFlag) != 0 {
|
||||
c.Login = config.GetLoginByName(loginFlag)
|
||||
if c.Login, err = config.GetLoginByName(loginFlag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Login == nil {
|
||||
log.Fatalf("Login name '%s' does not exist", loginFlag)
|
||||
return nil, fmt.Errorf("login name '%s' does not exist", loginFlag)
|
||||
}
|
||||
} else if c.Login == nil {
|
||||
if c.Login, err = config.GetDefaultLogin(); err != nil {
|
||||
if err.Error() == "No available login" {
|
||||
// TODO: maybe we can directly start interact.CreateLogin() (only if
|
||||
// we're sure we can interactively!), as gh cli does.
|
||||
fmt.Println(`No gitea login configured. To start using tea, first run
|
||||
return nil, fmt.Errorf(`no gitea login configured. To start using tea, first run
|
||||
tea login add
|
||||
and then run your command again.`)
|
||||
and then run your command again`)
|
||||
}
|
||||
os.Exit(1)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only prompt for confirmation if the fallback login is not explicitly set as default
|
||||
@@ -150,10 +154,10 @@ and then run your command again.`)
|
||||
Value(&fallback).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
log.Fatalf("Get confirm failed: %v", err)
|
||||
return nil, fmt.Errorf("get confirm failed: %w", err)
|
||||
}
|
||||
if !fallback {
|
||||
os.Exit(1)
|
||||
return nil, ErrCommandCanceled
|
||||
}
|
||||
} else if !c.Login.Default {
|
||||
fmt.Fprintf(os.Stderr, "NOTE: no gitea login detected, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
|
||||
@@ -166,5 +170,5 @@ and then run your command again.`)
|
||||
c.IsGlobal = globalFlag
|
||||
c.Command = cmd
|
||||
c.Output = cmd.String("output")
|
||||
return &c
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
@@ -4,31 +4,28 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Ensure checks if requirements on the context are set, and terminates otherwise.
|
||||
func (ctx *TeaContext) Ensure(req CtxRequirement) {
|
||||
// Ensure checks if requirements on the context are set.
|
||||
func (ctx *TeaContext) Ensure(req CtxRequirement) error {
|
||||
if req.LocalRepo && ctx.LocalRepo == nil {
|
||||
fmt.Println("Local repository required: Execute from a repo dir, or specify a path with --repo.")
|
||||
os.Exit(1)
|
||||
return errors.New("local repository required: execute from a repo dir, or specify a path with --repo")
|
||||
}
|
||||
|
||||
if req.RemoteRepo && len(ctx.RepoSlug) == 0 {
|
||||
fmt.Println("Remote repository required: Specify ID via --repo or execute from a local git repo.")
|
||||
os.Exit(1)
|
||||
return errors.New("remote repository required: specify id via --repo or execute from a local git repo")
|
||||
}
|
||||
|
||||
if req.Org && len(ctx.Org) == 0 {
|
||||
fmt.Println("Organization required: Specify organization via --org.")
|
||||
os.Exit(1)
|
||||
return errors.New("organization required: specify organization via --org")
|
||||
}
|
||||
|
||||
if req.Global && !ctx.IsGlobal {
|
||||
fmt.Println("Global scope required: Specify --global.")
|
||||
os.Exit(1)
|
||||
return errors.New("global scope required: specify --global")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CtxRequirement specifies context needed for operation
|
||||
|
||||
113
modules/context/context_require_test.go
Normal file
113
modules/context/context_require_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"code.gitea.io/tea/modules/git"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnsureReturnsRequirementErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx TeaContext
|
||||
req CtxRequirement
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing local repo",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{LocalRepo: true},
|
||||
wantErr: "local repository required",
|
||||
},
|
||||
{
|
||||
name: "missing remote repo",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{RemoteRepo: true},
|
||||
wantErr: "remote repository required",
|
||||
},
|
||||
{
|
||||
name: "missing org",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{Org: true},
|
||||
wantErr: "organization required",
|
||||
},
|
||||
{
|
||||
name: "missing global scope",
|
||||
ctx: TeaContext{},
|
||||
req: CtxRequirement{Global: true},
|
||||
wantErr: "global scope required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.ctx.Ensure(tt.req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSucceedsWhenRequirementsMet(t *testing.T) {
|
||||
ctx := TeaContext{
|
||||
LocalRepo: &git.TeaRepo{},
|
||||
RepoSlug: "owner/repo",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Org: "myorg",
|
||||
IsGlobal: true,
|
||||
}
|
||||
err := ctx.Ensure(CtxRequirement{
|
||||
LocalRepo: true,
|
||||
RemoteRepo: true,
|
||||
Org: true,
|
||||
Global: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEnsureSucceedsWithNoRequirements(t *testing.T) {
|
||||
ctx := TeaContext{}
|
||||
err := ctx.Ensure(CtxRequirement{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetRemoteRepoHTMLURL(t *testing.T) {
|
||||
t.Run("requires remote repo", func(t *testing.T) {
|
||||
ctx := &TeaContext{}
|
||||
_, err := ctx.GetRemoteRepoHTMLURL()
|
||||
require.ErrorContains(t, err, "remote repository required")
|
||||
})
|
||||
|
||||
t.Run("returns repo url when context is complete", func(t *testing.T) {
|
||||
ctx := &TeaContext{
|
||||
Login: &config.Login{URL: "https://gitea.example.com"},
|
||||
RepoSlug: "owner/repo",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
url, err := ctx.GetRemoteRepoHTMLURL()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://gitea.example.com/owner/repo", url)
|
||||
})
|
||||
|
||||
t.Run("trims trailing slash from login URL", func(t *testing.T) {
|
||||
ctx := &TeaContext{
|
||||
Login: &config.Login{URL: "https://gitea.example.com/"},
|
||||
RepoSlug: "owner/repo",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
url, err := ctx.GetRemoteRepoHTMLURL()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://gitea.example.com/owner/repo", url)
|
||||
})
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// If that flag is unset, and output is not piped, prompts the user first.
|
||||
func ShowCommentsMaybeInteractive(ctx *context.TeaContext, idx int64, totalComments int) error {
|
||||
if ctx.Bool("comments") {
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()}
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
|
||||
c := ctx.Login.Client()
|
||||
comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, idx, opts)
|
||||
if err != nil {
|
||||
@@ -40,7 +40,7 @@ func ShowCommentsMaybeInteractive(ctx *context.TeaContext, idx int64, totalComme
|
||||
// ShowCommentsPaginated prompts if issue/pr comments should be shown and continues to do so.
|
||||
func ShowCommentsPaginated(ctx *context.TeaContext, idx int64, totalComments int) error {
|
||||
c := ctx.Login.Client()
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()}
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
|
||||
prompt := "show comments?"
|
||||
commentsLoaded := 0
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func getPullIndex(ctx *context.TeaContext, branch string) (int64, error) {
|
||||
c := ctx.Login.Client()
|
||||
opts := gitea.ListPullRequestsOptions{
|
||||
State: gitea.StateOpen,
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(ctx.Command),
|
||||
}
|
||||
selected := ""
|
||||
loadMoreOption := "PR not found? Load more PRs..."
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// ActionSecretsList prints a list of action secrets
|
||||
func ActionSecretsList(secrets []*gitea.Secret, output string) {
|
||||
func ActionSecretsList(secrets []*gitea.Secret, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"Name",
|
||||
@@ -27,11 +27,11 @@ func ActionSecretsList(secrets []*gitea.Secret, output string) {
|
||||
|
||||
if len(secrets) == 0 {
|
||||
fmt.Printf("No secrets found\n")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// ActionVariableDetails prints details of a specific action variable
|
||||
@@ -43,7 +43,7 @@ func ActionVariableDetails(variable *gitea.RepoActionVariable) {
|
||||
}
|
||||
|
||||
// ActionVariablesList prints a list of action variables
|
||||
func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) {
|
||||
func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"Name",
|
||||
@@ -68,9 +68,9 @@ func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) {
|
||||
|
||||
if len(variables) == 0 {
|
||||
fmt.Printf("No variables found\n")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string {
|
||||
}
|
||||
|
||||
// ActionRunsList prints a list of workflow runs
|
||||
func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) {
|
||||
func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"ID",
|
||||
@@ -74,11 +74,11 @@ func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) {
|
||||
|
||||
if len(runs) == 0 {
|
||||
fmt.Printf("No workflow runs found\n")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// ActionRunDetails prints detailed information about a workflow run
|
||||
@@ -114,7 +114,7 @@ func ActionRunDetails(run *gitea.ActionWorkflowRun) {
|
||||
}
|
||||
|
||||
// ActionWorkflowJobsList prints a list of workflow jobs
|
||||
func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) {
|
||||
func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"ID",
|
||||
@@ -147,15 +147,15 @@ func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) {
|
||||
|
||||
if len(jobs) == 0 {
|
||||
fmt.Printf("No jobs found\n")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(0, true)
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// WorkflowsList prints a list of workflow files with active status
|
||||
func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) {
|
||||
func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) error {
|
||||
t := table{
|
||||
headers: []string{
|
||||
"Active",
|
||||
@@ -180,9 +180,9 @@ func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]
|
||||
|
||||
if len(workflows) == 0 {
|
||||
fmt.Printf("No workflows found\n")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
t.sort(1, true) // Sort by name column
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionRunsListEmpty(t *testing.T) {
|
||||
@@ -18,7 +19,7 @@ func TestActionRunsListEmpty(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionRunsList([]*gitea.ActionWorkflowRun{}, "")
|
||||
require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{}, ""))
|
||||
}
|
||||
|
||||
func TestActionRunsListWithData(t *testing.T) {
|
||||
@@ -49,7 +50,7 @@ func TestActionRunsListWithData(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionRunsList(runs, "")
|
||||
require.NoError(t, ActionRunsList(runs, ""))
|
||||
}
|
||||
|
||||
func TestActionRunDetails(t *testing.T) {
|
||||
@@ -90,7 +91,7 @@ func TestActionWorkflowJobsListEmpty(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, "")
|
||||
require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, ""))
|
||||
}
|
||||
|
||||
func TestActionWorkflowJobsListWithData(t *testing.T) {
|
||||
@@ -119,7 +120,7 @@ func TestActionWorkflowJobsListWithData(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionWorkflowJobsList(jobs, "")
|
||||
require.NoError(t, ActionWorkflowJobsList(jobs, ""))
|
||||
}
|
||||
|
||||
func TestFormatDurationMinutes(t *testing.T) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionSecretsListEmpty(t *testing.T) {
|
||||
@@ -21,7 +22,7 @@ func TestActionSecretsListEmpty(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionSecretsList([]*gitea.Secret{}, "")
|
||||
require.NoError(t, ActionSecretsList([]*gitea.Secret{}, ""))
|
||||
}
|
||||
|
||||
func TestActionSecretsListWithData(t *testing.T) {
|
||||
@@ -43,7 +44,7 @@ func TestActionSecretsListWithData(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionSecretsList(secrets, "")
|
||||
require.NoError(t, ActionSecretsList(secrets, ""))
|
||||
|
||||
// Test JSON output format to verify structure
|
||||
var buf bytes.Buffer
|
||||
@@ -55,7 +56,7 @@ func TestActionSecretsListWithData(t *testing.T) {
|
||||
testTable.addRow(secret.Name, FormatTime(secret.Created, true))
|
||||
}
|
||||
|
||||
testTable.fprint(&buf, "json")
|
||||
require.NoError(t, testTable.fprint(&buf, "json"))
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "TEST_SECRET_1") {
|
||||
@@ -92,7 +93,7 @@ func TestActionVariablesListEmpty(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionVariablesList([]*gitea.RepoActionVariable{}, "")
|
||||
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{}, ""))
|
||||
}
|
||||
|
||||
func TestActionVariablesListWithData(t *testing.T) {
|
||||
@@ -118,7 +119,7 @@ func TestActionVariablesListWithData(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionVariablesList(variables, "")
|
||||
require.NoError(t, ActionVariablesList(variables, ""))
|
||||
|
||||
// Test JSON output format to verify structure and truncation
|
||||
var buf bytes.Buffer
|
||||
@@ -134,7 +135,7 @@ func TestActionVariablesListWithData(t *testing.T) {
|
||||
testTable.addRow(variable.Name, value, strconv.Itoa(int(variable.RepoID)))
|
||||
}
|
||||
|
||||
testTable.fprint(&buf, "json")
|
||||
require.NoError(t, testTable.fprint(&buf, "json"))
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "TEST_VARIABLE_1") {
|
||||
@@ -165,7 +166,7 @@ func TestActionVariablesListValueTruncation(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
ActionVariablesList([]*gitea.RepoActionVariable{variable}, "")
|
||||
require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{variable}, ""))
|
||||
|
||||
// Test the truncation logic directly
|
||||
value := variable.Value
|
||||
|
||||
@@ -17,7 +17,7 @@ func formatByteSize(size int64) string {
|
||||
}
|
||||
|
||||
// ReleaseAttachmentsList prints a listing of release attachments
|
||||
func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) {
|
||||
func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Name",
|
||||
"Size",
|
||||
@@ -30,5 +30,5 @@ func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) {
|
||||
)
|
||||
}
|
||||
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// BranchesList prints a listing of the branches
|
||||
func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) {
|
||||
fmt.Println(fields)
|
||||
func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) error {
|
||||
printables := make([]printable, len(branches))
|
||||
|
||||
for i, branch := range branches {
|
||||
@@ -25,7 +24,7 @@ func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtectio
|
||||
}
|
||||
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
type printableBranch struct {
|
||||
@@ -54,17 +53,17 @@ func (x printableBranch) FormatField(field string, machineReadable bool) string
|
||||
}
|
||||
merging := ""
|
||||
for _, entry := range x.protection.MergeWhitelistTeams {
|
||||
approving += entry + "/"
|
||||
merging += entry + "/"
|
||||
}
|
||||
for _, entry := range x.protection.MergeWhitelistUsernames {
|
||||
approving += entry + "/"
|
||||
merging += entry + "/"
|
||||
}
|
||||
pushing := ""
|
||||
for _, entry := range x.protection.PushWhitelistTeams {
|
||||
approving += entry + "/"
|
||||
pushing += entry + "/"
|
||||
}
|
||||
for _, entry := range x.protection.PushWhitelistUsernames {
|
||||
approving += entry + "/"
|
||||
pushing += entry + "/"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"- enable-push: %t\n- approving: %s\n- merging: %s\n- pushing: %s\n",
|
||||
|
||||
33
modules/print/branch_test.go
Normal file
33
modules/print/branch_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrintableBranchProtectionUsesSeparateWhitelists(t *testing.T) {
|
||||
protection := &gitea.BranchProtection{
|
||||
EnablePush: true,
|
||||
ApprovalsWhitelistTeams: []string{"approve-team"},
|
||||
ApprovalsWhitelistUsernames: []string{"approve-user"},
|
||||
MergeWhitelistTeams: []string{"merge-team"},
|
||||
MergeWhitelistUsernames: []string{"merge-user"},
|
||||
PushWhitelistTeams: []string{"push-team"},
|
||||
PushWhitelistUsernames: []string{"push-user"},
|
||||
}
|
||||
|
||||
result := printableBranch{
|
||||
branch: &gitea.Branch{Name: "main"},
|
||||
protection: protection,
|
||||
}.FormatField("protection", false)
|
||||
|
||||
assert.Contains(t, result, "- approving: approve-team/approve-user/")
|
||||
assert.Contains(t, result, "- merging: merge-team/merge-user/")
|
||||
assert.Contains(t, result, "- pushing: push-team/push-user/")
|
||||
assert.NotContains(t, result, "- approving: approve-team/approve-user/merge-team/")
|
||||
}
|
||||
@@ -45,8 +45,8 @@ func formatReactions(reactions []*gitea.Reaction) string {
|
||||
}
|
||||
|
||||
// IssuesPullsList prints a listing of issues & pulls
|
||||
func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) {
|
||||
printIssues(issues, output, fields)
|
||||
func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) error {
|
||||
return printIssues(issues, output, fields)
|
||||
}
|
||||
|
||||
// IssueFields are all available fields to print with IssuesList()
|
||||
@@ -73,7 +73,7 @@ var IssueFields = []string{
|
||||
"repo",
|
||||
}
|
||||
|
||||
func printIssues(issues []*gitea.Issue, output string, fields []string) {
|
||||
func printIssues(issues []*gitea.Issue, output string, fields []string) error {
|
||||
labelMap := map[int64]string{}
|
||||
printables := make([]printable, len(issues))
|
||||
machineReadable := isMachineReadable(output)
|
||||
@@ -90,7 +90,7 @@ func printIssues(issues []*gitea.Issue, output string, fields []string) {
|
||||
}
|
||||
|
||||
t := tableFromItems(fields, printables, machineReadable)
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
type printableIssue struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// LabelsList prints a listing of labels
|
||||
func LabelsList(labels []*gitea.Label, output string) {
|
||||
func LabelsList(labels []*gitea.Label, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Index",
|
||||
"Color",
|
||||
@@ -26,5 +26,5 @@ func LabelsList(labels []*gitea.Label, output string) {
|
||||
label.Description,
|
||||
)
|
||||
}
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func LoginDetails(login *config.Login) {
|
||||
}
|
||||
|
||||
// LoginsList prints a listing of logins
|
||||
func LoginsList(logins []config.Login, output string) {
|
||||
func LoginsList(logins []config.Login, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Name",
|
||||
"URL",
|
||||
@@ -50,5 +50,5 @@ func LoginsList(logins []config.Login, output string) {
|
||||
)
|
||||
}
|
||||
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@ func MilestoneDetails(milestone *gitea.Milestone) {
|
||||
}
|
||||
|
||||
// MilestonesList prints a listing of milestones
|
||||
func MilestonesList(news []*gitea.Milestone, output string, fields []string) {
|
||||
func MilestonesList(news []*gitea.Milestone, output string, fields []string) error {
|
||||
printables := make([]printable, len(news))
|
||||
for i, x := range news {
|
||||
printables[i] = &printableMilestone{x}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
t.sort(0, true)
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// MilestoneFields are all available fields to print with MilestonesList
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
)
|
||||
|
||||
// NotificationsList prints a listing of notification threads
|
||||
func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) {
|
||||
func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) error {
|
||||
printables := make([]printable, len(news))
|
||||
for i, x := range news {
|
||||
printables[i] = &printableNotification{x}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// NotificationFields are all available fields to print with NotificationsList
|
||||
|
||||
@@ -22,10 +22,10 @@ func OrganizationDetails(org *gitea.Organization) {
|
||||
}
|
||||
|
||||
// OrganizationsList prints a listing of the organizations
|
||||
func OrganizationsList(organizations []*gitea.Organization, output string) {
|
||||
func OrganizationsList(organizations []*gitea.Organization, output string) error {
|
||||
if len(organizations) == 0 {
|
||||
fmt.Println("No organizations found")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
t := tableWithHeader(
|
||||
@@ -46,5 +46,5 @@ func OrganizationsList(organizations []*gitea.Organization, output string) {
|
||||
)
|
||||
}
|
||||
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
|
||||
}
|
||||
|
||||
// PullsList prints a listing of pulls
|
||||
func PullsList(prs []*gitea.PullRequest, output string, fields []string) {
|
||||
printPulls(prs, output, fields)
|
||||
func PullsList(prs []*gitea.PullRequest, output string, fields []string) error {
|
||||
return printPulls(prs, output, fields)
|
||||
}
|
||||
|
||||
// PullFields are all available fields to print with PullsList()
|
||||
@@ -170,7 +170,7 @@ var PullFields = []string{
|
||||
"comments",
|
||||
}
|
||||
|
||||
func printPulls(pulls []*gitea.PullRequest, output string, fields []string) {
|
||||
func printPulls(pulls []*gitea.PullRequest, output string, fields []string) error {
|
||||
labelMap := map[int64]string{}
|
||||
printables := make([]printable, len(pulls))
|
||||
machineReadable := isMachineReadable(output)
|
||||
@@ -187,7 +187,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) {
|
||||
}
|
||||
|
||||
t := tableFromItems(fields, printables, machineReadable)
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
type printablePull struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// ReleasesList prints a listing of releases
|
||||
func ReleasesList(releases []*gitea.Release, output string) {
|
||||
func ReleasesList(releases []*gitea.Release, output string) error {
|
||||
t := tableWithHeader(
|
||||
"Tag-Name",
|
||||
"Title",
|
||||
@@ -33,5 +33,5 @@ func ReleasesList(releases []*gitea.Release, output string) {
|
||||
)
|
||||
}
|
||||
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ import (
|
||||
)
|
||||
|
||||
// ReposList prints a listing of the repos
|
||||
func ReposList(repos []*gitea.Repository, output string, fields []string) {
|
||||
func ReposList(repos []*gitea.Repository, output string, fields []string) error {
|
||||
printables := make([]printable, len(repos))
|
||||
for i, r := range repos {
|
||||
printables[i] = &printableRepo{r}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// RepoDetails print an repo formatted to stdout
|
||||
@@ -113,7 +113,7 @@ func (x printableRepo) FormatField(field string, machineReadable bool) string {
|
||||
case "forks":
|
||||
return fmt.Sprintf("%d", x.Forks)
|
||||
case "id":
|
||||
return x.FullName
|
||||
return fmt.Sprintf("%d", x.ID)
|
||||
case "name":
|
||||
return x.Name
|
||||
case "owner":
|
||||
|
||||
33
modules/print/repo_test.go
Normal file
33
modules/print/repo_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReposListUsesNumericIDField(t *testing.T) {
|
||||
repos := []*gitea.Repository{{
|
||||
ID: 123,
|
||||
Name: "tea",
|
||||
Owner: &gitea.User{
|
||||
UserName: "gitea",
|
||||
},
|
||||
}}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
tbl := tableFromItems([]string{"id", "name"}, []printable{&printableRepo{repos[0]}}, true)
|
||||
require.NoError(t, tbl.fprint(buf, "json"))
|
||||
|
||||
var result []map[string]string
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "123", result[0]["id"])
|
||||
require.Equal(t, "tea", result[0]["name"])
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// table provides infrastructure to easily print (sorted) lists in different formats
|
||||
@@ -72,34 +75,26 @@ func (t table) Less(i, j int) bool {
|
||||
return t.values[i][t.sortColumn] < t.values[j][t.sortColumn]
|
||||
}
|
||||
|
||||
func (t *table) print(output string) {
|
||||
t.fprint(os.Stdout, output)
|
||||
func (t *table) print(output string) error {
|
||||
return t.fprint(os.Stdout, output)
|
||||
}
|
||||
|
||||
func (t *table) fprint(f io.Writer, output string) {
|
||||
func (t *table) fprint(f io.Writer, output string) error {
|
||||
switch output {
|
||||
case "", "table":
|
||||
outputTable(f, t.headers, t.values)
|
||||
return outputTable(f, t.headers, t.values)
|
||||
case "csv":
|
||||
outputDsv(f, t.headers, t.values, ",")
|
||||
return outputDsv(f, t.headers, t.values, ',')
|
||||
case "simple":
|
||||
outputSimple(f, t.headers, t.values)
|
||||
return outputSimple(f, t.headers, t.values)
|
||||
case "tsv":
|
||||
outputDsv(f, t.headers, t.values, "\t")
|
||||
return outputDsv(f, t.headers, t.values, '\t')
|
||||
case "yml", "yaml":
|
||||
outputYaml(f, t.headers, t.values)
|
||||
return outputYaml(f, t.headers, t.values)
|
||||
case "json":
|
||||
outputJSON(f, t.headers, t.values)
|
||||
return outputJSON(f, t.headers, t.values)
|
||||
default:
|
||||
fmt.Fprintf(f, `"unknown output type '%s', available types are:
|
||||
- csv: comma-separated values
|
||||
- simple: space-separated values
|
||||
- table: auto-aligned table format (default)
|
||||
- tsv: tab-separated values
|
||||
- yaml: YAML format
|
||||
- json: JSON format
|
||||
`, output)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("unknown output type %q, available types are: csv, simple, table, tsv, yaml, json", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,41 +113,59 @@ func outputTable(f io.Writer, headers []string, values [][]string) error {
|
||||
}
|
||||
|
||||
// outputSimple prints structured data as space delimited value
|
||||
func outputSimple(f io.Writer, headers []string, values [][]string) {
|
||||
func outputSimple(f io.Writer, headers []string, values [][]string) error {
|
||||
for _, value := range values {
|
||||
fmt.Fprint(f, strings.Join(value, " "))
|
||||
fmt.Fprintf(f, "\n")
|
||||
if _, err := fmt.Fprintln(f, strings.Join(value, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outputDsv prints structured data as delimiter separated value format
|
||||
func outputDsv(f io.Writer, headers []string, values [][]string, delimiterOpt ...string) {
|
||||
delimiter := ","
|
||||
if len(delimiterOpt) > 0 {
|
||||
delimiter = delimiterOpt[0]
|
||||
// outputDsv prints structured data as delimiter separated value format.
|
||||
func outputDsv(f io.Writer, headers []string, values [][]string, delimiter rune) error {
|
||||
writer := csv.NewWriter(f)
|
||||
writer.Comma = delimiter
|
||||
if err := writer.Write(headers); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(f, "\""+strings.Join(headers, "\""+delimiter+"\"")+"\"")
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(f, "\"")
|
||||
fmt.Fprint(f, strings.Join(value, "\""+delimiter+"\""))
|
||||
fmt.Fprintf(f, "\"")
|
||||
fmt.Fprintf(f, "\n")
|
||||
if err := writer.Write(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
// outputYaml prints structured data as yaml
|
||||
func outputYaml(f io.Writer, headers []string, values [][]string) {
|
||||
func outputYaml(f io.Writer, headers []string, values [][]string) error {
|
||||
root := &yaml.Node{Kind: yaml.SequenceNode}
|
||||
for _, value := range values {
|
||||
fmt.Fprintln(f, "-")
|
||||
row := &yaml.Node{Kind: yaml.MappingNode}
|
||||
for j, val := range value {
|
||||
row.Content = append(row.Content, &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: headers[j],
|
||||
})
|
||||
|
||||
valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val}
|
||||
intVal, _ := strconv.Atoi(val)
|
||||
if strconv.Itoa(intVal) == val {
|
||||
fmt.Fprintf(f, " %s: %s\n", headers[j], val)
|
||||
valueNode.Tag = "!!int"
|
||||
} else {
|
||||
fmt.Fprintf(f, " %s: '%s'\n", headers[j], strings.ReplaceAll(val, "'", "''"))
|
||||
valueNode.Tag = "!!str"
|
||||
}
|
||||
row.Content = append(row.Content, valueNode)
|
||||
}
|
||||
root.Content = append(root.Content, row)
|
||||
}
|
||||
encoder := yaml.NewEncoder(f)
|
||||
if err := encoder.Encode(root); err != nil {
|
||||
_ = encoder.Close()
|
||||
return err
|
||||
}
|
||||
return encoder.Close()
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -166,42 +179,52 @@ func toSnakeCase(str string) string {
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
// outputJSON prints structured data as json
|
||||
// Since golang's map is unordered, we need to ensure consistent ordering, we have
|
||||
// to output the JSON ourselves.
|
||||
func outputJSON(f io.Writer, headers []string, values [][]string) {
|
||||
fmt.Fprintln(f, "[")
|
||||
itemCount := len(values)
|
||||
headersCount := len(headers)
|
||||
const space = " "
|
||||
for i, value := range values {
|
||||
fmt.Fprintf(f, "%s{\n", space)
|
||||
for j, val := range value {
|
||||
v, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to format JSON for value '%s': %v\n", val, err)
|
||||
return
|
||||
}
|
||||
key, err := json.Marshal(toSnakeCase(headers[j]))
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to format JSON for header '%s': %v\n", headers[j], err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(f, "%s:%s", key, v)
|
||||
if j != headersCount-1 {
|
||||
fmt.Fprintln(f, ",")
|
||||
} else {
|
||||
fmt.Fprintln(f)
|
||||
}
|
||||
}
|
||||
// orderedRow preserves header insertion order when marshaled to JSON.
|
||||
type orderedRow struct {
|
||||
keys []string
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
if i != itemCount-1 {
|
||||
fmt.Fprintf(f, "%s},\n", space)
|
||||
} else {
|
||||
fmt.Fprintf(f, "%s}\n", space)
|
||||
func (o orderedRow) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
for i, k := range o.keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val, err := json.Marshal(o.values[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf.Write(key)
|
||||
buf.WriteByte(':')
|
||||
buf.Write(val)
|
||||
}
|
||||
fmt.Fprintln(f, "]")
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// outputJSON prints structured data as json, preserving header field order.
|
||||
func outputJSON(f io.Writer, headers []string, values [][]string) error {
|
||||
snakeHeaders := make([]string, len(headers))
|
||||
for i, h := range headers {
|
||||
snakeHeaders[i] = toSnakeCase(h)
|
||||
}
|
||||
rows := make([]orderedRow, 0, len(values))
|
||||
for _, value := range values {
|
||||
row := orderedRow{keys: snakeHeaders, values: make(map[string]string, len(headers))}
|
||||
for j, val := range value {
|
||||
row.values[snakeHeaders[j]] = val
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
encoder := json.NewEncoder(f)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(rows)
|
||||
}
|
||||
|
||||
func isMachineReadable(outputFormat string) bool {
|
||||
|
||||
@@ -5,10 +5,14 @@ package print
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestToSnakeCase(t *testing.T) {
|
||||
@@ -29,7 +33,7 @@ func TestPrint(t *testing.T) {
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
tData.fprint(buf, "json")
|
||||
require.NoError(t, tData.fprint(buf, "json"))
|
||||
result := []struct {
|
||||
A string
|
||||
B string
|
||||
@@ -51,22 +55,62 @@ func TestPrint(t *testing.T) {
|
||||
|
||||
buf.Reset()
|
||||
|
||||
tData.fprint(buf, "yaml")
|
||||
require.NoError(t, tData.fprint(buf, "yaml"))
|
||||
|
||||
assert.Equal(t, `-
|
||||
A: 'new a'
|
||||
B: 'some bbbb'
|
||||
-
|
||||
A: 'AAAAA'
|
||||
B: 'b2'
|
||||
-
|
||||
A: '"abc'
|
||||
B: '"def'
|
||||
-
|
||||
A: '''abc'
|
||||
B: 'de''f'
|
||||
-
|
||||
A: '\abc'
|
||||
B: '''def\'
|
||||
`, buf.String())
|
||||
var yamlResult []map[string]string
|
||||
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &yamlResult))
|
||||
assert.Equal(t, []map[string]string{
|
||||
{"A": "new a", "B": "some bbbb"},
|
||||
{"A": "AAAAA", "B": "b2"},
|
||||
{"A": "\"abc", "B": "\"def"},
|
||||
{"A": "'abc", "B": "de'f"},
|
||||
{"A": "\\abc", "B": "'def\\"},
|
||||
}, yamlResult)
|
||||
}
|
||||
|
||||
func TestPrintCSVUsesEscaping(t *testing.T) {
|
||||
tData := &table{
|
||||
headers: []string{"A", "B"},
|
||||
values: [][]string{
|
||||
{"hello,world", `quote "here"`},
|
||||
{"multi\nline", "plain"},
|
||||
},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
require.NoError(t, tData.fprint(buf, "csv"))
|
||||
|
||||
reader := csv.NewReader(bytes.NewReader(buf.Bytes()))
|
||||
records, err := reader.ReadAll()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, [][]string{
|
||||
{"A", "B"},
|
||||
{"hello,world", `quote "here"`},
|
||||
{"multi\nline", "plain"},
|
||||
}, records)
|
||||
}
|
||||
|
||||
func TestPrintJSONPreservesFieldOrder(t *testing.T) {
|
||||
tData := &table{
|
||||
headers: []string{"Zebra", "Apple", "Mango"},
|
||||
values: [][]string{{"z", "a", "m"}},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
require.NoError(t, tData.fprint(buf, "json"))
|
||||
|
||||
// Keys must appear in header order (Zebra, Apple, Mango), not sorted alphabetically
|
||||
raw := buf.String()
|
||||
zebraIdx := bytes.Index([]byte(raw), []byte(`"zebra"`))
|
||||
appleIdx := bytes.Index([]byte(raw), []byte(`"apple"`))
|
||||
mangoIdx := bytes.Index([]byte(raw), []byte(`"mango"`))
|
||||
assert.Greater(t, appleIdx, zebraIdx, "apple should appear after zebra")
|
||||
assert.Greater(t, mangoIdx, appleIdx, "mango should appear after apple")
|
||||
}
|
||||
|
||||
func TestPrintUnknownOutputReturnsError(t *testing.T) {
|
||||
tData := &table{headers: []string{"A"}, values: [][]string{{"value"}}}
|
||||
|
||||
err := tData.fprint(io.Discard, "unknown")
|
||||
require.ErrorContains(t, err, `unknown output type "unknown"`)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// TrackedTimesList print list of tracked times to stdout
|
||||
func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) {
|
||||
func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) error {
|
||||
printables := make([]printable, len(times))
|
||||
var totalDuration int64
|
||||
for i, t := range times {
|
||||
@@ -26,7 +26,7 @@ func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []st
|
||||
t.addRowSlice(total)
|
||||
}
|
||||
|
||||
t.print(outputType)
|
||||
return t.print(outputType)
|
||||
}
|
||||
|
||||
// TrackedTimeFields contains all available fields for printing of tracked times.
|
||||
|
||||
@@ -52,13 +52,13 @@ func UserDetails(user *gitea.User) {
|
||||
}
|
||||
|
||||
// UserList prints a listing of the users
|
||||
func UserList(user []*gitea.User, output string, fields []string) {
|
||||
func UserList(user []*gitea.User, output string, fields []string) error {
|
||||
printables := make([]printable, len(user))
|
||||
for i, u := range user {
|
||||
printables[i] = &printableUser{u}
|
||||
}
|
||||
t := tableFromItems(fields, printables, isMachineReadable(output))
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// UserFields are the available fields to print with UserList()
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// WebhooksList prints a listing of webhooks
|
||||
func WebhooksList(hooks []*gitea.Hook, output string) {
|
||||
func WebhooksList(hooks []*gitea.Hook, output string) error {
|
||||
t := tableWithHeader(
|
||||
"ID",
|
||||
"Type",
|
||||
@@ -48,7 +48,7 @@ func WebhooksList(hooks []*gitea.Hook, output string) {
|
||||
)
|
||||
}
|
||||
|
||||
t.print(output)
|
||||
return t.print(output)
|
||||
}
|
||||
|
||||
// WebhookDetails prints detailed information about a webhook
|
||||
|
||||
@@ -51,10 +51,7 @@ func TestWebhooksList(t *testing.T) {
|
||||
|
||||
for _, format := range outputFormats {
|
||||
t.Run("Format_"+format, func(t *testing.T) {
|
||||
// Should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
WebhooksList(hooks, format)
|
||||
})
|
||||
assert.NoError(t, WebhooksList(hooks, format))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,16 +60,12 @@ func TestWebhooksListEmpty(t *testing.T) {
|
||||
// Test with empty hook list
|
||||
hooks := []*gitea.Hook{}
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
WebhooksList(hooks, "table")
|
||||
})
|
||||
assert.NoError(t, WebhooksList(hooks, "table"))
|
||||
}
|
||||
|
||||
func TestWebhooksListNil(t *testing.T) {
|
||||
// Test with nil hook list
|
||||
assert.NotPanics(t, func() {
|
||||
WebhooksList(nil, "table")
|
||||
})
|
||||
assert.NoError(t, WebhooksList(nil, "table"))
|
||||
}
|
||||
|
||||
func TestWebhookDetails(t *testing.T) {
|
||||
|
||||
@@ -55,7 +55,9 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe
|
||||
}
|
||||
|
||||
// ... if there already exist a login with same name
|
||||
if login := config.GetLoginByName(name); login != nil {
|
||||
if login, err := config.GetLoginByName(name); err != nil {
|
||||
return err
|
||||
} else if login != nil {
|
||||
return fmt.Errorf("login name '%s' has already been used", login.Name)
|
||||
}
|
||||
// ... if we already use this token
|
||||
@@ -202,7 +204,9 @@ func GenerateLoginName(url, user string) (string, error) {
|
||||
|
||||
// append user name if login name already exists
|
||||
if len(user) != 0 {
|
||||
if login := config.GetLoginByName(name); login != nil {
|
||||
if login, err := config.GetLoginByName(name); err != nil {
|
||||
return "", err
|
||||
} else if login != nil {
|
||||
return name + "_" + user, nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user