// 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 }