From c0502d014873c23e0a0ee8f90715f9f980c960e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:09:28 -0700 Subject: [PATCH 1/2] fix(server): background PR sync reads summaries in batches Concurrent summary reads on one host and credential now share aliased GraphQL requests of 25 through a RequestResolver, falling back to `gh pr view` for anything the batch could not answer. The sync sweep runs 25 groups at once so a sweep's reads land in the same batch. Co-Authored-By: Claude Opus 5.5 --- .../orchestration/PullRequestSyncReactor.ts | 4 +- .../pullRequest/GitHubPullRequestCli.test.ts | 151 +++++++++---- .../src/pullRequest/GitHubPullRequestCli.ts | 203 +++++++++++++----- .../pullRequest/gitHubPullRequestJson.test.ts | 53 +++++ .../src/pullRequest/gitHubPullRequestJson.ts | 123 +++++++++++ 5 files changed, 437 insertions(+), 97 deletions(-) diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 35819d9e7430..319ea46ad64b 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -303,7 +303,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(logSkipped("pull request sync skipped", { key })), ) : Effect.void, - { concurrency: 8, discard: true }, + // As wide as one batched summary read, so the sweep's reads on a host arrive together and + // GitHub answers them in one request rather than one `gh pr view` apiece. + { concurrency: 25, discard: true }, ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index fd5ab04e8a81..3607114b6c99 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -452,77 +452,137 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("reads linked pull request status with the overview fields in one request", () => + it.effect("reads linked pull requests on one host together, filed back by position", () => Effect.gen(function* () { + const node = (number: number) => ({ + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }); mockedExecute.mockReturnValueOnce( Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, - ], - body: "", + data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }), ), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const summary = yield* cli.getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); assert.deepStrictEqual( { - number: summary.number, - state: summary.state, - headBranch: summary.headBranch, - isDraft: summary.isDraft, - author: summary.author?.login, - additions: summary.additions, - deletions: summary.deletions, - changedFiles: summary.changedFiles, - reviewDecision: summary.reviewDecision, - checksState: summary.checksState, - mergeability: summary.mergeability, + number: seven?.number, + state: seven?.state, + headBranch: seven?.headBranch, + author: seven?.author?.login, + changedFiles: seven?.changedFiles, + reviewDecision: seven?.reviewDecision, + checksState: seven?.checksState, + mergeability: seven?.mergeability, }, { number: 7, state: "open", - headBranch: "feat/summary", - isDraft: false, + headBranch: "feat/7", author: "octocat", - additions: 12, - deletions: 3, changedFiles: 2, reviewDecision: "approved", checksState: "passing", mergeability: "mergeable", }, ); + assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain( + 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + ); + expect(document).toContain("pullRequest(number: 8)"); + }), + ); + + it.effect("reads a pull request the batch said nothing about on its own", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "SUCCESS", + name: "ci", + }, + ], + body: "", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const read = yield* cli + .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args).toEqual([ "pr", "view", "7", @@ -531,7 +591,6 @@ layer("GitHubPullRequestCli.layer", (it) => { "--json", expect.stringContaining("statusCheckRollup"), ]); - expect(mockedGetPullRequest).not.toHaveBeenCalled(); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 723fcfc4d9b9..3ed2a200711c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,10 +1,15 @@ import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -48,6 +53,7 @@ import { decodePullRequestCoreJson, PULL_REQUEST_CORE_GRAPHQL_QUERY, type GitHubPullRequestCore, + type GitHubPullRequestSummary, decodePullRequestPreviewJson, PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, decodePullRequestFilesJson, @@ -58,6 +64,7 @@ import { decodePullRequestSearchJson, decodePullRequestStacksJson, decodePullRequestStatsJson, + decodePullRequestSummariesJson, decodeReactionSubjectScopeJson, decodeReviewerCandidatesJson, decodeLabelCandidatesJson, @@ -67,6 +74,7 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, @@ -419,6 +427,23 @@ export interface GitHubPullRequestStat { */ const STAT_ALIASES_PER_REQUEST = 25; const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestCliError +> {} export interface GitHubPullRequestSearchBatch { /** Rows across every repository asked for, newest update first, each naming its own. */ @@ -1727,6 +1752,133 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe(Effect.map(([, runs]) => runs)); + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. + const viewPullRequestSummary = (input: PullRequestSummaryRead) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, + }), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), + ); + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request)); + const batched = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + query, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + return GitHubPullRequestCli.of({ withVerifiedCredential, getRoutingIdentity, @@ -1933,56 +2085,7 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, - // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands - // the thread overview its author, diff stat, review decision and checks in the same read. - getPullRequestSummary: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestSummary", - cause: decoded.failure, - }), - ); - } - const detail = decoded.success; - return Effect.succeed({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - updatedAt: detail.updatedAt, - closedAt: detail.closedAt ?? null, - mergedAt: detail.mergedAt ?? null, - isDraft: detail.isDraft, - author: detail.author, - additions: detail.additions, - deletions: detail.deletions, - changedFiles: detail.changedFiles, - reviewDecision: detail.reviewDecision, - checksState: detail.checksState, - mergeability: detail.mergeability, - }); - }), - ), + getPullRequestSummary, getPullRequestDetail, getPullRequestPreview: (input) => { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 81033e2e0d94..7008ca739150 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,8 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + buildPullRequestSummariesGraphQlQuery, + decodePullRequestSummariesJson, buildReviewSubmissionJson, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, @@ -1883,3 +1885,54 @@ describe("pull request stack membership batches", () => { ); }); }); + +describe("batched pull request summaries", () => { + it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), + ).toBeNull(); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), + ).toBeNull(); + expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); + }); + + it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { + const decoded = decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Merged", + url: "https://github.com/acme/web/pull/7", + author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, + headRefName: "feat/seven", + baseRefName: "main", + state: "MERGED", + mergedAt: "2026-08-24T00:00:00Z", + closedAt: "2026-08-24T00:00:00Z", + updatedAt: "2026-08-24T00:00:00Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + }, + s1: { pullRequest: null }, + s2: { pullRequest: { number: 9 } }, + rateLimit: { cost: 1 }, + }, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect([...decoded.success.keys()]).toEqual([0]); + expect(decoded.success.get(0)).toMatchObject({ + number: 7, + state: "merged", + mergedAt: "2026-08-24T00:00:00Z", + author: { login: "renovate", isBot: true }, + checksState: "failing", + mergeability: "unknown", + additions: 0, + }); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index eaaa4e34da84..9be1b2a5844b 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1847,6 +1847,129 @@ export function decodePullRequestStatsJson( return Result.succeed(stats); } +/** + * The fields a linked thread keeps current, for many pull requests in one aliased read. Same + * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup + * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. + */ +const PULL_REQUEST_SUMMARY_SELECTION = + "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + + "updatedAt mergedAt closedAt headRefName baseRefName " + + "author { __typename login avatarUrl ... on User { name } } " + + "latestReviews(first: 20) { nodes { state author { login } } } " + + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/** + * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written + * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". + */ +export function buildPullRequestSummariesGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`, + ); + } + return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +} + +const RawSummarySchema = Schema.Struct({ + ...RawSearchItemSchema.fields, + changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.String), +}); +const decodeSummaries = decodeJsonResult( + Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), + ), + ), + ), + }), +); +const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly closedAt: string | null; + readonly mergedAt: string | null; + readonly updatedAt: string; + readonly author: PullRequestActor | null; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly reviewDecision: PullRequestReviewDecision | null; + readonly checksState: PullRequestChecksState | null; + readonly mergeability: PullRequestMergeability; +} + +/** + * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or + * one whose fields no longer decode, is absent rather than failing the rest of the batch. + */ +export function decodePullRequestSummariesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const summaries = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeSummaryEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + summaries.set(Number(index), { + number: pr.number, + title: pr.title, + url: pr.url, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + state: toState(pr), + isDraft: pr.isDraft ?? false, + closedAt: trimmed(pr.closedAt), + mergedAt: trimmed(pr.mergedAt), + updatedAt: pr.updatedAt, + author: toActor(pr.author), + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changedFiles ?? 0, + reviewDecision: toReviewDecisionWithReviews( + pr.reviewDecision, + (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), + ), + // One enum for the head commit, dressed as a single check like the search row's. + checksState: rollupChecksState( + (pr.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + ), + mergeability: toMergeability(pr.mergeable), + }); + } + return Result.succeed(summaries); +} + export interface GitHubPullRequestCore extends GitHubPullRequestDetail { readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; From ec1440fccfc4efddf2e5e17501b07d1c4b8dd330 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:20:40 -0700 Subject: [PATCH 2/2] test(server): scope the JSON suppression to the stringify call Co-Authored-By: Claude Opus 5.5 --- apps/server/src/pullRequest/GitHubPullRequestCli.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 3607114b6c99..291105e25cd9 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -476,8 +476,8 @@ layer("GitHubPullRequestCli.layer", (it) => { }); mockedExecute.mockReturnValueOnce( Effect.succeed( - // @effect-diagnostics-next-line preferSchemaOverJson:off output( + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }),