* feat(cli): improve agent ergonomics * fix(cli): address code review findings - Fix nil pointer dereference in confirmDestructive when flags is nil - Deduplicate dry-run logic by delegating to dryRunExit - Remove deprecated net.Error.Temporary() call (dead since Go 1.18) - Add unit tests for resolveTasklistID and resolveCalendarID Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve PR #201 conflicts and follow-ups (#201) (thanks @salmonumbrella) * fix: resolve rebase fallout for PR #201 landing (#201) (thanks @salmonumbrella) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
134 lines
2.9 KiB
Go
134 lines
2.9 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
|
|
"github.com/99designs/keyring"
|
|
ggoogleapi "google.golang.org/api/googleapi"
|
|
|
|
"github.com/steipete/gogcli/internal/config"
|
|
gogapi "github.com/steipete/gogcli/internal/googleapi"
|
|
)
|
|
|
|
const (
|
|
// Exit code 0 is success.
|
|
// Exit code 1 is generic failure.
|
|
// Exit code 2 is usage/parse error (see usage.go).
|
|
// Exit code 3 is empty results (see paging.go).
|
|
|
|
exitCodeAuthRequired = 4
|
|
exitCodeNotFound = 5
|
|
exitCodePermissionDenied = 6
|
|
exitCodeRateLimited = 7
|
|
exitCodeRetryable = 8
|
|
exitCodeConfig = 10
|
|
|
|
// 130 is the conventional "interrupted" exit code (SIGINT / Ctrl-C).
|
|
exitCodeCancelled = 130
|
|
)
|
|
|
|
// stableExitCode wraps common/expected failure modes in ExitError so callers can
|
|
// branch on exit status without needing to parse human-oriented stderr.
|
|
func stableExitCode(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
var ee *ExitError
|
|
if errors.As(err, &ee) {
|
|
return err
|
|
}
|
|
|
|
if errors.Is(err, context.Canceled) {
|
|
return &ExitError{Code: exitCodeCancelled, Err: err}
|
|
}
|
|
|
|
var authErr *gogapi.AuthRequiredError
|
|
if errors.As(err, &authErr) {
|
|
return &ExitError{Code: exitCodeAuthRequired, Err: err}
|
|
}
|
|
|
|
var credErr *config.CredentialsMissingError
|
|
if errors.As(err, &credErr) {
|
|
return &ExitError{Code: exitCodeConfig, Err: err}
|
|
}
|
|
|
|
if errors.Is(err, keyring.ErrKeyNotFound) {
|
|
return &ExitError{Code: exitCodeAuthRequired, Err: err}
|
|
}
|
|
|
|
var gerr *ggoogleapi.Error
|
|
if errors.As(err, &gerr) {
|
|
if code := googleAPIExitCode(gerr); code != 1 {
|
|
return &ExitError{Code: code, Err: err}
|
|
}
|
|
}
|
|
|
|
var cbErr *gogapi.CircuitBreakerError
|
|
if errors.As(err, &cbErr) {
|
|
return &ExitError{Code: exitCodeRetryable, Err: err}
|
|
}
|
|
|
|
var ne net.Error
|
|
if errors.As(err, &ne) {
|
|
if ne.Timeout() {
|
|
return &ExitError{Code: exitCodeRetryable, Err: err}
|
|
}
|
|
}
|
|
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return &ExitError{Code: exitCodeRetryable, Err: err}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func googleAPIExitCode(err *ggoogleapi.Error) int {
|
|
if err == nil {
|
|
return 1
|
|
}
|
|
|
|
// google.golang.org/api/googleapi.Error includes Code and a list of structured
|
|
// "reason" values; we map the common ones to stable exit codes.
|
|
reason := ""
|
|
if len(err.Errors) > 0 {
|
|
reason = strings.TrimSpace(strings.ToLower(err.Errors[0].Reason))
|
|
}
|
|
|
|
switch err.Code {
|
|
case 401:
|
|
return exitCodeAuthRequired
|
|
case 403:
|
|
if isQuotaOrRateLimitReason(reason) {
|
|
return exitCodeRateLimited
|
|
}
|
|
return exitCodePermissionDenied
|
|
case 404:
|
|
return exitCodeNotFound
|
|
case 429:
|
|
return exitCodeRateLimited
|
|
default:
|
|
if err.Code >= 500 {
|
|
return exitCodeRetryable
|
|
}
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
func isQuotaOrRateLimitReason(reason string) bool {
|
|
switch strings.TrimSpace(strings.ToLower(reason)) {
|
|
case "ratelimitexceeded",
|
|
"userratelimitexceeded",
|
|
"quotaexceeded",
|
|
"dailylimitexceeded",
|
|
"resourceexhausted":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|