From b05e03416b520cb88228229a086c5cffa423abb9 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Fri, 27 Mar 2026 03:36:44 +0000 Subject: [PATCH] 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 Co-committed-by: techknowlogick --- Makefile | 2 +- README.md | 2 +- cmd/actions/runs/delete.go | 8 +- cmd/actions/runs/list.go | 16 +- cmd/actions/runs/list_test.go | 34 ++++ cmd/actions/runs/logs.go | 10 +- cmd/actions/runs/view.go | 14 +- cmd/actions/secrets/create.go | 8 +- cmd/actions/secrets/delete.go | 10 +- cmd/actions/secrets/list.go | 13 +- cmd/actions/secrets/list_test.go | 35 ++++ cmd/actions/variables/delete.go | 10 +- cmd/actions/variables/list.go | 8 +- cmd/actions/variables/list_test.go | 35 ++++ cmd/actions/variables/set.go | 8 +- cmd/actions/workflows/list.go | 13 +- cmd/admin.go | 5 +- cmd/admin/users/list.go | 11 +- cmd/api.go | 257 ++++++++++++++---------- cmd/api_test.go | 42 ++-- cmd/attachments/create.go | 9 +- cmd/attachments/delete.go | 9 +- cmd/attachments/list.go | 14 +- cmd/branches/list.go | 17 +- cmd/branches/protect.go | 9 +- cmd/clone.go | 5 +- cmd/comment.go | 9 +- cmd/detail_json.go | 93 +++++++++ cmd/flags/generic.go | 31 ++- cmd/flags/generic_test.go | 27 +++ cmd/issues.go | 117 ++++++----- cmd/issues/close.go | 9 +- cmd/issues/create.go | 9 +- cmd/issues/edit.go | 9 +- cmd/issues/list.go | 12 +- cmd/issues_test.go | 125 +++++++----- cmd/labels/create.go | 9 +- cmd/labels/delete.go | 9 +- cmd/labels/list.go | 14 +- cmd/labels/update.go | 10 +- cmd/login.go | 5 +- cmd/login/helper.go | 6 +- cmd/login/list.go | 3 +- cmd/login/oauth_refresh.go | 7 +- cmd/milestones.go | 9 +- cmd/milestones/create.go | 5 +- cmd/milestones/delete.go | 11 +- cmd/milestones/issues.go | 32 ++- cmd/milestones/list.go | 14 +- cmd/milestones/reopen.go | 18 +- cmd/notifications/list.go | 17 +- cmd/notifications/mark_as.go | 24 ++- cmd/open.go | 16 +- cmd/organizations.go | 5 +- cmd/organizations/create.go | 5 +- cmd/organizations/delete.go | 5 +- cmd/organizations/list.go | 11 +- cmd/pulls.go | 81 ++------ cmd/pulls/approve.go | 5 +- cmd/pulls/checkout.go | 11 +- cmd/pulls/clean.go | 9 +- cmd/pulls/create.go | 11 +- cmd/pulls/edit.go | 9 +- cmd/pulls/list.go | 14 +- cmd/pulls/merge.go | 9 +- cmd/pulls/reject.go | 5 +- cmd/pulls/review.go | 9 +- cmd/pulls/review_helpers.go | 4 +- cmd/releases/create.go | 9 +- cmd/releases/delete.go | 9 +- cmd/releases/edit.go | 9 +- cmd/releases/list.go | 14 +- cmd/repos.go | 5 +- cmd/repos/create.go | 6 +- cmd/repos/create_from_template.go | 5 +- cmd/repos/delete.go | 7 +- cmd/repos/edit.go | 9 +- cmd/repos/fork.go | 9 +- cmd/repos/list.go | 18 +- cmd/repos/migrate.go | 6 +- cmd/repos/search.go | 10 +- cmd/times/add.go | 9 +- cmd/times/delete.go | 9 +- cmd/times/list.go | 15 +- cmd/times/reset.go | 9 +- cmd/webhooks.go | 5 +- cmd/webhooks/create.go | 6 +- cmd/webhooks/delete.go | 5 +- cmd/webhooks/list.go | 13 +- cmd/webhooks/update.go | 5 +- cmd/whoami.go | 5 +- main.go | 5 + modules/config/config.go | 20 +- modules/config/lock_test.go | 44 ++-- modules/config/login.go | 15 +- modules/context/context.go | 44 ++-- modules/context/context_require.go | 21 +- modules/context/context_require_test.go | 113 +++++++++++ modules/interact/comments.go | 4 +- modules/interact/pull_merge.go | 2 +- modules/print/actions.go | 12 +- modules/print/actions_runs.go | 18 +- modules/print/actions_runs_test.go | 9 +- modules/print/actions_test.go | 15 +- modules/print/attachment.go | 4 +- modules/print/branch.go | 13 +- modules/print/branch_test.go | 33 +++ modules/print/issue.go | 8 +- modules/print/label.go | 4 +- modules/print/login.go | 4 +- modules/print/milestone.go | 4 +- modules/print/notification.go | 4 +- modules/print/organization.go | 6 +- modules/print/pull.go | 8 +- modules/print/release.go | 4 +- modules/print/repo.go | 6 +- modules/print/repo_test.go | 33 +++ modules/print/table.go | 159 ++++++++------- modules/print/table_test.go | 80 ++++++-- modules/print/times.go | 4 +- modules/print/user.go | 4 +- modules/print/webhook.go | 4 +- modules/print/webhook_test.go | 13 +- modules/task/login_create.go | 8 +- 124 files changed, 1610 insertions(+), 759 deletions(-) create mode 100644 cmd/detail_json.go create mode 100644 modules/context/context_require_test.go create mode 100644 modules/print/branch_test.go create mode 100644 modules/print/repo_test.go diff --git a/Makefile b/Makefile index 1d15dac..487461c 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") # Tool packages with pinned versions GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 ifneq ($(DRONE_TAG),) VERSION ?= $(subst v,,$(DRONE_TAG)) diff --git a/README.md b/README.md index 1cf66a2..6ca016d 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ tea man --out ./tea.man ## Compilation -Make sure you have a current go version installed (1.13 or newer). +Make sure you have a current Go version installed (1.26 or newer). - To compile the source yourself with the recommended flags & tags: ```sh diff --git a/cmd/actions/runs/delete.go b/cmd/actions/runs/delete.go index 5e7d9eb..6ca6954 100644 --- a/cmd/actions/runs/delete.go +++ b/cmd/actions/runs/delete.go @@ -36,7 +36,13 @@ func runRunsDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("run ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() runIDStr := cmd.Args().First() diff --git a/cmd/actions/runs/list.go b/cmd/actions/runs/list.go index 8e3cdb0..73dba88 100644 --- a/cmd/actions/runs/list.go +++ b/cmd/actions/runs/list.go @@ -83,7 +83,13 @@ func parseTimeFlag(value string) (time.Time, error) { // RunRunsList lists workflow runs func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() // Parse time filters @@ -98,7 +104,7 @@ func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { } // Build list options - listOpts := flags.GetListOptions() + listOpts := flags.GetListOptions(cmd) runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ ListOptions: listOpts, @@ -112,15 +118,13 @@ func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { } if runs == nil { - print.ActionRunsList(nil, c.Output) - return nil + return print.ActionRunsList(nil, c.Output) } // Filter by time if specified filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until) - print.ActionRunsList(filteredRuns, c.Output) - return nil + return print.ActionRunsList(filteredRuns, c.Output) } // filterRunsByTime filters runs based on time range diff --git a/cmd/actions/runs/list_test.go b/cmd/actions/runs/list_test.go index 176eef7..1486e54 100644 --- a/cmd/actions/runs/list_test.go +++ b/cmd/actions/runs/list_test.go @@ -4,10 +4,15 @@ package runs import ( + stdctx "context" + "os" "testing" "time" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/config" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestFilterRunsByTime(t *testing.T) { @@ -75,3 +80,32 @@ func TestFilterRunsByTime(t *testing.T) { }) } } + +func TestRunRunsListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdRunsList.Name, + Flags: CmdRunsList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunRunsList(stdctx.Background(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/runs/logs.go b/cmd/actions/runs/logs.go index 7a2637e..bebcf63 100644 --- a/cmd/actions/runs/logs.go +++ b/cmd/actions/runs/logs.go @@ -42,7 +42,13 @@ func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("run ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() runIDStr := cmd.Args().First() @@ -78,7 +84,7 @@ func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error { // Otherwise, fetch all jobs and their logs jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return fmt.Errorf("failed to get jobs: %w", err) diff --git a/cmd/actions/runs/view.go b/cmd/actions/runs/view.go index 621ef67..9bba204 100644 --- a/cmd/actions/runs/view.go +++ b/cmd/actions/runs/view.go @@ -38,7 +38,13 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("run ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() runIDStr := cmd.Args().First() @@ -59,7 +65,7 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { // Fetch and print jobs if requested if cmd.Bool("jobs") { jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return fmt.Errorf("failed to get jobs: %w", err) @@ -67,7 +73,9 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { if jobs != nil && len(jobs.Jobs) > 0 { fmt.Printf("\nJobs:\n\n") - print.ActionWorkflowJobsList(jobs.Jobs, c.Output) + if err := print.ActionWorkflowJobsList(jobs.Jobs, c.Output); err != nil { + return err + } } } diff --git a/cmd/actions/secrets/create.go b/cmd/actions/secrets/create.go index b9b13a6..e825f34 100644 --- a/cmd/actions/secrets/create.go +++ b/cmd/actions/secrets/create.go @@ -40,7 +40,13 @@ func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("secret name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() secretName := cmd.Args().First() diff --git a/cmd/actions/secrets/delete.go b/cmd/actions/secrets/delete.go index a031043..60d721e 100644 --- a/cmd/actions/secrets/delete.go +++ b/cmd/actions/secrets/delete.go @@ -35,7 +35,13 @@ func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("secret name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() secretName := cmd.Args().First() @@ -50,7 +56,7 @@ func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error { } } - _, err := client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName) + _, err = client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName) if err != nil { return err } diff --git a/cmd/actions/secrets/list.go b/cmd/actions/secrets/list.go index 51b9efb..0903e2e 100644 --- a/cmd/actions/secrets/list.go +++ b/cmd/actions/secrets/list.go @@ -29,16 +29,21 @@ var CmdSecretsList = cli.Command{ // RunSecretsList list action secrets func RunSecretsList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() secrets, _, err := client.ListRepoActionSecret(c.Owner, c.Repo, gitea.ListRepoActionSecretOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.ActionSecretsList(secrets, c.Output) - return nil + return print.ActionSecretsList(secrets, c.Output) } diff --git a/cmd/actions/secrets/list_test.go b/cmd/actions/secrets/list_test.go index 89c641a..8660830 100644 --- a/cmd/actions/secrets/list_test.go +++ b/cmd/actions/secrets/list_test.go @@ -4,7 +4,13 @@ package secrets import ( + stdctx "context" + "os" "testing" + + "code.gitea.io/tea/modules/config" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestSecretsListFlags(t *testing.T) { @@ -61,3 +67,32 @@ func TestSecretsListValidation(t *testing.T) { // This is fine - list commands typically ignore extra args } } + +func TestRunSecretsListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdSecretsList.Name, + Flags: CmdSecretsList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunSecretsList(stdctx.Background(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/variables/delete.go b/cmd/actions/variables/delete.go index b81ac64..f348374 100644 --- a/cmd/actions/variables/delete.go +++ b/cmd/actions/variables/delete.go @@ -35,7 +35,13 @@ func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("variable name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() variableName := cmd.Args().First() @@ -50,7 +56,7 @@ func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error { } } - _, err := client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName) + _, err = client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName) if err != nil { return err } diff --git a/cmd/actions/variables/list.go b/cmd/actions/variables/list.go index 73159fd..fe5ecb7 100644 --- a/cmd/actions/variables/list.go +++ b/cmd/actions/variables/list.go @@ -31,7 +31,13 @@ var CmdVariablesList = cli.Command{ // RunVariablesList list action variables func RunVariablesList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() if name := cmd.String("name"); name != "" { diff --git a/cmd/actions/variables/list_test.go b/cmd/actions/variables/list_test.go index f13987f..396487a 100644 --- a/cmd/actions/variables/list_test.go +++ b/cmd/actions/variables/list_test.go @@ -4,7 +4,13 @@ package variables import ( + stdctx "context" + "os" "testing" + + "code.gitea.io/tea/modules/config" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestVariablesListFlags(t *testing.T) { @@ -61,3 +67,32 @@ func TestVariablesListValidation(t *testing.T) { // This is fine - list commands typically ignore extra args } } + +func TestRunVariablesListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdVariablesList.Name, + Flags: CmdVariablesList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunVariablesList(stdctx.Background(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/variables/set.go b/cmd/actions/variables/set.go index 7a504c5..9f2c568 100644 --- a/cmd/actions/variables/set.go +++ b/cmd/actions/variables/set.go @@ -40,7 +40,13 @@ func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("variable name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() variableName := cmd.Args().First() diff --git a/cmd/actions/workflows/list.go b/cmd/actions/workflows/list.go index d40be22..4cdf10c 100644 --- a/cmd/actions/workflows/list.go +++ b/cmd/actions/workflows/list.go @@ -32,7 +32,13 @@ var CmdWorkflowsList = cli.Command{ // RunWorkflowsList lists workflow files in the repository func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() // Try to list workflow files from .gitea/workflows directory @@ -71,7 +77,7 @@ func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { // Get recent runs to check activity runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err == nil && runs != nil { for _, run := range runs.WorkflowRuns { @@ -81,6 +87,5 @@ func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { } } - print.WorkflowsList(workflows, workflowStatus, c.Output) - return nil + return print.WorkflowsList(workflows, workflowStatus, c.Output) } diff --git a/cmd/admin.go b/cmd/admin.go index 6e33a23..212dc3b 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -44,7 +44,10 @@ var cmdAdminUsers = cli.Command{ } func runAdminUserDetail(_ stdctx.Context, cmd *cli.Command, u string) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() user, _, err := client.GetUserInfo(u) if err != nil { diff --git a/cmd/admin/users/list.go b/cmd/admin/users/list.go index cc831d1..65c545e 100644 --- a/cmd/admin/users/list.go +++ b/cmd/admin/users/list.go @@ -34,7 +34,10 @@ var CmdUserList = cli.Command{ // RunUserList list users func RunUserList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } fields, err := userFieldsFlag.GetValues(cmd) if err != nil { @@ -43,13 +46,11 @@ func RunUserList(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.UserList(users, ctx.Output, fields) - - return nil + return print.UserList(users, ctx.Output, fields) } diff --git a/cmd/api.go b/cmd/api.go index 6041fe3..12c7feb 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -97,128 +97,39 @@ Note: if your endpoint contains ? or &, quote it to prevent shell expansion Flags: append(apiFlags(), flags.LoginRepoFlags...), } +type preparedAPIRequest struct { + Method string + Endpoint string + Headers map[string]string + Body []byte +} + func runApi(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - - // Get the endpoint argument - if cmd.NArg() < 1 { - return fmt.Errorf("endpoint argument required") + ctx, err := context.InitCommand(cmd) + if err != nil { + return err } - endpoint := cmd.Args().First() - - // Expand placeholders in endpoint - endpoint = expandPlaceholders(endpoint, ctx) - - // Parse headers - headers := make(map[string]string) - for _, h := range cmd.StringSlice("header") { - parts := strings.SplitN(h, ":", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid header format: %q (expected key:value)", h) - } - headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + request, err := prepareAPIRequest(cmd, ctx) + if err != nil { + return err } - // Build request body from fields var body io.Reader - stringFields := cmd.StringSlice("field") - typedFields := cmd.StringSlice("Field") - dataRaw := cmd.String("data") - - if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) { - return fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F") - } - - if dataRaw != "" { - var dataBytes []byte - var dataSource string - if strings.HasPrefix(dataRaw, "@") { - filename := dataRaw[1:] - var err error - if filename == "-" { - dataBytes, err = io.ReadAll(os.Stdin) - dataSource = "stdin" - } else { - dataBytes, err = os.ReadFile(filename) - dataSource = filename - } - if err != nil { - return fmt.Errorf("failed to read %q: %w", dataRaw, err) - } - } else { - dataBytes = []byte(dataRaw) - } - if !json.Valid(dataBytes) { - if dataSource != "" { - return fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource) - } - return fmt.Errorf("--data/-d value is not valid JSON") - } - body = bytes.NewReader(dataBytes) - } else if len(stringFields) > 0 || len(typedFields) > 0 { - bodyMap := make(map[string]any) - - // Process string fields (-f) - for _, f := range stringFields { - parts := strings.SplitN(f, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid field format: %q (expected key=value)", f) - } - key := parts[0] - if key == "" { - return fmt.Errorf("field key cannot be empty in %q", f) - } - if _, exists := bodyMap[key]; exists { - return fmt.Errorf("duplicate field key %q", key) - } - bodyMap[key] = parts[1] - } - - // Process typed fields (-F) - for _, f := range typedFields { - parts := strings.SplitN(f, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid field format: %q (expected key=value)", f) - } - key := parts[0] - if key == "" { - return fmt.Errorf("field key cannot be empty in %q", f) - } - if _, exists := bodyMap[key]; exists { - return fmt.Errorf("duplicate field key %q", key) - } - value := parts[1] - - parsedValue, err := parseTypedValue(value) - if err != nil { - return fmt.Errorf("failed to parse field %q: %w", key, err) - } - bodyMap[key] = parsedValue - } - - bodyBytes, err := json.Marshal(bodyMap) - if err != nil { - return fmt.Errorf("failed to encode request body: %w", err) - } - body = bytes.NewReader(bodyBytes) + if request.Body != nil { + body = bytes.NewReader(request.Body) } // Create API client and make request client := api.NewClient(ctx.Login) - method := strings.ToUpper(cmd.String("method")) - if !cmd.IsSet("method") { - if body != nil { - method = "POST" - } else { - method = "GET" - } - } - - resp, err := client.Do(method, endpoint, body, headers) + resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers) if err != nil { return fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", closeErr) + } + }() // Print headers to stderr if requested (so redirects/pipes work correctly) if cmd.Bool("include") { @@ -249,7 +160,11 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer file.Close() + defer func() { + if closeErr := file.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", closeErr) + } + }() output = file } @@ -267,6 +182,126 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { return nil } +func prepareAPIRequest(cmd *cli.Command, ctx *context.TeaContext) (*preparedAPIRequest, error) { + var err error + + // Get the endpoint argument + if cmd.NArg() < 1 { + return nil, fmt.Errorf("endpoint argument required") + } + endpoint := cmd.Args().First() + + // Expand placeholders in endpoint + endpoint = expandPlaceholders(endpoint, ctx) + + // Parse headers + headers := make(map[string]string) + for _, h := range cmd.StringSlice("header") { + parts := strings.SplitN(h, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid header format: %q (expected key:value)", h) + } + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + // Build request body from fields + var bodyBytes []byte + stringFields := cmd.StringSlice("field") + typedFields := cmd.StringSlice("Field") + dataRaw := cmd.String("data") + + if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) { + return nil, fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F") + } + + if dataRaw != "" { + var dataBytes []byte + var dataSource string + if strings.HasPrefix(dataRaw, "@") { + filename := dataRaw[1:] + if filename == "-" { + dataBytes, err = io.ReadAll(os.Stdin) + dataSource = "stdin" + } else { + dataBytes, err = os.ReadFile(filename) + dataSource = filename + } + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", dataRaw, err) + } + } else { + dataBytes = []byte(dataRaw) + } + if !json.Valid(dataBytes) { + if dataSource != "" { + return nil, fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource) + } + return nil, fmt.Errorf("--data/-d value is not valid JSON") + } + bodyBytes = dataBytes + } else if len(stringFields) > 0 || len(typedFields) > 0 { + bodyMap := make(map[string]any) + + // Process string fields (-f) + for _, f := range stringFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + if key == "" { + return nil, fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return nil, fmt.Errorf("duplicate field key %q", key) + } + bodyMap[key] = parts[1] + } + + // Process typed fields (-F) + for _, f := range typedFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + if key == "" { + return nil, fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return nil, fmt.Errorf("duplicate field key %q", key) + } + value := parts[1] + + parsedValue, err := parseTypedValue(value) + if err != nil { + return nil, fmt.Errorf("failed to parse field %q: %w", key, err) + } + bodyMap[key] = parsedValue + } + + bodyBytes, err = json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("failed to encode request body: %w", err) + } + } + method := strings.ToUpper(cmd.String("method")) + if !cmd.IsSet("method") { + if bodyBytes != nil { + method = "POST" + } else { + method = "GET" + } + } + + return &preparedAPIRequest{ + Method: method, + Endpoint: endpoint, + Headers: headers, + Body: bodyBytes, + }, nil +} + // parseTypedValue parses a value for -F flag, handling: // - @filename: read content from file // - @-: read content from stdin diff --git a/cmd/api_test.go b/cmd/api_test.go index 62449f3..87b242e 100644 --- a/cmd/api_test.go +++ b/cmd/api_test.go @@ -7,11 +7,8 @@ import ( stdctx "context" "encoding/json" "io" - "net/http" - "net/http/httptest" "os" "path/filepath" - "sync" "testing" "code.gitea.io/tea/modules/config" @@ -254,35 +251,18 @@ func TestParseTypedValue(t *testing.T) { }) } -// runApiWithArgs sets up a test server that captures requests, configures the -// login to point at it, and runs the api command with the given CLI args. -// Returns the captured HTTP method, body bytes, and any error from the command. +// runApiWithArgs configures a test login, parses the command line, and captures +// the prepared request without opening sockets or making HTTP requests. func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, err error) { t.Helper() - var mu sync.Mutex var capturedMethod string var capturedBody []byte - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, readErr := io.ReadAll(r.Body) - if readErr != nil { - t.Fatalf("failed to read request body: %v", readErr) - } - mu.Lock() - capturedMethod = r.Method - capturedBody = b - mu.Unlock() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - t.Cleanup(server.Close) - config.SetConfigForTesting(config.LocalConfig{ Logins: []config.Login{{ Name: "testLogin", - URL: server.URL, + URL: "https://gitea.example.com", Token: "test-token", User: "testUser", Default: true, @@ -295,7 +275,19 @@ func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, er cmd := cli.Command{ Name: "api", DisableSliceFlagSeparator: true, - Action: runApi, + Action: func(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + request, err := prepareAPIRequest(cmd, ctx) + if err != nil { + return err + } + capturedMethod = request.Method + capturedBody = append([]byte(nil), request.Body...) + return nil + }, Flags: append(apiFlags(), []cli.Flag{ &cli.StringFlag{Name: "login", Aliases: []string{"l"}}, &cli.StringFlag{Name: "repo", Aliases: []string{"r"}}, @@ -308,8 +300,6 @@ func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, er fullArgs := append([]string{"api", "--login", "testLogin"}, args...) runErr := cmd.Run(stdctx.Background(), fullArgs) - mu.Lock() - defer mu.Unlock() return capturedMethod, capturedBody, runErr } diff --git a/cmd/attachments/create.go b/cmd/attachments/create.go index e970d8f..dd46b46 100644 --- a/cmd/attachments/create.go +++ b/cmd/attachments/create.go @@ -27,8 +27,13 @@ var CmdReleaseAttachmentCreate = cli.Command{ } func runReleaseAttachmentCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() < 2 { diff --git a/cmd/attachments/delete.go b/cmd/attachments/delete.go index 238d92b..b9d0625 100644 --- a/cmd/attachments/delete.go +++ b/cmd/attachments/delete.go @@ -32,8 +32,13 @@ var CmdReleaseAttachmentDelete = cli.Command{ } func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() < 2 { diff --git a/cmd/attachments/list.go b/cmd/attachments/list.go index be6c142..c070ddc 100644 --- a/cmd/attachments/list.go +++ b/cmd/attachments/list.go @@ -31,8 +31,13 @@ var CmdReleaseAttachmentList = cli.Command{ // RunReleaseAttachmentList list release attachments func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() tag := ctx.Args().First() @@ -46,14 +51,13 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error { } attachments, _, err := ctx.Login.Client().ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.ReleaseAttachmentsList(attachments, ctx.Output) - return nil + return print.ReleaseAttachmentsList(attachments, ctx.Output) } func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { diff --git a/cmd/branches/list.go b/cmd/branches/list.go index 098b5f8..a77389c 100644 --- a/cmd/branches/list.go +++ b/cmd/branches/list.go @@ -38,8 +38,13 @@ var CmdBranchesList = cli.Command{ // RunBranchesList list branches func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } owner := ctx.Owner if ctx.IsSet("owner") { @@ -48,16 +53,15 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { var branches []*gitea.Branch var protections []*gitea.BranchProtection - var err error branches, _, err = ctx.Login.Client().ListRepoBranches(owner, ctx.Repo, gitea.ListRepoBranchesOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } protections, _, err = ctx.Login.Client().ListBranchProtections(owner, ctx.Repo, gitea.ListBranchProtectionsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err @@ -68,6 +72,5 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.BranchesList(branches, protections, ctx.Output, fields) - return nil + return print.BranchesList(branches, protections, ctx.Output, fields) } diff --git a/cmd/branches/protect.go b/cmd/branches/protect.go index 15b988b..7b88a39 100644 --- a/cmd/branches/protect.go +++ b/cmd/branches/protect.go @@ -45,8 +45,13 @@ var CmdBranchesUnprotect = cli.Command{ // RunBranchesProtect function to protect/unprotect a list of branches func RunBranchesProtect(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if !cmd.Args().Present() { return fmt.Errorf("must specify at least one branch") diff --git a/cmd/clone.go b/cmd/clone.go index 2bf24d4..f634a5e 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -48,7 +48,10 @@ When a host is specified in the repo-slug, it will override the login specified } func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { - teaCmd := context.InitCommand(cmd) + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } args := teaCmd.Args() if args.Len() < 1 { diff --git a/cmd/comment.go b/cmd/comment.go index 9c03cba..cb69041 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -36,8 +36,13 @@ var CmdAddComment = cli.Command{ } func runAddComment(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } args := ctx.Args() if args.Len() == 0 { diff --git a/cmd/detail_json.go b/cmd/detail_json.go new file mode 100644 index 0000000..7db03e0 --- /dev/null +++ b/cmd/detail_json.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "encoding/json" + "io" + "time" + + "code.gitea.io/sdk/gitea" +) + +type detailLabelData struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type detailCommentData struct { + ID int64 `json:"id"` + Author string `json:"author"` + Created time.Time `json:"created"` + Body string `json:"body"` +} + +type detailReviewData struct { + ID int64 `json:"id"` + Reviewer string `json:"reviewer"` + State gitea.ReviewStateType `json:"state"` + Body string `json:"body"` + Created time.Time `json:"created"` +} + +func buildDetailLabels(labels []*gitea.Label) []detailLabelData { + labelSlice := make([]detailLabelData, 0, len(labels)) + for _, label := range labels { + labelSlice = append(labelSlice, detailLabelData{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + }) + } + return labelSlice +} + +func buildDetailAssignees(assignees []*gitea.User) []string { + assigneeSlice := make([]string, 0, len(assignees)) + for _, assignee := range assignees { + assigneeSlice = append(assigneeSlice, username(assignee)) + } + return assigneeSlice +} + +func buildDetailComments(comments []*gitea.Comment) []detailCommentData { + commentSlice := make([]detailCommentData, 0, len(comments)) + for _, comment := range comments { + commentSlice = append(commentSlice, detailCommentData{ + ID: comment.ID, + Author: username(comment.Poster), + Body: comment.Body, + Created: comment.Created, + }) + } + return commentSlice +} + +func buildDetailReviews(reviews []*gitea.PullReview) []detailReviewData { + reviewSlice := make([]detailReviewData, 0, len(reviews)) + for _, review := range reviews { + reviewSlice = append(reviewSlice, detailReviewData{ + ID: review.ID, + Reviewer: username(review.Reviewer), + State: review.State, + Body: review.Body, + Created: review.Submitted, + }) + } + return reviewSlice +} + +func username(user *gitea.User) string { + if user == nil { + return "ghost" + } + return user.UserName +} + +func writeIndentedJSON(w io.Writer, data any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", "\t") + return encoder.Encode(data) +} diff --git a/cmd/flags/generic.go b/cmd/flags/generic.go index af2692a..46b1e5d 100644 --- a/cmd/flags/generic.go +++ b/cmd/flags/generic.go @@ -39,16 +39,33 @@ var OutputFlag = cli.StringFlag{ } var ( - paging gitea.ListOptions // ErrPage indicates that the provided page value is invalid (less than -1 or equal to 0). ErrPage = errors.New("page cannot be smaller than 1") // ErrLimit indicates that the provided limit value is invalid (negative). ErrLimit = errors.New("limit cannot be negative") ) -// GetListOptions returns configured paging struct -func GetListOptions() gitea.ListOptions { - return paging +const ( + defaultPageValue = 1 + defaultLimitValue = 30 +) + +// GetListOptions returns list options derived from the active command. +func GetListOptions(cmd *cli.Command) gitea.ListOptions { + page := cmd.Int("page") + if page == 0 { + page = defaultPageValue + } + + pageSize := cmd.Int("limit") + if pageSize == 0 { + pageSize = defaultLimitValue + } + + return gitea.ListOptions{ + Page: page, + PageSize: pageSize, + } } // PaginationFlags provides all pagination related flags @@ -62,14 +79,13 @@ var PaginationPageFlag = cli.IntFlag{ Name: "page", Aliases: []string{"p"}, Usage: "specify page", - Value: 1, + Value: defaultPageValue, Validator: func(i int) error { if i < 1 && i != -1 { return ErrPage } return nil }, - Destination: &paging.Page, } // PaginationLimitFlag provides flag for pagination options @@ -77,14 +93,13 @@ var PaginationLimitFlag = cli.IntFlag{ Name: "limit", Aliases: []string{"lm"}, Usage: "specify limit of items per page", - Value: 30, + Value: defaultLimitValue, Validator: func(i int) error { if i < 0 { return ErrLimit } return nil }, - Destination: &paging.PageSize, } // LoginOutputFlags defines login and output flags that should diff --git a/cmd/flags/generic_test.go b/cmd/flags/generic_test.go index 184afbf..37d4a7f 100644 --- a/cmd/flags/generic_test.go +++ b/cmd/flags/generic_test.go @@ -8,6 +8,7 @@ import ( "io" "testing" + "code.gitea.io/sdk/gitea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" @@ -123,3 +124,29 @@ func TestPaginationFailures(t *testing.T) { }) } } + +func TestGetListOptionsDoesNotLeakBetweenCommands(t *testing.T) { + var results []gitea.ListOptions + + run := func(args []string) { + t.Helper() + + cmd := cli.Command{ + Name: "test-paging", + Action: func(_ context.Context, cmd *cli.Command) error { + results = append(results, GetListOptions(cmd)) + return nil + }, + Flags: PaginationFlags, + } + + require.NoError(t, cmd.Run(context.Background(), args)) + } + + run([]string{"test", "--page", "5", "--limit", "10"}) + run([]string{"test"}) + + require.Len(t, results, 2) + assert.Equal(t, gitea.ListOptions{Page: 5, PageSize: 10}, results[0]) + assert.Equal(t, gitea.ListOptions{Page: defaultPageValue, PageSize: defaultLimitValue}, results[1]) +} diff --git a/cmd/issues.go b/cmd/issues.go index c9ff28e..16da65a 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -5,7 +5,6 @@ package cmd import ( stdctx "context" - "encoding/json" "fmt" "time" @@ -20,11 +19,7 @@ import ( "github.com/urfave/cli/v3" ) -type labelData struct { - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` -} +type labelData = detailLabelData type issueData struct { ID int64 `json:"id"` @@ -41,13 +36,17 @@ type issueData struct { Comments []commentData `json:"comments"` } -type commentData struct { - ID int64 `json:"id"` - Author string `json:"author"` - Created time.Time `json:"created"` - Body string `json:"body"` +type issueDetailClient interface { + GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) + GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) } +type issueCommentClient interface { + ListIssueComments(owner, repo string, index int64, opt gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) +} + +type commentData = detailCommentData + // CmdIssues represents to login a gitea server. var CmdIssues = cli.Command{ Name: "issues", @@ -80,17 +79,35 @@ func runIssues(ctx stdctx.Context, cmd *cli.Command) error { } func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { - ctx := context.InitCommand(cmd) - if ctx.IsSet("owner") { - ctx.Owner = ctx.String("owner") - } - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) - - idx, err := utils.ArgToIndex(index) + ctx, idx, err := resolveIssueDetailContext(cmd, index) if err != nil { return err } - client := ctx.Login.Client() + + return runIssueDetailWithClient(ctx, idx, ctx.Login.Client()) +} + +func resolveIssueDetailContext(cmd *cli.Command, index string) (*context.TeaContext, int64, error) { + ctx, err := context.InitCommand(cmd) + if err != nil { + return nil, 0, err + } + if ctx.IsSet("owner") { + ctx.Owner = ctx.String("owner") + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return nil, 0, err + } + + idx, err := utils.ArgToIndex(index) + if err != nil { + return nil, 0, err + } + + return ctx, idx, nil +} + +func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDetailClient) error { issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx) if err != nil { return err @@ -120,59 +137,37 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { } func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error { - c := ctx.Login.Client() - opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} + return runIssueDetailAsJSONWithClient(ctx, issue, ctx.Login.Client()) +} - labelSlice := make([]labelData, 0, len(issue.Labels)) - for _, label := range issue.Labels { - labelSlice = append(labelSlice, labelData{label.Name, label.Color, label.Description}) +func runIssueDetailAsJSONWithClient(ctx *context.TeaContext, issue *gitea.Issue, c issueCommentClient) error { + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} + comments := []*gitea.Comment{} + + if ctx.Bool("comments") { + var err error + comments, _, err = c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts) + if err != nil { + return err + } } - assigneesSlice := make([]string, 0, len(issue.Assignees)) - for _, assignee := range issue.Assignees { - assigneesSlice = append(assigneesSlice, assignee.UserName) - } + return writeIndentedJSON(ctx.Writer, buildIssueData(issue, comments)) +} - issueSlice := issueData{ +func buildIssueData(issue *gitea.Issue, comments []*gitea.Comment) issueData { + return issueData{ ID: issue.ID, Index: issue.Index, Title: issue.Title, State: issue.State, Created: issue.Created, - User: issue.Poster.UserName, + User: username(issue.Poster), Body: issue.Body, - Labels: labelSlice, - Assignees: assigneesSlice, + Labels: buildDetailLabels(issue.Labels), + Assignees: buildDetailAssignees(issue.Assignees), URL: issue.HTMLURL, ClosedAt: issue.Closed, - Comments: make([]commentData, 0), + Comments: buildDetailComments(comments), } - - if ctx.Bool("comments") { - comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts) - issueSlice.Comments = make([]commentData, 0, len(comments)) - - if err != nil { - return err - } - - for _, comment := range comments { - issueSlice.Comments = append(issueSlice.Comments, commentData{ - ID: comment.ID, - Author: comment.Poster.UserName, - Body: comment.Body, // Selected Field - Created: comment.Created, - }) - } - - } - - jsonData, err := json.MarshalIndent(issueSlice, "", "\t") - if err != nil { - return err - } - - _, err = fmt.Fprintf(ctx.Writer, "%s\n", jsonData) - - return err } diff --git a/cmd/issues/close.go b/cmd/issues/close.go index d70c25a..ea6f1b8 100644 --- a/cmd/issues/close.go +++ b/cmd/issues/close.go @@ -31,8 +31,13 @@ var CmdIssuesClose = cli.Command{ // editIssueState abstracts the arg parsing to edit the given issue func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() == 0 { return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) } diff --git a/cmd/issues/create.go b/cmd/issues/create.go index 2eb2699..d88ff2c 100644 --- a/cmd/issues/create.go +++ b/cmd/issues/create.go @@ -26,8 +26,13 @@ var CmdIssuesCreate = cli.Command{ } func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.IsInteractiveMode() { err := interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo) diff --git a/cmd/issues/edit.go b/cmd/issues/edit.go index a673e35..e21cf14 100644 --- a/cmd/issues/edit.go +++ b/cmd/issues/edit.go @@ -30,8 +30,13 @@ use an empty string (eg. --milestone "").`, } func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if !cmd.Args().Present() { return fmt.Errorf("must specify at least one issue index") diff --git a/cmd/issues/list.go b/cmd/issues/list.go index dd1a376..9033f84 100644 --- a/cmd/issues/list.go +++ b/cmd/issues/list.go @@ -33,7 +33,10 @@ var CmdIssuesList = cli.Command{ // RunIssuesList list issues func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } state, err := flags.ParseState(ctx.String("state")) if err != nil { @@ -69,7 +72,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { var issues []*gitea.Issue if ctx.Repo != "" { issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, Type: kind, KeyWord: ctx.String("keyword"), @@ -86,7 +89,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { } } else { issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, Type: kind, KeyWord: ctx.String("keyword"), @@ -109,6 +112,5 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.IssuesPullsList(issues, ctx.Output, fields) - return nil + return print.IssuesPullsList(issues, ctx.Output, fields) } diff --git a/cmd/issues_test.go b/cmd/issues_test.go index 48c79ff..6c8bf75 100644 --- a/cmd/issues_test.go +++ b/cmd/issues_test.go @@ -5,11 +5,8 @@ package cmd import ( "bytes" - stdctx "context" "encoding/json" "fmt" - "net/http" - "net/http/httptest" "testing" "time" @@ -27,6 +24,51 @@ const ( testRepo = "testRepo" ) +type fakeIssueCommentClient struct { + owner string + repo string + index int64 + comments []*gitea.Comment +} + +func (f *fakeIssueCommentClient) ListIssueComments(owner, repo string, index int64, _ gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.comments, nil, nil +} + +type fakeIssueDetailClient struct { + owner string + repo string + index int64 + issue *gitea.Issue + reactions []*gitea.Reaction +} + +func (f *fakeIssueDetailClient) GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.issue, nil, nil +} + +func (f *fakeIssueDetailClient) GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.reactions, nil, nil +} + +func toCommentPointers(comments []gitea.Comment) []*gitea.Comment { + result := make([]*gitea.Comment, 0, len(comments)) + for i := range comments { + comment := comments[i] + result = append(result, &comment) + } + return result +} + func createTestIssue(comments int, isClosed bool) gitea.Issue { issue := gitea.Issue{ ID: 42, @@ -160,25 +202,11 @@ func TestRunIssueDetailAsJSON(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - if path == fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", testOwner, testRepo, testCase.issue.Index) { - jsonComments, err := json.Marshal(testCase.comments) - if err != nil { - require.NoError(t, err, "Testing setup failed: failed to marshal comments") - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err = w.Write(jsonComments) - require.NoError(t, err, "Testing setup failed: failed to write out comments") - } else { - http.NotFound(w, r) - } - }) + client := &fakeIssueCommentClient{ + comments: toCommentPointers(testCase.comments), + } - server := httptest.NewServer(handler) - - testContext.Login.URL = server.URL + testContext.Login.URL = "https://gitea.example.com" testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index) var outBuffer bytes.Buffer @@ -187,16 +215,19 @@ func TestRunIssueDetailAsJSON(t *testing.T) { testContext.ErrWriter = &errBuffer if testCase.flagComments { - _ = testContext.Command.Set("comments", "true") + require.NoError(t, testContext.Set("comments", "true")) } else { - _ = testContext.Command.Set("comments", "false") + require.NoError(t, testContext.Set("comments", "false")) } - err := runIssueDetailAsJSON(&testContext, &testCase.issue) - - server.Close() + err := runIssueDetailAsJSONWithClient(&testContext, &testCase.issue, client) require.NoError(t, err, "Failed to run issue detail as JSON") + if testCase.flagComments { + assert.Equal(t, testOwner, client.owner) + assert.Equal(t, testRepo, client.repo) + assert.Equal(t, testCase.issue.Index, client.index) + } out := outBuffer.String() @@ -269,7 +300,7 @@ func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { issueIndex := int64(12) expectedOwner := "overrideOwner" expectedRepo := "overrideRepo" - issue := gitea.Issue{ + issue := &gitea.Issue{ ID: 99, Index: issueIndex, Title: "Owner override test", @@ -281,34 +312,10 @@ func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { HTMLURL: "https://example.test/issues/12", } - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", expectedOwner, expectedRepo, issueIndex): - jsonIssue, err := json.Marshal(issue) - require.NoError(t, err, "Testing setup failed: failed to marshal issue") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err = w.Write(jsonIssue) - require.NoError(t, err, "Testing setup failed: failed to write issue") - case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", expectedOwner, expectedRepo, issueIndex): - jsonReactions, err := json.Marshal([]gitea.Reaction{}) - require.NoError(t, err, "Testing setup failed: failed to marshal reactions") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err = w.Write(jsonReactions) - require.NoError(t, err, "Testing setup failed: failed to write reactions") - default: - http.NotFound(w, r) - } - }) - - server := httptest.NewServer(handler) - defer server.Close() - config.SetConfigForTesting(config.LocalConfig{ Logins: []config.Login{{ Name: "testLogin", - URL: server.URL, + URL: "https://gitea.example.com", Token: "token", User: "loginUser", Default: true, @@ -333,9 +340,19 @@ func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { require.NoError(t, cmd.Set("login", "testLogin")) require.NoError(t, cmd.Set("repo", expectedRepo)) require.NoError(t, cmd.Set("owner", expectedOwner)) - require.NoError(t, cmd.Set("output", "json")) require.NoError(t, cmd.Set("comments", "false")) - err := runIssueDetail(stdctx.Background(), &cmd, fmt.Sprintf("%d", issueIndex)) + teaCtx, idx, err := resolveIssueDetailContext(&cmd, fmt.Sprintf("%d", issueIndex)) + require.NoError(t, err) + + client := &fakeIssueDetailClient{ + issue: issue, + reactions: []*gitea.Reaction{}, + } + + err = runIssueDetailWithClient(teaCtx, idx, client) require.NoError(t, err, "Expected runIssueDetail to succeed") + assert.Equal(t, expectedOwner, client.owner) + assert.Equal(t, expectedRepo, client.repo) + assert.Equal(t, issueIndex, client.index) } diff --git a/cmd/labels/create.go b/cmd/labels/create.go index 38e08d7..5320521 100644 --- a/cmd/labels/create.go +++ b/cmd/labels/create.go @@ -46,8 +46,13 @@ var CmdLabelCreate = cli.Command{ } func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } labelFile := ctx.String("file") if len(labelFile) == 0 { diff --git a/cmd/labels/delete.go b/cmd/labels/delete.go index cc72e39..1199dcc 100644 --- a/cmd/labels/delete.go +++ b/cmd/labels/delete.go @@ -31,8 +31,13 @@ var CmdLabelDelete = cli.Command{ } func runLabelDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } labelID := ctx.Int64("id") client := ctx.Login.Client() diff --git a/cmd/labels/list.go b/cmd/labels/list.go index fcbe90d..ca66d3a 100644 --- a/cmd/labels/list.go +++ b/cmd/labels/list.go @@ -36,12 +36,17 @@ var CmdLabelsList = cli.Command{ // RunLabelsList list labels. func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() labels, _, err := client.ListRepoLabels(ctx.Owner, ctx.Repo, gitea.ListLabelsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err @@ -51,6 +56,5 @@ func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error { return task.LabelsExport(labels, ctx.String("save")) } - print.LabelsList(labels, ctx.Output) - return nil + return print.LabelsList(labels, ctx.Output) } diff --git a/cmd/labels/update.go b/cmd/labels/update.go index e1d69db..9e2cb1d 100644 --- a/cmd/labels/update.go +++ b/cmd/labels/update.go @@ -41,8 +41,13 @@ var CmdLabelUpdate = cli.Command{ } func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } id := ctx.Int64("id") var pName, pColor, pDescription *string @@ -61,7 +66,6 @@ func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error { pDescription = &description } - var err error _, _, err = ctx.Login.Client().EditLabel(ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{ Name: pName, Color: pColor, diff --git a/cmd/login.go b/cmd/login.go index 1c9fe8a..4e427ce 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -42,7 +42,10 @@ func runLogins(ctx context.Context, cmd *cli.Command) error { } func runLoginDetail(name string) error { - l := config.GetLoginByName(name) + l, err := config.GetLoginByName(name) + if err != nil { + return err + } if l == nil { fmt.Printf("Login '%s' do not exist\n\n", name) return nil diff --git a/cmd/login/helper.go b/cmd/login/helper.go index f2efcce..62858ea 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -101,7 +101,11 @@ var CmdLoginHelper = cli.Command{ // Use --login flag if provided, otherwise fall back to host lookup var userConfig *config.Login if loginName := cmd.String("login"); loginName != "" { - userConfig = config.GetLoginByName(loginName) + var lookupErr error + userConfig, lookupErr = config.GetLoginByName(loginName) + if lookupErr != nil { + log.Fatal(lookupErr) + } if userConfig == nil { log.Fatalf("Login '%s' not found", loginName) } diff --git a/cmd/login/list.go b/cmd/login/list.go index ea47515..79c442b 100644 --- a/cmd/login/list.go +++ b/cmd/login/list.go @@ -30,6 +30,5 @@ func RunLoginList(_ context.Context, cmd *cli.Command) error { if err != nil { return err } - print.LoginsList(logins, cmd.String("output")) - return nil + return print.LoginsList(logins, cmd.String("output")) } diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go index 653882e..af6012a 100644 --- a/cmd/login/oauth_refresh.go +++ b/cmd/login/oauth_refresh.go @@ -38,7 +38,10 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error { } // Get the login from config - login := config.GetLoginByName(loginName) + login, err := config.GetLoginByName(loginName) + if err != nil { + return err + } if login == nil { return fmt.Errorf("login '%s' not found", loginName) } @@ -49,7 +52,7 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error { } // Try to refresh the token - err := auth.RefreshAccessToken(login) + err = auth.RefreshAccessToken(login) if err == nil { fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) return nil diff --git a/cmd/milestones.go b/cmd/milestones.go index dfde115..915fae0 100644 --- a/cmd/milestones.go +++ b/cmd/milestones.go @@ -40,8 +40,13 @@ func runMilestones(ctx stdctx.Context, cmd *cli.Command) error { } func runMilestoneDetail(_ stdctx.Context, cmd *cli.Command, name string) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() milestone, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, name) diff --git a/cmd/milestones/create.go b/cmd/milestones/create.go index 86180b5..7447758 100644 --- a/cmd/milestones/create.go +++ b/cmd/milestones/create.go @@ -50,7 +50,10 @@ var CmdMilestonesCreate = cli.Command{ } func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } date := ctx.String("deadline") deadline := &time.Time{} diff --git a/cmd/milestones/delete.go b/cmd/milestones/delete.go index 4274283..e0e3eaa 100644 --- a/cmd/milestones/delete.go +++ b/cmd/milestones/delete.go @@ -24,10 +24,15 @@ var CmdMilestonesDelete = cli.Command{ } func deleteMilestone(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() - _, err := client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First()) + _, err = client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First()) return err } diff --git a/cmd/milestones/issues.go b/cmd/milestones/issues.go index 440f245..2f89bac 100644 --- a/cmd/milestones/issues.go +++ b/cmd/milestones/issues.go @@ -71,8 +71,13 @@ var CmdMilestoneRemoveIssue = cli.Command{ } func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() state, err := flags.ParseState(ctx.String("state")) @@ -97,7 +102,7 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { } issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), Milestones: []string{milestone}, Type: kind, State: state, @@ -110,13 +115,17 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return err } - print.IssuesPullsList(issues, ctx.Output, fields) - return nil + return print.IssuesPullsList(issues, ctx.Output, fields) } func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() != 2 { return fmt.Errorf("need two arguments") @@ -145,8 +154,13 @@ func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error { } func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() != 2 { return fmt.Errorf("need two arguments") diff --git a/cmd/milestones/list.go b/cmd/milestones/list.go index f09d587..2149a67 100644 --- a/cmd/milestones/list.go +++ b/cmd/milestones/list.go @@ -40,8 +40,13 @@ var CmdMilestonesList = cli.Command{ // RunMilestonesList list milestones func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } fields, err := fieldsFlag.GetValues(cmd) if err != nil { @@ -58,13 +63,12 @@ func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() milestones, _, err := client.ListRepoMilestones(ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, }) if err != nil { return err } - print.MilestonesList(milestones, ctx.Output, fields) - return nil + return print.MilestonesList(milestones, ctx.Output, fields) } diff --git a/cmd/milestones/reopen.go b/cmd/milestones/reopen.go index c530600..595eb07 100644 --- a/cmd/milestones/reopen.go +++ b/cmd/milestones/reopen.go @@ -29,8 +29,13 @@ var CmdMilestonesReopen = cli.Command{ } func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() == 0 { return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) } @@ -41,6 +46,13 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { } client := ctx.Login.Client() + repoURL := "" + if ctx.Args().Len() > 1 { + repoURL, err = ctx.GetRemoteRepoHTMLURL() + if err != nil { + return err + } + } for _, ms := range ctx.Args().Slice() { opts := gitea.EditMilestoneOption{ State: &state, @@ -52,7 +64,7 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { } if ctx.Args().Len() > 1 { - fmt.Printf("%s/milestone/%d\n", ctx.GetRemoteRepoHTMLURL(), milestone.ID) + fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID) } else { print.MilestoneDetails(milestone) } diff --git a/cmd/notifications/list.go b/cmd/notifications/list.go index 521a9b1..334b656 100644 --- a/cmd/notifications/list.go +++ b/cmd/notifications/list.go @@ -5,7 +5,6 @@ package notifications import ( stdctx "context" - "log" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" @@ -64,12 +63,15 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify var news []*gitea.NotificationThread var err error - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() all := ctx.Bool("mine") // This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733) - listOpts := flags.GetListOptions() + listOpts := flags.GetListOptions(cmd) if listOpts.Page == 0 { listOpts.Page = 1 } @@ -91,7 +93,9 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify SubjectTypes: subjects, }) } else { - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } news, _, err = client.ListRepoNotifications(ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{ ListOptions: listOpts, Status: status, @@ -99,9 +103,8 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify }) } if err != nil { - log.Fatal(err) + return err } - print.NotificationsList(news, ctx.Output, fields) - return nil + return print.NotificationsList(news, ctx.Output, fields) } diff --git a/cmd/notifications/mark_as.go b/cmd/notifications/mark_as.go index fb25e00..a191766 100644 --- a/cmd/notifications/mark_as.go +++ b/cmd/notifications/mark_as.go @@ -23,7 +23,10 @@ var CmdNotificationsMarkRead = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter, err := flags.NotificationStateFlag.GetValues(cmd) if err != nil { return err @@ -44,7 +47,10 @@ var CmdNotificationsMarkUnread = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter, err := flags.NotificationStateFlag.GetValues(cmd) if err != nil { return err @@ -65,7 +71,10 @@ var CmdNotificationsMarkPinned = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter, err := flags.NotificationStateFlag.GetValues(cmd) if err != nil { return err @@ -85,7 +94,10 @@ var CmdNotificationsUnpin = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter := []string{string(gitea.NotifyStatusPinned)} // NOTE: we implicitly mark it as read, to match web UI semantics. marking as unread might be more useful? return markNotificationAs(ctx, filter, gitea.NotifyStatusRead) @@ -109,7 +121,9 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt if allRepos { _, _, err = client.ReadNotifications(opts) } else { - cmd.Ensure(context.CtxRequirement{RemoteRepo: true}) + if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } _, _, err = client.ReadRepoNotifications(cmd.Owner, cmd.Repo, opts) } diff --git a/cmd/open.go b/cmd/open.go index f4f3041..7e6c448 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -28,8 +28,13 @@ var CmdOpen = cli.Command{ } func runOpen(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } var suffix string number := ctx.Args().Get(0) @@ -74,5 +79,10 @@ func runOpen(_ stdctx.Context, cmd *cli.Command) error { suffix = number } - return open.Run(path.Join(ctx.GetRemoteRepoHTMLURL(), suffix)) + repoURL, err := ctx.GetRemoteRepoHTMLURL() + if err != nil { + return err + } + + return open.Run(path.Join(repoURL, suffix)) } diff --git a/cmd/organizations.go b/cmd/organizations.go index 5ccb5a3..cccc54c 100644 --- a/cmd/organizations.go +++ b/cmd/organizations.go @@ -31,7 +31,10 @@ var CmdOrgs = cli.Command{ } func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error { - teaCtx := context.InitCommand(cmd) + teaCtx, err := context.InitCommand(cmd) + if err != nil { + return err + } if teaCtx.Args().Len() == 1 { return runOrganizationDetail(teaCtx) } diff --git a/cmd/organizations/create.go b/cmd/organizations/create.go index da96a22..94be20c 100644 --- a/cmd/organizations/create.go +++ b/cmd/organizations/create.go @@ -53,7 +53,10 @@ var CmdOrganizationCreate = cli.Command{ // RunOrganizationCreate sets up a new organization func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } if ctx.Args().Len() < 1 { return fmt.Errorf("organization name is required") diff --git a/cmd/organizations/delete.go b/cmd/organizations/delete.go index 5fb747e..b88f75d 100644 --- a/cmd/organizations/delete.go +++ b/cmd/organizations/delete.go @@ -28,7 +28,10 @@ var CmdOrganizationDelete = cli.Command{ // RunOrganizationDelete delete user organization func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() diff --git a/cmd/organizations/list.go b/cmd/organizations/list.go index 2c7a267..f279edc 100644 --- a/cmd/organizations/list.go +++ b/cmd/organizations/list.go @@ -29,17 +29,18 @@ var CmdOrganizationList = cli.Command{ // RunOrganizationList list user organizations func RunOrganizationList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() userOrganizations, _, err := client.ListUserOrgs(ctx.Login.User, gitea.ListOrgsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.OrganizationsList(userOrganizations, ctx.Output) - - return nil + return print.OrganizationsList(userOrganizations, ctx.Output) } diff --git a/cmd/pulls.go b/cmd/pulls.go index 7318246..e156428 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -5,7 +5,6 @@ package cmd import ( stdctx "context" - "encoding/json" "fmt" "time" @@ -20,26 +19,11 @@ import ( "github.com/urfave/cli/v3" ) -type pullLabelData struct { - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` -} +type pullLabelData = detailLabelData -type pullReviewData struct { - ID int64 `json:"id"` - Reviewer string `json:"reviewer"` - State gitea.ReviewStateType `json:"state"` - Body string `json:"body"` - Created time.Time `json:"created"` -} +type pullReviewData = detailReviewData -type pullCommentData struct { - ID int64 `json:"id"` - Author string `json:"author"` - Created time.Time `json:"created"` - Body string `json:"body"` -} +type pullCommentData = detailCommentData type pullData struct { ID int64 `json:"id"` @@ -103,8 +87,13 @@ func runPulls(ctx stdctx.Context, cmd *cli.Command) error { } func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } idx, err := utils.ArgToIndex(index) if err != nil { return err @@ -149,28 +138,7 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews []*gitea.PullReview) error { c := ctx.Login.Client() - opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} - - labelSlice := make([]pullLabelData, 0, len(pr.Labels)) - for _, label := range pr.Labels { - labelSlice = append(labelSlice, pullLabelData{label.Name, label.Color, label.Description}) - } - - assigneesSlice := make([]string, 0, len(pr.Assignees)) - for _, assignee := range pr.Assignees { - assigneesSlice = append(assigneesSlice, assignee.UserName) - } - - reviewsSlice := make([]pullReviewData, 0, len(reviews)) - for _, review := range reviews { - reviewsSlice = append(reviewsSlice, pullReviewData{ - ID: review.ID, - Reviewer: review.Reviewer.UserName, - State: review.State, - Body: review.Body, - Created: review.Submitted, - }) - } + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} mergedBy := "" if pr.MergedBy != nil { @@ -184,10 +152,10 @@ func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews State: pr.State, Created: pr.Created, Updated: pr.Updated, - User: pr.Poster.UserName, + User: username(pr.Poster), Body: pr.Body, - Labels: labelSlice, - Assignees: assigneesSlice, + Labels: buildDetailLabels(pr.Labels), + Assignees: buildDetailAssignees(pr.Assignees), URL: pr.HTMLURL, Base: pr.Base.Ref, Head: pr.Head.Ref, @@ -198,7 +166,7 @@ func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews MergedAt: pr.Merged, MergedBy: mergedBy, ClosedAt: pr.Closed, - Reviews: reviewsSlice, + Reviews: buildDetailReviews(reviews), Comments: make([]pullCommentData, 0), } @@ -208,23 +176,8 @@ func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews return err } - pullSlice.Comments = make([]pullCommentData, 0, len(comments)) - for _, comment := range comments { - pullSlice.Comments = append(pullSlice.Comments, pullCommentData{ - ID: comment.ID, - Author: comment.Poster.UserName, - Body: comment.Body, - Created: comment.Created, - }) - } + pullSlice.Comments = buildDetailComments(comments) } - jsonData, err := json.MarshalIndent(pullSlice, "", "\t") - if err != nil { - return err - } - - _, err = fmt.Fprintf(ctx.Writer, "%s\n", jsonData) - - return err + return writeIndentedJSON(ctx.Writer, pullSlice) } diff --git a/cmd/pulls/approve.go b/cmd/pulls/approve.go index 2f5529d..ba3b0ed 100644 --- a/cmd/pulls/approve.go +++ b/cmd/pulls/approve.go @@ -20,7 +20,10 @@ var CmdPullsApprove = cli.Command{ Description: "Approve a pull request", ArgsUsage: " []", Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } return runPullReview(ctx, gitea.ReviewStateApproved, false) }, Flags: flags.AllDefaultFlags, diff --git a/cmd/pulls/checkout.go b/cmd/pulls/checkout.go index d6b11eb..1219a5d 100644 --- a/cmd/pulls/checkout.go +++ b/cmd/pulls/checkout.go @@ -34,11 +34,16 @@ var CmdPullsCheckout = cli.Command{ } func runPullsCheckout(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{ + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{ LocalRepo: true, RemoteRepo: true, - }) + }); err != nil { + return err + } if ctx.Args().Len() != 1 { return fmt.Errorf("pull request index is required") } diff --git a/cmd/pulls/clean.go b/cmd/pulls/clean.go index 76194ea..05c1e85 100644 --- a/cmd/pulls/clean.go +++ b/cmd/pulls/clean.go @@ -32,8 +32,13 @@ var CmdPullsClean = cli.Command{ } func runPullsClean(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{LocalRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{LocalRepo: true}); err != nil { + return err + } if ctx.Args().Len() != 1 { return fmt.Errorf("pull request index is required") } diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index bdeb879..c0f74d7 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -49,11 +49,16 @@ var CmdPullsCreate = cli.Command{ } func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{ + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{ LocalRepo: true, RemoteRepo: true, - }) + }); err != nil { + return err + } // no args -> interactive mode if ctx.IsInteractiveMode() { diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go index 2635344..6fb05b5 100644 --- a/cmd/pulls/edit.go +++ b/cmd/pulls/edit.go @@ -17,8 +17,13 @@ import ( // editPullState abstracts the arg parsing to edit the given pull request func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() == 0 { return fmt.Errorf("pull request index is required") } diff --git a/cmd/pulls/list.go b/cmd/pulls/list.go index a13d7e4..d87e3af 100644 --- a/cmd/pulls/list.go +++ b/cmd/pulls/list.go @@ -30,8 +30,13 @@ var CmdPullsList = cli.Command{ // RunPullsList return list of pulls func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } state, err := flags.ParseState(ctx.String("state")) if err != nil { @@ -39,7 +44,7 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { } prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, }) if err != nil { @@ -51,6 +56,5 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.PullsList(prs, ctx.Output, fields) - return nil + return print.PullsList(prs, ctx.Output, fields) } diff --git a/cmd/pulls/merge.go b/cmd/pulls/merge.go index 11f36d1..a8de930 100644 --- a/cmd/pulls/merge.go +++ b/cmd/pulls/merge.go @@ -41,8 +41,13 @@ var CmdPullsMerge = cli.Command{ }, }, flags.AllDefaultFlags...), Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() != 1 { // If no PR index is provided, try interactive mode diff --git a/cmd/pulls/reject.go b/cmd/pulls/reject.go index b5a75de..67f2a70 100644 --- a/cmd/pulls/reject.go +++ b/cmd/pulls/reject.go @@ -19,7 +19,10 @@ var CmdPullsReject = cli.Command{ Description: "Request changes to a pull request", ArgsUsage: " ", Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } return runPullReview(ctx, gitea.ReviewStateRequestChanges, true) }, Flags: flags.AllDefaultFlags, diff --git a/cmd/pulls/review.go b/cmd/pulls/review.go index d5eaadb..58921fa 100644 --- a/cmd/pulls/review.go +++ b/cmd/pulls/review.go @@ -22,8 +22,13 @@ var CmdPullsReview = cli.Command{ Description: "Interactively review a pull request", ArgsUsage: "", Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() != 1 { return fmt.Errorf("must specify a PR index") diff --git a/cmd/pulls/review_helpers.go b/cmd/pulls/review_helpers.go index cd539f4..c21f84c 100644 --- a/cmd/pulls/review_helpers.go +++ b/cmd/pulls/review_helpers.go @@ -15,7 +15,9 @@ import ( // runPullReview handles the common logic for approving/rejecting pull requests func runPullReview(ctx *context.TeaContext, state gitea.ReviewStateType, requireComment bool) error { - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } minArgs := 1 if requireComment { diff --git a/cmd/releases/create.go b/cmd/releases/create.go index ba1b345..03d5eb7 100644 --- a/cmd/releases/create.go +++ b/cmd/releases/create.go @@ -68,8 +68,13 @@ var CmdReleaseCreate = cli.Command{ } func runReleaseCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } tag := ctx.String("tag") if cmd.Args().Present() { diff --git a/cmd/releases/delete.go b/cmd/releases/delete.go index a3acc8f..e4c7609 100644 --- a/cmd/releases/delete.go +++ b/cmd/releases/delete.go @@ -35,8 +35,13 @@ var CmdReleaseDelete = cli.Command{ } func runReleaseDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if !ctx.Args().Present() { diff --git a/cmd/releases/edit.go b/cmd/releases/edit.go index b1378ff..641a7fc 100644 --- a/cmd/releases/edit.go +++ b/cmd/releases/edit.go @@ -58,8 +58,13 @@ var CmdReleaseEdit = cli.Command{ } func runReleaseEdit(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() var isDraft, isPre *bool diff --git a/cmd/releases/list.go b/cmd/releases/list.go index 431e43a..adb3573 100644 --- a/cmd/releases/list.go +++ b/cmd/releases/list.go @@ -31,18 +31,22 @@ var CmdReleaseList = cli.Command{ // RunReleasesList list releases func RunReleasesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } releases, _, err := ctx.Login.Client().ListReleases(ctx.Owner, ctx.Repo, gitea.ListReleasesOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.ReleasesList(releases, ctx.Output) - return nil + return print.ReleasesList(releases, ctx.Output) } func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { diff --git a/cmd/repos.go b/cmd/repos.go index a192191..20930a6 100644 --- a/cmd/repos.go +++ b/cmd/repos.go @@ -45,7 +45,10 @@ func runRepos(ctx stdctx.Context, cmd *cli.Command) error { } func runRepoDetail(_ stdctx.Context, cmd *cli.Command, path string) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() repoOwner, repoName := utils.GetOwnerAndRepo(path, ctx.Owner) repo, _, err := client.GetRepo(repoOwner, repoName) diff --git a/cmd/repos/create.go b/cmd/repos/create.go index 3486352..21b19cd 100644 --- a/cmd/repos/create.go +++ b/cmd/repos/create.go @@ -103,11 +103,13 @@ var CmdRepoCreate = cli.Command{ } func runRepoCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() var ( repo *gitea.Repository - err error trustmodel gitea.TrustModel ) diff --git a/cmd/repos/create_from_template.go b/cmd/repos/create_from_template.go index 7670980..27a6df3 100644 --- a/cmd/repos/create_from_template.go +++ b/cmd/repos/create_from_template.go @@ -83,7 +83,10 @@ var CmdRepoCreateFromTemplate = cli.Command{ } func runRepoCreateFromTemplate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() templateOwner, templateRepo := utils.GetOwnerAndRepo(ctx.String("template"), ctx.Login.User) diff --git a/cmd/repos/delete.go b/cmd/repos/delete.go index 517ea85..abb9f89 100644 --- a/cmd/repos/delete.go +++ b/cmd/repos/delete.go @@ -46,7 +46,10 @@ var CmdRepoRm = cli.Command{ } func runRepoDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() @@ -76,7 +79,7 @@ func runRepoDelete(_ stdctx.Context, cmd *cli.Command) error { } } - _, err := client.DeleteRepo(owner, repoName) + _, err = client.DeleteRepo(owner, repoName) if err != nil { return err } diff --git a/cmd/repos/edit.go b/cmd/repos/edit.go index aa7d4bc..22a3a55 100644 --- a/cmd/repos/edit.go +++ b/cmd/repos/edit.go @@ -60,8 +60,13 @@ var CmdRepoEdit = cli.Command{ } func runRepoEdit(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() opts := gitea.EditRepoOption{} diff --git a/cmd/repos/fork.go b/cmd/repos/fork.go index 764c027..81dd16e 100644 --- a/cmd/repos/fork.go +++ b/cmd/repos/fork.go @@ -33,8 +33,13 @@ var CmdRepoFork = cli.Command{ } func runRepoFork(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() opts := gitea.CreateForkOption{} diff --git a/cmd/repos/list.go b/cmd/repos/list.go index 4bb9e44..56be1d9 100644 --- a/cmd/repos/list.go +++ b/cmd/repos/list.go @@ -58,7 +58,10 @@ var CmdReposList = cli.Command{ // RunReposList list repositories func RunReposList(_ stdctx.Context, cmd *cli.Command) error { - teaCmd := context.InitCommand(cmd) + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } client := teaCmd.Login.Client() typeFilter, err := getTypeFilter(cmd) @@ -76,11 +79,11 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { } // not an org, treat as user rps, _, err = client.ListUserRepos(owner, gitea.ListReposOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } else { rps, _, err = client.ListOrgRepos(owner, gitea.ListOrgReposOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } if err != nil { @@ -92,7 +95,7 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { return err } rps, _, err = client.SearchRepos(gitea.SearchRepoOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), StarredByUserID: user.ID, }) if err != nil { @@ -105,11 +108,11 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return err } - rps = paginateRepos(allRepos, flags.GetListOptions()) + rps = paginateRepos(allRepos, flags.GetListOptions(cmd)) } else { var err error rps, _, err = client.ListMyRepos(gitea.ListReposOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err @@ -126,8 +129,7 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.ReposList(reposFiltered, teaCmd.Output, fields) - return nil + return print.ReposList(reposFiltered, teaCmd.Output, fields) } func filterReposByType(repos []*gitea.Repository, t gitea.RepoType) []*gitea.Repository { diff --git a/cmd/repos/migrate.go b/cmd/repos/migrate.go index 6f13fec..3f158b7 100644 --- a/cmd/repos/migrate.go +++ b/cmd/repos/migrate.go @@ -109,11 +109,13 @@ var CmdRepoMigrate = cli.Command{ } func runRepoMigrate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() var ( repo *gitea.Repository - err error service gitea.GitServiceType ) diff --git a/cmd/repos/search.go b/cmd/repos/search.go index 4dbef58..e395a2d 100644 --- a/cmd/repos/search.go +++ b/cmd/repos/search.go @@ -58,7 +58,10 @@ var CmdReposSearch = cli.Command{ } func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { - teaCmd := context.InitCommand(cmd) + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } client := teaCmd.Login.Client() var ownerID int64 @@ -109,7 +112,7 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { } rps, _, err := client.SearchRepos(gitea.SearchRepoOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), OwnerID: ownerID, IsPrivate: isPrivate, IsArchived: isArchived, @@ -127,6 +130,5 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return err } - print.ReposList(rps, teaCmd.Output, fields) - return nil + return print.ReposList(rps, teaCmd.Output, fields) } diff --git a/cmd/times/add.go b/cmd/times/add.go index 868ffbe..efeca25 100644 --- a/cmd/times/add.go +++ b/cmd/times/add.go @@ -32,8 +32,13 @@ var CmdTrackedTimesAdd = cli.Command{ } func runTrackedTimesAdd(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() < 2 { return fmt.Errorf("No issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText) diff --git a/cmd/times/delete.go b/cmd/times/delete.go index d0b57af..b1101b9 100644 --- a/cmd/times/delete.go +++ b/cmd/times/delete.go @@ -26,8 +26,13 @@ var CmdTrackedTimesDelete = cli.Command{ } func runTrackedTimesDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() < 2 { diff --git a/cmd/times/list.go b/cmd/times/list.go index 9a21c71..9eec82c 100644 --- a/cmd/times/list.go +++ b/cmd/times/list.go @@ -72,12 +72,16 @@ Depending on your permissions on the repository, only your own tracked times mig // RunTimesList list repositories func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() var times []*gitea.TrackedTime - var err error var from, until time.Time var fields []string @@ -95,7 +99,7 @@ func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { } opts := gitea.ListTrackedTimesOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), Since: from, Before: until, } @@ -133,6 +137,5 @@ func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { } } - print.TrackedTimesList(times, ctx.Output, fields, ctx.Bool("total")) - return nil + return print.TrackedTimesList(times, ctx.Output, fields, ctx.Bool("total")) } diff --git a/cmd/times/reset.go b/cmd/times/reset.go index 47c22e5..c2dd1b3 100644 --- a/cmd/times/reset.go +++ b/cmd/times/reset.go @@ -25,8 +25,13 @@ var CmdTrackedTimesReset = cli.Command{ } func runTrackedTimesReset(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() != 1 { diff --git a/cmd/webhooks.go b/cmd/webhooks.go index b17cd04..63f9052 100644 --- a/cmd/webhooks.go +++ b/cmd/webhooks.go @@ -64,7 +64,10 @@ func runWebhooksDefault(ctx stdctx.Context, cmd *cli.Command) error { } func runWebhookDetail(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() webhookID, err := utils.ArgToIndex(cmd.Args().First()) diff --git a/cmd/webhooks/create.go b/cmd/webhooks/create.go index 81bf5fc..57eb468 100644 --- a/cmd/webhooks/create.go +++ b/cmd/webhooks/create.go @@ -59,7 +59,10 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("webhook URL is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() webhookType := gitea.HookType(cmd.String("type")) @@ -95,7 +98,6 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error { } var hook *gitea.Hook - var err error if c.IsGlobal { return fmt.Errorf("global webhooks not yet supported in this version") } else if len(c.Org) > 0 { diff --git a/cmd/webhooks/delete.go b/cmd/webhooks/delete.go index 7f7348d..fe4a5e3 100644 --- a/cmd/webhooks/delete.go +++ b/cmd/webhooks/delete.go @@ -37,7 +37,10 @@ func runWebhooksDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("webhook ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() webhookID, err := utils.ArgToIndex(cmd.Args().First()) diff --git a/cmd/webhooks/list.go b/cmd/webhooks/list.go index 07c2bce..ccabdb2 100644 --- a/cmd/webhooks/list.go +++ b/cmd/webhooks/list.go @@ -30,26 +30,27 @@ var CmdWebhooksList = cli.Command{ // RunWebhooksList list webhooks func RunWebhooksList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() var hooks []*gitea.Hook - var err error if c.IsGlobal { return fmt.Errorf("global webhooks not yet supported in this version") } else if len(c.Org) > 0 { hooks, _, err = client.ListOrgHooks(c.Org, gitea.ListHooksOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } else { hooks, _, err = client.ListRepoHooks(c.Owner, c.Repo, gitea.ListHooksOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } if err != nil { return err } - print.WebhooksList(hooks, c.Output) - return nil + return print.WebhooksList(hooks, c.Output) } diff --git a/cmd/webhooks/update.go b/cmd/webhooks/update.go index 923a5ea..256f2a9 100644 --- a/cmd/webhooks/update.go +++ b/cmd/webhooks/update.go @@ -61,7 +61,10 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("webhook ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() webhookID, err := utils.ArgToIndex(cmd.Args().First()) diff --git a/cmd/whoami.go b/cmd/whoami.go index c6fb029..be461a5 100644 --- a/cmd/whoami.go +++ b/cmd/whoami.go @@ -19,7 +19,10 @@ var CmdWhoami = cli.Command{ Usage: "Show current logged in user", ArgsUsage: " ", // command does not accept arguments Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() user, _, _ := client.GetMyUserInfo() print.UserDetails(user) diff --git a/main.go b/main.go index 393c617..1ad0405 100644 --- a/main.go +++ b/main.go @@ -6,10 +6,12 @@ package main // import "code.gitea.io/tea" import ( "context" + "errors" "fmt" "os" "code.gitea.io/tea/cmd" + teacontext "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/debug" ) @@ -18,6 +20,9 @@ func main() { app.Flags = append(app.Flags, debug.CliFlag()) err := app.Run(context.Background(), preprocessArgs(os.Args)) if err != nil { + if errors.Is(err, teacontext.ErrCommandCanceled) { + os.Exit(0) + } // app.Run already exits for errors implementing ErrorCoder, // so we only handle generic errors with code 1 here. fmt.Fprintf(app.ErrWriter, "Error: %v\n", err) diff --git a/modules/config/config.go b/modules/config/config.go index d5be554..247c3a9 100644 --- a/modules/config/config.go +++ b/modules/config/config.go @@ -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() diff --git a/modules/config/lock_test.go b/modules/config/lock_test.go index 28e9323..2dde519 100644 --- a/modules/config/lock_test.go +++ b/modules/config/lock_test.go @@ -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 diff --git a/modules/config/login.go b/modules/config/login.go index d6ac4c2..0e83892 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -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 diff --git a/modules/context/context.go b/modules/context/context.go index 901e224..b692036 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -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 } diff --git a/modules/context/context_require.go b/modules/context/context_require.go index 2e97618..93a94df 100644 --- a/modules/context/context_require.go +++ b/modules/context/context_require.go @@ -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 diff --git a/modules/context/context_require_test.go b/modules/context/context_require_test.go new file mode 100644 index 0000000..a3a338c --- /dev/null +++ b/modules/context/context_require_test.go @@ -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) + }) +} diff --git a/modules/interact/comments.go b/modules/interact/comments.go index 93b18f0..844153d 100644 --- a/modules/interact/comments.go +++ b/modules/interact/comments.go @@ -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 diff --git a/modules/interact/pull_merge.go b/modules/interact/pull_merge.go index 863345e..3191677 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -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..." diff --git a/modules/print/actions.go b/modules/print/actions.go index 39e8680..d2bb232 100644 --- a/modules/print/actions.go +++ b/modules/print/actions.go @@ -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) } diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go index c2e2a40..ff9398f 100644 --- a/modules/print/actions_runs.go +++ b/modules/print/actions_runs.go @@ -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) } diff --git a/modules/print/actions_runs_test.go b/modules/print/actions_runs_test.go index cf4283e..8cf5693 100644 --- a/modules/print/actions_runs_test.go +++ b/modules/print/actions_runs_test.go @@ -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) { diff --git a/modules/print/actions_test.go b/modules/print/actions_test.go index 788c934..129e55c 100644 --- a/modules/print/actions_test.go +++ b/modules/print/actions_test.go @@ -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 diff --git a/modules/print/attachment.go b/modules/print/attachment.go index cdfbb5d..074b85c 100644 --- a/modules/print/attachment.go +++ b/modules/print/attachment.go @@ -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) } diff --git a/modules/print/branch.go b/modules/print/branch.go index 1375a30..b5d9150 100644 --- a/modules/print/branch.go +++ b/modules/print/branch.go @@ -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", diff --git a/modules/print/branch_test.go b/modules/print/branch_test.go new file mode 100644 index 0000000..d6db9d8 --- /dev/null +++ b/modules/print/branch_test.go @@ -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/") +} diff --git a/modules/print/issue.go b/modules/print/issue.go index d9bd3ff..ca4fc5a 100644 --- a/modules/print/issue.go +++ b/modules/print/issue.go @@ -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 { diff --git a/modules/print/label.go b/modules/print/label.go index a3ed7e8..4be8753 100644 --- a/modules/print/label.go +++ b/modules/print/label.go @@ -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) } diff --git a/modules/print/login.go b/modules/print/login.go index 6a800fc..9e98fe8 100644 --- a/modules/print/login.go +++ b/modules/print/login.go @@ -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) } diff --git a/modules/print/milestone.go b/modules/print/milestone.go index 2be1ada..e53a63c 100644 --- a/modules/print/milestone.go +++ b/modules/print/milestone.go @@ -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 diff --git a/modules/print/notification.go b/modules/print/notification.go index 4fd457c..d0e0c1d 100644 --- a/modules/print/notification.go +++ b/modules/print/notification.go @@ -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 diff --git a/modules/print/organization.go b/modules/print/organization.go index 7ec5708..9b627a1 100644 --- a/modules/print/organization.go +++ b/modules/print/organization.go @@ -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) } diff --git a/modules/print/pull.go b/modules/print/pull.go index 179dc25..c4e537e 100644 --- a/modules/print/pull.go +++ b/modules/print/pull.go @@ -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 { diff --git a/modules/print/release.go b/modules/print/release.go index 8c2428c..07ab141 100644 --- a/modules/print/release.go +++ b/modules/print/release.go @@ -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) } diff --git a/modules/print/repo.go b/modules/print/repo.go index a69d2b7..353bc8b 100644 --- a/modules/print/repo.go +++ b/modules/print/repo.go @@ -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": diff --git a/modules/print/repo_test.go b/modules/print/repo_test.go new file mode 100644 index 0000000..767d7a0 --- /dev/null +++ b/modules/print/repo_test.go @@ -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"]) +} diff --git a/modules/print/table.go b/modules/print/table.go index 322088a..7f9ca04 100644 --- a/modules/print/table.go +++ b/modules/print/table.go @@ -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 { diff --git a/modules/print/table_test.go b/modules/print/table_test.go index 42c94a3..e39c287 100644 --- a/modules/print/table_test.go +++ b/modules/print/table_test.go @@ -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"`) } diff --git a/modules/print/times.go b/modules/print/times.go index a9d8932..cb3b118 100644 --- a/modules/print/times.go +++ b/modules/print/times.go @@ -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. diff --git a/modules/print/user.go b/modules/print/user.go index 437538d..a60c0ad 100644 --- a/modules/print/user.go +++ b/modules/print/user.go @@ -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() diff --git a/modules/print/webhook.go b/modules/print/webhook.go index 2110b5e..6193e21 100644 --- a/modules/print/webhook.go +++ b/modules/print/webhook.go @@ -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 diff --git a/modules/print/webhook_test.go b/modules/print/webhook_test.go index b7746d3..83963a1 100644 --- a/modules/print/webhook_test.go +++ b/modules/print/webhook_test.go @@ -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) { diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 00af245..0759809 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -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 } }