diff --git a/README.md b/README.md index 3364d6f8c..0ae37191b 100644 --- a/README.md +++ b/README.md @@ -514,6 +514,13 @@ The following sets of tools are available: - `run_id`: Workflow run ID (required when using failed_only) (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) +- **get_pull_request_ci_failures** - Get PR CI failures + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + - `return_content`: Returns actual log content instead of URLs (default: true) (boolean, optional) + - `tail_lines`: Number of lines to return from the end of each log (default: 500) (number, optional) + - **get_workflow_run** - Get workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -965,6 +972,13 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `title`: PR title (string, required) +- **get_pull_request_ci_failures** - Get PR CI failures + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + - `return_content`: Returns actual log content instead of URLs (default: true) (boolean, optional) + - `tail_lines`: Number of lines to return from the end of each log (default: 500) (number, optional) + - **list_pull_requests** - List pull requests - `base`: Filter by base branch (string, optional) - `direction`: Sort direction (string, optional) diff --git a/docs/remote-server.md b/docs/remote-server.md index e06d41a75..7203f1728 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| Default | Default toolset (recommended for most users) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go index 546b5324c..f92eeb9c7 100644 --- a/pkg/buffer/buffer.go +++ b/pkg/buffer/buffer.go @@ -32,7 +32,7 @@ func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines in writeIndex := 0 scanner := bufio.NewScanner(httpResp.Body) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // 10MB max line size for scanner.Scan() { line := scanner.Text() diff --git a/pkg/github/__toolsnaps__/get_pull_request_ci_failures.snap b/pkg/github/__toolsnaps__/get_pull_request_ci_failures.snap new file mode 100644 index 000000000..8c84caeed --- /dev/null +++ b/pkg/github/__toolsnaps__/get_pull_request_ci_failures.snap @@ -0,0 +1,50 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get PR CI failures" + }, + "description": "Get failed CI workflow job logs for a pull request. This tool finds workflow runs triggered by a PR, identifies failed jobs, and retrieves their logs for debugging CI failures.", + "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "pullNumber" + ], + "properties": { + "include_annotations": { + "type": "boolean", + "description": "Include GitHub Check Run annotations - structured error messages with file/line info (default: true). Set to false to reduce output size.", + "default": true + }, + "include_logs": { + "type": "boolean", + "description": "Include tail of job logs for context (default: true). Set to false if annotations are sufficient or to reduce output size.", + "default": true + }, + "max_failed_jobs": { + "type": "number", + "description": "Maximum number of failed jobs to fetch details for (default: 3). Use 0 for no limit. Reduce if output is too large.", + "default": 3 + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "pullNumber": { + "type": "number", + "description": "Pull request number" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "tail_lines": { + "type": "number", + "description": "Number of log lines to include from end of each job (default: 100). Reduce if output is too large.", + "default": 100 + } + } + }, + "name": "get_pull_request_ci_failures" +} \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 81ed55296..c6d01ee72 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -684,18 +684,36 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con // handleFailedJobLogs gets logs for all failed jobs in a workflow run func handleFailedJobLogs(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{ + // First, get all jobs for the workflow run with pagination + var allJobs []*github.WorkflowJob + opts := &github.ListWorkflowJobsOptions{ Filter: "latest", - }) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + ListOptions: github.ListOptions{ + PerPage: 100, + Page: 1, + }, + } + + for { + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + + allJobs = append(allJobs, jobs.Jobs...) + + // Check if there are more pages + if resp.NextPage == 0 { + _ = resp.Body.Close() + break + } + _ = resp.Body.Close() + opts.Page = resp.NextPage } - defer func() { _ = resp.Body.Close() }() // Filter for failed jobs var failedJobs []*github.WorkflowJob - for _, job := range jobs.Jobs { + for _, job := range allJobs { if job.GetConclusion() == "failure" { failedJobs = append(failedJobs, job) } @@ -705,7 +723,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo result := map[string]any{ "message": "No failed jobs found in this workflow run", "run_id": runID, - "total_jobs": len(jobs.Jobs), + "total_jobs": len(allJobs), "failed_jobs": 0, } r, _ := json.Marshal(result) @@ -733,7 +751,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo result := map[string]any{ "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), "run_id": runID, - "total_jobs": len(jobs.Jobs), + "total_jobs": len(allJobs), "failed_jobs": len(failedJobs), "logs": logResults, "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, @@ -1328,3 +1346,526 @@ func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelper return utils.NewToolResultText(string(r)), nil, nil } } + +// GetPullRequestCIFailures creates a tool to get failed CI job logs for a pull request +func GetPullRequestCIFailures(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "get_pull_request_ci_failures", + Description: t("TOOL_GET_PR_CI_FAILURES_DESCRIPTION", "Get failed CI workflow job logs for a pull request. This tool finds workflow runs triggered by a PR, identifies failed jobs, and retrieves their logs for debugging CI failures."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_PR_CI_FAILURES_USER_TITLE", "Get PR CI failures"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: DescriptionRepositoryOwner, + }, + "repo": { + Type: "string", + Description: DescriptionRepositoryName, + }, + "pullNumber": { + Type: "number", + Description: "Pull request number", + }, + "max_failed_jobs": { + Type: "number", + Description: "Maximum number of failed jobs to fetch details for (default: 3). Use 0 for no limit. Reduce if output is too large.", + Default: json.RawMessage(`3`), + }, + "include_annotations": { + Type: "boolean", + Description: "Include GitHub Check Run annotations - structured error messages with file/line info (default: true). Set to false to reduce output size.", + Default: json.RawMessage(`true`), + }, + "include_logs": { + Type: "boolean", + Description: "Include tail of job logs for context (default: true). Set to false if annotations are sufficient or to reduce output size.", + Default: json.RawMessage(`true`), + }, + "tail_lines": { + Type: "number", + Description: "Number of log lines to include from end of each job (default: 100). Reduce if output is too large.", + Default: json.RawMessage(`100`), + }, + }, + Required: []string{"owner", "repo", "pullNumber"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get optional parameters with defaults + maxFailedJobs, err := OptionalIntParamWithDefault(args, "max_failed_jobs", 3) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + includeAnnotations, err := OptionalBoolParamWithDefault(args, "include_annotations", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + includeLogs, err := OptionalBoolParamWithDefault(args, "include_logs", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + tailLines, err := OptionalIntParamWithDefault(args, "tail_lines", 100) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Step 1: Get the PR to find the head SHA and merge commit SHA + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + headSHA := pr.GetHead().GetSHA() + mergeCommitSHA := pr.GetMergeCommitSHA() + + if headSHA == "" { + return utils.NewToolResultError("Pull request has no head SHA"), nil, nil + } + + // Step 2: List workflow runs for both head SHA and merge commit SHA + // Many CI workflows run on the merge commit (refs/pull//merge), not the head SHA + runsMap := make(map[int64]*github.WorkflowRun) + + // Query for head SHA + headSHARuns, resp, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, &github.ListWorkflowRunsOptions{ + HeadSHA: headSHA, + ListOptions: github.ListOptions{ + PerPage: 100, + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs for head SHA", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + for _, run := range headSHARuns.WorkflowRuns { + runsMap[run.GetID()] = run + } + + // Query for merge commit SHA if available (deduplicate by run ID) + if mergeCommitSHA != "" && mergeCommitSHA != headSHA { + mergeRuns, resp, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, &github.ListWorkflowRunsOptions{ + HeadSHA: mergeCommitSHA, + ListOptions: github.ListOptions{ + PerPage: 100, + }, + }) + if err != nil { + // Log error but continue - merge SHA runs are supplementary + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to list workflow runs for merge SHA", resp, err) + } else { + defer func() { _ = resp.Body.Close() }() + for _, run := range mergeRuns.WorkflowRuns { + runsMap[run.GetID()] = run + } + } + } + + // Step 3: Find failed workflow runs and collect their failed job logs + // Process failed workflow runs + var failedRunResults []map[string]any + totalJobsWithDetails := 0 + + for _, run := range runsMap { + if !isCIFailure(run.GetConclusion()) { + continue + } + + budget := -1 // unlimited + if maxFailedJobs > 0 { + budget = maxFailedJobs - totalJobsWithDetails + } + + runResult, resp, err := getFailedJobsForRun(ctx, client, owner, repo, run, includeAnnotations, includeLogs, tailLines, contentWindowSize, budget) + if err != nil { + runResult = map[string]any{"run_id": run.GetID(), "error": err.Error()} + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) + } + + if n, ok := runResult["jobs_with_details"].(int); ok { + totalJobsWithDetails += n + } + failedRunResults = append(failedRunResults, runResult) + } + + // Collect job IDs we already processed to avoid duplicates + processedIDs := make(map[int64]bool) + for _, runResult := range failedRunResults { + // Handle both []map[string]any and []any (Go doesn't auto-convert) + switch jobs := runResult["jobs"].(type) { + case []map[string]any: + for _, job := range jobs { + if id, ok := job["job_id"].(int64); ok { + processedIDs[id] = true + } + } + case []any: + for _, job := range jobs { + if jobMap, ok := job.(map[string]any); ok { + if id, ok := jobMap["job_id"].(int64); ok { + processedIDs[id] = true + } + } + } + } + } + + // Fetch check runs from the Checks API (e.g., dorny/test-reporter). + // IMPORTANT: these may be attached either to the PR head SHA or to the merge commit SHA, + // so we query both and de-duplicate by check_run_id. + var thirdPartyCheckRuns []map[string]any + if includeAnnotations { + remaining := -1 + if maxFailedJobs > 0 { + remaining = maxFailedJobs - totalJobsWithDetails + } + + thirdPartyCheckRunsByID := map[int64]map[string]any{} + addRuns := func(runs []map[string]any) { + for _, r := range runs { + if id, ok := r["check_run_id"].(int64); ok { + thirdPartyCheckRunsByID[id] = r + } + } + } + + // Check runs can be attached to: + // - head SHA + // - merge commit SHA + // - merge ref (refs/pull//merge) + refs := []string{headSHA} + if mergeCommitSHA != "" && mergeCommitSHA != headSHA { + refs = append(refs, mergeCommitSHA) + } + refs = append(refs, fmt.Sprintf("refs/pull/%d/merge", pullNumber)) + + for _, ref := range refs { + addRuns(getThirdPartyCheckRuns(ctx, client, owner, repo, ref, remaining, processedIDs)) + if remaining > 0 { + remaining = remaining - len(thirdPartyCheckRunsByID) + if remaining < 0 { + remaining = 0 + } + } + if remaining == 0 { + break + } + } + + for _, v := range thirdPartyCheckRunsByID { + thirdPartyCheckRuns = append(thirdPartyCheckRuns, v) + } + } + + if len(failedRunResults) == 0 && len(thirdPartyCheckRuns) == 0 { + result := map[string]any{ + "message": "No failed workflow runs or check runs found", + "pull_number": pullNumber, + "head_sha": headSHA, + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } + + result := map[string]any{ + "pull_number": pullNumber, + "head_sha": headSHA, + "workflow_runs": failedRunResults, + } + if len(thirdPartyCheckRuns) > 0 { + result["third_party_check_runs"] = thirdPartyCheckRuns + } + + 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 + } +} + +// isCIFailure returns true if the conclusion indicates a failure +func isCIFailure(conclusion string) bool { + return conclusion == "failure" || conclusion == "timed_out" || conclusion == "cancelled" +} + +func containsFailureMarkers(s string) bool { + ls := strings.ToLower(s) + return strings.Contains(ls, "failed") || + strings.Contains(ls, "failure") || + strings.Contains(ls, "error") || + strings.Contains(s, "❌") || + strings.Contains(ls, "✗") +} + +// getFailedJobsForRun gets the failed jobs and their logs/annotations for a specific workflow run +func getFailedJobsForRun(ctx context.Context, client *github.Client, owner, repo string, run *github.WorkflowRun, includeAnnotations, includeLogs bool, tailLines, contentWindowSize, maxJobsWithDetails int) (map[string]any, *github.Response, error) { + runID := run.GetID() + + // Get all jobs for this run with pagination + var allJobs []*github.WorkflowJob + var lastResp *github.Response + opts := &github.ListWorkflowJobsOptions{ + Filter: "latest", + ListOptions: github.ListOptions{PerPage: 100}, + } + + for { + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) + if err != nil { + return nil, resp, fmt.Errorf("failed to list workflow jobs for run %d: %w", runID, err) + } + lastResp = resp + allJobs = append(allJobs, jobs.Jobs...) + if resp.NextPage == 0 { + _ = resp.Body.Close() + break + } + _ = resp.Body.Close() + opts.Page = resp.NextPage + } + + // Process failed jobs + var jobResults []map[string]any + jobsWithDetails, jobsSkipped := 0, 0 + + for _, job := range allJobs { + if !isCIFailure(job.GetConclusion()) { + continue + } + + shouldFetchDetails := maxJobsWithDetails == -1 || jobsWithDetails < maxJobsWithDetails + jobResult := map[string]any{ + "job_id": job.GetID(), + "job_name": job.GetName(), + "conclusion": job.GetConclusion(), + "html_url": job.GetHTMLURL(), + } + + // Add failed steps + for _, step := range job.Steps { + if step.GetConclusion() == "failure" { + if jobResult["failed_steps"] == nil { + jobResult["failed_steps"] = []map[string]any{} + } + jobResult["failed_steps"] = append(jobResult["failed_steps"].([]map[string]any), map[string]any{ + "name": step.GetName(), + "number": step.GetNumber(), + }) + } + } + + if shouldFetchDetails { + addJobDetails(ctx, client, owner, repo, job.GetID(), jobResult, includeAnnotations, includeLogs, tailLines, contentWindowSize) + jobsWithDetails++ + } else { + jobResult["details_skipped"] = true + jobsSkipped++ + } + jobResults = append(jobResults, jobResult) + } + + return map[string]any{ + "run_id": runID, + "run_name": run.GetName(), + "html_url": run.GetHTMLURL(), + "conclusion": run.GetConclusion(), + "failed_jobs": len(jobResults), + "jobs_with_details": jobsWithDetails, + "jobs": jobResults, + }, lastResp, nil +} + +// addJobDetails adds annotations and/or logs to the job result map +func addJobDetails(ctx context.Context, client *github.Client, owner, repo string, jobID int64, result map[string]any, includeAnnotations, includeLogs bool, tailLines, contentWindowSize int) { + if includeAnnotations { + const maxJobAnnotations = 50 + if annotations, _ := fetchAnnotations(ctx, client, owner, repo, jobID, maxJobAnnotations); len(annotations) > 0 { + result["annotations"] = annotations + } + } + if includeLogs { + logData, _, err := getJobLogData(ctx, client, owner, repo, jobID, "", true, tailLines, contentWindowSize) + if err != nil { + result["logs_error"] = err.Error() + } else if content, ok := logData["logs_content"]; ok { + result["logs_tail"] = content + } + } +} + +// fetchAnnotations fetches check run annotations for a job/check run +func fetchAnnotations(ctx context.Context, client *github.Client, owner, repo string, checkRunID int64, limit int) ([]map[string]any, bool) { + var result []map[string]any + opts := &github.ListOptions{PerPage: 100} + if limit == 0 { + return nil, false + } + + for { + annotations, resp, err := client.Checks.ListCheckRunAnnotations(ctx, owner, repo, checkRunID, opts) + if err != nil { + return nil, false + } + for _, ann := range annotations { + a := map[string]any{"message": ann.GetMessage()} + if p := ann.GetPath(); p != "" { + a["path"] = p + } + if l := ann.GetStartLine(); l > 0 { + a["line"] = l + } + if t := ann.GetTitle(); t != "" { + a["title"] = t + } + result = append(result, a) + if limit > 0 && len(result) >= limit { + // We intentionally stop early to avoid large payloads. + _ = resp.Body.Close() + return result, true + } + } + if resp.NextPage == 0 { + _ = resp.Body.Close() + break + } + _ = resp.Body.Close() + opts.Page = resp.NextPage + } + return result, false +} + +// getThirdPartyCheckRuns fetches check runs from third-party tools (e.g., dorny/test-reporter) +// These tools create separate check runs with their own annotations (not attached to workflow jobs) +// excludeIDs contains workflow job IDs to skip (since workflow jobs are also check runs) +func getThirdPartyCheckRuns(ctx context.Context, client *github.Client, owner, repo, ref string, budget int, excludeIDs map[int64]bool) []map[string]any { + if budget <= 0 && budget != -1 { + return nil + } + + // Fetch all check runs with pagination + var allCheckRuns []*github.CheckRun + opts := &github.ListCheckRunsOptions{ + // "all" is important: some tools emit multiple runs and the "latest" view can hide the report we want. + Filter: github.Ptr("all"), + ListOptions: github.ListOptions{PerPage: 100}, + } + for { + checkRuns, resp, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, ref, opts) + if err != nil { + return nil + } + allCheckRuns = append(allCheckRuns, checkRuns.CheckRuns...) + if resp.NextPage == 0 { + _ = resp.Body.Close() + break + } + _ = resp.Body.Close() + opts.Page = resp.NextPage + } + + var result []map[string]any + processed := 0 + + for _, cr := range allCheckRuns { + // Skip workflow jobs we already processed (by ID) + if excludeIDs[cr.GetID()] { + continue + } + + appName := "" + if app := cr.GetApp(); app != nil { + appName = app.GetName() + } + + // Candidate selection (cheap): consider only check runs that either failed or have output content. + // Then decide whether to return them based on: + // - failure conclusion, OR + // - failure markers in output, OR + // - presence of annotations (detected via a tiny probe), which is common for test reporters. + hasOutput := false + title := "" + summary := "" + if output := cr.GetOutput(); output != nil { + title = output.GetTitle() + summary = output.GetSummary() + hasOutput = title != "" || summary != "" + } + conc := cr.GetConclusion() + if !isCIFailure(conc) && !hasOutput { + continue + } + + r := map[string]any{ + "check_run_id": cr.GetID(), + "name": cr.GetName(), + "conclusion": cr.GetConclusion(), + "html_url": cr.GetHTMLURL(), + } + if appName != "" { + r["app"] = appName + } + if budget == -1 || processed < budget { + // Always include (truncated) output summary/title when present; it's small and high-signal. + if title != "" { + r["title"] = title + } + if summary != "" { + if len(summary) > 4000 { + summary = summary[:4000] + "..." + } + r["summary"] = summary + } + + shouldReturn := isCIFailure(conc) || (hasOutput && containsFailureMarkers(title+"\n"+summary)) + if !shouldReturn && hasOutput { + probe, _ := fetchAnnotations(ctx, client, owner, repo, cr.GetID(), 1) + shouldReturn = len(probe) > 0 + } + if !shouldReturn { + continue + } + + // Fetch only a small tail of annotations to keep payload bounded. + const maxAnnotations = 50 + annotations, truncated := fetchAnnotations(ctx, client, owner, repo, cr.GetID(), maxAnnotations) + if len(annotations) > 0 { + r["annotations"] = annotations + } + if truncated { + r["annotations_truncated"] = true + } + processed++ + } else { + r["details_skipped"] = true + } + result = append(result, r) + } + return result +} + diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 6d9921f2e..7fea81f7c 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1440,3 +1440,1103 @@ func Test_RerunFailedJobs(t *testing.T) { assert.Contains(t, inputSchema.Properties, "run_id") assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) } + +func Test_GetPullRequestCIFailures(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetPullRequestCIFailures(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_pull_request_ci_failures", tool.Name) + assert.NotEmpty(t, tool.Description) + inputSchema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "repo") + assert.Contains(t, inputSchema.Properties, "pullNumber") + assert.Contains(t, inputSchema.Properties, "include_annotations") + assert.Contains(t, inputSchema.Properties, "include_logs") + assert.Contains(t, inputSchema.Properties, "tail_lines") + assert.Contains(t, inputSchema.Properties, "max_failed_jobs") + assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "pullNumber"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + checkResponse func(t *testing.T, response map[string]any) + }{ + { + name: "successful CI failure retrieval with failed jobs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(123), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abc123sha"), + Ref: github.Ptr("feature-branch"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(1001)), + Name: github.Ptr("CI"), + WorkflowID: github.Ptr(int64(100)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/runs/1001"), + }, + { + ID: github.Ptr(int64(1002)), + Name: github.Ptr("Deploy"), + WorkflowID: github.Ptr(int64(101)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/runs/1002"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(2001)), + Name: github.Ptr("test-job"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + Steps: []*github.TaskStep{ + { + Name: github.Ptr("Run tests"), + Number: github.Ptr(int64(3)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + }, + { + ID: github.Ptr(int64(2002)), + Name: github.Ptr("build-job"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/2001") + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, float64(123), response["pull_number"]) + assert.Equal(t, "abc123sha", response["head_sha"]) + assert.Contains(t, response, "workflow_runs") + }, + }, + { + name: "no failed workflow runs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(456), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("def456sha"), + Ref: github.Ptr("main"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(1001)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(456), + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, "No failed workflow runs or check runs found", response["message"]) + assert.Equal(t, float64(456), response["pull_number"]) + }, + }, + { + name: "no workflow runs found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(789), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("ghi789sha"), + Ref: github.Ptr("test-branch"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(0), + WorkflowRuns: []*github.WorkflowRun{}, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(789), + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, "No failed workflow runs or check runs found", response["message"]) + assert.Equal(t, float64(789), response["pull_number"]) + }, + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "repo": "repo", + "pullNumber": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "pullNumber": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "missing required parameter pullNumber", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: pullNumber", + }, + { + name: "PR not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Not Found", + }) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetPullRequestCIFailures(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, _, err := handler(context.Background(), &request, tc.requestArgs) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectError { + // For API errors, just verify we got an error + assert.True(t, result.IsError) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + if tc.checkResponse != nil { + tc.checkResponse(t, response) + } + }) + } +} + +func Test_GetPullRequestCIFailures_WithContentReturn(t *testing.T) { + logContent := "2023-01-01T10:00:00.000Z Error: test failed\n2023-01-01T10:00:01.000Z at TestClass.test(Test.java:42)" + + // Create a test server to serve log content + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(123), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abc123sha"), + Ref: github.Ptr("feature-branch"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(1001)), + Name: github.Ptr("CI"), + WorkflowID: github.Ptr(int64(100)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/runs/1001"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(1), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(2001)), + Name: github.Ptr("test-job"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetPullRequestCIFailures(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + "include_annotations": false, // Only test logs, not annotations + "include_logs": true, + }) + args := map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + "include_annotations": false, + "include_logs": true, + } + + result, _, err := handler(context.Background(), &request, args) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["pull_number"]) + + // Verify that workflow runs are included + workflowRuns, ok := response["workflow_runs"].([]any) + require.True(t, ok) + require.Len(t, workflowRuns, 1) + + // Verify the first workflow run has jobs with log content + firstRun, ok := workflowRuns[0].(map[string]any) + require.True(t, ok) + jobs, ok := firstRun["jobs"].([]any) + require.True(t, ok) + require.Len(t, jobs, 1) + + // Verify log content is present + firstJob, ok := jobs[0].(map[string]any) + require.True(t, ok) + assert.Contains(t, firstJob, "logs_tail") +} + +func Test_GetPullRequestCIFailures_WithAnnotations(t *testing.T) { + // Test the use_annotations functionality (default behavior) + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(123), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abc123sha"), + Ref: github.Ptr("feature-branch"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(1001)), + Name: github.Ptr("CI"), + WorkflowID: github.Ptr(int64(100)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/runs/1001"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(1), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(2001)), + Name: github.Ptr("test-job"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/runs/1001/jobs/2001"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/check-runs/2001/annotations", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + annotations := []*github.CheckRunAnnotation{ + { + Path: github.Ptr("src/test.js"), + StartLine: github.Ptr(42), + EndLine: github.Ptr(42), + AnnotationLevel: github.Ptr("failure"), + Message: github.Ptr("Expected true but got false"), + Title: github.Ptr("Test assertion failed"), + }, + { + Path: github.Ptr("src/auth.js"), + StartLine: github.Ptr(100), + EndLine: github.Ptr(105), + AnnotationLevel: github.Ptr("failure"), + Message: github.Ptr("TypeError: Cannot read property 'id' of undefined"), + Title: github.Ptr("Runtime error"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(annotations) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetPullRequestCIFailures(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + // Default use_annotations=true + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + }) + args := map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + } + + result, _, err := handler(context.Background(), &request, args) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["pull_number"]) + + // Verify that workflow runs are included + workflowRuns, ok := response["workflow_runs"].([]any) + require.True(t, ok) + require.Len(t, workflowRuns, 1) + + // Verify the first workflow run has jobs with annotations + firstRun, ok := workflowRuns[0].(map[string]any) + require.True(t, ok) + jobs, ok := firstRun["jobs"].([]any) + require.True(t, ok) + require.Len(t, jobs, 1) + + // Verify annotations are present + firstJob, ok := jobs[0].(map[string]any) + require.True(t, ok) + assert.Contains(t, firstJob, "annotations") + + // Check annotations content + annotations, ok := firstJob["annotations"].([]any) + require.True(t, ok) + assert.Len(t, annotations, 2) + + firstAnnotation, ok := annotations[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "src/test.js", firstAnnotation["path"]) + assert.Equal(t, float64(42), firstAnnotation["line"]) + assert.Equal(t, "Expected true but got false", firstAnnotation["message"]) + assert.Equal(t, "Test assertion failed", firstAnnotation["title"]) +} + +func Test_GetPullRequestCIFailures_MergeSHADiscovery(t *testing.T) { + // Test that workflows found only via merge commit SHA (not head SHA) are discovered + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(123), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("head-sha-abc"), + Ref: github.Ptr("feature-branch"), + }, + MergeCommitSHA: github.Ptr("merge-sha-xyz"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headSHA := r.URL.Query().Get("head_sha") + + var runs *github.WorkflowRuns + if headSHA == "head-sha-abc" { + // No runs found for head SHA + runs = &github.WorkflowRuns{ + TotalCount: github.Ptr(0), + WorkflowRuns: []*github.WorkflowRun{}, + } + } else if headSHA == "merge-sha-xyz" { + // Failed run found only for merge SHA + runs = &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(5001)), + Name: github.Ptr("Merge CI"), + WorkflowID: github.Ptr(int64(500)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/runs/5001"), + HeadSHA: github.Ptr("merge-sha-xyz"), + }, + }, + } + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(1), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(6001)), + Name: github.Ptr("merge-test-job"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/6001") + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetPullRequestCIFailures(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + }) + args := map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + } + + result, _, err := handler(context.Background(), &request, args) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify that the failed run from merge SHA was discovered + assert.Equal(t, float64(123), response["pull_number"]) + assert.Equal(t, "head-sha-abc", response["head_sha"]) + + // Verify the workflow run is from merge SHA + workflowRuns, ok := response["workflow_runs"].([]any) + require.True(t, ok) + require.Len(t, workflowRuns, 1) + + firstRun, ok := workflowRuns[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(5001), firstRun["run_id"]) + assert.Equal(t, "Merge CI", firstRun["run_name"]) +} + +func Test_GetPullRequestCIFailures_PaginatedJobs(t *testing.T) { + // Test that failed jobs appearing on page 2+ of ListWorkflowJobs are discovered + callCount := 0 + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(456), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("paginated-sha"), + Ref: github.Ptr("feature-branch"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(7001)), + Name: github.Ptr("Large CI"), + WorkflowID: github.Ptr(int64(700)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/runs/7001"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + page := r.URL.Query().Get("page") + + var jobs *github.Jobs + if page == "" || page == "1" { + // First page: 2 successful jobs + jobs = &github.Jobs{ + TotalCount: github.Ptr(4), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(8001)), + Name: github.Ptr("job-1"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(8002)), + Name: github.Ptr("job-2"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + }, + } + // Indicate there's a next page + w.Header().Set("Link", `; rel="next"`) + } else if page == "2" { + // Second page: 2 failed jobs - these should be discovered! + jobs = &github.Jobs{ + TotalCount: github.Ptr(4), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(8003)), + Name: github.Ptr("job-3-failed"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + { + ID: github.Ptr(int64(8004)), + Name: github.Ptr("job-4-failed"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + // No more pages + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetPullRequestCIFailures(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(456), + }) + args := map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(456), + } + + result, _, err := handler(context.Background(), &request, args) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify that both pages were fetched + assert.GreaterOrEqual(t, callCount, 2, "Should have fetched at least 2 pages of jobs") + + assert.Equal(t, float64(456), response["pull_number"]) + + // Verify workflow run details + workflowRuns, ok := response["workflow_runs"].([]any) + require.True(t, ok) + require.Len(t, workflowRuns, 1) + + firstRun, ok := workflowRuns[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(2), firstRun["failed_jobs"]) // 2 failed jobs from page 2 + + // Verify the failed jobs are present + jobLogs, ok := firstRun["jobs"].([]any) + require.True(t, ok) + assert.Len(t, jobLogs, 2) + + // Check job names + jobNames := make([]string, 0, 2) + for _, job := range jobLogs { + jobMap, ok := job.(map[string]any) + require.True(t, ok) + jobNames = append(jobNames, jobMap["job_name"].(string)) + } + assert.Contains(t, jobNames, "job-3-failed") + assert.Contains(t, jobNames, "job-4-failed") +} + +func Test_GetPullRequestCIFailures_ThirdPartyCheckRuns(t *testing.T) { + // Test that check runs from third-party tools (like dorny/test-reporter) are discovered + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + pr := &github.PullRequest{ + Number: github.Ptr(123), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("test-sha"), + Ref: github.Ptr("feature-branch"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(pr) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // No workflow runs - only third-party check runs exist + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(0), + WorkflowRuns: []*github.WorkflowRun{}, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/commits/test-sha/check-runs", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + checkRuns := &github.ListCheckRunsResults{ + Total: github.Ptr(2), + CheckRuns: []*github.CheckRun{ + { + ID: github.Ptr(int64(9001)), + Name: github.Ptr("Test Results"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + HTMLURL: github.Ptr("https://github.com/owner/repo/runs/9001"), + App: &github.App{ + Name: github.Ptr("dorny/test-reporter"), + }, + Output: &github.CheckRunOutput{ + Title: github.Ptr("3 tests failed"), + Summary: github.Ptr("## Failed Tests\n- test_login\n- test_logout\n- test_register"), + }, + }, + { + ID: github.Ptr(int64(9002)), + Name: github.Ptr("Coverage Report"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + App: &github.App{ + Name: github.Ptr("codecov"), + }, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(checkRuns) + }), + ), + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/check-runs/9001/annotations", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + annotations := []*github.CheckRunAnnotation{ + { + Path: github.Ptr("tests/auth/login.test.ts"), + StartLine: github.Ptr(42), + EndLine: github.Ptr(42), + AnnotationLevel: github.Ptr("failure"), + Message: github.Ptr("Expected status 200 but received 401"), + Title: github.Ptr("test_login failed"), + }, + { + Path: github.Ptr("tests/auth/logout.test.ts"), + StartLine: github.Ptr(15), + EndLine: github.Ptr(15), + AnnotationLevel: github.Ptr("failure"), + Message: github.Ptr("Session not properly cleared"), + Title: github.Ptr("test_logout failed"), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(annotations) + }), + ), + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/check-runs/9002/annotations", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Codecov has no annotations + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]*github.CheckRunAnnotation{}) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetPullRequestCIFailures(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + }) + args := map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + } + + result, _, err := handler(context.Background(), &request, args) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify third_party_check_runs is present + thirdPartyCheckRuns, ok := response["third_party_check_runs"].([]any) + require.True(t, ok, "third_party_check_runs should be present") + require.Len(t, thirdPartyCheckRuns, 1, "Should have 1 third-party check run (only the one with annotations/output)") + + // Verify the check run details + firstCheckRun, ok := thirdPartyCheckRuns[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "Test Results", firstCheckRun["name"]) + assert.Equal(t, "dorny/test-reporter", firstCheckRun["app"]) + assert.Contains(t, firstCheckRun["summary"], "Failed Tests") + + // Verify annotations from third-party check run + annotations, ok := firstCheckRun["annotations"].([]any) + require.True(t, ok) + assert.Len(t, annotations, 2) + + firstAnn, ok := annotations[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "tests/auth/login.test.ts", firstAnn["path"]) + assert.Equal(t, float64(42), firstAnn["line"]) + assert.Equal(t, "test_login failed", firstAnn["title"]) + assert.Contains(t, firstAnn["message"], "Expected status 200") +} + +func Test_GetJobLogs_FailedOnly_PaginatedJobs(t *testing.T) { + // Test that handleFailedJobLogs also properly paginates + callCount := 0 + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + page := r.URL.Query().Get("page") + + var jobs *github.Jobs + if page == "" || page == "1" { + // First page: 1 successful job + jobs = &github.Jobs{ + TotalCount: github.Ptr(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(9001)), + Name: github.Ptr("success-job"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + }, + } + // Indicate there's a next page + w.Header().Set("Link", `; rel="next"`) + } else if page == "2" { + // Second page: 2 failed jobs + jobs = &github.Jobs{ + TotalCount: github.Ptr(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(9002)), + Name: github.Ptr("failed-job-1"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + { + ID: github.Ptr(int64(9003)), + Name: github.Ptr("failed-job-2"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(999), + "failed_only": true, + }) + args := map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(999), + "failed_only": true, + } + + result, _, err := handler(context.Background(), &request, args) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify pagination occurred + assert.GreaterOrEqual(t, callCount, 2, "Should have fetched at least 2 pages") + + // Verify we found both failed jobs from page 2 + assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) + assert.Equal(t, float64(999), response["run_id"]) + assert.Equal(t, float64(3), response["total_jobs"]) + assert.Equal(t, float64(2), response["failed_jobs"]) + + // Verify the logs + logs, ok := response["logs"].([]any) + assert.True(t, ok) + assert.Len(t, logs, 2) + + // Check job names + jobNames := make([]string, 0, 2) + for _, log := range logs { + logMap, ok := log.(map[string]any) + assert.True(t, ok) + jobNames = append(jobNames, logMap["job_name"].(string)) + } + assert.Contains(t, jobNames, "failed-job-1") + assert.Contains(t, jobNames, "failed-job-2") +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index ffb7f1852..5666fca6d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -228,6 +228,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(PullRequestRead(getClient, getGQLClient, cache, t, flags)), toolsets.NewServerTool(ListPullRequests(getClient, t)), toolsets.NewServerTool(SearchPullRequests(getClient, t)), + toolsets.NewServerTool(GetPullRequestCIFailures(getClient, t, contentWindowSize)), ). AddWriteTools( toolsets.NewServerTool(MergePullRequest(getClient, t)), @@ -283,6 +284,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), + toolsets.NewServerTool(GetPullRequestCIFailures(getClient, t, contentWindowSize)), toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)),