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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,7 @@ The following sets of tools are available:
5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.
6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.
7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.
8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.
(string, required)
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
Expand Down
5 changes: 3 additions & 2 deletions pkg/github/__toolsnaps__/pull_request_read.snap
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
"inputSchema": {
"properties": {
"method": {
"description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n",
"description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n",
"enum": [
"get",
"get_diff",
"get_status",
"get_files",
"get_review_comments",
"get_reviews",
"get_comments"
"get_comments",
"get_check_runs"
],
"type": "string"
},
Expand Down
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const (
PostReposGitTreesByOwnerByRepo = "POST /repos/{owner}/{repo}/git/trees"
GetReposCommitsStatusByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/status"
GetReposCommitsStatusesByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/statuses"
GetReposCommitsCheckRunsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/check-runs"

// Issues endpoints
GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}"
Expand Down
39 changes: 39 additions & 0 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,42 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch {
Protected: branch.GetProtected(),
}
}

// MinimalCheckRun is the trimmed output type for check run objects.
type MinimalCheckRun struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
DetailsURL string `json:"details_url,omitempty"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}

// MinimalCheckRunsResult is the trimmed output type for check runs list results.
type MinimalCheckRunsResult struct {
TotalCount int `json:"total_count"`
CheckRuns []MinimalCheckRun `json:"check_runs"`
}

// convertToMinimalCheckRun converts a GitHub API CheckRun to MinimalCheckRun
func convertToMinimalCheckRun(checkRun *github.CheckRun) MinimalCheckRun {
minimalCheckRun := MinimalCheckRun{
ID: checkRun.GetID(),
Name: checkRun.GetName(),
Status: checkRun.GetStatus(),
Conclusion: checkRun.GetConclusion(),
HTMLURL: checkRun.GetHTMLURL(),
DetailsURL: checkRun.GetDetailsURL(),
}

if checkRun.StartedAt != nil {
minimalCheckRun.StartedAt = checkRun.StartedAt.Format("2006-01-02T15:04:05Z")
}
if checkRun.CompletedAt != nil {
minimalCheckRun.CompletedAt = checkRun.CompletedAt.Format("2006-01-02T15:04:05Z")
}

return minimalCheckRun
}
71 changes: 70 additions & 1 deletion pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ Possible options:
5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.
6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.
7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.
8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.
`,
Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments"},
Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"},
},
"owner": {
Type: "string",
Expand Down Expand Up @@ -129,6 +130,9 @@ Possible options:
case "get_comments":
result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, pagination, deps.GetFlags())
return result, nil, err
case "get_check_runs":
result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination)
return result, nil, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
Expand Down Expand Up @@ -265,6 +269,71 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep
return utils.NewToolResultText(string(r)), nil
}

func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
// First get the PR to get 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
}
defer func() { _ = resp.Body.Close() }()

Comment on lines +282 to +283
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deferred response-body closer captures the resp variable by reference, but resp is reassigned later when calling client.Checks.ListCheckRunsForRef. This means the first defer will end up closing the second response body (and the PR response body may never be closed), potentially leaking connections. Use defer resp.Body.Close() (evaluated at defer time) or capture resp.Body in the deferred function’s arguments so each response is closed correctly.

Copilot uses AI. Check for mistakes.
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil
}

// Get check runs for the head SHA
opts := &github.ListCheckRunsOptions{
ListOptions: github.ListOptions{
PerPage: pagination.PerPage,
Page: pagination.Page,
},
}

checkRuns, resp, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, *pr.Head.SHA, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get check runs",
resp,
err,
), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get check runs", resp, body), nil
}

// Convert to minimal check runs to reduce context usage
minimalCheckRuns := make([]MinimalCheckRun, 0, len(checkRuns.CheckRuns))
for _, checkRun := range checkRuns.CheckRuns {
minimalCheckRuns = append(minimalCheckRuns, convertToMinimalCheckRun(checkRun))
}

minimalResult := MinimalCheckRunsResult{
TotalCount: checkRuns.GetTotal(),
CheckRuns: minimalCheckRuns,
}

r, err := json.Marshal(minimalResult)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return utils.NewToolResultText(string(r)), nil
}

func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
opts := &github.ListOptions{
PerPage: pagination.PerPage,
Expand Down
155 changes: 155 additions & 0 deletions pkg/github/pullrequests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,161 @@ func Test_GetPullRequestStatus(t *testing.T) {
}
}

func Test_GetPullRequestCheckRuns(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))

assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})

// Setup mock PR for successful PR fetch
mockPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Head: &github.PullRequestBranch{
SHA: github.Ptr("abcd1234"),
Ref: github.Ptr("feature-branch"),
},
}

// Setup mock check runs for success case
mockCheckRuns := &github.ListCheckRunsResults{
Total: github.Ptr(2),
CheckRuns: []*github.CheckRun{
{
ID: github.Ptr(int64(1)),
Name: github.Ptr("build"),
Status: github.Ptr("completed"),
Conclusion: github.Ptr("success"),
HTMLURL: github.Ptr("https://github.com/owner/repo/runs/1"),
},
{
ID: github.Ptr(int64(2)),
Name: github.Ptr("test"),
Status: github.Ptr("completed"),
Conclusion: github.Ptr("success"),
HTMLURL: github.Ptr("https://github.com/owner/repo/runs/2"),
},
},
}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedCheckRuns *github.ListCheckRunsResults
expectedErrMsg string
}{
{
name: "successful check runs fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns),
}),
requestArgs: map[string]interface{}{
"method": "get_check_runs",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedCheckRuns: mockCheckRuns,
},
{
name: "PR fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
requestArgs: map[string]interface{}{
"method": "get_check_runs",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request",
},
{
name: "check runs fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
GetReposCommitsCheckRunsByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
requestArgs: map[string]interface{}{
"method": "get_check_runs",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: true,
expectedErrMsg: "failed to get check runs",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
RepoAccessCache: stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)

// Create call request
request := createMCPRequest(tc.requestArgs)

// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)

// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}

require.NoError(t, err)
require.False(t, result.IsError)

// Parse the result and get the text content if no error
textContent := getTextResult(t, result)

// Unmarshal and verify the result (using minimal type)
var returnedCheckRuns MinimalCheckRunsResult
err = json.Unmarshal([]byte(textContent.Text), &returnedCheckRuns)
require.NoError(t, err)
assert.Equal(t, *tc.expectedCheckRuns.Total, returnedCheckRuns.TotalCount)
assert.Len(t, returnedCheckRuns.CheckRuns, len(tc.expectedCheckRuns.CheckRuns))
for i, checkRun := range returnedCheckRuns.CheckRuns {
assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Name, checkRun.Name)
assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Status, checkRun.Status)
assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Conclusion, checkRun.Conclusion)
}
})
}
}

func Test_UpdatePullRequestBranch(t *testing.T) {
// Verify tool definition once
serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper)
Expand Down
Loading