Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions gbar/Sources/Auth/AuthErrorCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ enum AuthErrorCopy {
case let .http(code): httpMessage(code)
case .badURL: "That API base URL looks invalid — check it under Advanced."
case let .rateLimited(until): rateLimitMessage(until: until)
// The batch hydration catches `.graphQL` and falls back to REST, so it never reaches
// user-facing copy; map it to the generic message defensively.
case .graphQL: "Something went wrong talking to GitHub. Try again."
}
}

Expand Down
67 changes: 67 additions & 0 deletions gbar/Sources/GitHub/GitHubAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ enum MergeMethod: String, CaseIterable {
}
}

/// One PR to hydrate in a batch: repo `owner/name` slug + number.
struct PRRef: Hashable {
let repo: String
let number: Int
}

/// Detail + reviews + check-runs + the viewer's repo merge signals for one PR, in the same
/// decoded shapes the REST path produces — so the store's existing `deriveGate`/`ciRollup` logic
/// consumes a batched result unchanged. `mergeInfo` is nil when GraphQL omitted the repo's
/// permissions (→ an optimistic gate, matching a missing `repoMergeInfo` cache entry).
struct PullRequestBundle {
let detail: PullRequestDetail
let reviews: [PullRequestReview]
let checkRuns: [CheckRun]
let mergeInfo: RepoMergeInfo?
}

/// The GitHub data surface gbar needs. A protocol so the store can be tested against a
/// fake, and so a future hosted/webhook backend can drop in behind the same interface.
protocol GitHubAPI: Sendable {
Expand All @@ -30,6 +47,12 @@ protocol GitHubAPI: Sendable {
func pullRequest(repo: String, number: Int) async throws -> PullRequestDetail
/// Fetch the reviews submitted on a pull request (`owner/name` slug + number).
func reviews(repo: String, number: Int) async throws -> [PullRequestReview]
/// Batch-hydrate many PRs — detail + reviews + check-runs + repo merge signals — in one (or a
/// few internally-chunked) GraphQL round-trip(s), collapsing the per-PR REST N+1. Best-effort
/// per node: a PR the viewer can't resolve comes back absent (skipped, like a failed REST
/// fetch). Throws on a transport/decode/whole-response GraphQL failure so the caller can fall
/// back to the per-PR REST path (GitHub Enterprise servers may not expose every field).
func pullRequestBatch(_ refs: [PRRef]) async throws -> [PRRef: PullRequestBundle]
/// Fetch a repository's detail (`owner/name` slug) — used for the viewer's permissions.
func repository(repo: String) async throws -> RepositoryInfo
/// Submit an approving review on a pull request, with an optional review body.
Expand Down Expand Up @@ -64,6 +87,10 @@ struct GitHubClient: GitHubAPI {
/// (nil when GitHub sent neither). The poll loop backs off to this time instead of
/// hammering the same cadence into a longer lockout.
case rateLimited(until: Date?)
/// A GraphQL response carried a top-level `errors` array with no usable `data` (a malformed
/// query, a revoked scope, or a GHE version missing a requested field). The batch caller
/// catches this and falls back to the per-PR REST hydration.
case graphQL(String)
}

let baseURL: URL
Expand Down Expand Up @@ -156,6 +183,28 @@ struct GitHubClient: GitHubAPI {
return try Self.decoder.decode(RepositoryInfo.self, from: data)
}

/// How many PRs to pack into a single GraphQL query. A point-based query stays well within
/// GitHub's 500k-node limit at this width, and keeps any one request's response bounded; the
/// chunks run sequentially so a large inbox never fans out a burst of concurrent POSTs.
static let graphQLBatchSize = 25

func pullRequestBatch(_ refs: [PRRef]) async throws -> [PRRef: PullRequestBundle] {
guard !refs.isEmpty else { return [:] }
let endpoint = AppConfig.graphQLURL(forAPI: baseURL)
var result: [PRRef: PullRequestBundle] = [:]
// Chunk sequentially: one POST per ≤`graphQLBatchSize` PRs, merged into the result. A
// per-chunk failure propagates so the whole account falls back to REST (partial GraphQL +
// partial REST would double-hydrate and muddy the request-budget win).
for chunk in refs.chunked(into: Self.graphQLBatchSize) {
let payload = GitHubGraphQL.batchQuery(for: chunk)
let request = try makeGraphQLRequest(url: endpoint, body: payload)
let data = try await execute(request)
let bundles = try GitHubGraphQL.decodeBatch(data, for: chunk)
result.merge(bundles) { _, new in new }
}
return result
}

func approvePullRequest(repo: String, number: Int, body: String?) async throws {
var payload = ["event": "APPROVE"]
if let body, !body.isEmpty { payload["body"] = body }
Expand Down Expand Up @@ -329,6 +378,24 @@ struct GitHubClient: GitHubAPI {
return request
}

/// Build a POST to the GraphQL endpoint (a different host/path than the REST `baseURL`, so it
/// takes an explicit `url`) carrying gbar's standard auth/version headers and a JSON body.
/// Reuses the https guard so the bearer token never crosses cleartext. Unlike the REST helper
/// this uses the default cache policy — GraphQL POSTs aren't cached/revalidated (no ETag), and
/// the batch's whole value is one fresh round-trip.
private func makeGraphQLRequest(url: URL, body: GitHubGraphQL.RequestBody) throws -> URLRequest {
guard url.scheme?.lowercased() == "https" else { throw ClientError.badURL }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version")
request.setValue("gbar", forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try Self.encoder.encode(body)
return request
}

/// Run a request and return its body, throwing `ClientError.http` on any non-2xx status.
private func execute(_ request: URLRequest) async throws -> Data {
try await executeWithResponse(request).0
Expand Down
96 changes: 96 additions & 0 deletions gbar/Sources/GitHub/GitHubGraphQLQuery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Foundation

/// Builds the batched GraphQL query that collapses the per-PR REST hydration N+1 into one
/// round-trip, and decodes the response back into the REST-shaped value types the store already
/// consumes (see `GraphQLPRMapping.swift`). Pure/stateless so both halves are unit-testable
/// without a network.
enum GitHubGraphQL {
/// The `{ "query": …, "variables": … }` POST body GitHub's GraphQL endpoint expects.
struct RequestBody: Encodable {
let query: String
let variables: [String: Value]

/// A GraphQL variable value — only the two scalar shapes this query needs (repo
/// owner/name strings and the PR number int), encoded as a bare JSON scalar.
enum Value: Encodable {
case string(String)
case int(Int)

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .string(value): try container.encode(value)
case let .int(value): try container.encode(value)
}
}
}
}

/// The PR fields fetched per node — mirrors exactly what the REST detail + reviews + check-runs
/// triple returns, so the mapping can reconstruct `PullRequestDetail`/`PullRequestReview`/
/// `CheckRun`. `mergeStateStatus` is GraphQL's analogue of REST `mergeable_state`; an older GHE
/// server that doesn't expose it fails the whole query → the caller falls back to REST.
private static let prFragment = """
fragment PRF on PullRequest {
number state isDraft mergeable mergeStateStatus headRefOid headRefName
title url databaseId createdAt updatedAt
author { login }
reviews(last: 100) { nodes { author { login } state submittedAt } }
commits(last: 1) { nodes { commit { statusCheckRollup {
state
contexts(first: 100) { nodes {
__typename
... on CheckRun { databaseId name status conclusion startedAt completedAt }
} }
} } } }
}
"""

/// The viewer's merge signals, read off the same `repository` node the PR is fetched under —
/// so folding repo permissions into the batch costs no extra round-trip.
private static let repoFields = "viewerPermission mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"

/// Build the aliased batch query + variables for a chunk of PR refs. Each ref becomes an
/// `r{i}: repository(owner:$o{i}, name:$n{i}) { … pullRequest(number:$p{i}) { …PRF } }`
/// selection so a single query resolves them all; the response aliases map back by index.
static func batchQuery(for refs: [PRRef]) -> RequestBody {
var params: [String] = []
var selections: [String] = []
var variables: [String: RequestBody.Value] = [:]
for (index, ref) in refs.enumerated() {
let (owner, name) = splitSlug(ref.repo)
params.append("$o\(index): String!, $n\(index): String!, $p\(index): Int!")
selections.append(
"r\(index): repository(owner: $o\(index), name: $n\(index)) "
+ "{ \(repoFields) pullRequest(number: $p\(index)) { ...PRF } }"
)
variables["o\(index)"] = .string(owner)
variables["n\(index)"] = .string(name)
variables["p\(index)"] = .int(ref.number)
}
let query = """
query BatchPRs(\(params.joined(separator: ", "))) {
\(selections.joined(separator: "\n"))
}
\(prFragment)
"""
return RequestBody(query: query, variables: variables)
}

/// Split an `owner/name` slug. A malformed slug (no `/`) keeps the whole string as the owner
/// and an empty name, which GitHub resolves to a null node the mapping skips.
static func splitSlug(_ slug: String) -> (owner: String, name: String) {
let parts = slug.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { return (slug, "") }
return (String(parts[0]), String(parts[1]))
}
}

extension Array {
/// Split into contiguous sub-arrays of at most `size` elements (the last may be shorter).
/// `size <= 0` yields a single chunk with everything, so a misconfigured batch size can't loop.
func chunked(into size: Int) -> [[Element]] {
guard size > 0 else { return isEmpty ? [] : [self] }
return stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
}
}
Loading
Loading