diff --git a/README.md b/README.md index ed6c6f549..506deb189 100644 --- a/README.md +++ b/README.md @@ -508,6 +508,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) @@ -959,6 +966,13 @@ Options are: - `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 1030911ef..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) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Default | ["Default" toolset](../README.md#default-toolset) | 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/github/__toolsnaps__/get_pull_request_ci_failures.snap b/pkg/github/__toolsnaps__/get_pull_request_ci_failures.snap new file mode 100644 index 000000000..6c1abbf37 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_pull_request_ci_failures.snap @@ -0,0 +1,40 @@ +{ + "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": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "pullNumber": { + "type": "number", + "description": "Pull request number" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "return_content": { + "type": "boolean", + "description": "Returns actual log content instead of URLs (default: true)", + "default": true + }, + "tail_lines": { + "type": "number", + "description": "Number of lines to return from the end of each log (default: 500)", + "default": 500 + } + } + }, + "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..e109be9c3 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -1328,3 +1328,244 @@ 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", + }, + "return_content": { + Type: "boolean", + Description: "Returns actual log content instead of URLs (default: true)", + Default: json.RawMessage(`true`), + }, + "tail_lines": { + Type: "number", + Description: "Number of lines to return from the end of each log (default: 500)", + Default: json.RawMessage(`500`), + }, + }, + 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 + returnContent, err := OptionalBoolParamWithDefault(args, "return_content", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + tailLines, err := OptionalIntParamWithDefault(args, "tail_lines", 500) + 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 + 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() + headBranch := pr.GetHead().GetRef() + + if headSHA == "" { + return utils.NewToolResultError("Pull request has no head SHA"), nil, nil + } + + // Step 2: List workflow runs for this SHA + workflowRuns, resp, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, &github.ListWorkflowRunsOptions{ + HeadSHA: headSHA, + ListOptions: github.ListOptions{ + PerPage: 100, // Get a good number of runs + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if workflowRuns.GetTotalCount() == 0 { + result := map[string]any{ + "message": "No workflow runs found for this pull request", + "pull_number": pullNumber, + "head_sha": headSHA, + "head_branch": headBranch, + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } + + // Step 3: Find failed workflow runs and collect their failed job logs + var failedRunResults []map[string]any + totalFailedJobs := 0 + + for _, run := range workflowRuns.WorkflowRuns { + // Only process failed or completed runs with failures + conclusion := run.GetConclusion() + if conclusion != "failure" && conclusion != "timed_out" && conclusion != "cancelled" { + continue + } + + // Get failed job logs for this run + runResult, resp, err := getFailedJobsForRun(ctx, client, owner, repo, run, returnContent, tailLines, contentWindowSize) + if err != nil { + // Log error but continue with other runs + runResult = map[string]any{ + "run_id": run.GetID(), + "run_name": run.GetName(), + "workflow": run.GetWorkflowID(), + "error": err.Error(), + } + ctx, err2 := ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) + if err2 != nil { + fmt.Printf("failed to record GitHub API error in context: %v\n", err2) + } + } + + if failedJobCount, ok := runResult["failed_jobs"].(int); ok { + totalFailedJobs += failedJobCount + } + + failedRunResults = append(failedRunResults, runResult) + } + + if len(failedRunResults) == 0 { + result := map[string]any{ + "message": "No failed workflow runs found for this pull request", + "pull_number": pullNumber, + "head_sha": headSHA, + "head_branch": headBranch, + "total_runs": workflowRuns.GetTotalCount(), + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } + + result := map[string]any{ + "message": fmt.Sprintf("Found %d failed workflow run(s) with %d failed job(s)", len(failedRunResults), totalFailedJobs), + "pull_number": pullNumber, + "head_sha": headSHA, + "head_branch": headBranch, + "total_runs": workflowRuns.GetTotalCount(), + "failed_runs": len(failedRunResults), + "total_failed_jobs": totalFailedJobs, + "workflow_runs": failedRunResults, + "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 + } +} + +// getFailedJobsForRun gets the failed jobs and their logs for a specific workflow run +func getFailedJobsForRun(ctx context.Context, client *github.Client, owner, repo string, run *github.WorkflowRun, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { + runID := run.GetID() + + // Get all jobs for this run + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ + Filter: "latest", + }) + if err != nil { + return nil, resp, fmt.Errorf("failed to list workflow jobs for run %d: %w", runID, err) + } + defer func() { _ = resp.Body.Close() }() + + // Filter for failed jobs + var failedJobs []*github.WorkflowJob + for _, job := range jobs.Jobs { + jobConclusion := job.GetConclusion() + if jobConclusion == "failure" || jobConclusion == "timed_out" || jobConclusion == "cancelled" { + failedJobs = append(failedJobs, job) + } + } + + // Collect logs for failed jobs + var jobLogs []map[string]any + for _, job := range failedJobs { + jobResult, _, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) + if err != nil { + // Include error info but continue + jobResult = map[string]any{ + "job_id": job.GetID(), + "job_name": job.GetName(), + "conclusion": job.GetConclusion(), + "error": err.Error(), + } + } else { + // Add conclusion to result + jobResult["conclusion"] = job.GetConclusion() + // Add failed step information if available + var failedSteps []map[string]any + for _, step := range job.Steps { + if step.GetConclusion() == "failure" { + failedSteps = append(failedSteps, map[string]any{ + "name": step.GetName(), + "number": step.GetNumber(), + "conclusion": step.GetConclusion(), + }) + } + } + if len(failedSteps) > 0 { + jobResult["failed_steps"] = failedSteps + } + } + jobLogs = append(jobLogs, jobResult) + } + + result := map[string]any{ + "run_id": runID, + "run_name": run.GetName(), + "workflow_id": run.GetWorkflowID(), + "html_url": run.GetHTMLURL(), + "conclusion": run.GetConclusion(), + "status": run.GetStatus(), + "total_jobs": len(jobs.Jobs), + "failed_jobs": len(failedJobs), + "jobs": jobLogs, + } + + return result, resp, nil +} diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 6d9921f2e..ee3d8eb05 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1440,3 +1440,427 @@ 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, "return_content") + assert.Contains(t, inputSchema.Properties, "tail_lines") + 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.Contains(t, response["message"], "Found 1 failed workflow run") + assert.Equal(t, float64(123), response["pull_number"]) + assert.Equal(t, "abc123sha", response["head_sha"]) + assert.Equal(t, "feature-branch", response["head_branch"]) + assert.Equal(t, float64(2), response["total_runs"]) + assert.Equal(t, float64(1), response["failed_runs"]) + 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 found for this pull request", 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 workflow runs found for this pull request", 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), + "return_content": true, + }) + args := map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(123), + "return_content": 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.Contains(t, response["message"], "Found 1 failed workflow run") + assert.Equal(t, float64(123), response["pull_number"]) + + // Verify that workflow runs are included + workflowRuns, ok := response["workflow_runs"].([]any) + assert.True(t, ok) + assert.Len(t, workflowRuns, 1) + + // Verify the first workflow run has jobs with log content + firstRun, ok := workflowRuns[0].(map[string]any) + assert.True(t, ok) + jobs, ok := firstRun["jobs"].([]any) + assert.True(t, ok) + assert.Len(t, jobs, 1) + + // Verify log content is present + firstJob, ok := jobs[0].(map[string]any) + assert.True(t, ok) + assert.Contains(t, firstJob, "logs_content") + assert.Contains(t, firstJob["logs_content"], "Error: test failed") +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d37af98b8..f5dd1123c 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, 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)),