Skip to content

feat(github): collapse per-PR hydration N+1 with a GraphQL batch query - #86

Merged
jaylann merged 5 commits into
stagefrom
feat/graphql-pr-batch
Jul 6, 2026
Merged

feat(github): collapse per-PR hydration N+1 with a GraphQL batch query#86
jaylann merged 5 commits into
stagefrom
feat/graphql-pr-batch

Conversation

@jaylann

@jaylann jaylann commented Jul 5, 2026

Copy link
Copy Markdown
Owner

What

Lands the structural fix for #83: one batched GraphQL query per account replaces the per-PR REST hydration N+1.

Each poll hydrated every distinct open PR with up to 3 REST calls (GET pulls/{n} + commits/{sha}/check-runs + pulls/{n}/reviews) plus a per-repo permission fetch — on a large inbox (measured ~193 req for one cold refresh of a 76-PR inbox) this could exhaust the core 5000-req/hr limit. A new pullRequestBatch on the GitHubAPI protocol fetches detail + reviews + check-runs + the viewer's repo merge signals for all PRs in one query.

Design principle: transport swap, not a logic rewrite

The GraphQL client maps the response back onto the existing decoded value types (PullRequestDetail / PullRequestReview / CheckRun / RepoMergeInfo), so the store's unchanged deriveGate / ciRollup / checkRowModel derivation consumes a batched result identically. Everything downstream (gates, CI banners, hydration marks, notifications) is untouched.

Fallback (GHE + robustness)

  • On by default; any GraphQL failure (a GHE server missing mergeStateStatus, a transport error) auto-falls-back to the per-PR REST path per account.
  • Per-node nulls (a repo/PR the viewer can't resolve) degrade to a REST retry for that one PR.
  • The REST path (incl. hydrateRepoPermissions and the updated_at short-circuit) is retained verbatim as the fallback.

Files

  • GitHubGraphQLQuery.swift (new) — aliased batch query builder + internal chunking (25 PRs/query).
  • GraphQLPRMapping.swift (new) — response decode + mapping (incl. legacy StatusContextCheckRun, mergeStateStatusmergeable_state, viewerPermissioncanMerge).
  • AppConfig.graphQLURL(forAPI:) — public + GHE endpoint derivation.
  • AppStoreHydration.swift — GraphQL fast path in the drain with REST fallback; folds each bundle's repo merge info into repoMergeInfo so a later gate-only refresh after approve still finds it.
  • AppStore.useGraphQLBatch flag (default true; tests flip it to exercise the REST short-circuit).

Tests

  • GraphQLPRMappingTests — decode/map a canned response: bundle fields, mergeStateStatus mapping table, CheckRun + StatusContext → rollup, per-node null tolerance, top-level errors → throw, query builder + chunking + slug split.
  • AppConfigWebHostTests — GraphQL endpoint (public + GHE + port + fallback).
  • AppStoreTests — batch path hydrates via one round-trip (no per-PR REST), and batchError → REST fallback still hydrates.
  • Existing REST short-circuit tests pinned to useGraphQLBatch = false.
  • Full suite: 280 tests, 0 failures; just check clean.

Closes #83

jaylann added 5 commits July 5, 2026 15:22
Each poll hydrated every distinct open PR with up to 3 REST calls
(detail + reviews + check-runs) plus a per-repo permission fetch — an
N+1 that could exhaust the core 5000-req/hr limit on a large inbox.

Add a batched `pullRequestBatch` to the `GitHubAPI` protocol backed by
one GraphQL query per account: it fetches detail + reviews + check-runs
+ the viewer's repo merge signals for all PRs at once, then maps the
result back onto the existing REST value types so the store's
`deriveGate`/`ciRollup` logic consumes it unchanged (a transport swap,
not a logic rewrite). On by default; any GraphQL failure (a GHE server
missing a field, a transport error) auto-falls-back to the per-PR REST
path per account, and per-node nulls degrade to a REST retry for that PR.

- `GitHubGraphQLQuery.swift`: aliased batch query builder + chunking.
- `GraphQLPRMapping.swift`: response decode + mapping (incl. legacy
  StatusContext → CheckRun, mergeStateStatus → mergeable_state).
- `AppConfig.graphQLURL(forAPI:)`: public + GHE endpoint derivation.
- Store folds each bundle's repo merge info into `repoMergeInfo` so a
  later gate-only refresh after approve still finds it.

Closes #83
…ch REST

The GraphQL statusCheckRollup returns both CheckRun and legacy StatusContext
(commit-status API) nodes, but the REST commits/{sha}/check-runs endpoint the
fallback path uses returns only CheckRuns. Consuming StatusContext made the
batch path's CI state diverge from its REST fallback — a status-only repo would
flash a CI dot that vanished on any poll that fell back to REST. Filter to
CheckRun nodes so both paths agree. Extending CI to commit statuses would need
both paths changed together (follow-up).
- Strip redundant explicit Sendable + wrap single-line bodies flagged by CI's
  newer swiftformat (the local .claude worktree exclude had hidden them).
- graphQLURL: an api.-prefixed host (public or GHE Cloud data-residency
  api.<tenant>.ghe.com) serves GraphQL at /graphql, not /api/graphql — key on the
  host prefix so data-residency tenants hit the batch path instead of 404→REST.
- Add a FakeGitHubAPI omit knob + AppStoreTests case exercising the per-node
  partial fallback (one PR resolves via GraphQL, an omitted one falls back to
  per-PR REST in the same wave).
Merge of stage (#85) added a required gateIssueSeq to fold(); the GraphQL batch
path now ticks nextGateWriteSeq() per PR before issuing the batch and passes it
when folding, so a batch-hydrated gate obeys the same merge-poll write-preserve
ordering as the REST drain (#84). Fixes the CI build/test break from the stale base.
@jaylann
jaylann merged commit 9327be6 into stage Jul 6, 2026
7 checks passed
@jaylann
jaylann deleted the feat/graphql-pr-batch branch July 6, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Collapse the per-PR hydration N+1 with a GraphQL batch query

1 participant