mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
The hints for a missing repository, a pull request number that names an issue and an existing pull request read the API with raw GETs from the app layer. Read them through SDK lookups in services, which reuse the failed request's instance and credential, and let transport only hand out that client. Signed-off-by: bircni <bircni@icloud.com>
247 lines
8.6 KiB
Go
247 lines
8.6 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"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 services.RepositoryState
|
|
status := http.StatusNotFound
|
|
if resource.Kind != "repository" {
|
|
state, status = services.LookupRepository(ctx, apiErr, repo)
|
|
}
|
|
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.Issues}, "pulls": {"pull requests are", state.PullRequests},
|
|
"wiki": {"the wiki is", state.Wiki}, "releases": {"releases are", state.Releases},
|
|
"actions": {"Actions are", state.Actions}, "projects": {"projects are", state.Projects},
|
|
}
|
|
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" {
|
|
if index, err := strconv.ParseInt(number, 10, 64); err == nil && services.LookupIsIssue(ctx, apiErr, repo, index) {
|
|
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 unarchive " + conflict.Scope
|
|
}
|
|
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 ready " + number
|
|
}
|
|
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.ReasonNotDone:
|
|
if conflict.Kind == "run" && conflict.Key != "" {
|
|
return "cancel it first: tea run cancel " + 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 ""
|
|
}
|
|
if pullURL := services.LookupPullURL(ctx, apiErr, conflict.Scope, base, head); pullURL != "" {
|
|
return "see " + pullURL
|
|
}
|
|
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
|
|
}
|