From 99b8c100e7d78ef368276d4f7262028b8415c225 Mon Sep 17 00:00:00 2001 From: Kaitlin Barnes Date: Tue, 3 Feb 2026 11:55:47 -0800 Subject: [PATCH 1/3] Add support for get_check_runs --- .../__toolsnaps__/pull_request_read.snap | 5 +- pkg/github/helper_test.go | 5 +- pkg/github/minimal_types.go | 39 +++++ pkg/github/pullrequests.go | 71 +++++++- pkg/github/pullrequests_test.go | 155 ++++++++++++++++++ 5 files changed, 270 insertions(+), 5 deletions(-) diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index a8591fc5c..3c5eeeca9 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -7,7 +7,7 @@ "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", @@ -15,7 +15,8 @@ "get_files", "get_review_comments", "get_reviews", - "get_comments" + "get_comments", + "get_check_runs" ], "type": "string" }, diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 0bb73008e..003dec0c0 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -48,8 +48,9 @@ const ( PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" PostReposGitTreesByOwnerByRepo = "POST /repos/{owner}/{repo}/git/trees" - GetReposCommitsStatusByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/status" - GetReposCommitsStatusesByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/statuses" + 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}" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index c6a0ea849..f914514b4 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -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 +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index f546865b2..a10491b56 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -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", @@ -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 } @@ -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() }() + + 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, diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index d2664479d..e810dd617 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -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) From fa4080045ef0669f4b5ffdf6a32755dc0f3102bf Mon Sep 17 00:00:00 2001 From: Kaitlin Barnes Date: Tue, 3 Feb 2026 11:59:34 -0800 Subject: [PATCH 2/3] Run generate-docs --- README.md | 1 + pkg/github/helper_test.go | 6 +++--- pkg/github/pullrequests_test.go | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index afe003002..a95c61871 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 003dec0c0..1d1c4b895 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -48,9 +48,9 @@ const ( PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" 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" + 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}" diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index e810dd617..0d9c239f3 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1462,8 +1462,8 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { { name: "successful check runs fetch", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), - GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), }), requestArgs: map[string]interface{}{ "method": "get_check_runs", From d42bfaa7ddb93c8d995d3a35c0b136bd02a58807 Mon Sep 17 00:00:00 2001 From: Kaitlin Barnes Date: Wed, 4 Feb 2026 15:50:08 -0800 Subject: [PATCH 3/3] Address AI code review comment --- pkg/github/pullrequests.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index a10491b56..077cc49cc 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -279,7 +279,7 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, err, ), nil } - defer func() { _ = resp.Body.Close() }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) @@ -305,7 +305,7 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, err, ), nil } - defer func() { _ = resp.Body.Close() }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body)