feat: explain failed requests and exit silently on interrupt

Failures of tea's own API requests now name what was missing or refused
instead of "resource or API endpoint unavailable":

  issue #999 not found in alice/project
  repository alice/nope not found on http://localhost:3001 (or you don't have access)
  issues are disabled for bob/project
  #1 in alice/project is an issue, not a pull request; view it with: tea issue view 1
  alice/old-stuff is archived (read-only); unarchive it with: tea repo edit ...

A 404 on a repository endpoint looks the repository up once to tell a missing
or hidden repository from a disabled feature, an empty repository or an issue
number given to a pull request command. Refusals get their next step, a
rejected GITEA_TOKEN and unreachable servers are named, and timeoutError words
an expired --timeout. Errors a command already wrapped with its own context,
and raw tea api responses, keep their wording.

Ctrl-C and SIGTERM exit with 130 without printing an error, and a declined
confirmation prints "Canceled." with exit code 3.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:48:49 +02:00
1 parent 2d4d34ddfc
commit 9b1d3a61e6
4 files changed
+437 -9

No files matched your search

+255
View File
@@ -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
}
+114
View File
@@ -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")
}
+19 -7
View File
@@ -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
}
+49 -2
View File
@@ -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())
}