Skip to content

Commit 631703f

Browse files
SamMorrowDrumsdylanpulverCopilot
committed
fix(copilot): keep rate limit denials out of the review permission hint
A 403 carrying X-RateLimit-Remaining: 0, or a secondary rate limit documentation URL, reached copilotReviewErrMsg as a rate limit error. It was explained as a missing repository or missing write access, and the repository read it triggered was refused for the same reason, so the caller paid an extra call to be told the wrong thing. Return the base message for both rate limit error types so the rate limit text stands on its own, and trim the helper and its tests to the comments the code cannot state. Co-authored-by: Dylan Pulver <dylanpulver@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 698e354 commit 631703f

2 files changed

Lines changed: 43 additions & 21 deletions

File tree

pkg/github/copilot.go

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package github
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
@@ -900,7 +901,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server
900901
)
901902
if err != nil {
902903
return ghErrors.NewGitHubAPIErrorResponse(ctx,
903-
copilotReviewErrMsg(ctx, client, "failed to request copilot review", owner, repo, pullNumber, resp),
904+
copilotReviewErrMsg(ctx, client, "failed to request copilot review", owner, repo, pullNumber, resp, err),
904905
resp,
905906
err,
906907
), nil, nil
@@ -920,31 +921,32 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server
920921
})
921922
}
922923

923-
// copilotReviewErrMsg explains the opaque failures of the review request
924-
// endpoint used by request_copilot_review.
925-
//
926-
// Requesting a reviewer needs write access to the repository. Being the author
927-
// of the pull request does not grant it, which is why a fork contributor can be
928-
// offered a Copilot review by the web UI and still be refused by the API. See
924+
// copilotReviewErrMsg disambiguates the bare 404 this endpoint returns when the
925+
// caller lacks write access, which is otherwise indistinguishable from a missing
926+
// repository or pull request. Authoring the pull request does not grant write
927+
// access, so fork contributors are refused here even though the website offers
928+
// them a Copilot review.
929929
// https://docs.github.com/en/pull-requests/reference/pull-request-reviews#requesting-and-requiring-reviews
930-
//
931-
// The endpoint is documented to answer a caller who is not a collaborator with
932-
// 403 or 422, but in practice it answers with 404 Not Found, which on its own
933-
// is indistinguishable from a repository or pull request that does not exist.
934-
// Reading the repository tells the two apart, and only runs once the request
935-
// has already failed.
936-
func copilotReviewErrMsg(ctx context.Context, client *github.Client, base, owner, repo string, pullNumber int, resp *github.Response) string {
930+
func copilotReviewErrMsg(ctx context.Context, client *github.Client, base, owner, repo string, pullNumber int, resp *github.Response, err error) string {
937931
if resp == nil || (resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusForbidden) {
938932
return base
939933
}
940934

935+
// Rate limiting is also reported as 403, and the read below would be refused
936+
// for the same reason, so leave the caller with the rate limit message.
937+
var rateLimitErr *github.RateLimitError
938+
var abuseErr *github.AbuseRateLimitError
939+
if errors.As(err, &rateLimitErr) || errors.As(err, &abuseErr) {
940+
return base
941+
}
942+
941943
repository, _, repoErr := client.Repositories.Get(ctx, owner, repo)
942944
switch {
943945
case repoErr != nil:
944-
return fmt.Sprintf("%s. %s/%s could not be read with the current credentials, so either it does not exist or the credentials cannot reach it. "+
945-
"GitHub refuses this endpoint the same way when the authenticated user has no write access to the repository.", base, owner, repo)
946+
return fmt.Sprintf("%s. %s/%s could not be read with the current credentials, so it may not exist or the credentials may not reach it. "+
947+
"Lacking write access is refused with the same status.", base, owner, repo)
946948
case !repository.GetPermissions().GetPush():
947-
return fmt.Sprintf("%s. The authenticated user has no write access to %s/%s, and GitHub requires write access to request a reviewer even from the author of the pull request. "+
949+
return fmt.Sprintf("%s. The authenticated user has no write access to %s/%s, and GitHub requires write access to request a reviewer, even from the author of the pull request. "+
948950
"Request the Copilot review from the pull request page on the GitHub website instead, or ask someone with write access to request it.", base, owner, repo)
949951
default:
950952
return fmt.Sprintf("%s. The authenticated user has write access to %s/%s, so check that pull request #%d exists there and that Copilot code review is available for the repository. "+

pkg/github/copilot_test.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8+
"strconv"
89
"strings"
910
"testing"
11+
"time"
1012

1113
"github.com/github/github-mcp-server/internal/githubv4mock"
1214
"github.com/github/github-mcp-server/internal/toolsnaps"
@@ -929,9 +931,6 @@ func Test_RequestCopilotReview(t *testing.T) {
929931
expectedErrMsg: "failed to request copilot review",
930932
},
931933
{
932-
// The author of a cross-fork pull request has no write access on the
933-
// upstream repository, so GitHub refuses the review request with a 404
934-
// that says nothing about permissions.
935934
name: "pull request author without write access",
936935
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
937936
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusNotFound, map[string]any{"message": "Not Found"}),
@@ -1006,7 +1005,6 @@ func Test_RequestCopilotReview(t *testing.T) {
10061005
expectedErrMsg: "owner/repo could not be read with the current credentials",
10071006
},
10081007
{
1009-
// A failure that carries no permission signal keeps the original message.
10101008
name: "server error is not explained as a permission problem",
10111009
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
10121010
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusInternalServerError, map[string]any{"message": "Internal Server Error"}),
@@ -1020,6 +1018,28 @@ func Test_RequestCopilotReview(t *testing.T) {
10201018
expectedErrMsg: "failed to request copilot review",
10211019
unexpectedErrMsg: "write access",
10221020
},
1021+
{
1022+
name: "rate limited forbidden is not explained as a permission problem",
1023+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1024+
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
1025+
w.Header().Set("X-RateLimit-Remaining", "0")
1026+
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10))
1027+
w.WriteHeader(http.StatusForbidden)
1028+
_, _ = w.Write([]byte(`{"message": "API rate limit exceeded"}`))
1029+
},
1030+
GetReposByOwnerByRepo: func(_ http.ResponseWriter, _ *http.Request) {
1031+
t.Error("repository should not be read while rate limited")
1032+
},
1033+
}),
1034+
requestArgs: map[string]any{
1035+
"owner": "owner",
1036+
"repo": "repo",
1037+
"pullNumber": float64(1),
1038+
},
1039+
expectError: true,
1040+
expectedErrMsg: "rate limit exceeded",
1041+
unexpectedErrMsg: "write access",
1042+
},
10231043
}
10241044

10251045
for _, tc := range tests {

0 commit comments

Comments
 (0)