diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d757651592..ce3bc6bcf8 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -2,8 +2,10 @@ package errors import ( "context" + "errors" "fmt" "net/http" + "time" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v82/github" @@ -159,6 +161,25 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github if ctx != nil { _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } + + // Handle rate limit errors with clear, actionable messages + var rateLimitErr *github.RateLimitError + if errors.As(err, &rateLimitErr) { + retryIn := time.Until(rateLimitErr.Rate.Reset.Time).Round(time.Second) + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn)) + } + + var abuseErr *github.AbuseRateLimitError + if errors.As(err, &abuseErr) { + if abuseErr.RetryAfter != nil { + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Retry after %v.", message, abuseErr.RetryAfter.Round(time.Second))) + } + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Wait before retrying.", message)) + } + return utils.NewToolResultErrorFromErr(message, err) } diff --git a/pkg/github/actions.go b/pkg/github/actions.go index c3b5bb8c71..aafefb6ca5 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -125,6 +125,61 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo return utils.NewToolResultText(string(r)), nil, nil } +// handleAllJobLogs gets logs for all jobs in a workflow run +func handleAllJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { + // First, get all jobs for the workflow run + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ + Filter: "latest", + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if len(jobs.Jobs) == 0 { + result := map[string]any{ + "message": "No jobs found in this workflow run", + "run_id": runID, + "total_jobs": 0, + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } + + // Collect logs for all jobs + var logResults []map[string]any + for _, job := range jobs.Jobs { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) + if err != nil { + // Continue with other jobs even if one fails + jobResult = map[string]any{ + "job_id": job.GetID(), + "job_name": job.GetName(), + "error": err.Error(), + } + // Enable reporting of status codes and error causes + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling + } + + logResults = append(logResults, jobResult) + } + + result := map[string]any{ + "message": fmt.Sprintf("Retrieved logs for %d jobs", len(jobs.Jobs)), + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "logs": logResults, + "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + // getJobLogData retrieves log data for a single job, either as URL or content func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { // Get the download URL for the job logs @@ -716,19 +771,20 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i if failedOnly && runID == 0 { return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil } - if !failedOnly && jobID == 0 { - return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil - } - if failedOnly && runID > 0 { - // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + if runID > 0 { + if failedOnly { + // Handle failed-only mode: get logs for all failed jobs in the workflow run + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + } + // Handle all jobs mode: get logs for all jobs in the workflow run + return handleAllJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) } else if jobID > 0 { // Handle single job mode return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) } - return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil + return utils.NewToolResultError("Either job_id or run_id must be provided"), nil, nil }, ) return tool diff --git a/pkg/github/search.go b/pkg/github/search.go index d5ddb4a72a..d3b954b715 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -173,7 +173,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "query": { Type: "string", - Description: "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.", + Description: "Search query using GitHub code search syntax. Qualifiers: repo:owner/repo, org:name, language:Go, path:src/*.js, path:/regex/, symbol:Name, content:term, is:archived|fork. Boolean: AND, OR, NOT, parentheses. Regex: surround with slashes e.g. /sparse.*index/. Glob in path: path:*.ts, path:/src/**/*.js. Examples: 'MyFunc language:go repo:owner/repo', 'symbol:WithContext language:go', '/GetAttributes|SetAttributes/ repo:owner/repo', '(Foo OR Bar) path:src repo:owner/repo'.", }, "sort": { Type: "string",