From afba1efc88adbc62b7fdd4de76617b5e8d5a8973 Mon Sep 17 00:00:00 2001 From: Radu-Stefan Popa <150839549+Radu028@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:57:59 +0200 Subject: [PATCH 1/2] fix(github): pull requests missing refs/pull/N/head no longer show as not found GitHub can keep no refs/pull//head for a pull request it otherwise serves. The detail and action-state reads compare against that ref, and the answer's lone NOT_FOUND on baseRef.compare was read as a missing pull request. Accept exactly that answer and keep its data, with the comparison unknown. A NOT_FOUND now means a missing resource only on a root field or repository.pullRequest, and GraphQL errors keep GitHub's message and path. Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitHubPullRequestApi.test.ts | 115 +++++++++++++++++- .../src/server/GitHubPullRequestApi.ts | 83 +++++++++---- .../src/server/GitHubRequestPolicy.ts | 12 +- .../src/server/gitHubGraphQlClient.test.ts | 33 ++++- .../src/server/gitHubGraphQlClient.ts | 27 +++- 5 files changed, 238 insertions(+), 32 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 61005227a168c..3e939e4873bd8 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -30,6 +30,12 @@ const refusedReviewer = { message: "Resource not accessible by integration", path: ["repository", "pullRequest", "reviewRequests", "nodes", 0, "requestedReviewer"], }; +// What GitHub answers for a pull request that has no `refs/pull//head`. +const unresolvedPullRef = { + type: "NOT_FOUND", + message: "Could not resolve head ref 'refs/pull/7/head'.", + path: ["repository", "pullRequest", "baseRef", "compare"], +}; const pullRequestInput = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; type PullRequestRead = ( input: typeof pullRequestInput, @@ -2204,6 +2210,23 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect.each([ + { action: "merge", mutation: "mergePullRequest(" }, + { action: "update-branch", mutation: "updatePullRequestBranch(" }, + ] as const)("runs $action on a pull request GitHub has no pull ref for", ({ action, mutation }) => + Effect.gen(function* () { + route([ + "query PullRequestActionState", + { ...actionState({ baseRef: { compare: null } }), errors: [unresolvedPullRef] }, + ]); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + yield* cli.runPullRequestAction({ ...pullRequestInput, action }); + + expect(variablesOf(mutation)).toHaveLength(1); + }), + ); + it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); @@ -3085,7 +3108,12 @@ layer("GitHubPullRequestApi.layer", (it) => { }).pipe(Effect.asVoid, Effect.flip); expect(error._tag).toBe(tag ?? "GitHubApiResponseError"); if (error._tag === "GitHubApiResponseError") - expect(error).toMatchObject({ status, githubErrors: errors.map((e) => e.message) }); + expect(error).toMatchObject({ + status, + githubErrors: errors.map((e) => + "path" in e ? `${e.message} (at ${e.path.join(".")})` : e.message, + ), + }); expect(mockedExecute).toHaveBeenCalledTimes(index + 1); } }), @@ -3230,6 +3258,91 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + const unresolvedCore = { + ...coreResponse({ baseRef: { compare: null } }), + errors: [unresolvedPullRef], + }; + it.effect.each([ + { description: "alone", answers: [unresolvedCore], requestedReviewers: [100] }, + { + description: "beside a refused reviewer", + answers: [ + { ...unresolvedCore, errors: [unresolvedPullRef, refusedReviewer] }, + unresolvedCore, + ], + requestedReviewers: [100, 0], + }, + ])( + "reads a pull request without a pull ref, its comparison unknown ($description)", + ({ answers, requestedReviewers }) => + Effect.gen(function* () { + for (const answer of answers) + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(answer)))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const detail = yield* cli.getPullRequestDetail(pullRequestInput); + expect(detail.number).toBe(7); + expect(detail.comparison).toBeNull(); + expect(variablesOf("PullRequestCore").map((v) => v["requestedReviewers"])).toEqual( + requestedReviewers, + ); + }), + ); + + it.effect("fails a missing pull ref beside another error, in GitHub's words", () => + Effect.gen(function* () { + const refusedChecks = { + type: "FORBIDDEN", + message: "Resource not accessible by integration", + path: ["repository", "pullRequest", "commits", "nodes", 0, "commit", "statusCheckRollup"], + }; + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output(encodeJson({ ...unresolvedCore, errors: [unresolvedPullRef, refusedChecks] })), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const error = yield* Effect.flip(cli.getPullRequestDetail(pullRequestInput)); + expect(error).toMatchObject({ + _tag: "GitHubApiResponseError", + status: 200, + githubErrors: [ + "Could not resolve head ref 'refs/pull/7/head'. (at repository.pullRequest.baseRef.compare)", + "Resource not accessible by integration (at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup)", + ], + }); + }), + ); + + it.effect("still reads a pull request GitHub cannot find as missing, in GitHub's words", () => + Effect.gen(function* () { + const { data } = coreResponse(); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { repository: { ...data.repository, pullRequest: null } }, + errors: [ + { + type: "NOT_FOUND", + message: "Could not resolve to a PullRequest with the number of 7.", + path: ["repository", "pullRequest"], + }, + ], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const error = yield* Effect.flip(cli.getPullRequestDetail(pullRequestInput)); + expect(error).toMatchObject({ + _tag: "GitHubApiNotFoundError", + githubErrors: [ + "Could not resolve to a PullRequest with the number of 7. (at repository.pullRequest)", + ], + }); + }), + ); + it.effect.each([false, true])( "reads every check when the combined response has another page (reviewer refusal: %s)", (refused) => diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index a35ff2e2d0a8a..d1461bee9f6c9 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -49,7 +49,11 @@ import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as GitHubGraphQlClient from "./gitHubGraphQlClient.ts"; import { PullRequestBatchesGraphQLGroup } from "./pullRequestBatches.graphql.ts"; import { PullRequestMutationsGraphQLGroup } from "./pullRequestMutations.graphql.ts"; -import { PullRequestReadsGraphQLGroup } from "./pullRequestReads.graphql.ts"; +import { + PullRequestActionState, + PullRequestCore, + PullRequestReadsGraphQLGroup, +} from "./pullRequestReads.graphql.ts"; import { PullRequestReviewsGraphQLGroup } from "./pullRequestReviews.graphql.ts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; @@ -325,23 +329,54 @@ const isGraphQlClientError = (error: unknown): error is GraphQLClientError.Graph const { isNotFoundOnly } = GitHubGraphQlClient; +const isMissingPullRef = ({ path, type }: GraphQLClientError.GraphQLError): boolean => + type === "NOT_FOUND" && path?.join(".") === "repository.pullRequest.baseRef.compare"; + const isRefusedRequestedReviewersOnly = (error: unknown): boolean => isGraphQlClientError(error) && error.reason._tag === "ResponseError" && error.reason.status === 200 && - error.reason.errors.every( - ({ path, type }) => - type === "FORBIDDEN" && - path?.length === 6 && - path[0] === "repository" && - path[1] === "pullRequest" && - path[2] === "reviewRequests" && - path[3] === "nodes" && - typeof path[4] === "number" && - Number.isInteger(path[4]) && - path[4] >= 0 && - path[5] === "requestedReviewer", - ); + // A missing pull ref can come along; the retried read tolerates it. + error.reason.errors.every((graphQlError) => { + const { path, type } = graphQlError; + return ( + isMissingPullRef(graphQlError) || + (type === "FORBIDDEN" && + path?.length === 6 && + path[0] === "repository" && + path[1] === "pullRequest" && + path[2] === "reviewRequests" && + path[3] === "nodes" && + typeof path[4] === "number" && + Number.isInteger(path[4]) && + path[4] >= 0 && + path[5] === "requestedReviewer") + ); + }); + +const isMissingPullRefOnly = ( + error: E, +): error is E & + GraphQLClientError.GraphQLClientError & { readonly reason: GraphQLClientError.ResponseError } => + isGraphQlClientError(error) && + error.reason._tag === "ResponseError" && + error.reason.status === 200 && + error.reason.errors.every(isMissingPullRef); + +/** + * GitHub can lack `refs/pull//head` for a pull request it otherwise answers, and then + * fails only the comparison against it. The rest of that answer stands, its comparison unknown. + */ +const toleratingMissingPullRef = + (decode: (data: unknown) => Effect.Effect) => + (effect: Effect.Effect) => + effect.pipe( + Effect.catchIf(isMissingPullRefOnly, (error) => + decode(error.reason.data).pipe(Effect.mapError(() => error)), + ), + ); +const decodePullRequestCore = Schema.decodeUnknownEffect(PullRequestCore.result); +const decodePullRequestActionState = Schema.decodeUnknownEffect(PullRequestActionState.result); /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -1410,14 +1445,16 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); const operation = "getPullRequestDetail"; const read = (requestedReviewers: number) => - client.PullRequestCore({ - owner, - name, - number: input.number, - headRef: `refs/pull/${input.number}/head`, - required: asksRequired(input.host), - requestedReviewers, - }); + client + .PullRequestCore({ + owner, + name, + number: input.number, + headRef: `refs/pull/${input.number}/head`, + required: asksRequired(input.host), + requestedReviewers, + }) + .pipe(toleratingMissingPullRef(decodePullRequestCore)); return SourceControlRateLimit.Interactive.pipe( Effect.flatMap((allowReserve) => read(100).pipe( @@ -1630,6 +1667,7 @@ export const make = Effect.gen(function* () { ? new GitHubRequestPolicy.GitHubApiNotFoundError({ host: input.host, operation: input.operation, + githubErrors: GitHubGraphQlClient.describeErrors(error), }) : readError(input.cwd, input.operation, error), ), @@ -2540,6 +2578,7 @@ export const make = Effect.gen(function* () { headRef: `refs/pull/${input.number}/head`, }) .pipe( + toleratingMissingPullRef(decodePullRequestActionState), graphql({ ...target, allowReserve: true }), Effect.map((result) => result.repository?.pullRequest ?? null), Effect.flatMap(found(target)), diff --git a/packages/source-control-github/src/server/GitHubRequestPolicy.ts b/packages/source-control-github/src/server/GitHubRequestPolicy.ts index b30d2645af54c..8e1ebe8bba87f 100644 --- a/packages/source-control-github/src/server/GitHubRequestPolicy.ts +++ b/packages/source-control-github/src/server/GitHubRequestPolicy.ts @@ -60,10 +60,18 @@ export class GitHubApiRateLimitError extends Schema.TaggedError()( "GitHubApiNotFoundError", - { host: Schema.String, operation: Schema.String }, + { + host: Schema.String, + operation: Schema.String, + githubErrors: Schema.optionalKey(Schema.Array(Schema.String)), + }, ) { override get message(): string { - return "GitHub could not find the requested resource, or the credential cannot see it."; + const missing = + "GitHub could not find the requested resource, or the credential cannot see it."; + return this.githubErrors !== undefined && this.githubErrors.length > 0 + ? `${missing} GitHub said: ${this.githubErrors.join("; ")}` + : missing; } } diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts index 5027e618f9e93..85b00f9d7c4da 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts @@ -241,7 +241,9 @@ describe("call", () => { const { layer } = harness(() => json({ data: { repository: null }, - errors: [{ type: "NOT_FOUND", message: "Could not resolve to a Repository" }], + errors: [ + { type: "NOT_FOUND", path: ["repository"], message: "Could not resolve to a Repository" }, + ], }), ); return Effect.gen(function* () { @@ -250,10 +252,39 @@ describe("call", () => { _tag: "GitHubApiNotFoundError", host: "github.com", operation: "stats", + githubErrors: ["Could not resolve to a Repository (at repository)"], }); }).pipe(Effect.provide(layer)); }); + it.effect( + "fails a NOT_FOUND below the pull request as GitHub's reason, not a missing one", + () => { + const { layer } = harness(() => + json({ + data: { repository: { pullRequest: stats } }, + errors: [ + { + type: "NOT_FOUND", + path: ["repository", "pullRequest", "baseRef", "compare"], + message: "Could not resolve head ref 'refs/pull/7/head'.", + }, + ], + }), + ); + return Effect.gen(function* () { + const error = yield* Effect.flip(callStats()); + expect(error).toMatchObject({ + _tag: "GitHubApiResponseError", + status: 200, + githubErrors: [ + "Could not resolve head ref 'refs/pull/7/head'. (at repository.pullRequest.baseRef.compare)", + ], + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect( "fails NOT_FOUND alongside another error as a failure carrying GitHub's reasons", () => { diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index 45442bb4f186a..7a70ca58e7bff 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -37,10 +37,28 @@ function queryHash(query: string): string { return (hash >>> 0).toString(16).padStart(8, "0"); } +/** + * Whether every error is a NOT_FOUND on a lookup: a root field (with its list items) or + * `repository.pullRequest`. A NOT_FOUND deeper down is one field GitHub could not resolve. + */ export const isNotFoundOnly = (error: unknown): boolean => GraphQLClientError.isGraphQLClientError(error) && error.reason._tag === "ResponseError" && - error.reason.errors.every((graphQlError) => graphQlError["type"] === "NOT_FOUND"); + error.reason.errors.every( + ({ type, path }) => + type === "NOT_FOUND" && + (path === undefined || + path.slice(1).every((segment) => typeof segment === "number") || + (path.length === 2 && path[0] === "repository" && path[1] === "pullRequest")), + ); + +/** GitHub's message for each error in an answer, with the field it names. */ +export const describeErrors = (error: GraphQLClientError.GraphQLClientError) => + error.reason._tag === "ResponseError" + ? error.reason.errors.map(({ message, path }) => + path === undefined || path.length === 0 ? message : `${message} (at ${path.join(".")})`, + ) + : []; /** Share REST's credentials/slots/quota/pause; leave non-rate-limit errors to each lookup. */ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< @@ -212,14 +230,11 @@ const fromClientError = ( const context = { host: GitHubRequestPolicy.normalizeHost(target.host), operation: target.operation, + githubErrors: describeErrors(error), }; return isNotFoundOnly(error) ? new GitHubRequestPolicy.GitHubApiNotFoundError(context) - : new GitHubRequestPolicy.GitHubApiResponseError({ - ...context, - status: reason.status ?? 200, - githubErrors: reason.errors.map((graphQlError) => graphQlError.message), - }); + : new GitHubRequestPolicy.GitHubApiResponseError({ ...context, status: reason.status ?? 200 }); }; /** Map response errors to policy errors; callers map decode errors to read errors. */ From d67c50a51a7a9d7fa73f781350bf5b990ea0061a Mon Sep 17 00:00:00 2001 From: Radu-Stefan Popa <150839549+Radu028@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:07:00 +0200 Subject: [PATCH 2/2] fix(github): a missing issue or pull request number is still a not-found lookup Co-Authored-By: Claude Opus 5.5 --- .../src/server/gitHubGraphQlClient.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index 1f42232783c0d..b948d5e605a49 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -44,8 +44,9 @@ function queryHash(query: string): string { } /** - * Whether every error is a NOT_FOUND on a lookup: a root field (with its list items) or - * `repository.pullRequest`. A NOT_FOUND deeper down is one field GitHub could not resolve. + * Whether every error is a NOT_FOUND on a lookup: a root field (with its list items), + * `repository.pullRequest` or `repository.issueOrPullRequest`. A NOT_FOUND deeper down is one + * field GitHub could not resolve. */ export const isNotFoundOnly = (error: unknown): boolean => GraphQLClientError.isGraphQLClientError(error) && @@ -55,7 +56,9 @@ export const isNotFoundOnly = (error: unknown): boolean => type === "NOT_FOUND" && (path === undefined || path.slice(1).every((segment) => typeof segment === "number") || - (path.length === 2 && path[0] === "repository" && path[1] === "pullRequest")), + (path.length === 2 && + path[0] === "repository" && + (path[1] === "pullRequest" || path[1] === "issueOrPullRequest"))), ); /** GitHub's message for each error in an answer, with the field it names. */