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:
techknowlogick
2026-03-27 03:36:44 +00:00
committed by techknowlogick
parent 21881525a8
commit b05e03416b
124 changed files with 1610 additions and 759 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

View 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)
})
}

View File

@@ -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

View File

@@ -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..."

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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",

View 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/")
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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":

View 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"])
}

View File

@@ -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 {

View File

@@ -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"`)
}

View File

@@ -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.

View File

@@ -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()

View File

@@ -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

View File

@@ -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) {

View File

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