4e6329eb46
create_branch currently forwards the compact 422 dump, which hides ruleset details the GitHub UI already shows. Unwrap ErrorResponse so agents can see each validation message and recover.
317 lines
11 KiB
Go
317 lines
11 KiB
Go
package errors
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/github/github-mcp-server/pkg/utils"
|
|
"github.com/google/go-github/v89/github"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
type GitHubAPIError struct {
|
|
Message string `json:"message"`
|
|
Response *github.Response `json:"-"`
|
|
Err error `json:"-"`
|
|
}
|
|
|
|
// NewGitHubAPIError creates a new GitHubAPIError with the provided message, response, and error.
|
|
func newGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
|
|
return &GitHubAPIError{
|
|
Message: message,
|
|
Response: resp,
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
func (e *GitHubAPIError) Error() string {
|
|
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
|
|
}
|
|
|
|
type GitHubGraphQLError struct {
|
|
Message string `json:"message"`
|
|
Err error `json:"-"`
|
|
}
|
|
|
|
func newGitHubGraphQLError(message string, err error) *GitHubGraphQLError {
|
|
return &GitHubGraphQLError{
|
|
Message: message,
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
func (e *GitHubGraphQLError) Error() string {
|
|
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
|
|
}
|
|
|
|
type GitHubRawAPIError struct {
|
|
Message string `json:"message"`
|
|
Response *http.Response `json:"-"`
|
|
Err error `json:"-"`
|
|
}
|
|
|
|
func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
|
|
return &GitHubRawAPIError{
|
|
Message: message,
|
|
Response: resp,
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
func (e *GitHubRawAPIError) Error() string {
|
|
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
|
|
}
|
|
|
|
type GitHubErrorKey struct{}
|
|
type GitHubCtxErrors struct {
|
|
api []*GitHubAPIError
|
|
graphQL []*GitHubGraphQLError
|
|
raw []*GitHubRawAPIError
|
|
}
|
|
|
|
// ContextWithGitHubErrors updates or creates a context with a pointer to GitHub error information (to be used by middleware).
|
|
func ContextWithGitHubErrors(ctx context.Context) context.Context {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
|
// If the context already has GitHubCtxErrors, we just empty the slices to start fresh
|
|
val.api = []*GitHubAPIError{}
|
|
val.graphQL = []*GitHubGraphQLError{}
|
|
val.raw = []*GitHubRawAPIError{}
|
|
} else {
|
|
// If not, we create a new GitHubCtxErrors and set it in the context
|
|
ctx = context.WithValue(ctx, GitHubErrorKey{}, &GitHubCtxErrors{})
|
|
}
|
|
|
|
return ctx
|
|
}
|
|
|
|
// GetGitHubAPIErrors retrieves the slice of GitHubAPIErrors from the context.
|
|
func GetGitHubAPIErrors(ctx context.Context) ([]*GitHubAPIError, error) {
|
|
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
|
return val.api, nil // return the slice of API errors from the context
|
|
}
|
|
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
|
}
|
|
|
|
// GetGitHubGraphQLErrors retrieves the slice of GitHubGraphQLErrors from the context.
|
|
func GetGitHubGraphQLErrors(ctx context.Context) ([]*GitHubGraphQLError, error) {
|
|
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
|
return val.graphQL, nil // return the slice of GraphQL errors from the context
|
|
}
|
|
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
|
}
|
|
|
|
// GetGitHubRawAPIErrors retrieves the slice of GitHubRawAPIErrors from the context.
|
|
func GetGitHubRawAPIErrors(ctx context.Context) ([]*GitHubRawAPIError, error) {
|
|
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
|
return val.raw, nil // return the slice of raw API errors from the context
|
|
}
|
|
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
|
}
|
|
|
|
func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Response, err error) (context.Context, error) {
|
|
apiErr := newGitHubAPIError(message, resp, err)
|
|
if ctx != nil {
|
|
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
|
|
}
|
|
return ctx, nil
|
|
}
|
|
|
|
func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) {
|
|
graphQLErr := newGitHubGraphQLError(message, err)
|
|
if ctx != nil {
|
|
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling
|
|
}
|
|
return ctx, nil
|
|
}
|
|
|
|
func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) {
|
|
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
|
val.api = append(val.api, err) // append the error to the existing slice in the context
|
|
return ctx, nil
|
|
}
|
|
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
|
}
|
|
|
|
func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError) (context.Context, error) {
|
|
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
|
val.graphQL = append(val.graphQL, err) // append the error to the existing slice in the context
|
|
return ctx, nil
|
|
}
|
|
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
|
}
|
|
|
|
func addRawAPIErrorToContext(ctx context.Context, err *GitHubRawAPIError) (context.Context, error) {
|
|
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
|
val.raw = append(val.raw, err)
|
|
return ctx, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
|
}
|
|
|
|
// NewGitHubAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
|
|
func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult {
|
|
apiErr := newGitHubAPIError(message, resp, err)
|
|
if ctx != nil {
|
|
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
|
|
}
|
|
|
|
var rateLimitErr *github.RateLimitError
|
|
if stderrors.As(err, &rateLimitErr) {
|
|
resetTime := rateLimitErr.Rate.Reset.Time
|
|
if !resetTime.IsZero() {
|
|
retryIn := time.Until(resetTime).Round(time.Second)
|
|
if retryIn > 0 {
|
|
return utils.NewToolResultError(fmt.Sprintf(
|
|
"%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn))
|
|
}
|
|
}
|
|
return utils.NewToolResultError(fmt.Sprintf(
|
|
"%s: GitHub API rate limit exceeded. Wait before retrying.", message))
|
|
}
|
|
|
|
var abuseErr *github.AbuseRateLimitError
|
|
if stderrors.As(err, &abuseErr) {
|
|
if abuseErr.RetryAfter != nil {
|
|
retryAfter := abuseErr.RetryAfter.Round(time.Second)
|
|
if retryAfter > 0 {
|
|
return utils.NewToolResultError(fmt.Sprintf(
|
|
"%s: GitHub secondary rate limit exceeded. Retry after %v.",
|
|
message, retryAfter))
|
|
}
|
|
}
|
|
return utils.NewToolResultError(fmt.Sprintf(
|
|
"%s: GitHub secondary rate limit exceeded. Wait before retrying.", message))
|
|
}
|
|
|
|
return utils.NewToolResultErrorFromErr(message, formattedGitHubAPIError(err))
|
|
}
|
|
|
|
// formattedGitHubAPIError unwraps a github.ErrorResponse so tool results include
|
|
// nested validation messages (for example repository ruleset violations) instead
|
|
// of go-github's compact 422 dump.
|
|
func formattedGitHubAPIError(err error) error {
|
|
var ghErr *github.ErrorResponse
|
|
if !stderrors.As(err, &ghErr) {
|
|
return err
|
|
}
|
|
|
|
var parts []string
|
|
switch {
|
|
case ghErr.Response != nil && ghErr.Response.StatusCode != 0 && ghErr.Message != "":
|
|
parts = append(parts, fmt.Sprintf("HTTP %d %s", ghErr.Response.StatusCode, ghErr.Message))
|
|
case ghErr.Response != nil && ghErr.Response.StatusCode != 0:
|
|
parts = append(parts, fmt.Sprintf("HTTP %d", ghErr.Response.StatusCode))
|
|
case ghErr.Message != "":
|
|
parts = append(parts, ghErr.Message)
|
|
}
|
|
|
|
for _, item := range ghErr.Errors {
|
|
detail := strings.TrimSpace(item.Message)
|
|
if detail == "" {
|
|
var bits []string
|
|
if item.Resource != "" {
|
|
bits = append(bits, item.Resource)
|
|
}
|
|
if item.Field != "" {
|
|
bits = append(bits, item.Field)
|
|
}
|
|
if item.Code != "" {
|
|
bits = append(bits, item.Code)
|
|
}
|
|
detail = strings.Join(bits, " ")
|
|
}
|
|
if detail != "" {
|
|
parts = append(parts, detail)
|
|
}
|
|
}
|
|
|
|
if ghErr.DocumentationURL != "" {
|
|
parts = append(parts, "See "+ghErr.DocumentationURL)
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return err
|
|
}
|
|
return stderrors.New(strings.Join(parts, "\n"))
|
|
}
|
|
|
|
// NewGitHubGraphQLErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
|
|
func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err error) *mcp.CallToolResult {
|
|
graphQLErr := newGitHubGraphQLError(message, err)
|
|
if ctx != nil {
|
|
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling
|
|
}
|
|
return utils.NewToolResultErrorFromErr(message, err)
|
|
}
|
|
|
|
// NewGitHubRawAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
|
|
func NewGitHubRawAPIErrorResponse(ctx context.Context, message string, resp *http.Response, err error) *mcp.CallToolResult {
|
|
rawErr := newGitHubRawAPIError(message, resp, err)
|
|
if ctx != nil {
|
|
_, _ = addRawAPIErrorToContext(ctx, rawErr) // Explicitly ignore error for graceful handling
|
|
}
|
|
return utils.NewToolResultErrorFromErr(message, err)
|
|
}
|
|
|
|
// NewGitHubAPIStatusErrorResponse handles cases where the API call succeeds (err == nil)
|
|
// but returns an unexpected HTTP status code. It creates a synthetic error from the
|
|
// status code and response body, then records it in context for observability tracking.
|
|
func NewGitHubAPIStatusErrorResponse(ctx context.Context, message string, resp *github.Response, body []byte) *mcp.CallToolResult {
|
|
err := fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
|
return NewGitHubAPIErrorResponse(ctx, message, resp, err)
|
|
}
|
|
|
|
// StructuredResolutionError is a machine-readable error returned by name-resolution
|
|
// helpers (e.g. resolving a project field or single-select option by name). Agents
|
|
// can parse the JSON body to self-correct without re-prompting.
|
|
//
|
|
// Kind values:
|
|
// - "field_not_found" — no project field matches the supplied name
|
|
// - "field_ambiguous" — more than one project field shares the supplied name
|
|
// - "option_not_found" — no option on the resolved single-select field matches
|
|
// - "option_ambiguous" — duplicate option names on the resolved field
|
|
// - "item_not_in_project" — the issue/PR exists but is not an item on the project
|
|
// - "wrong_field_type" — the named field is not the data type the caller expected
|
|
type StructuredResolutionError struct {
|
|
Kind string `json:"error"`
|
|
Name string `json:"name,omitempty"`
|
|
Field string `json:"field,omitempty"`
|
|
Candidates []any `json:"candidates,omitempty"`
|
|
Hint string `json:"hint,omitempty"`
|
|
}
|
|
|
|
// Error implements the error interface; the message is the JSON body so that the
|
|
// downstream tool result also carries the structured payload as plain text.
|
|
func (e *StructuredResolutionError) Error() string {
|
|
b, err := json.Marshal(e)
|
|
if err != nil {
|
|
return fmt.Sprintf(`{"error":%q,"name":%q}`, e.Kind, e.Name)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// NewStructuredResolutionError constructs a StructuredResolutionError.
|
|
func NewStructuredResolutionError(kind, name, hint string, candidates []any) *StructuredResolutionError {
|
|
return &StructuredResolutionError{
|
|
Kind: kind,
|
|
Name: name,
|
|
Hint: hint,
|
|
Candidates: candidates,
|
|
}
|
|
}
|
|
|
|
// NewStructuredResolutionErrorResponse returns an mcp.CallToolResult whose text body
|
|
// is the JSON-serialised StructuredResolutionError, suitable for agent self-correction.
|
|
func NewStructuredResolutionErrorResponse(err *StructuredResolutionError) *mcp.CallToolResult {
|
|
return utils.NewToolResultError(err.Error())
|
|
}
|