533 lines
16 KiB
Go
533 lines
16 KiB
Go
// Package errors provides error translation and enhancement for Gitea SDK errors.
|
|
// It maps cryptic SDK error messages to human-readable descriptions and adds
|
|
// context about the operation being performed.
|
|
package errors
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrorCategory represents the category of an error for easier handling.
|
|
type ErrorCategory string
|
|
|
|
const (
|
|
// CategoryFile represents file/directory related errors.
|
|
CategoryFile ErrorCategory = "file"
|
|
// CategoryAuth represents authentication/authorization errors.
|
|
CategoryAuth ErrorCategory = "auth"
|
|
// CategoryRepo represents repository related errors.
|
|
CategoryRepo ErrorCategory = "repo"
|
|
// CategoryIssue represents issue related errors.
|
|
CategoryIssue ErrorCategory = "issue"
|
|
// CategoryPull represents pull request related errors.
|
|
CategoryPull ErrorCategory = "pull"
|
|
// CategoryBranch represents branch/tag related errors.
|
|
CategoryBranch ErrorCategory = "branch"
|
|
// CategoryActions represents Actions CI/CD related errors.
|
|
CategoryActions ErrorCategory = "actions"
|
|
// CategoryNetwork represents network/timeout related errors.
|
|
CategoryNetwork ErrorCategory = "network"
|
|
// CategoryUnknown represents unknown/uncategorized errors.
|
|
CategoryUnknown ErrorCategory = "unknown"
|
|
)
|
|
|
|
// EnhancedError wraps an error with a human-readable translation and context.
|
|
type EnhancedError struct {
|
|
// Original is the underlying error from the SDK or API.
|
|
Original error
|
|
// Translated is the human-readable error message.
|
|
Translated string
|
|
// Category helps identify the type of error for programmatic handling.
|
|
Category ErrorCategory
|
|
// Operation is the name of the operation that failed (e.g., "GetFile").
|
|
Operation string
|
|
// Context contains additional contextual information (e.g., parameters).
|
|
Context map[string]string
|
|
// Timestamp is when the error was created.
|
|
Timestamp time.Time
|
|
}
|
|
|
|
// Error returns the human-readable translated error message.
|
|
func (e *EnhancedError) Error() string {
|
|
if e.Translated != "" {
|
|
return e.Translated
|
|
}
|
|
if e.Original != nil {
|
|
return e.Original.Error()
|
|
}
|
|
return "unknown error"
|
|
}
|
|
|
|
// Unwrap returns the original error for error chain inspection.
|
|
func (e *EnhancedError) Unwrap() error {
|
|
return e.Original
|
|
}
|
|
|
|
// WithContext adds context information to the error and returns a new EnhancedError.
|
|
func (e *EnhancedError) WithContext(key, value string) *EnhancedError {
|
|
if e.Context == nil {
|
|
e.Context = make(map[string]string)
|
|
}
|
|
e.Context[key] = value
|
|
return e
|
|
}
|
|
|
|
// WithOperation sets the operation name for the error and returns the error for chaining.
|
|
// This is a fluent API method for building error context.
|
|
//
|
|
// Example:
|
|
//
|
|
// err := errors.TranslateError(sdkErr, nil).
|
|
// WithOperation("GetFile").
|
|
// WithParam("owner", "gitea").
|
|
// WithParam("repo", "tea")
|
|
func (e *EnhancedError) WithOperation(op string) *EnhancedError {
|
|
e.Operation = op
|
|
return e
|
|
}
|
|
|
|
// WithParam adds a single context parameter to the error and returns the error for chaining.
|
|
// This is a fluent API method for building error context one parameter at a time.
|
|
//
|
|
// Example:
|
|
//
|
|
// err := errors.TranslateError(sdkErr, nil).
|
|
// WithOperation("GetFile").
|
|
// WithParam("owner", "gitea").
|
|
// WithParam("path", "README.md")
|
|
func (e *EnhancedError) WithParam(key, value string) *EnhancedError {
|
|
return e.WithContext(key, value)
|
|
}
|
|
|
|
// FormatDetailed returns a JSON-like structured representation of the error.
|
|
// This format is suitable for logging and debugging, providing all error details
|
|
// in a machine-readable format.
|
|
//
|
|
// Example output:
|
|
//
|
|
// {
|
|
// "error": "File or directory not found",
|
|
// "category": "file",
|
|
// "operation": "GetFile",
|
|
// "timestamp": "2024-01-15T10:30:00Z",
|
|
// "context": {
|
|
// "owner": "gitea",
|
|
// "repo": "tea",
|
|
// "path": "README.md"
|
|
// },
|
|
// "original": "GetContents failed with status 404"
|
|
// }
|
|
func (e *EnhancedError) FormatDetailed() string {
|
|
details := map[string]any{
|
|
"error": e.Error(),
|
|
"category": e.Category,
|
|
"timestamp": e.Timestamp.Format(time.RFC3339),
|
|
}
|
|
|
|
if e.Operation != "" {
|
|
details["operation"] = e.Operation
|
|
}
|
|
|
|
if len(e.Context) > 0 {
|
|
details["context"] = e.Context
|
|
}
|
|
|
|
if e.Original != nil && e.Original.Error() != e.Error() {
|
|
details["original"] = e.Original.Error()
|
|
}
|
|
|
|
jsonBytes, err := json.MarshalIndent(details, "", " ")
|
|
if err != nil {
|
|
// Fallback to simple format if JSON marshaling fails
|
|
return e.Format()
|
|
}
|
|
|
|
return string(jsonBytes)
|
|
}
|
|
|
|
// Format returns a detailed error message including context.
|
|
func (e *EnhancedError) Format() string {
|
|
var parts []string
|
|
|
|
if e.Operation != "" {
|
|
parts = append(parts, fmt.Sprintf("Operation: %s", e.Operation))
|
|
}
|
|
|
|
parts = append(parts, fmt.Sprintf("Error: %s", e.Error()))
|
|
|
|
if e.Category != "" && e.Category != CategoryUnknown {
|
|
parts = append(parts, fmt.Sprintf("Category: %s", e.Category))
|
|
}
|
|
|
|
if len(e.Context) > 0 {
|
|
var ctxParts []string
|
|
for k, v := range e.Context {
|
|
ctxParts = append(ctxParts, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
parts = append(parts, fmt.Sprintf("Context: %s", strings.Join(ctxParts, ", ")))
|
|
}
|
|
|
|
if e.Original != nil && e.Original.Error() != e.Error() {
|
|
parts = append(parts, fmt.Sprintf("Original: %s", e.Original.Error()))
|
|
}
|
|
|
|
return strings.Join(parts, " | ")
|
|
}
|
|
|
|
// TranslateError translates a Gitea SDK error to a human-readable error
|
|
// with context enhancement. The context map can contain operation name,
|
|
// parameters, or any other relevant information.
|
|
//
|
|
// Example:
|
|
//
|
|
// err := someGiteaOperation()
|
|
// if err != nil {
|
|
// return TranslateError(err, map[string]string{
|
|
// "operation": "GetFile",
|
|
// "owner": "gitea",
|
|
// "repo": "tea",
|
|
// "path": "README.md",
|
|
// })
|
|
// }
|
|
func TranslateError(err error, context map[string]string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// If already an EnhancedError, just add context
|
|
var existing *EnhancedError
|
|
if errors.As(err, &existing) {
|
|
if context != nil {
|
|
for k, v := range context {
|
|
existing.WithContext(k, v)
|
|
}
|
|
}
|
|
return existing
|
|
}
|
|
|
|
// Determine translation based on error content
|
|
translated, category := translateErrorMessage(err)
|
|
|
|
operation := ""
|
|
if context != nil {
|
|
operation = context["operation"]
|
|
}
|
|
|
|
enhanced := &EnhancedError{
|
|
Original: err,
|
|
Translated: translated,
|
|
Category: category,
|
|
Operation: operation,
|
|
Context: context,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
return enhanced
|
|
}
|
|
|
|
// translateErrorMessage maps SDK error strings to human-readable messages.
|
|
func translateErrorMessage(err error) (string, ErrorCategory) {
|
|
if err == nil {
|
|
return "", CategoryUnknown
|
|
}
|
|
|
|
msg := err.Error()
|
|
lowerMsg := strings.ToLower(msg)
|
|
|
|
// HTTP status code based translations (for HTTPError)
|
|
if strings.Contains(msg, "status 404") || strings.Contains(msg, "404") {
|
|
// Check for specific API operations in the error message
|
|
if strings.Contains(lowerMsg, "getcontents") || strings.Contains(lowerMsg, "listcontents") {
|
|
return "File or directory not found", CategoryFile
|
|
}
|
|
if strings.Contains(lowerMsg, "getuser") || strings.Contains(lowerMsg, "getuserbyname") {
|
|
return "User or organization not found", CategoryAuth
|
|
}
|
|
if strings.Contains(lowerMsg, "getrepo") {
|
|
return "Repository not found", CategoryRepo
|
|
}
|
|
if strings.Contains(lowerMsg, "getissue") {
|
|
return "Issue not found", CategoryIssue
|
|
}
|
|
if strings.Contains(lowerMsg, "getpullrequest") || strings.Contains(lowerMsg, "getpull") {
|
|
return "Pull request not found", CategoryPull
|
|
}
|
|
if strings.Contains(lowerMsg, "getbranch") || strings.Contains(lowerMsg, "gettag") {
|
|
return "Branch or tag not found", CategoryBranch
|
|
}
|
|
return "Resource not found", CategoryUnknown
|
|
}
|
|
|
|
if strings.Contains(msg, "status 401") || strings.Contains(msg, "401") {
|
|
return "Authentication failed - check your access token", CategoryAuth
|
|
}
|
|
|
|
if strings.Contains(msg, "status 403") || strings.Contains(msg, "403") {
|
|
return "Permission denied - you don't have access to this resource", CategoryAuth
|
|
}
|
|
|
|
// SDK method name based translations
|
|
translations := []struct {
|
|
pattern string
|
|
message string
|
|
category ErrorCategory
|
|
}{
|
|
{"GetContents", "File or directory not found", CategoryFile},
|
|
{"GetContentsOrList", "File or directory not found", CategoryFile},
|
|
{"GetUserByName", "User or organization not found", CategoryAuth},
|
|
{"GetUser", "User or organization not found", CategoryAuth},
|
|
{"GetRepo", "Repository not found", CategoryRepo},
|
|
{"GetIssue", "Issue not found", CategoryIssue},
|
|
{"GetPullRequest", "Pull request not found", CategoryPull},
|
|
{"GetBranch", "Branch not found", CategoryBranch},
|
|
{"GetTag", "Tag not found", CategoryBranch},
|
|
{"ListContents", "Directory not found or empty", CategoryFile},
|
|
{"CreateFile", "Failed to create file - it may already exist", CategoryFile},
|
|
{"UpdateFile", "Failed to update file - it may not exist or SHA mismatch", CategoryFile},
|
|
{"DeleteFile", "Failed to delete file - it may not exist", CategoryFile},
|
|
{"CreateBranch", "Failed to create branch", CategoryBranch},
|
|
{"DeleteBranch", "Failed to delete branch - it may not exist or be protected", CategoryBranch},
|
|
{"CreateIssue", "Failed to create issue", CategoryIssue},
|
|
{"EditIssue", "Failed to update issue - it may not exist", CategoryIssue},
|
|
{"CreatePullRequest", "Failed to create pull request", CategoryPull},
|
|
{"EditPullRequest", "Failed to update pull request", CategoryPull},
|
|
{"CreateRelease", "Failed to create release", CategoryRepo},
|
|
{"EditRelease", "Failed to update release", CategoryRepo},
|
|
{"CreateWikiPage", "Failed to create wiki page", CategoryRepo},
|
|
{"EditWikiPage", "Failed to update wiki page", CategoryRepo},
|
|
{"AddCollaborator", "Failed to add collaborator", CategoryAuth},
|
|
{"RemoveCollaborator", "Failed to remove collaborator", CategoryAuth},
|
|
{"CreateDeployKey", "Failed to create deploy key", CategoryAuth},
|
|
{"DeleteDeployKey", "Failed to delete deploy key", CategoryAuth},
|
|
}
|
|
|
|
for _, t := range translations {
|
|
if strings.Contains(msg, t.pattern) {
|
|
return t.message, t.category
|
|
}
|
|
}
|
|
|
|
// Timeout and network errors
|
|
if strings.Contains(lowerMsg, "timeout") || strings.Contains(lowerMsg, "deadline exceeded") {
|
|
return "Request timed out - the server took too long to respond", CategoryNetwork
|
|
}
|
|
|
|
if strings.Contains(lowerMsg, "connection refused") || strings.Contains(lowerMsg, "no such host") {
|
|
return "Network error - cannot connect to server", CategoryNetwork
|
|
}
|
|
|
|
// Default: return original message with unknown category
|
|
return msg, CategoryUnknown
|
|
}
|
|
|
|
// IsNotFound checks if an error is a "not found" type error.
|
|
// It works with EnhancedError and HTTPError types.
|
|
func IsNotFound(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var enhanced *EnhancedError
|
|
if errors.As(err, &enhanced) {
|
|
switch enhanced.Category {
|
|
case CategoryFile, CategoryRepo, CategoryIssue, CategoryPull, CategoryBranch:
|
|
return true
|
|
}
|
|
return strings.Contains(enhanced.Translated, "not found")
|
|
}
|
|
|
|
// Check for HTTP 404
|
|
var httpErr interface{ Error() string }
|
|
if errors.As(err, &httpErr) {
|
|
if strings.Contains(httpErr.Error(), "404") || strings.Contains(httpErr.Error(), "status 404") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check error message
|
|
lowerMsg := strings.ToLower(err.Error())
|
|
return strings.Contains(lowerMsg, "not found") ||
|
|
strings.Contains(lowerMsg, "404")
|
|
}
|
|
|
|
// IsAuthError checks if an error is an authentication or authorization error.
|
|
// This includes 401 (unauthorized) and 403 (forbidden) HTTP errors.
|
|
func IsAuthError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var enhanced *EnhancedError
|
|
if errors.As(err, &enhanced) {
|
|
return enhanced.Category == CategoryAuth
|
|
}
|
|
|
|
// Check for HTTP 401/403
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "401") || strings.Contains(msg, "status 401") ||
|
|
strings.Contains(msg, "403") || strings.Contains(msg, "status 403") {
|
|
return true
|
|
}
|
|
|
|
// Check error message
|
|
lowerMsg := strings.ToLower(msg)
|
|
return strings.Contains(lowerMsg, "authentication") ||
|
|
strings.Contains(lowerMsg, "unauthorized") ||
|
|
strings.Contains(lowerMsg, "permission denied") ||
|
|
strings.Contains(lowerMsg, "forbidden") ||
|
|
strings.Contains(lowerMsg, "access token")
|
|
}
|
|
|
|
// IsActionsAPIUnavailable checks if an error indicates that the Actions API
|
|
// is not available on the current Gitea version.
|
|
func IsActionsAPIUnavailable(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var enhanced *EnhancedError
|
|
if errors.As(err, &enhanced) {
|
|
return enhanced.Category == CategoryActions ||
|
|
strings.Contains(enhanced.Translated, "not supported on this Gitea version")
|
|
}
|
|
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "actions") &&
|
|
(strings.Contains(msg, "not found") ||
|
|
strings.Contains(msg, "method not allowed") ||
|
|
strings.Contains(msg, "404") ||
|
|
strings.Contains(msg, "405"))
|
|
}
|
|
|
|
// IsTimeout checks if an error is a timeout error.
|
|
func IsTimeout(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var enhanced *EnhancedError
|
|
if errors.As(err, &enhanced) {
|
|
return enhanced.Category == CategoryNetwork ||
|
|
strings.Contains(enhanced.Translated, "timed out")
|
|
}
|
|
|
|
lowerMsg := strings.ToLower(err.Error())
|
|
return strings.Contains(lowerMsg, "timeout") ||
|
|
strings.Contains(lowerMsg, "deadline exceeded") ||
|
|
strings.Contains(lowerMsg, "context deadline")
|
|
}
|
|
|
|
// IsNetworkError checks if an error is a network connectivity error.
|
|
func IsNetworkError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var enhanced *EnhancedError
|
|
if errors.As(err, &enhanced) {
|
|
return enhanced.Category == CategoryNetwork
|
|
}
|
|
|
|
lowerMsg := strings.ToLower(err.Error())
|
|
return strings.Contains(lowerMsg, "connection") ||
|
|
strings.Contains(lowerMsg, "network") ||
|
|
strings.Contains(lowerMsg, "no such host") ||
|
|
strings.Contains(lowerMsg, "dial tcp")
|
|
}
|
|
|
|
// NewEnhancedError creates a new EnhancedError with the given parameters.
|
|
func NewEnhancedError(original error, translated string, category ErrorCategory) *EnhancedError {
|
|
return &EnhancedError{
|
|
Original: original,
|
|
Translated: translated,
|
|
Category: category,
|
|
Context: make(map[string]string),
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// Wrap wraps an error with additional context information.
|
|
func Wrap(err error, operation string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return TranslateError(err, map[string]string{"operation": operation})
|
|
}
|
|
|
|
// HTTPError represents an HTTP error response.
|
|
// This interface is used to check for HTTP status codes.
|
|
type HTTPError interface {
|
|
error
|
|
Status() int
|
|
}
|
|
|
|
// statusError is a simple implementation of HTTPError for testing.
|
|
type statusError struct {
|
|
status int
|
|
message string
|
|
}
|
|
|
|
func (e *statusError) Error() string { return e.message }
|
|
func (e *statusError) Status() int { return e.status }
|
|
|
|
// IsHTTPError checks if an error is an HTTP error with the given status code.
|
|
func IsHTTPError(err error, statusCode int) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// Check if it's our HTTPError type
|
|
var httpErr HTTPError
|
|
if errors.As(err, &httpErr) {
|
|
return httpErr.Status() == statusCode
|
|
}
|
|
|
|
// Check error message for status code
|
|
msg := err.Error()
|
|
return strings.Contains(msg, fmt.Sprintf("status %d", statusCode)) ||
|
|
strings.Contains(msg, fmt.Sprintf("%d", statusCode))
|
|
}
|
|
|
|
// Common HTTP status check helpers
|
|
|
|
// IsUnauthorized checks if the error is an HTTP 401 Unauthorized.
|
|
func IsUnauthorized(err error) bool {
|
|
return IsHTTPError(err, http.StatusUnauthorized)
|
|
}
|
|
|
|
// IsForbidden checks if the error is an HTTP 403 Forbidden.
|
|
func IsForbidden(err error) bool {
|
|
return IsHTTPError(err, http.StatusForbidden)
|
|
}
|
|
|
|
// IsNotFoundHTTP checks if the error is an HTTP 404 Not Found.
|
|
func IsNotFoundHTTP(err error) bool {
|
|
return IsHTTPError(err, http.StatusNotFound)
|
|
}
|
|
|
|
// IsServerError checks if the error is an HTTP 5xx server error.
|
|
func IsServerError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var httpErr HTTPError
|
|
if errors.As(err, &httpErr) {
|
|
return httpErr.Status() >= 500 && httpErr.Status() < 600
|
|
}
|
|
|
|
msg := err.Error()
|
|
for i := 500; i < 600; i++ {
|
|
if strings.Contains(msg, fmt.Sprintf("status %d", i)) ||
|
|
strings.Contains(msg, fmt.Sprintf("%d", i)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|