Skip to content

refactor(errors): improve GitHubAPIError handling, unwrap support, and GoDoc documentation - #15

Merged
axeon3 merged 1 commit into
agentic-autofix/fix-code-scanning-alertsfrom
axeon3-patch-1
Oct 25, 2026
Merged

axeon3 merged 1 commit into
agentic-autofix/fix-code-scanning-alertsfrom
axeon3-patch-1

Conversation

@axeon3

@axeon3 axeon3 commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Refactors the errors package to standardize error formatting, support Go's error unwrapping chain, guard against nil-pointer dereferences and concurrent context mutations, and add complete GoDoc comments across all exported and internal types/helpers.
Changes

Documentation: Added package-level and symbol-level GoDoc comments for GitHubAPIError, GitHubGraphQLError, GitHubRawAPIError, GitHubCtxErrors, and all context/MCP response helpers.

Error Unwrapping & Formatting:

    Added Unwrap() error to GitHubAPIError, GitHubGraphQLError, and GitHubRawAPIError so callers can inspect wrapped errors via errors.Is and errors.As.

    Consolidated .Error() string formatting into formatErrorMessage to avoid allocating temporary fmt.Errorf("%w") wrappers and prevent %!w(<nil>) output when Err is nil.

Resilience & Thread Safety:

    Guarded NewGitHubAPIStatusErrorResponse, formatGitHubValidationError, StructuredResolutionError, and context accessors against nil pointers/contexts.

    Added a sync.Mutex and slice cloning to GitHubCtxErrors to prevent data races when multiple goroutines record or read errors from a shared request context

package errors fixed:

import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"time"

"github.com/github/github-mcp-server/v2/pkg/sanitize"
"github.com/github/github-mcp-server/v2/pkg/utils"
"github.com/google/go-github/v92/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 {
if e == nil {
return ""
}
if e.Err == nil {
return e.Message
}
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}

func (e *GitHubAPIError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}

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 {
if e == nil {
return ""
}
if e.Err == nil {
return e.Message
}
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}

func (e *GitHubGraphQLError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}

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 {
if e == nil {
return ""
}
if e.Err == nil {
return e.Message
}
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}

func (e *GitHubRawAPIError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}

type GitHubErrorKey struct{}

type GitHubCtxErrors struct {
mu sync.Mutex
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 && val != nil {
val.mu.Lock()
val.api = []*GitHubAPIError{}
val.graphQL = []*GitHubGraphQLError{}
val.raw = []*GitHubRawAPIError{}
val.mu.Unlock()
} else {
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 ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
defer val.mu.Unlock()
return slices.Clone(val.api), nil
}
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 ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
defer val.mu.Unlock()
return slices.Clone(val.graphQL), nil
}
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 ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
defer val.mu.Unlock()
return slices.Clone(val.raw), nil
}
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 ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
val.api = append(val.api, err)
val.mu.Unlock()
return ctx, nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError) (context.Context, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
val.graphQL = append(val.graphQL, err)
val.mu.Unlock()
return ctx, nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

func addRawAPIErrorToContext(ctx context.Context, err *GitHubRawAPIError) (context.Context, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
val.raw = append(val.raw, err)
val.mu.Unlock()
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) && rateLimitErr != nil {
	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) && abuseErr != nil {
	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, formatGitHubValidationError(resp, err))

}

// formatGitHubValidationError exposes the parsed fields of 422 responses without
// including request or response metadata from the underlying HTTP exchange.
func formatGitHubValidationError(resp *github.Response, err error) error {
var ghErr *github.ErrorResponse
if !stderrors.As(err, &ghErr) || ghErr == nil {
return err
}

statusCode := 0
if ghErr.Response != nil {
	statusCode = ghErr.Response.StatusCode
}
if statusCode == 0 && resp != nil && resp.Response != nil {
	statusCode = resp.StatusCode
}
if statusCode != http.StatusUnprocessableEntity {
	return err
}

parts := make([]string, 0, len(ghErr.Errors)+1)
if summary := sanitizeGitHubValidationText(ghErr.Message); summary != "" {
	parts = append(parts, summary)
}
for _, validationErr := range ghErr.Errors {
	if detail := formatGitHubValidationDetail(validationErr); detail != "" {
		parts = append(parts, detail)
	}
}

if len(parts) == 0 {
	return stderrors.New("GitHub API validation failed")
}
return stderrors.New(strings.Join(parts, "\n"))

}

func formatGitHubValidationDetail(validationErr github.Error) string {
resource := sanitizeGitHubValidationText(validationErr.Resource)
field := sanitizeGitHubValidationText(validationErr.Field)
code := sanitizeGitHubValidationText(validationErr.Code)
message := sanitizeGitHubValidationText(validationErr.Message)

location := strings.Trim(strings.Join([]string{resource, field}, "."), ".")
switch {
case location != "" && code != "":
	location += " (" + code + ")"
case location == "":
	location = code
}

switch {
case location != "" && message != "":
	return location + ": " + message
case message != "":
	return message
default:
	return location
}

}

func sanitizeGitHubValidationText(value string) string {
sanitized := sanitize.PlainText(value)
return strings.Join(strings.Fields(sanitized), " ")
}

// 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 {
var err error
if resp != nil && resp.Response != nil {
err = fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
} else {
err = fmt.Errorf("unexpected status: %s", 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 {
if e == nil {
return {"error":"unknown"}
}
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 {
if err == nil {
return utils.NewToolResultError({"error":"unknown"})
}
return utils.NewToolResultError(err.Error())
}

Signed-off-by: axe <axeonetwothree@gmail.com>

@axeon3 axeon3 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Package errors provides structured error wrappers, context-based error
// tracking for observability middleware, and MCP tool error response builders
// for GitHub REST, GraphQL, and raw HTTP API calls.
package errors

import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"time"

"github.com/github/github-mcp-server/v2/pkg/sanitize"
"github.com/github/github-mcp-server/v2/pkg/utils"
"github.com/google/go-github/v92/github"
"github.com/modelcontextprotocol/go-sdk/mcp"

)

// GitHubAPIError represents a failed GitHub REST API call made via the
// go-github client. It pairs a human-readable context message with the
// underlying github.Response and error for observability and inspection.
type GitHubAPIError struct {
Message string json:"message"
Response *github.Response json:"-"
Err error json:"-"
}

// newGitHubAPIError creates a new GitHubAPIError with the provided context
// message, GitHub API response, and underlying error.
func newGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
return &GitHubAPIError{
Message: message,
Response: resp,
Err: err,
}
}

// Error implements the error interface, formatting the context message and
// underlying error.
func (e *GitHubAPIError) Error() string {
if e == nil {
return ""
}
return formatErrorMessage(e.Message, e.Err)
}

// Unwrap returns the underlying error for use with errors.Is and errors.As.
func (e *GitHubAPIError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}

// GitHubGraphQLError represents a failed GitHub GraphQL API query or mutation.
// It pairs a human-readable context message with the underlying error.
type GitHubGraphQLError struct {
Message string json:"message"
Err error json:"-"
}

// newGitHubGraphQLError creates a new GitHubGraphQLError with the provided
// context message and underlying error.
func newGitHubGraphQLError(message string, err error) *GitHubGraphQLError {
return &GitHubGraphQLError{
Message: message,
Err: err,
}
}

// Error implements the error interface, formatting the context message and
// underlying error.
func (e *GitHubGraphQLError) Error() string {
if e == nil {
return ""
}
return formatErrorMessage(e.Message, e.Err)
}

// Unwrap returns the underlying error for use with errors.Is and errors.As.
func (e *GitHubGraphQLError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}

// GitHubRawAPIError represents a failed raw HTTP request to a GitHub endpoint
// (outside the standard go-github typed client). It retains the raw
// http.Response and underlying error for middleware inspection.
type GitHubRawAPIError struct {
Message string json:"message"
Response *http.Response json:"-"
Err error json:"-"
}

// newGitHubRawAPIError creates a new GitHubRawAPIError with the provided
// context message, raw HTTP response, and underlying error.
func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
return &GitHubRawAPIError{
Message: message,
Response: resp,
Err: err,
}
}

// Error implements the error interface, formatting the context message and
// underlying error.
func (e *GitHubRawAPIError) Error() string {
if e == nil {
return ""
}
return formatErrorMessage(e.Message, e.Err)
}

// Unwrap returns the underlying error for use with errors.Is and errors.As.
func (e *GitHubRawAPIError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}

// formatErrorMessage formats a context message and an optional underlying error.
func formatErrorMessage(message string, err error) string {
switch {
case message != "" && err != nil:
return fmt.Sprintf("%s: %v", message, err)
case err != nil:
return err.Error()
default:
return message
}
}

// GitHubErrorKey is the context key used to store and retrieve GitHubCtxErrors.
type GitHubErrorKey struct{}

// GitHubCtxErrors holds slices of REST, GraphQL, and raw HTTP errors collected
// during the lifecycle of a request context for downstream middleware logging
// and telemetry.
type GitHubCtxErrors struct {
mu sync.Mutex
api []*GitHubAPIError
graphQL []*GitHubGraphQLError
raw []*GitHubRawAPIError
}

// ContextWithGitHubErrors initializes or resets a GitHubCtxErrors container in
// the provided context so middleware and tool handlers can record GitHub API
// errors during request execution.
func ContextWithGitHubErrors(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
val.api = []*GitHubAPIError{}
val.graphQL = []*GitHubGraphQLError{}
val.raw = []*GitHubRawAPIError{}
val.mu.Unlock()
} else {
ctx = context.WithValue(ctx, GitHubErrorKey{}, &GitHubCtxErrors{})
}

return ctx

}

// GetGitHubAPIErrors retrieves a copy of the GitHubAPIError slice recorded in
// the context. It returns an error if the context is nil or not initialized
// with ContextWithGitHubErrors.
func GetGitHubAPIErrors(ctx context.Context) ([]*GitHubAPIError, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
defer val.mu.Unlock()
return slices.Clone(val.api), nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

// GetGitHubGraphQLErrors retrieves a copy of the GitHubGraphQLError slice
// recorded in the context. It returns an error if the context is nil or not
// initialized with ContextWithGitHubErrors.
func GetGitHubGraphQLErrors(ctx context.Context) ([]*GitHubGraphQLError, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
defer val.mu.Unlock()
return slices.Clone(val.graphQL), nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

// GetGitHubRawAPIErrors retrieves a copy of the GitHubRawAPIError slice
// recorded in the context. It returns an error if the context is nil or not
// initialized with ContextWithGitHubErrors.
func GetGitHubRawAPIErrors(ctx context.Context) ([]*GitHubRawAPIError, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
defer val.mu.Unlock()
return slices.Clone(val.raw), nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

// NewGitHubAPIErrorToCtx records a new GitHubAPIError in the context without
// generating an MCP tool error response. If the context does not contain
// GitHubCtxErrors, the error is silently ignored for graceful degradation.
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)
}
return ctx, nil
}

// NewGitHubGraphQLErrorToCtx records a new GitHubGraphQLError in the context
// without generating an MCP tool error response. If the context does not
// contain GitHubCtxErrors, the error is silently ignored for graceful
// degradation.
func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) {
graphQLErr := newGitHubGraphQLError(message, err)
if ctx != nil {
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr)
}
return ctx, nil
}

func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
val.api = append(val.api, err)
val.mu.Unlock()
return ctx, nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError) (context.Context, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
val.graphQL = append(val.graphQL, err)
val.mu.Unlock()
return ctx, nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

func addRawAPIErrorToContext(ctx context.Context, err *GitHubRawAPIError) (context.Context, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok && val != nil {
val.mu.Lock()
val.raw = append(val.raw, err)
val.mu.Unlock()
return ctx, nil
}
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
}

// NewGitHubAPIErrorResponse records a GitHubAPIError in the context for
// middleware observability and returns a formatted MCP CallToolResult error.
// It provides specialized actionable messages for primary rate limits,
// secondary (abuse) rate limits, and 422 validation errors.
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)
}

var rateLimitErr *github.RateLimitError
if stderrors.As(err, &rateLimitErr) && rateLimitErr != nil {
	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) && abuseErr != nil {
	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, formatGitHubValidationError(resp, err))

}

// formatGitHubValidationError extracts and sanitizes field-level validation
// details from HTTP 422 (Unprocessable Entity) GitHub ErrorResponses while
// omitting raw HTTP exchange metadata.
func formatGitHubValidationError(resp *github.Response, err error) error {
var ghErr *github.ErrorResponse
if !stderrors.As(err, &ghErr) || ghErr == nil {
return err
}

statusCode := 0
if ghErr.Response != nil {
	statusCode = ghErr.Response.StatusCode
}
if statusCode == 0 && resp != nil && resp.Response != nil {
	statusCode = resp.StatusCode
}
if statusCode != http.StatusUnprocessableEntity {
	return err
}

parts := make([]string, 0, len(ghErr.Errors)+1)
if summary := sanitizeGitHubValidationText(ghErr.Message); summary != "" {
	parts = append(parts, summary)
}
for _, validationErr := range ghErr.Errors {
	if detail := formatGitHubValidationDetail(validationErr); detail != "" {
		parts = append(parts, detail)
	}
}

if len(parts) == 0 {
	return stderrors.New("GitHub API validation failed")
}
return stderrors.New(strings.Join(parts, "\n"))

}

// formatGitHubValidationDetail formats a single github.Error entry into a
// concise "Resource.field (code): message" string.
func formatGitHubValidationDetail(validationErr github.Error) string {
resource := sanitizeGitHubValidationText(validationErr.Resource)
field := sanitizeGitHubValidationText(validationErr.Field)
code := sanitizeGitHubValidationText(validationErr.Code)
message := sanitizeGitHubValidationText(validationErr.Message)

location := strings.Trim(strings.Join([]string{resource, field}, "."), ".")
switch {
case location != "" && code != "":
	location += " (" + code + ")"
case location == "":
	location = code
}

switch {
case location != "" && message != "":
	return location + ": " + message
case message != "":
	return message
default:
	return location
}

}

// sanitizeGitHubValidationText strips control/unsafe characters and collapses
// whitespace in validation error fields.
func sanitizeGitHubValidationText(value string) string {
sanitized := sanitize.PlainText(value)
return strings.Join(strings.Fields(sanitized), " ")
}

// NewGitHubGraphQLErrorResponse records a GitHubGraphQLError in the context
// for middleware observability and returns an MCP CallToolResult error.
func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err error) *mcp.CallToolResult {
graphQLErr := newGitHubGraphQLError(message, err)
if ctx != nil {
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr)
}
return utils.NewToolResultErrorFromErr(message, err)
}

// NewGitHubRawAPIErrorResponse records a GitHubRawAPIError in the context for
// middleware observability and returns an MCP CallToolResult error.
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)
}
return utils.NewToolResultErrorFromErr(message, err)
}

// NewGitHubAPIStatusErrorResponse handles cases where a GitHub REST API call
// returns a nil client error but an unexpected HTTP status code. It synthesizes
// an error from the status code and body, records it in the context, and
// returns an MCP CallToolResult error.
func NewGitHubAPIStatusErrorResponse(ctx context.Context, message string, resp *github.Response, body []byte) *mcp.CallToolResult {
var err error
if resp != nil && resp.Response != nil {
err = fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
} else {
err = fmt.Errorf("unexpected status: %s", 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 {
if e == nil {
return {"error":"unknown"}
}
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 {
if err == nil {
return utils.NewToolResultError({"error":"unknown"})
}
return utils.NewToolResultError(err.Error())
}

@axeon3
axeon3 merged commit 03892c1 into agentic-autofix/fix-code-scanning-alerts Oct 25, 2026
12 checks passed
@axeon3
axeon3 deleted the axeon3-patch-1 branch October 25, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant