Skip to content

Commit 1b864f2

Browse files
committed
Add get_pull_request_ci_failures tool to retrieve failed CI job logs for PRs
This tool allows AI agents to automatically find and retrieve CI failure logs for a pull request without manual copy-pasting from the GitHub web UI. Features: - Finds workflow runs triggered by a PR using the head SHA - Identifies failed workflow runs (failure, timed_out, cancelled) - Collects logs from all failed jobs with failed step information - Returns log content by default (configurable via return_content) - Supports tail_lines parameter to limit log output The tool is added to both pull_requests (default) and actions toolsets.
1 parent fa2d802 commit 1b864f2

File tree

6 files changed

+719
-1
lines changed

6 files changed

+719
-1
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,13 @@ The following sets of tools are available:
508508
- `run_id`: Workflow run ID (required when using failed_only) (number, optional)
509509
- `tail_lines`: Number of lines to return from the end of the log (number, optional)
510510

511+
- **get_pull_request_ci_failures** - Get PR CI failures
512+
- `owner`: Repository owner (string, required)
513+
- `pullNumber`: Pull request number (number, required)
514+
- `repo`: Repository name (string, required)
515+
- `return_content`: Returns actual log content instead of URLs (default: true) (boolean, optional)
516+
- `tail_lines`: Number of lines to return from the end of each log (default: 500) (number, optional)
517+
511518
- **get_workflow_run** - Get workflow run
512519
- `owner`: Repository owner (string, required)
513520
- `repo`: Repository name (string, required)
@@ -959,6 +966,13 @@ Options are:
959966
- `repo`: Repository name (string, required)
960967
- `title`: PR title (string, required)
961968

969+
- **get_pull_request_ci_failures** - Get PR CI failures
970+
- `owner`: Repository owner (string, required)
971+
- `pullNumber`: Pull request number (number, required)
972+
- `repo`: Repository name (string, required)
973+
- `return_content`: Returns actual log content instead of URLs (default: true) (boolean, optional)
974+
- `tail_lines`: Number of lines to return from the end of each log (default: 500) (number, optional)
975+
962976
- **list_pull_requests** - List pull requests
963977
- `base`: Filter by base branch (string, optional)
964978
- `direction`: Sort direction (string, optional)

docs/remote-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
1919
<!-- START AUTOMATED TOOLSETS -->
2020
| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |
2121
|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
22-
| 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) |
22+
| 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) |
2323
| 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) |
2424
| 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) |
2525
| 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) |
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "Get PR CI failures"
5+
},
6+
"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.",
7+
"inputSchema": {
8+
"type": "object",
9+
"required": [
10+
"owner",
11+
"repo",
12+
"pullNumber"
13+
],
14+
"properties": {
15+
"owner": {
16+
"type": "string",
17+
"description": "Repository owner"
18+
},
19+
"pullNumber": {
20+
"type": "number",
21+
"description": "Pull request number"
22+
},
23+
"repo": {
24+
"type": "string",
25+
"description": "Repository name"
26+
},
27+
"return_content": {
28+
"type": "boolean",
29+
"description": "Returns actual log content instead of URLs (default: true)",
30+
"default": true
31+
},
32+
"tail_lines": {
33+
"type": "number",
34+
"description": "Number of lines to return from the end of each log (default: 500)",
35+
"default": 500
36+
}
37+
}
38+
},
39+
"name": "get_pull_request_ci_failures"
40+
}

pkg/github/actions.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,3 +1328,241 @@ func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelper
13281328
return utils.NewToolResultText(string(r)), nil, nil
13291329
}
13301330
}
1331+
1332+
// GetPullRequestCIFailures creates a tool to get failed CI job logs for a pull request
1333+
func GetPullRequestCIFailures(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
1334+
return mcp.Tool{
1335+
Name: "get_pull_request_ci_failures",
1336+
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."),
1337+
Annotations: &mcp.ToolAnnotations{
1338+
Title: t("TOOL_GET_PR_CI_FAILURES_USER_TITLE", "Get PR CI failures"),
1339+
ReadOnlyHint: true,
1340+
},
1341+
InputSchema: &jsonschema.Schema{
1342+
Type: "object",
1343+
Properties: map[string]*jsonschema.Schema{
1344+
"owner": {
1345+
Type: "string",
1346+
Description: DescriptionRepositoryOwner,
1347+
},
1348+
"repo": {
1349+
Type: "string",
1350+
Description: DescriptionRepositoryName,
1351+
},
1352+
"pullNumber": {
1353+
Type: "number",
1354+
Description: "Pull request number",
1355+
},
1356+
"return_content": {
1357+
Type: "boolean",
1358+
Description: "Returns actual log content instead of URLs (default: true)",
1359+
Default: json.RawMessage(`true`),
1360+
},
1361+
"tail_lines": {
1362+
Type: "number",
1363+
Description: "Number of lines to return from the end of each log (default: 500)",
1364+
Default: json.RawMessage(`500`),
1365+
},
1366+
},
1367+
Required: []string{"owner", "repo", "pullNumber"},
1368+
},
1369+
},
1370+
func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1371+
owner, err := RequiredParam[string](args, "owner")
1372+
if err != nil {
1373+
return utils.NewToolResultError(err.Error()), nil, nil
1374+
}
1375+
repo, err := RequiredParam[string](args, "repo")
1376+
if err != nil {
1377+
return utils.NewToolResultError(err.Error()), nil, nil
1378+
}
1379+
pullNumber, err := RequiredInt(args, "pullNumber")
1380+
if err != nil {
1381+
return utils.NewToolResultError(err.Error()), nil, nil
1382+
}
1383+
1384+
// Get optional parameters with defaults
1385+
returnContent, err := OptionalBoolParamWithDefault(args, "return_content", true)
1386+
if err != nil {
1387+
return utils.NewToolResultError(err.Error()), nil, nil
1388+
}
1389+
tailLines, err := OptionalIntParamWithDefault(args, "tail_lines", 500)
1390+
if err != nil {
1391+
return utils.NewToolResultError(err.Error()), nil, nil
1392+
}
1393+
1394+
client, err := getClient(ctx)
1395+
if err != nil {
1396+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
1397+
}
1398+
1399+
// Step 1: Get the PR to find the head SHA
1400+
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
1401+
if err != nil {
1402+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err), nil, nil
1403+
}
1404+
defer func() { _ = resp.Body.Close() }()
1405+
1406+
headSHA := pr.GetHead().GetSHA()
1407+
headBranch := pr.GetHead().GetRef()
1408+
1409+
if headSHA == "" {
1410+
return utils.NewToolResultError("Pull request has no head SHA"), nil, nil
1411+
}
1412+
1413+
// Step 2: List workflow runs for this SHA
1414+
workflowRuns, resp, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, &github.ListWorkflowRunsOptions{
1415+
HeadSHA: headSHA,
1416+
ListOptions: github.ListOptions{
1417+
PerPage: 100, // Get a good number of runs
1418+
},
1419+
})
1420+
if err != nil {
1421+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs", resp, err), nil, nil
1422+
}
1423+
defer func() { _ = resp.Body.Close() }()
1424+
1425+
if workflowRuns.GetTotalCount() == 0 {
1426+
result := map[string]any{
1427+
"message": "No workflow runs found for this pull request",
1428+
"pull_number": pullNumber,
1429+
"head_sha": headSHA,
1430+
"head_branch": headBranch,
1431+
}
1432+
r, _ := json.Marshal(result)
1433+
return utils.NewToolResultText(string(r)), nil, nil
1434+
}
1435+
1436+
// Step 3: Find failed workflow runs and collect their failed job logs
1437+
var failedRunResults []map[string]any
1438+
totalFailedJobs := 0
1439+
1440+
for _, run := range workflowRuns.WorkflowRuns {
1441+
// Only process failed or completed runs with failures
1442+
conclusion := run.GetConclusion()
1443+
if conclusion != "failure" && conclusion != "timed_out" && conclusion != "cancelled" {
1444+
continue
1445+
}
1446+
1447+
// Get failed job logs for this run
1448+
runResult, resp, err := getFailedJobsForRun(ctx, client, owner, repo, run, returnContent, tailLines, contentWindowSize)
1449+
if err != nil {
1450+
// Log error but continue with other runs
1451+
runResult = map[string]any{
1452+
"run_id": run.GetID(),
1453+
"run_name": run.GetName(),
1454+
"workflow": run.GetWorkflowID(),
1455+
"error": err.Error(),
1456+
}
1457+
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err)
1458+
}
1459+
1460+
if failedJobCount, ok := runResult["failed_jobs"].(int); ok {
1461+
totalFailedJobs += failedJobCount
1462+
}
1463+
1464+
failedRunResults = append(failedRunResults, runResult)
1465+
}
1466+
1467+
if len(failedRunResults) == 0 {
1468+
result := map[string]any{
1469+
"message": "No failed workflow runs found for this pull request",
1470+
"pull_number": pullNumber,
1471+
"head_sha": headSHA,
1472+
"head_branch": headBranch,
1473+
"total_runs": workflowRuns.GetTotalCount(),
1474+
}
1475+
r, _ := json.Marshal(result)
1476+
return utils.NewToolResultText(string(r)), nil, nil
1477+
}
1478+
1479+
result := map[string]any{
1480+
"message": fmt.Sprintf("Found %d failed workflow run(s) with %d failed job(s)", len(failedRunResults), totalFailedJobs),
1481+
"pull_number": pullNumber,
1482+
"head_sha": headSHA,
1483+
"head_branch": headBranch,
1484+
"total_runs": workflowRuns.GetTotalCount(),
1485+
"failed_runs": len(failedRunResults),
1486+
"total_failed_jobs": totalFailedJobs,
1487+
"workflow_runs": failedRunResults,
1488+
"return_format": map[string]bool{"content": returnContent, "urls": !returnContent},
1489+
}
1490+
1491+
r, err := json.Marshal(result)
1492+
if err != nil {
1493+
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
1494+
}
1495+
1496+
return utils.NewToolResultText(string(r)), nil, nil
1497+
}
1498+
}
1499+
1500+
// getFailedJobsForRun gets the failed jobs and their logs for a specific workflow run
1501+
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) {
1502+
runID := run.GetID()
1503+
1504+
// Get all jobs for this run
1505+
jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{
1506+
Filter: "latest",
1507+
})
1508+
if err != nil {
1509+
return nil, resp, fmt.Errorf("failed to list workflow jobs for run %d: %w", runID, err)
1510+
}
1511+
defer func() { _ = resp.Body.Close() }()
1512+
1513+
// Filter for failed jobs
1514+
var failedJobs []*github.WorkflowJob
1515+
for _, job := range jobs.Jobs {
1516+
jobConclusion := job.GetConclusion()
1517+
if jobConclusion == "failure" || jobConclusion == "timed_out" || jobConclusion == "cancelled" {
1518+
failedJobs = append(failedJobs, job)
1519+
}
1520+
}
1521+
1522+
// Collect logs for failed jobs
1523+
var jobLogs []map[string]any
1524+
for _, job := range failedJobs {
1525+
jobResult, _, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize)
1526+
if err != nil {
1527+
// Include error info but continue
1528+
jobResult = map[string]any{
1529+
"job_id": job.GetID(),
1530+
"job_name": job.GetName(),
1531+
"conclusion": job.GetConclusion(),
1532+
"error": err.Error(),
1533+
}
1534+
} else {
1535+
// Add conclusion to result
1536+
jobResult["conclusion"] = job.GetConclusion()
1537+
// Add failed step information if available
1538+
var failedSteps []map[string]any
1539+
for _, step := range job.Steps {
1540+
if step.GetConclusion() == "failure" {
1541+
failedSteps = append(failedSteps, map[string]any{
1542+
"name": step.GetName(),
1543+
"number": step.GetNumber(),
1544+
"conclusion": step.GetConclusion(),
1545+
})
1546+
}
1547+
}
1548+
if len(failedSteps) > 0 {
1549+
jobResult["failed_steps"] = failedSteps
1550+
}
1551+
}
1552+
jobLogs = append(jobLogs, jobResult)
1553+
}
1554+
1555+
result := map[string]any{
1556+
"run_id": runID,
1557+
"run_name": run.GetName(),
1558+
"workflow_id": run.GetWorkflowID(),
1559+
"html_url": run.GetHTMLURL(),
1560+
"conclusion": run.GetConclusion(),
1561+
"status": run.GetStatus(),
1562+
"total_jobs": len(jobs.Jobs),
1563+
"failed_jobs": len(failedJobs),
1564+
"jobs": jobLogs,
1565+
}
1566+
1567+
return result, resp, nil
1568+
}

0 commit comments

Comments
 (0)