diff --git a/internal/app/errors.go b/internal/app/errors.go new file mode 100644 index 00000000..698872f6 --- /dev/null +++ b/internal/app/errors.go @@ -0,0 +1,255 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "cmp" + "context" + "errors" + "fmt" + "net" + "net/http" + "strings" + "syscall" + "time" + + "gitea.dev/tea/internal/services" + "gitea.dev/tea/internal/transport" +) + +// lookupTimeout bounds the follow-up reads that explain a failed request. +const lookupTimeout = 5 * time.Second + +// explainedError replaces the message of err and keeps err reachable for exit codes. +type explainedError struct { + text string + err error +} + +func (e *explainedError) Error() string { return e.text } +func (e *explainedError) Unwrap() error { return e.err } + +// explain gives err the message text. +func explain(err error, format string, args ...any) error { + return &explainedError{text: fmt.Sprintf(format, args...), err: err} +} + +// Explain words a command failure for people. It names the resource a failed request addressed +// ("issue #9 not found in alice/x"), tells a missing or inaccessible repository from a disabled +// feature, adds the next step to refusals Gitea explains, and words rejected credentials, +// unreachable servers and expired timeouts. Exit codes are unchanged. +func Explain(ctx context.Context, deps Dependencies, err error) error { + if err == nil || errors.As(err, new(*explainedError)) { + return err + } + if apiErr, ok := errors.AsType[*transport.APIError](err); ok { + return explainAPIError(ctx, deps, err, apiErr) + } + if failure, ok := errors.AsType[*transport.RequestFailure](err); ok { + if reason := unreachable(failure.Err); reason != "" && failure.Server != "" { + return explain(err, "cannot reach %s (%s); check --host or your network", failure.Server, reason) + } + } + if errors.Is(err, context.DeadlineExceeded) { + return explain(err, "timed out before the operation finished") + } + return err +} + +func explainAPIError(ctx context.Context, deps Dependencies, err error, apiErr *transport.APIError) error { + if !explainable(err, apiErr) { + return err + } + if apiErr.Status == http.StatusUnauthorized { + instance := cmp.Or(apiErr.Instance, "the instance") + if deps.Getenv != nil && deps.Getenv("GITEA_TOKEN") != "" { + return explain(err, "GITEA_TOKEN was rejected by %s; fix or unset GITEA_TOKEN", instance) + } + return explain(err, "authentication rejected by %s; run tea auth login", instance) + } + // Errors a service classified keep its wording; others are classified by their endpoint. + classified := err + if !errors.As(err, new(*services.NotFoundError)) && !errors.As(err, new(*services.ConflictError)) { + classified = services.ClassifyRequest(err) + } + ctx, cancel := context.WithTimeout(ctx, lookupTimeout) + defer cancel() + resource := services.RequestResource(apiErr.Endpoint) + if apiErr.Status == http.StatusNotFound && resource.Repository != "" { + if explained := explainRepositoryNotFound(ctx, apiErr, resource); explained != "" { + return explain(err, "%s", explained) + } + } + if conflict, ok := errors.AsType[*services.ConflictError](classified); ok && conflict.Hint == "" { + if hint := conflictHint(ctx, apiErr, conflict); hint != "" { + return explain(err, "%s; %s", conflict.Error(), hint) + } + } + if classified != err { + return explain(err, "%s", classified.Error()) + } + return err +} + +// explainable reports whether err says no more than the failed request: its text is the API +// error's or a classification of it. Context a caller added ("release deleted, but tag +// deletion failed: ...") is kept, and so are raw responses, such as those of tea api, which +// were not recorded from one of tea's requests. +func explainable(err error, apiErr *transport.APIError) bool { + if apiErr.Instance == "" { + return false + } + text := err.Error() + if notFound, ok := errors.AsType[*services.NotFoundError](err); ok && notFound.Error() == text { + return true + } + if conflict, ok := errors.AsType[*services.ConflictError](err); ok && conflict.Error() == text { + return true + } + return apiErr.Error() == text +} + +// explainRepositoryNotFound tells why a request on a repository found nothing: the repository is +// missing or hidden, the feature is disabled, the repository is empty, or a pull request number +// names an issue. It returns "" when the resource itself is missing. +func explainRepositoryNotFound(ctx context.Context, apiErr *transport.APIError, resource services.Resource) string { + repo := resource.Repository + var state struct { + Empty bool `json:"empty"` + HasIssues bool `json:"has_issues"` + HasPullRequests bool `json:"has_pull_requests"` + HasWiki bool `json:"has_wiki"` + HasReleases bool `json:"has_releases"` + HasActions bool `json:"has_actions"` + HasProjects bool `json:"has_projects"` + } + status := http.StatusNotFound + if resource.Kind != "repository" { + status = apiErr.Lookup(ctx, "repos/"+repo, &state) + } + switch status { + case http.StatusNotFound: + return fmt.Sprintf("repository %s not found on %s (or you don't have access)", repo, cmp.Or(apiErr.Instance, "the instance")) + case http.StatusOK: + default: + return "" + } + features := map[string]struct { + name string + enabled bool + }{ + "issues": {"issues are", state.HasIssues}, "pulls": {"pull requests are", state.HasPullRequests}, + "wiki": {"the wiki is", state.HasWiki}, "releases": {"releases are", state.HasReleases}, + "actions": {"Actions are", state.HasActions}, "projects": {"projects are", state.HasProjects}, + } + if feature, ok := features[resource.Section]; ok && !feature.enabled { + return feature.name + " disabled for " + repo + } + if state.Empty && resource.Section == "pulls" { + return repo + " is empty; it has no pull requests" + } + if number, ok := strings.CutPrefix(resource.Key, "#"); ok && resource.Kind == "pull request" { + var issue struct { + PullRequest any `json:"pull_request"` + } + if apiErr.Lookup(ctx, "repos/"+repo+"/issues/"+number, &issue) == http.StatusOK && issue.PullRequest == nil { + return fmt.Sprintf("#%s in %s is an issue, not a pull request; view it with: tea issue view %s", number, repo, number) + } + } + return "" +} + +// conflictHint returns the next step for a refused request, or "". +func conflictHint(ctx context.Context, apiErr *transport.APIError, conflict *services.ConflictError) string { + number := strings.TrimPrefix(conflict.Key, "#") + switch conflict.Reason { + case services.ReasonArchived: + if conflict.Scope != "" { + return "unarchive it with: tea repo edit " + conflict.Scope + " --archived=false" + } + case services.ReasonExists: + return existsHint(ctx, apiErr, conflict) + case services.ReasonMerged: + if apiErr.Method == http.MethodPatch { + return "its state can no longer change" + } + case services.ReasonDraft: + if number != conflict.Key { + return "mark it ready with: tea pr edit " + number + " --ready" + } + case services.ReasonOwnReview: + return "leave a comment instead" + case services.ReasonNoChanges: + return "check out your feature branch or pass --head" + case services.ReasonDisabled: + if conflict.Key != "" { + return "enable it with: tea workflow enable " + conflict.Key + } + case services.ReasonOwnsRepos: + return "transfer or delete them first" + } + return "" +} + +func existsHint(ctx context.Context, apiErr *transport.APIError, conflict *services.ConflictError) string { + switch conflict.Kind { + case "pull request": + head, base, ok := strings.Cut(strings.TrimPrefix(conflict.Key, "for "), " into ") + if !ok || conflict.Scope == "" { + return "" + } + var pull struct { + URL string `json:"html_url"` + } + if apiErr.Lookup(ctx, "repos/"+conflict.Scope+"/pulls/"+base+"/"+head, &pull) == http.StatusOK && pull.URL != "" { + return "see " + pull.URL + } + case "wiki page": + return "edit it with: tea wiki edit " + conflict.Key + } + return "" +} + +// unreachable words a failure to connect, or returns "" for other request failures. +func unreachable(err error) string { + var dnsErr *net.DNSError + var opErr *net.OpError + switch { + case errors.Is(err, syscall.ECONNREFUSED): + return "connection refused" + case errors.As(err, &dnsErr) && dnsErr.IsNotFound: + return "no such host" + case errors.As(err, &opErr) && opErr.Op == "dial" && opErr.Timeout(): + return "connection timed out" + case errors.As(err, &opErr) && opErr.Op == "dial": + return opErr.Err.Error() + } + return "" +} + +// timeoutError words an expired --timeout as "timed out after 4s waiting for run 34" instead +// of "context deadline exceeded". Other errors are returned unchanged; the result still matches +// context.DeadlineExceeded, so the exit code stays the same. +func timeoutError(err error, d time.Duration, format string, args ...any) error { + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + text := "timed out after " + shortDuration(d) + if format != "" { + text += " " + fmt.Sprintf(format, args...) + } + return &explainedError{text: text, err: err} +} + +// shortDuration formats d without zero units, such as 1h, 1h30m or 45s. +func shortDuration(d time.Duration) string { + text := d.String() + if strings.HasSuffix(text, "m0s") { + text = strings.TrimSuffix(text, "0s") + } + if strings.HasSuffix(text, "h0m") { + text = strings.TrimSuffix(text, "0m") + } + return text +} diff --git a/internal/app/errors_test.go b/internal/app/errors_test.go new file mode 100644 index 00000000..aba5670c --- /dev/null +++ b/internal/app/errors_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.dev/tea/internal/clierr" + "github.com/stretchr/testify/require" +) + +// fakeGitea answers the requests in responses, keyed by "METHOD path", with a status and body, +// and every other request with Gitea's bare 404. +func fakeGitea(t *testing.T, responses map[string][2]string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response, ok := responses[r.Method+" "+r.URL.Path] + if !ok { + response = [2]string{"404", `{"message":"not found"}`} + } + var status int + _, _ = fmt.Sscan(response[0], &status) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(response[1])) + })) + t.Cleanup(server.Close) + return server +} + +func TestExplainNamesResourcesAndTellsWhyRepositoriesFail(t *testing.T) { + server := fakeGitea(t, map[string][2]string{ + "GET /api/v1/user": {"200", `{"login":"alice"}`}, + "GET /api/v1/repos/alice/project": {"200", `{"full_name":"alice/project","has_issues":true,"has_pull_requests":true}`}, + "GET /api/v1/repos/alice/project/issues/1": {"200", `{"number":1,"pull_request":null}`}, + "GET /api/v1/repos/bob/project": {"200", `{"full_name":"bob/project","has_issues":false,"has_pull_requests":true}`}, + "GET /api/v1/repos/alice/empty": {"200", `{"full_name":"alice/empty","empty":true,"has_issues":true,"has_pull_requests":true}`}, + "GET /api/v1/repos/alice/old": {"200", `{"full_name":"alice/old","archived":true,"has_issues":true}`}, + "POST /api/v1/repos/alice/old/issues": {"423", `{"message":"repo is archived"}`}, + "POST /api/v1/repos/alice/project/pulls": {"409", `{"message":"pull request already exists for these targets [id: 16, issue_id: 3, head_repo_id: 1, base_repo_id: 1, head_branch: feat, base_branch: main]"}`}, + "GET /api/v1/repos/alice/project/pulls/main/feat": {"200", `{"number":3,"html_url":"https://example.org/alice/project/pulls/3"}`}, + "GET /api/v1/repos/alice/project/pulls/3": {"200", `{"number":3,"state":"open","draft":true,"head":{"sha":"abc"}}`}, + "POST /api/v1/repos/alice/project/pulls/3/merge": {"405", `{"message":"Work in progress PRs cannot be merged"}`}, + }) + for _, tc := range []struct { + name string + args []string + want string + }{ + {"missing issue", []string{"issue", "view", "999", "-R", "alice/project"}, "issue #999 not found in alice/project"}, + {"missing repository", []string{"issue", "list", "-R", "alice/nope"}, "repository alice/nope not found on " + server.URL + " (or you don't have access)"}, + {"disabled issues", []string{"issue", "list", "-R", "bob/project"}, "issues are disabled for bob/project"}, + {"issue as pull request", []string{"pr", "view", "1", "-R", "alice/project"}, "#1 in alice/project is an issue, not a pull request; view it with: tea issue view 1"}, + {"missing pull request", []string{"pr", "view", "999", "-R", "alice/project"}, "pull request #999 not found in alice/project"}, + {"empty repository", []string{"pr", "list", "-R", "alice/empty"}, "alice/empty is empty; it has no pull requests"}, + {"archived repository", []string{"issue", "create", "-R", "alice/old", "--title", "x", "--body", "y"}, "alice/old is archived (read-only); unarchive it with: tea repo edit alice/old --archived=false"}, + {"existing pull request", []string{"pr", "create", "-R", "alice/project", "--head", "feat", "--base", "main", "--title", "x", "--body", "y"}, "pull request for feat into main already exists in alice/project; see https://example.org/alice/project/pulls/3"}, + {"context added by the command", []string{"pr", "merge", "3", "-R", "alice/project", "--yes"}, "pull request #3 is a draft; mark it ready for review first: Gitea API returned HTTP 405 for POST /api/v1/repos/alice/project/pulls/3/merge: Work in progress PRs cannot be merged"}, + {"raw api response", []string{"api", "users/nobody"}, "not found (HTTP 404 GET /api/v1/users/nobody)"}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, _, _ := serverDeps(t, server.URL) + err := Execute(context.Background(), deps, append(tc.args, "--no-input")) + require.Error(t, err) + explained := Explain(context.Background(), deps, err) + require.EqualError(t, explained, tc.want) + require.Equal(t, clierr.ExitCode(err), clierr.ExitCode(explained)) + }) + } +} + +func TestExplainWordsCredentialAndConnectionFailures(t *testing.T) { + rejecting := fakeGitea(t, map[string][2]string{"GET /api/v1/user": {"401", `{"message":"token is invalid"}`}}) + deps, _, _ := serverDeps(t, rejecting.URL) + err := Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project"}) + explained := Explain(context.Background(), deps, err) + require.EqualError(t, explained, "GITEA_TOKEN was rejected by "+rejecting.URL+"; fix or unset GITEA_TOKEN") + require.Equal(t, clierr.Authentication, clierr.ExitCode(explained)) + + closed := httptest.NewServer(http.NotFoundHandler()) + closed.Close() + deps, _, _ = serverDeps(t, closed.URL) + err = Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project"}) + require.EqualError(t, Explain(context.Background(), deps, err), "cannot reach "+closed.URL+" (connection refused); check --host or your network") +} + +func TestTimeoutErrorsNameTheWaitAndKeepTheExitCode(t *testing.T) { + err := fmt.Errorf("watch: %w", context.DeadlineExceeded) + for _, tc := range []struct { + d time.Duration + want string + }{ + {4 * time.Second, "timed out after 4s waiting for run 34"}, + {time.Hour, "timed out after 1h waiting for run 34"}, + {90 * time.Minute, "timed out after 1h30m waiting for run 34"}, + {2 * time.Minute, "timed out after 2m waiting for run 34"}, + } { + timeout := timeoutError(err, tc.d, "waiting for run %d", 34) + require.EqualError(t, timeout, tc.want) + require.ErrorIs(t, timeout, context.DeadlineExceeded) + require.Equal(t, clierr.ExitCode(err), clierr.ExitCode(timeout)) + require.Equal(t, timeout, Explain(context.Background(), Dependencies{}, timeout), "explained errors are kept") + } + other := errors.New("boom") + require.Equal(t, other, timeoutError(other, time.Second, "waiting")) + require.EqualError(t, Explain(context.Background(), Dependencies{}, err), "timed out before the operation finished") +} diff --git a/main.go b/main.go index 2aff3487..9fd6a8f6 100644 --- a/main.go +++ b/main.go @@ -18,14 +18,26 @@ import ( func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - deps := app.System() - err := app.Execute(ctx, deps, os.Args[1:]) - if ctx.Err() != nil && errors.Is(err, context.Canceled) { + code := run(ctx, app.System(), os.Args[1:]) + stop() + os.Exit(code) +} + +// run executes tea, reports a failure on stderr and returns the exit code. An interrupt exits +// silently, and a declined confirmation prints only "Canceled.". +func run(ctx context.Context, deps app.Dependencies, args []string) int { + err := app.Execute(ctx, deps, args) + if err != nil && ctx.Err() != nil { + // Whatever failed after Ctrl-C or SIGTERM failed because of it; the user knows why. err = clierr.Wrap(clierr.Interrupted, err) } - if err != nil { - fmt.Fprintf(deps.Streams.ErrOut, "Error: %v\n", err) + code := clierr.ExitCode(err) + switch { + case err == nil, code == clierr.Interrupted: + case code == clierr.Canceled && !errors.Is(err, context.DeadlineExceeded): + fmt.Fprintln(deps.Streams.ErrOut, "Canceled.") + default: + fmt.Fprintf(deps.Streams.ErrOut, "Error: %v\n", app.Explain(ctx, deps, err)) } - os.Exit(clierr.ExitCode(err)) + return code } diff --git a/main_test.go b/main_test.go index d0c72456..436712c8 100644 --- a/main_test.go +++ b/main_test.go @@ -18,6 +18,11 @@ import ( "testing" "time" + "gitea.dev/tea/internal/app" + "gitea.dev/tea/internal/auth" + "gitea.dev/tea/internal/settings" + "gitea.dev/tea/internal/terminal" + "gitea.dev/tea/internal/transport" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,6 +76,48 @@ func TestSignalInterruptReturns130(t *testing.T) { t.Fatal("CLI did not exit after interrupt") } require.Empty(t, stdout.String()) - require.Equal(t, 1, strings.Count(stderr.String(), "Error:")) - require.NotContains(t, stderr.String(), "disposable-test-token") + require.Empty(t, stderr.String(), "an interrupt exits silently") +} + +type decliningPrompter struct{} + +func (decliningPrompter) Input(context.Context, string) (string, error) { return "", nil } +func (decliningPrompter) SecretInput(context.Context, string) (string, error) { + return "", nil +} +func (decliningPrompter) Confirm(context.Context, string) (bool, error) { return false, nil } + +func TestRunReportsDeclinedConfirmationsAndExplainedFailures(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/user": + _, _ = w.Write([]byte(`{"login":"alice"}`)) + case "/api/v1/repos/alice/project": + _, _ = w.Write([]byte(`{"full_name":"alice/project","has_issues":true}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + } + })) + defer server.Close() + store := settings.New(t.TempDir()) + require.NoError(t, store.Update(settings.TargetHosts, func(s *settings.Snapshot) error { + s.Hosts.Hosts[server.URL] = settings.Host{User: "alice", Users: map[string]settings.Account{"alice": {AuthMethod: settings.AuthToken, CredentialStore: settings.StoreFile}}} + return nil + })) + var stderr bytes.Buffer + deps := app.Dependencies{ + Settings: store, Credentials: &auth.Store{Dir: store.Dir}, Client: transport.New, Prompter: decliningPrompter{}, + Streams: terminal.Streams{In: &bytes.Buffer{}, Out: &bytes.Buffer{}, ErrOut: &stderr, InTTY: true, ErrTTY: true}, + Getenv: func(string) string { return "" }, + } + require.Equal(t, 3, run(context.Background(), deps, []string{"auth", "logout"})) + require.Equal(t, "Canceled.\n", stderr.String()) + + stderr.Reset() + deps.Getenv = func(key string) string { + return map[string]string{"GITEA_TOKEN": "token", "TEA_HOST": server.URL}[key] + } + require.Equal(t, 1, run(context.Background(), deps, []string{"issue", "view", "9", "-R", "alice/project", "--no-input"})) + require.Equal(t, "Error: issue #9 not found in alice/project\n", stderr.String()) }