Skip to content

Commit 698e354

Browse files
dylanpulverSamMorrowDrums
authored andcommitted
fix(copilot): explain review request denials instead of forwarding a bare 404
The review request endpoint requires write access to the repository, and GitHub refuses a caller without it with 404 Not Found rather than a permission error. Authoring the pull request does not grant that access, so a fork contributor can be offered a Copilot review by the web UI and still be refused by request_copilot_review, with nothing in the tool result to say why. On 403 or 404 the tool now reads the repository once so it can name the cause. A caller without write access is told so directly and pointed at the web UI. When the repository cannot be read at all, or when write access is present, the message says so and points at the likelier cause.
1 parent 0bb1e56 commit 698e354

2 files changed

Lines changed: 134 additions & 6 deletions

File tree

pkg/github/copilot.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server
900900
)
901901
if err != nil {
902902
return ghErrors.NewGitHubAPIErrorResponse(ctx,
903-
"failed to request copilot review",
903+
copilotReviewErrMsg(ctx, client, "failed to request copilot review", owner, repo, pullNumber, resp),
904904
resp,
905905
err,
906906
), nil, nil
@@ -920,6 +920,38 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server
920920
})
921921
}
922922

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
929+
// 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 {
937+
if resp == nil || (resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusForbidden) {
938+
return base
939+
}
940+
941+
repository, _, repoErr := client.Repositories.Get(ctx, owner, repo)
942+
switch {
943+
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+
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. "+
948+
"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)
949+
default:
950+
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. "+
951+
"Copilot code review is not available on GitHub Enterprise Server.", base, owner, repo, pullNumber)
952+
}
953+
}
954+
923955
func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.ServerPrompt {
924956
return inventory.NewServerPrompt(
925957
ToolsetMetadataIssues,

pkg/github/copilot_test.go

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -886,11 +886,12 @@ func Test_RequestCopilotReview(t *testing.T) {
886886
}
887887

888888
tests := []struct {
889-
name string
890-
mockedClient *http.Client
891-
requestArgs map[string]any
892-
expectError bool
893-
expectedErrMsg string
889+
name string
890+
mockedClient *http.Client
891+
requestArgs map[string]any
892+
expectError bool
893+
expectedErrMsg string
894+
unexpectedErrMsg string
894895
}{
895896
{
896897
name: "successful request",
@@ -927,6 +928,98 @@ func Test_RequestCopilotReview(t *testing.T) {
927928
expectError: true,
928929
expectedErrMsg: "failed to request copilot review",
929930
},
931+
{
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.
935+
name: "pull request author without write access",
936+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
937+
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusNotFound, map[string]any{"message": "Not Found"}),
938+
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{
939+
Name: github.Ptr("repo"),
940+
Permissions: &github.RepositoryPermissions{
941+
Pull: github.Ptr(true),
942+
Push: github.Ptr(false),
943+
},
944+
}),
945+
}),
946+
requestArgs: map[string]any{
947+
"owner": "owner",
948+
"repo": "repo",
949+
"pullNumber": float64(1),
950+
},
951+
expectError: true,
952+
expectedErrMsg: "The authenticated user has no write access to owner/repo",
953+
},
954+
{
955+
name: "forbidden is explained the same way as not found",
956+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
957+
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusForbidden, map[string]any{"message": "Forbidden"}),
958+
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{
959+
Name: github.Ptr("repo"),
960+
Permissions: &github.RepositoryPermissions{
961+
Pull: github.Ptr(true),
962+
Push: github.Ptr(false),
963+
},
964+
}),
965+
}),
966+
requestArgs: map[string]any{
967+
"owner": "owner",
968+
"repo": "repo",
969+
"pullNumber": float64(1),
970+
},
971+
expectError: true,
972+
expectedErrMsg: "The authenticated user has no write access to owner/repo",
973+
},
974+
{
975+
name: "write access present points at the pull request instead",
976+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
977+
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusNotFound, map[string]any{"message": "Not Found"}),
978+
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{
979+
Name: github.Ptr("repo"),
980+
Permissions: &github.RepositoryPermissions{
981+
Pull: github.Ptr(true),
982+
Push: github.Ptr(true),
983+
},
984+
}),
985+
}),
986+
requestArgs: map[string]any{
987+
"owner": "owner",
988+
"repo": "repo",
989+
"pullNumber": float64(999),
990+
},
991+
expectError: true,
992+
expectedErrMsg: "check that pull request #999 exists there",
993+
},
994+
{
995+
name: "unreadable repository",
996+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
997+
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusNotFound, map[string]any{"message": "Not Found"}),
998+
GetReposByOwnerByRepo: mockResponse(t, http.StatusNotFound, map[string]any{"message": "Not Found"}),
999+
}),
1000+
requestArgs: map[string]any{
1001+
"owner": "owner",
1002+
"repo": "repo",
1003+
"pullNumber": float64(1),
1004+
},
1005+
expectError: true,
1006+
expectedErrMsg: "owner/repo could not be read with the current credentials",
1007+
},
1008+
{
1009+
// A failure that carries no permission signal keeps the original message.
1010+
name: "server error is not explained as a permission problem",
1011+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1012+
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusInternalServerError, map[string]any{"message": "Internal Server Error"}),
1013+
}),
1014+
requestArgs: map[string]any{
1015+
"owner": "owner",
1016+
"repo": "repo",
1017+
"pullNumber": float64(1),
1018+
},
1019+
expectError: true,
1020+
expectedErrMsg: "failed to request copilot review",
1021+
unexpectedErrMsg: "write access",
1022+
},
9301023
}
9311024

9321025
for _, tc := range tests {
@@ -949,6 +1042,9 @@ func Test_RequestCopilotReview(t *testing.T) {
9491042
require.True(t, result.IsError)
9501043
errorContent := getErrorResult(t, result)
9511044
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
1045+
if tc.unexpectedErrMsg != "" {
1046+
assert.NotContains(t, errorContent.Text, tc.unexpectedErrMsg)
1047+
}
9521048
return
9531049
}
9541050

0 commit comments

Comments
 (0)