Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/remote-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
<!-- START AUTOMATED TOOLSETS -->
| 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) |
Expand Down
40 changes: 40 additions & 0 deletions pkg/github/__toolsnaps__/get_pull_request_ci_failures.snap
Original file line number Diff line number Diff line change
@@ -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"
}
241 changes: 241 additions & 0 deletions pkg/github/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading