mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
4 files changed
+437
-9
No files matched your search
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user