@charlie-labs/github-api
v1.4.2
Published
A typed, explicit, resource-oriented GitHub API client for pull-request review tools.
Downloads
1,650
Readme
@charlie-labs/github-api
A server-side TypeScript client for retrieving GitHub data used by pull-request review tools. The package keeps values close to GitHub's REST and GraphQL shapes while making pagination, resource costs, revision safety, cancellation, and operational behavior explicit.
Version 1.x is Worker-first: it targets Cloudflare Workers/workerd without
nodejs_compat or Node polyfills while preserving its GitHub.com, Node.js 22+,
and ESM compatibility contract. This README is the package API guide; the
generated export inventory is
docs/api-reference.md, observable guarantees are in
docs/runtime-contracts.md, and pure projection
behavior is in docs/projections.md. Package boundaries
and evolution policy are recorded in docs/design.md and
docs/maintenance.md, and prescriptive implementation and
review rules are in
docs/engineering-standards.md. GitHub
Enterprise Server is not supported. Custom HTTPS API origins remain allowed as
before but are experimental and do not imply GHES support. Plaintext HTTP is
test-only: the scheme and lexical localhost hostname are matched with narrow
ASCII case-insensitivity, while canonical dotted-decimal IPv4 literals in
127.0.0.0/8 and exact IPv6 loopback ::1 (written [::1] in a URL) are also
accepted. All other raw authority syntax must remain canonical. Base URL
credentials, queries, and fragments are rejected, and this validation performs
no DNS resolution.
Building on Cloudflare? Start with Build a GitHub data Worker on Cloudflare for the recommended adoption path and the exact deployable starter.
1.x surface
- Explicit anonymous-public REST access, static PATs, and asynchronous token providers.
- A Worker-native
/github-appsubpath for bounded App JWT creation, downscoped installation-token minting, explicit revocation, and a reusable single-flight token provider. - A package-owned, injectable streaming HTTP transport.
- Bounded JSON responses, cancellation, response-start and body-idle timeouts.
- Exact safe-read retries, shared primary/secondary rate-limit pauses, and a serialized mutation dispatch lane.
- Bounded route-compatible same-origin redirects for high-level reads; mutation and cross-origin redirects are rejected.
- Safe normalized REST/GraphQL errors, sanitized request events, and rate-limit observations.
pulls.get().- Repository
pulls.list.page(),.pages(),.all(), and.allWithMeta(). pulls.files.page(),.pages(),.all(), and.allWithMeta().- Paginated REST
pulls.commits,pulls.reviews, andpulls.reviewCommentsreads. - Revision-guarded
pulls.filesAtRevision(). - Exact-byte, revision-guarded
pulls.diff.get()andpulls.diff.stream(). - Exact Git commit/tree/blob reads and bounded
sources.getFile()/sources.getFiles()acquisition. - Generated GraphQL documents and read-only paginated
pulls.reviewThreads,pulls.reviewThreads.comments,pulls.latestReviews,pulls.statusChecks,pulls.timeline, andpulls.viewedFilesresources. - A bounded one-request
pulls.reviewFirstPaint.get()acquisition and an additive head-onlypulls.revisionProbe.get()GraphQL read. - Credentialed, viewer-neutral, revision-verified
pulls.mergeabilitySnapshot.get()acquisition, backed by checked-in generatedPullMergeabilitySnapshot,PullMergeabilitySnapshotContinuation, andPullMergeabilityRevisionProbedocuments plus stable snapshot DTO types. - Direct review-comment and review-thread replies, review-thread resolve/unresolve, viewer-specific file mark/unmark, and revision-preconditioned pull-request merge.
- Paginated REST
checks.forRef,statuses.forRef, and aggregate-preservingstatuses.combinedForRefreads. - Protected-branch, branch-protection, repository/organization ruleset, and branch-applicable active-rule reads.
- Stateless, resource-bound REST and GraphQL cursors, collection budgets, and explicit completeness.
- Pure review-thread, status-check, timeline, latest-review, viewed-file, pull-ref source locator, and pull-file patch projections.
- Typed and explicitly untyped raw REST calls, plus code-generated and raw-string GraphQL calls.
- Optional, authorization-namespace-isolated conditional caching for eligible REST JSON reads; every hit is revalidated and no cache is enabled by default.
- A lean
/integrationsubpath with explicit execution profiles, cache/resource descriptors, physical-attempt coordination contracts, and pure webhook invalidation planning. - A Worker-native
/webhookssubpath for exact-byte GitHub HMAC verification, verify-before-parse invalidation planning, bounded portable messages, and opaque delivery keys. - A lean
/pullssubpath with REST-only, generated review-read, and first-paint-only clients for measured Worker bundles.
Development
Requirements:
- Bun 1.3 or newer for development.
- Cloudflare Workers/workerd with Web Platform APIs and no
nodejs_compat, or Node.js 22 or newer, for package consumers.
bun install
bun install --cwd examples/cloudflare --frozen-lockfile
bun install --cwd examples/cloudflare/minimal --frozen-lockfile
bun run checkcheck:package is the complete independently publishable package gate.
check:cloudflare packs that build and verifies both repository-only
Cloudflare consumers against the tarball under workerd:
- the minimal two-Worker starter, which is the copyable introduction to public-repository anonymous or GitHub App reads, a private Service Binding, and hot plus conditional caching; and
- the comprehensive reference lab, which adds the deterministic GitHub fixture, invalidation Worker, Durable Objects, Queue, KV/R2, live-test isolation, and performance harness.
The starter independently installs and locks the published
@charlie-labs/[email protected]. The root verifier replaces that dependency
with a freshly packed artifact so every package change is tested against both
Cloudflare consumers before publication.
prepack intentionally runs only check:package; publishing never depends on
example-only packages.
The isolated real-GitHub compatibility suite is manual-only and remains
outside normal CI, packaging, and publication; maintainers should follow
docs/live-testing.md.
Deterministic consumer tests can use the stable testing subpath without retaining authorization-bearing requests:
import { createGitHubApi } from "@charlie-labs/github-api";
import {
createGitHubEventRecorder,
createMockGitHubTransport,
githubJsonResponse,
} from "@charlie-labs/github-api/testing";
const recorder = createGitHubEventRecorder();
const transport = createMockGitHubTransport((request) =>
githubJsonResponse(request, { number: 417 }),
);
const github = createGitHubApi({
auth: { token: "fixture-token" },
onEvent: recorder.onEvent,
transport,
});The mock handler receives the live request inside the test boundary; the helper does not record requests or serialize their headers.
Quick start
Choose GitHub authentication explicitly. Missing auth never means anonymous:
import { createGitHubApi } from "@charlie-labs/github-api";
const withToken = createGitHubApi({
auth: { token: process.env.GITHUB_TOKEN! },
});
const withProvider = createGitHubApi({
auth: {
async tokenProvider({ operation, signal }) {
return await credentialBroker.tokenForGitHub({ operation, signal });
},
},
});
const anonymousPublic = createGitHubApi({
auth: { anonymous: true },
});
const github = withToken;
const pull = await github.pulls.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});
const openPulls = await github.pulls.list.allWithMeta({
owner: "acme",
repo: "widgets",
filters: {
state: "open",
base: "main",
head: { owner: "acme", ref: "feature/report" },
},
order: { by: "updated", direction: "desc" },
});
const files = await github.pulls.files.allWithMeta({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});
const statuses = await github.pulls.statusChecks.allWithMeta({
owner: "acme",
repo: "widgets",
pullNumber: 417,
headOid: pull.head.sha,
});
const commits = await github.pulls.commits.allWithMeta({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});Fetch-backed transport
The root entrypoint exports the package's standard Fetch-to-transport adapter:
export type GitHubFetch = (request: Request) => Response | Promise<Response>;
export interface GitHubFetchTransportOptions {
readonly fetch?: GitHubFetch;
}
export function createGitHubFetchTransport(
options?: GitHubFetchTransportOptions,
): GitHubTransport;Omit fetch to use the runtime's standard global fetch:
import { createGitHubApi, createGitHubFetchTransport } from "@charlie-labs/github-api";
const github = createGitHubApi({
auth: { token: process.env.GITHUB_TOKEN! },
transport: createGitHubFetchTransport(),
});A trusted server-side Fetch-compatible boundary can instead route the complete GitHub request. For example, a private Cloudflare Service Binding can be used as the upstream:
interface Bindings {
readonly GITHUB_TOKEN: string;
readonly GITHUB_UPSTREAM: {
fetch(request: Request): Promise<Response>;
};
}
const github = createGitHubApi({
auth: { token: env.GITHUB_TOKEN },
transport: createGitHubFetchTransport({
fetch: (request) => env.GITHUB_UPSTREAM.fetch(request),
}),
});createGitHubApi({ transport }) remains the single request-execution
customization seam; there is no createGitHubApi({ fetch }) option. Consumers
may supply Fetch-compatible fixture or fake behavior at that seam, but the
package provides no fixture data, scenario engine, or fake GitHub behavior.
Security: a custom fetch receives fully formed requests, potentially including authorization headers. Run it only across a trusted server-side boundary, never a public, client-side, or otherwise untrusted hop, and do not log or persist request headers, bodies, credentials, or response bodies.
The remaining examples use github as a credentialed client. The package never
reads a named environment variable itself. An asynchronous provider is called
for every physical attempt, so it can rotate an expiring credential for the
same principal. Static and provider credentials must be non-empty strings with
no whitespace, ASCII control characters, or DEL; accepted opaque strings,
including non-ASCII non-whitespace characters, are forwarded unchanged. The
package reports provider rejection as a generic provider failure and a
successfully resolved malformed value as an invalid returned credential,
without retaining provider errors or credential text. If the caller cancels
while a provider is pending, cancellation wins when the provider settles.
Anonymous mode sends no Authorization header. It can use the package-owned
REST GET/HEAD resources when GitHub exposes the selected repository data
publicly. Package-owned GraphQL operations, mutations, viewer-specific
resources, and every raw path reject locally because they require
authentication or bypass the reviewed public surface. An upstream permission
failure remains the ordinary normalized GitHub error; the package never falls
back between anonymous and credentialed modes.
Application/user authentication and GitHub API authentication are separate concerns. A Worker may authenticate its own caller while deliberately choosing an anonymous-public, installation-token, or user-token GitHub client. The application owns the repository allowlist and public exposure policy. GitHub limits unauthenticated REST requests to 60 per hour per originating IP, so anonymous mode is intended for bounded, heavily cached public reads—not an uncached arbitrary GitHub proxy.
Reuse one client for each credential/principal and authorization context. Scheduling and rate-limit pauses are client-local, so creating many clients can bypass their protection; a token provider should rotate credentials only within the same visibility and rate-limit context.
GitHub App installation authentication
Use a static token for a simple operator-controlled integration whose one credential, visibility, and lifetime are managed outside this package. Use a GitHub App installation provider for a long-running service that is installed on repositories, should mint short-lived credentials, or needs explicit repository and permission downscoping. Both plug into the same root-client authentication contract.
The tree-shakeable @charlie-labs/github-api/github-app subpath implements the
GitHub App credential protocol without reading environment variables or Worker
bindings:
import { createGitHubApi } from "@charlie-labs/github-api";
import {
createGitHubAppTokenProvider,
type GitHubAppTokenProvider,
} from "@charlie-labs/github-api/github-app";
interface GitHubAppBindings {
// Prefer the App's client ID as GitHub's JWT issuer.
GITHUB_APP_ID: string;
GITHUB_APP_INSTALLATION_ID: string;
GITHUB_APP_PRIVATE_KEY: string;
GITHUB_REPOSITORY_ID: string;
}
let tokenProvider: GitHubAppTokenProvider | undefined;
let github: ReturnType<typeof createGitHubApi> | undefined;
function githubFor(env: GitHubAppBindings) {
if (github !== undefined) {
return github;
}
tokenProvider ??= createGitHubAppTokenProvider({
appId: env.GITHUB_APP_ID,
installationId: Number(env.GITHUB_APP_INSTALLATION_ID),
privateKey: env.GITHUB_APP_PRIVATE_KEY,
repositoryIds: [Number(env.GITHUB_REPOSITORY_ID)],
permissions: {
contents: "read",
pull_requests: "read",
},
});
github = createGitHubApi({
auth: { tokenProvider },
});
return github;
}The application—not the package—reads and protects the App private key, App ID, installation ID, and repository IDs. Create one provider for one App and API origin, then reuse it across clients and requests in that isolate. Do not create a provider for every request or use one provider to multiplex unrelated authorization contexts.
The provider delegates installation-token requests, in-isolate caching,
expiration handling, refresh, and concurrent-request coalescing to
@octokit/auth-app. provider.refresh() performs Octokit's documented
refresh: true cache bypass and returns the complete installation
authentication result. The callable provider returns only its token so it is
directly assignable to root auth.tokenProvider.
The appId option names the JWT iss field for compatibility with GitHub's
terminology. Pass the GitHub App client ID (GitHub's recommended issuer) or a
legacy positive numeric App ID. The caller is responsible for supplying that
GitHub-issued identity; package preflight enforces only bounded, safe issuer
syntax and cannot prove that GitHub issued the value.
repositoryIds, repositoryNames, and permissions use Octokit's installation
authentication options directly. Omitting them asks GitHub for the
installation's available repositories and granted permissions. Prefer an
explicit repository and permission downscope for a data Worker. A provider
copies these values when it is created, so later mutation of caller-owned
objects or arrays cannot silently change its credential scope.
The package intentionally follows Octokit's response behavior: GitHub's reported token scope is returned rather than compared with the requested scope. A freshly minted result contains the response's repository and permission metadata. A result reconstructed from Octokit's serialized cache may contain the requested metadata instead. Treat GitHub authorization failures on subsequent API requests as authoritative; applications that need a separate policy assertion should apply it explicitly.
For lower-level or short-lived workflows the subpath also exports:
createGitHubAppJwt()for Octokit-compatible RS256 App JWT creation from a caller-supplied PKCS#1 or PKCS#8 PEM key;mintGitHubAppInstallationToken()for one fresh Octokit installation-token request; andrevokeGitHubAppInstallationToken()for explicit token revocation through Octokit's request layer.
A short-lived workflow that requires cleanup can call mint, use the token, and
call revoke in its own finally block. Keeping that control flow in the
application avoids a package-specific callback/error protocol. Ordinary
root-client requests never revoke a cached provider token.
One-shot mint and revoke helpers accept an injected fetch, custom API base,
and caller AbortSignal. App JWT signing uses the standard timing and claim
behavior from universal-github-app-jwt, the same signer Octokit uses: iat
is 30 seconds before the observed clock and exp is 10 minutes after iat.
The optional injected clock exists for deterministic tests; timing overrides
and cancellation are not part of the local signing API. Provider refresh
follows Octokit's operation-level behavior: an already-aborted package
operation is rejected before authentication begins, but aborting one waiter
does not cancel Octokit's shared refresh. Private keys, JWTs, tokens,
authorization headers, and response bodies are removed from package-created
errors.
The optional GitHubAppTokenCache is Octokit's get(key)/set(key, value)
cache contract. Octokit owns the opaque key and serialized value. Its cache key
does not currently include the App ID or API base URL, so do not share one
cache namespace across Apps or origins. The package does not add persistence,
delete/invalidation, leases, or distributed coordination around that contract.
When global refresh serialization matters, make a credential-sharded Durable Object own one provider and expose a bounded token-broker RPC. Do not route every cached GitHub data read through that object: Workers Cache should satisfy a fresh data hit before credential coordination runs.
Lean pull clients and first paint
Latency-sensitive Workers that need only pull reads can avoid importing the root client's raw, mutation, diff, source, projection, and unrelated resource surfaces:
import {
createGitHubRestPullApi,
createGitHubReviewFirstPaintApi,
createGitHubReviewPullApi,
} from "@charlie-labs/github-api/pulls";
const publicPulls = createGitHubRestPullApi({ auth: { anonymous: true } });
const firstPaintPulls = createGitHubReviewFirstPaintApi({
auth: { token: await tokenForSamePrincipal() },
});
const reviewPulls = createGitHubReviewPullApi({
auth: { token: await tokenForSamePrincipal() },
});
const summary = await publicPulls.pulls.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});
const publicOpenPulls = await publicPulls.pulls.list.page({
owner: "acme",
repo: "widgets",
filters: { state: "open" },
order: { by: "updated", direction: "desc" },
pageSize: 50,
});
const firstPaint = await firstPaintPulls.pulls.reviewFirstPaint.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
expectedHeadOid: summary.head.sha,
reviewThreadPageSize: 10,
nestedCommentPageSize: 5,
statusPageSize: 20,
latestReviewPageSize: 10,
timelinePageSize: 10,
includeViewedFiles: false,
});createGitHubRestPullApi() accepts anonymous or credentialed auth and exposes
only pulls.get, pulls.list, pulls.files, pulls.commits, pulls.reviews,
pulls.reviewComments, and pulls.filesAtRevision.
createGitHubReviewFirstPaintApi() is the smallest credentialed GraphQL
surface: it exposes only the one-request pulls.reviewFirstPaint.get() path.
Its physical module boundary lets a Worker bundle retain only the generated
PullReviewFirstPaint operation.
createGitHubReviewPullApi() requires credentialed auth and exposes only
read-only generated GraphQL pull resources. None of the lean factories exposes raw
requests, mutations, diff acquisition, Git/source resources, or projections.
Use the root createGitHubApi() when those capabilities are needed.
The separate credentialed createGitHubMergeabilityApi() factory exposes only
pulls.mergeabilitySnapshot.get() plus events and rate-limit observation:
import {
createGitHubMergeabilityApi,
expectedPullRevisionFromPullListItem,
} from "@charlie-labs/github-api/pulls";
const mergeabilityApi = createGitHubMergeabilityApi({
auth: { token: await tokenForSamePrincipal() },
});
const snapshot = await mergeabilityApi.pulls.mergeabilitySnapshot.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
expectedRevision: expectedPullRevisionFromPullListItem(summary),
});ExpectedPullRevision extends the existing PullRevision vocabulary with
baseRefName, so its exact tuple is headSha, baseSha, and baseRefName.
expectedPullRevisionFromPullListItem() snapshots and validates those fields
from the Octokit-derived PullRequestSummary; it is pure and performs no I/O.
The helper is exported from both the package root and /pulls.
The pull request's baseSha and the current target of its named base branch are
distinct GitHub identities and may differ after the branch advances. The
snapshot preserves the former in revision.baseSha/pullRequest.baseOid and
the latter in pullRequest.baseRef.target.oid; comparison.baseTarget.oid
matches that current base-ref target. Revision verification never substitutes
the mutable branch target for the pull request's observed base SHA.
An unpaginated expected-revision acquisition is exactly two GraphQL requests:
the composite snapshot and trailing full-revision probe. Omitting
expectedRevision adds a discovery probe for exactly three requests and allows
one discard/retry if any revision tuple field moves. Four fixed-size
connections advance together, so continuation count follows maximum page depth,
not summed depth. Nullable acquisition roots and late non-GraphQL continuation
failures become explicit partial sections only after successful trailing
verification; any non-empty GraphQL errors response rejects and discards data.
The checked Cloudflare bundle fixtures exercise this supported factory boundary
without exporting internal documents. The general parsed-module baseline
(baseline.json) measures the mergeability bundle at 114,289 raw bytes, 31,204
gzip bytes, and 41 parsed modules; its root namespace grew by 50,697 raw/11,342
gzip bytes over the 350,461/80,742 control. The separate optimization baseline
(optimization-baseline.json) measures the mergeability candidate at 114,289
raw bytes, 31,204 gzip bytes, 41 parsed modules, and 25 optimization-contributing
modules; its broad-REST root grew by 48,431 raw/10,845 gzip bytes over the
349,519/80,301 control. The optimization import audit retains only
PullMergeabilitySnapshot, PullMergeabilitySnapshotContinuation, and
PullMergeabilityRevisionProbe. Both gzip growth measurements remain below their
reviewed 12,000-byte general and 11,000-byte optimization feature-growth
ceilings. These are deterministic local bundle measurements, not deployed
latency or live GitHub cost claims.
The verified GraphQL operation observes applicable policy through Ref.rules.
GraphQL permission, schema, or selected-field errors reject the operation and
discard partial data. Only nullable roots and late non-GraphQL continuation
failures can produce revision-verified partial snapshots. This method never hides
a REST fallback inside the logical snapshot, because that would change both
physical request counts and revision-observation timing. A separately cacheable
REST compatibility component may be considered only if live parity fails and that
component receives separate contract, permission, request-plan, and cache review.
reviewFirstPaint.get() is one named logical operation and one generated
GraphQL request. It returns a purpose-built PullReviewFirstPaint, not a
generic response envelope. Defaults are 10 threads, five comments per thread,
20 status contexts, 10 latest reviews, and 10 timeline items. Viewed-file
state is omitted unless includeViewedFiles: true, in which case its default
page is 25 items and remains credential/viewer-specific.
Each returned top-level or nested page has its own resource-bound opaque
cursor. Continue them explicitly through reviewThreads,
reviewThreads.comments, statusChecks, latestReviews, timeline, or
viewedFiles; one first-paint cursor never advances another connection.
expectedHeadOid is an optional same-response head check. It prevents
returning first-paint data for a different observed head, but a one-request
observation is not a before/after snapshot guard.
When consistency across acquisition time matters more than the single-request latency path, the broader review client exposes an explicit composite:
const verifiedFirstPaint = await reviewPulls.pulls.reviewFirstPaint.getAtRevision({
owner: "acme",
repo: "widgets",
pullNumber: 417,
expectedHeadOid: summary.head.sha,
});getAtRevision() performs a generated head probe, the bounded first-paint
read, and a second generated head probe under one logical request ID, deadline,
cancellation signal, scheduler/rate-limit context, and terminal event. It also
checks the head selected by the middle response. Movement at any boundary
raises GitHubPullHeadChangedError, and no first-paint value is returned.
The operation-narrow factory intentionally omits this three-request method so
using .get() cannot retain the probe document accidentally.
Outer reviewThreads pages are also viewer-specific because their selected
viewerCan* fields depend on the authenticated viewer. Integration descriptors
therefore require viewer isolation for those pages and for first paint; the
nested comment-only resource does not select viewer capability fields.
const thread = firstPaint.reviewThreads.items[0];
const nextComments =
thread?.comments.nextCursor === undefined
? undefined
: await reviewPulls.pulls.reviewThreads.comments.page({
owner: "acme",
repo: "widgets",
pullNumber: 417,
threadId: thread.id,
pageSize: 20,
cursor: thread.comments.nextCursor,
});
const allLatestReviews = await reviewPulls.pulls.latestReviews.allWithMeta({
owner: "acme",
repo: "widgets",
pullNumber: 417,
pageSize: 50,
});When only pull/head identity is needed, the additive generated probe avoids selecting the complete REST pull representation:
const revision = await reviewPulls.pulls.revisionProbe.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
expectedHeadOid: summary.head.sha,
});The probe returns the generated pull identity selection directly and raises
the same typed head-movement error when expectedHeadOid differs. Existing
pulls.filesAtRevision() and pulls.diff methods continue to use REST pull
reads before and after acquisition throughout 1.x; the new probe does not
silently change their upstream source or completion semantics.
Worker integration primitives
@charlie-labs/github-api/integration is a side-effect-free policy subpath for
data Workers. It owns no Cache API entry, KV namespace, R2 bucket, Durable
Object, Queue, webhook route, or GitHub App token lifecycle.
Execution profiles expand one latency intent into ordinary per-call controls:
import { resolveGitHubExecutionProfile } from "@charlie-labs/github-api/integration";
const profile = resolveGitHubExecutionProfile("interactive", {
pageSize: 40,
});
const firstPage = await github.pulls.files.page({
owner: "acme",
repo: "widgets",
pullNumber: 417,
...profile.request,
...profile.retry,
pageSize: profile.collection.pageSize,
});interactive favors a strict overall deadline and a small first page,
background-complete favors bounded complete traversal and refresh work, and
streaming favors fast response start and explicit verified completion.
Resolution is deterministic and deeply frozen. Spreading the controls into a
call snapshots them with the rest of that logical operation; omitting a
profile preserves the existing client defaults. cacheIntent is guidance for
the caller's storage layer, not hidden package I/O.
Exact-content operations still require their named byte limit. For a streaming diff, map the reviewed profile value explicitly:
const streaming = resolveGitHubExecutionProfile("streaming");
const diff = await github.pulls.diff.stream({
owner: "acme",
repo: "widgets",
pullNumber: 417,
baseSha: pull.base.sha,
headSha: pull.head.sha,
...streaming.request,
...streaming.retry,
maxBytes: streaming.request.maxResponseBytes,
});The explicit maxBytes bounds exact diff bytes; maxResponseBytes separately
bounds the composite pull-verification JSON.
Resource planning is intentionally two stage. Plan before the read, then finalize only with evidence from a successful complete result:
import {
finalizeGitHubResourcePlan,
planGitHubRead,
} from "@charlie-labs/github-api/integration";
const plan = await planGitHubRead({
auth: { mode: "anonymous" },
operation: "pulls.get",
arguments: {
owner: "acme",
repo: "widgets",
pullNumber: 417,
},
});
const pull = await anonymousPublic.pulls.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});
const descriptor = finalizeGitHubResourcePlan(plan, { state: "success" });The descriptor supplements pull; it never wraps or mutates the GitHub value.
It carries a versioned opaque key, canonical resource and operation,
representation, auth partition, revision/cursor dimensions, freshness
ceilings, invalidation tags, verification state, and an explicit storage
decision. Credentialed planning requires the same authorization namespace
concept used by the conditional cache, but emits only a SHA-256-derived opaque
partition. Token-authenticated public data remains credential-partitioned.
Custom API identities are hashed and private URLs are never emitted.
Finalization accepts only the exact deeply frozen plan object issued by
planGitHubRead() in that runtime instance; copying, mutating, forging, or
serializing the provisional plan removes that capability. Finalize beside the
read, then pass the completed descriptor across a Service Binding if needed.
For pulls.diff.stream, a bare completion assertion is never sufficient:
consume or cancel the body, await stream.completion, and pass that exact
package-issued result to finalization. Only its registered verified result
can permit storage, and it must match the plan's repository, pull, and revision.
pulls.mergeabilitySnapshot.get uses the same capability principle: pass the
exact package-issued snapshot object. Copies, foreign snapshots, partial
snapshots, incomplete collections, attributed issues, and revision mismatches
produce no descriptor.
Mergeability planning accepts an optional cache policy:
const plan = await planGitHubRead({
auth: {
mode: "credentialed",
authorizationNamespace: "installation:42/visibility:v3",
installationId: 42,
},
cache: { invalidation: "webhook-generation-fenced" },
operation: "pulls.mergeabilitySnapshot.get",
arguments: { owner: "acme", repo: "widgets", pullNumber: 417 },
});The default is ttl-only. Complete raw mergeable: "UNKNOWN" snapshots have
a five-second fresh ceiling in either mode. Other complete snapshots have a
15-second ceiling in TTL-only mode and a five-minute ceiling only when the
generation-fenced mode was explicitly selected. Both use 15 seconds of
stale-while-revalidate and 30 seconds of stale-if-error. Tags express
dependencies; their presence does not prove webhook delivery health.
Generation fencing and exact-key single-flight remain consumer-owned. On a miss: read the repository generation, acquire the exact-key lease, recheck the cache, obtain a complete verified snapshot, reread the generation, finalize and store only if the generation is unchanged, then release the lease. A fresh hit performs no authentication, coordination, or upstream work.
Use the fixed github-anonymous-public-v1 partition only for data actually
read by an anonymous client. Authenticated observations must never be relabeled
public. Cache policy by class is conservative:
| Resource/result state | Storage guidance |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Verified SHA-addressed object or exact source | May be long-lived after object verification; a recursive tree must also be non-truncated |
| Verified pull revision or completed diff stream | May be stored only after final revision/completion verification |
| Mutable pull/review/check/status state | Short bounded freshness; prefer webhook invalidation and bounded stale handling |
| Complete mergeability snapshot | Full response only; exact returned headSha/baseSha/baseRefName capability and verified tuple required |
| Viewer-specific state | Credentialed and storable only with an explicitly viewer-isolated namespace |
| Lazy pages, unverified flat hard-capped collections, partial/truncated results, failures, cancellation, overflow, or rate-limit responses | No store |
| Anonymous 404 | No store by default; it is not durable proof that a resource is public or absent |
An optional coordinator can enforce cross-isolate policy around every physical attempt:
const coordinated = createGitHubApi({
auth: { tokenProvider },
coordination: {
rateBucket: "installation:opaque-42",
coordinator,
background: executionContext,
},
});acquire() receives only safe operation/resource/auth-bucket dimensions, one
sanitized abort signal, and the logical deadline when present. Its lease is
released exactly once after the attempt, including retries, redirects,
GraphQL errors at HTTP 200, body failures, stream cancellation, and revision
probes. observe() receives bounded rate/cost/retry facts. The integration
must choose an opaque rateBucket; the package never hashes or persists a
token as identity. Supplying background.waitUntil() permits post-attempt
observation to be registered as background work, but permit release remains
deterministic.
Use @charlie-labs/github-api/webhooks at a framework-neutral webhook boundary.
The caller still owns the HTTP route and must collect the request body without
decoding, normalizing, or re-encoding it:
import {
encodeGitHubInvalidationMessage,
verifyAndPlanGitHubWebhook,
} from "@charlie-labs/github-api/webhooks";
// Enforce a transport/content-length/stream limit before allocating this
// buffer. The helper's maxBodyBytes is a second, mandatory defense before HMAC.
const exactBody = await request.arrayBuffer();
const verified = await verifyAndPlanGitHubWebhook({
body: exactBody,
headers: request.headers,
secret: env.GITHUB_WEBHOOK_SECRET,
maxBodyBytes: 256 * 1024,
maxPlanResources: 64,
maxPlanRefreshCandidates: 32,
});
if (verified.plan.state === "planned") {
const queueBody = encodeGitHubInvalidationMessage({
deliveryId: verified.delivery.deliveryId,
namespace: "tenant:acme", // optional and explicitly integration-owned
plan: verified.plan,
plannedAt: new Date().toISOString(),
});
await env.INVALIDATIONS.send(queueBody);
}The convenience helper copies and bounds the exact bytes, strictly parses only
X-GitHub-Delivery, X-GitHub-Event, and X-Hub-Signature-256, verifies the
lowercase sha256=<64 hex> HMAC, then—and only then—strictly decodes UTF-8 and
JSON and invokes invalidationPlanForWebhook(). The default body limit is
256 KiB and may be configured up to 16 MiB. String secrets use UTF-8; exact
secret byte views are also accepted. The HMAC authenticates the raw body, not
the event or delivery headers; treat those headers as validated transport
metadata, not as independently signed authorization claims.
Unknown events/actions remain explicit frozen unsupported plans.
Authentication, UTF-8, JSON, complexity, and configured planner-budget
failures are distinct safe GitHubWebhookError codes. The helpers never log,
persist, enqueue, purge, acknowledge, retry, fetch replacement state, or treat
the webhook payload as authoritative GitHub state.
For branch push, create, and delete events, the plan retains the canonical
full git-ref resource and full-ref checks tag and also emits the exact
nonempty suffix of refs/heads/ as a second checks tag only when that suffix
does not begin with refs/, preventing namespace collisions. This lets
descriptors planned with either main or refs/heads/main overlap without
aliasing tags or other ref namespaces. Supported pull_request_review actions (submitted,
edited, and dismissed) include pulls.latestReviews.allWithMeta; review
comment and thread actions do not. Additive expansion still obeys configured
resource/candidate bounds all-or-error. This is minor additive behavior:
consumers may observe additional tags or the latest-review refresh candidate,
with no schema/version change. A custom refresh-candidate bound below 4 now
returns an unsupported plan for those review-state events instead of a
truncated plan.
Portable messages are deterministic UTF-8 JSON with a strict version-1 schema.
Only a planned invalidation can be encoded. The message contains the plan,
canonical delivery ID and event, caller-supplied canonical timestamp, and an
optional bounded, well-formed opaque namespace; it contains no secret,
signature, raw body, arbitrary header, or token. The default encoded/decoded
limit is 64 KiB and may be configured up to 1 MiB. Decoding rejects unknown
versions, extra or missing fields, noncanonical arrays, malformed values, and
over-budget input.
githubWebhookDeliveryKey() separately derives an opaque,
namespace-separated ghwd1: idempotency key from the versioned canonical
delivery ID. It does not deduplicate, sequence, persist, lease, retry,
dead-letter, or acknowledge at-least-once broker deliveries.
Consumers that already authenticate and decode webhooks elsewhere can keep using the pure planner directly:
import { invalidationPlanForWebhook } from "@charlie-labs/github-api/integration";
const invalidation = await invalidationPlanForWebhook({
eventName: authenticatedEventName,
payload: authenticatedSizeBoundedPayload,
});Fetch only what you need
Each high-level method maps to one explicit GitHub resource. Start with
pulls.get() only when pull metadata or exact base/head identities are needed;
use filters such as review-comment since, timeline since, check-run
checkName/status/filter/appId, and ruleset targets to avoid fetching
irrelevant data. Use .page() when one page is enough. The package does not
poll null mergeability, fetch rendered Markdown variants, combine REST file
statistics with viewer-specific GraphQL state, or issue hidden policy lookups.
The explicitly composite methods do make companion requests:
pulls.filesAtRevision()reads the pull before and after all file pages.pulls.diff.get()and.stream()read the pull before and after the diff.sources.getFile()and.getFiles()traverse the requested commit, trees, and raw blob objects.pulls.reviewFirstPaint.get()deliberately selects several small, independently pageable review connections in one bounded generated GraphQL request.pulls.mergeabilitySnapshot.get()combines a viewer-neutral first page, synchronized continuation rounds, and a trailing full-revision probe under one request ID, deadline, cancellation context, budget, and event sequence. Its deeply frozen snapshot reconciles collection totals and reports bounded per-section diagnostics when a late continuation cannot complete.
Raw calls acquire one page and never aggregate it.
Return shapes and pagination
One-to-one reads and mutations return GitHub values directly. A high-level
method never returns a generic { data, metadata } envelope. Operational
status, headers, validators, logical request correlation, retries, and rate
limits belong to events; raw.rest returns the complete Octokit response
envelope. Response-derived failures may additionally expose the sanitized
GitHub response request ID as the optional error-only
GitHubApiError.githubRequestId for support correlation. Persistence timestamps,
freshness, storage keys, and invalidation state belong to the consumer.
The named semantic acquisitions are deliberate exceptions:
PullRequestFilesAtRevision, PullRequestDiff, PullRequestDiffStream, and
AcquiredSourceContent carry only the revision, completeness, object identity,
Git mode, or content-classification fields needed to interpret their data
safely. PullReviewFirstPaint is the separately justified bounded review-page
container: its connection values remain GitHub-shaped pages with independent
continuations. None carries a generic metadata bag or transport and
persistence metadata.
Paginated resources have four forms:
const first = await github.pulls.files.page({
owner: "acme",
repo: "widgets",
pullNumber: 417,
pageSize: 50,
});
for await (const page of github.pulls.files.pages({
owner: "acme",
repo: "widgets",
pullNumber: 417,
pageSize: 50,
maxPages: 20,
maxItems: 1_000,
})) {
consume(page.items);
}
const flatFiles = await github.pulls.files.all({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});
const filesWithMeta = await github.pulls.files.allWithMeta({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});pulls.list uses those same four forms and returns GitHub's pull-request list
summary items. filters.state, filters.base, and the structured
filters.head: { owner, ref } map directly to GitHub's server-side controls;
order.by and order.direction map to sort and direction. Omitted controls
remain omitted so GitHub owns its defaults. Each page is one list request with
no hidden pulls.get() calls, mergeability lookup, or other per-pull
enrichment. The operation supports explicit anonymous-public clients for
public repositories and is also exposed by the lean /pulls entrypoint.
Defaults are 100 items per page, at most 100 pages and 10,000 items, and at
most 16 MiB for each JSON response. Per-call pageSize, maxPages,
maxItems, and maxResponseBytes override client defaults. Cursors are opaque,
bounded, resource/locator/filter/page-size/API-version-bound, stateless values
that may be persisted and resumed by another client in the same API context:
await cursorStore.put("pull-files", first.nextCursor);
const next = first.nextCursor
? await github.pulls.files.page({
owner: "acme",
repo: "widgets",
pullNumber: 417,
pageSize: 50,
cursor: first.nextCursor,
})
: undefined;Collections are fetched sequentially without sorting or deduplication. GitHub
lists are mutable, so even completeness: { state: "complete" } means only
that the documented traversal terminated without a known hard cap; it is not a
snapshot. Nested review-thread comment pageInfo remains authoritative and is
not silently collected with outer thread pages.
statuses.combinedForRef.all() is the sole non-flat .all() result. It keeps
the aggregate state, resolved SHA, repository, totalCount, items, and
whether aggregate state changed while collecting. Every other ordinary
paginated .all() returns a flat array.
Every status-check page re-verifies that the pull still points to headOid.
Viewed-file state is specific to the authenticated GitHub viewer; the package
does not persist or mirror it and keeps it separate from REST file
patches/statistics.
pulls.reviews and pulls.reviewComments request GitHub's raw-Markdown
representation. Review-comment filters (sort, direction, and since) are
passed through exactly and are bound into continuation cursors. Review-comment
and timeline since values use
YYYY-MM-DDTHH:MM:SS[.fraction]Z: uppercase UTC with complete seconds, an
optional one-to-nine-digit fraction, and a valid Gregorian calendar date and
time. Other ISO 8601 variants, offsets, leap seconds, and normalized overflow
are rejected before authentication. Accepted strings are forwarded unchanged
and their exact values are cursor-bound. GitHub exposes at most 3,000 pull files
and 250 commits through the corresponding REST endpoints. At either exact
boundary GitHub cannot prove whether more items exist, so use
pulls.commits.allWithMeta() when the difference between complete and
possibly-truncated matters, and inspect the same metadata from
pulls.files.allWithMeta() or pulls.filesAtRevision(). Raising local budgets
does not overcome either upstream cap.
The six GraphQL collection resources paginate their named connections, so
.all() and .allWithMeta() may issue multiple requests. Each review-thread
node contains one nested comment page; the ordinary thread read defaults to
100 nested comments but accepts nestedCommentPageSize, while first paint
defaults to five. Continue a thread's comments through
pulls.reviewThreads.comments with that thread ID. Pull threads, latest
reviews, timeline, and viewed state usually require Pull requests read
permission. Status rollups can additionally depend on Checks read and Commit
statuses read permissions. The default timeline is an intentionally curated
review timeline, not every GitHub timeline union member; use a
consumer-generated document through raw.graphql.execute() for another
selection.
In that curated timeline, AssignedEvent and UnassignedEvent project as
kind: "assigned" and kind: "unassigned", while LabeledEvent and
UnlabeledEvent project as kind: "labeled" and kind: "unlabeled". All four
expose id, actor, and occurredAt; the original nullable GitHub-shaped
payload remains at source.assignee or source.label. No event-level url is
exposed or synthesized for these variants.
Already-fetched GraphQL and REST values can be reconciled without hidden I/O:
import {
buildReviewThreads,
buildStatusChecks,
} from "@charlie-labs/github-api/projections";
const contexts = await github.pulls.statusChecks.all({
owner: "acme",
repo: "widgets",
pullNumber: 417,
headOid: pull.head.sha,
});
const projected = buildStatusChecks(contexts);
const [threads, comments, reviews] = await Promise.all([
github.pulls.reviewThreads.all({
owner: "acme",
repo: "widgets",
pullNumber: 417,
}),
github.pulls.reviewComments.all({
owner: "acme",
repo: "widgets",
pullNumber: 417,
}),
github.pulls.reviews.all({
owner: "acme",
repo: "widgets",
pullNumber: 417,
}),
]);
const projectedThreads = buildReviewThreads({ threads, comments, reviews });Projection results retain every raw source record and report ambiguities as data. They do not make independently fetched resources an atomic snapshot. Independent REST/GraphQL calls and pure projections are not one GitHub transaction. Boundary guards cannot detect an A-to-B-to-A movement that returns to the expected refs, and status/review collections can still change while their pages are read.
Revision-guarded pull diff
Both diff methods require the exact reviewed base/head SHAs and an explicit decoded-byte limit. The package requests GitHub's authenticated pull diff with identity content coding, preserves arbitrary representation bytes, and verifies the pull at both boundaries:
const revision = {
baseSha: pull.base.sha,
headSha: pull.head.sha,
};
const diff = await github.pulls.diff.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
...revision,
maxBytes: 8 * 1024 * 1024,
});
consumeExactBytes(diff.bytes);get() returns nothing until final verification succeeds. stream() verifies
before returning, but successful consumption is not the same as verified
completion: final pull verification occurs before the stream closes
successfully, and the separate completion promise always resolves exactly
once rather than rejecting.
const streamed = await github.pulls.diff.stream({
owner: "acme",
repo: "widgets",
pullNumber: 417,
...revision,
maxBytes: 8 * 1024 * 1024,
});
for await (const bytes of streamed.body) {
consumeExactBytes(bytes);
}
const completion = await streamed.completion;
if (completion.state !== "verified") {
throw completion.error;
}Cancelling the body early aborts upstream work, skips final verification, and
settles completion as cancelled. A slow consumer does not trigger the
body-idle timer while it is not asking for another chunk; backpressure is
preserved. Consumers requiring all-or-error verified bytes should prefer
get().
GitHub does not guarantee that the pull-level diff is complete. The package
always returns completeness: { state: "not-guaranteed-by-github", ... } and
does not infer completeness from size, line count, file markers, status, or the
optional per-file patch. It never substitutes compare-commits, whose merge
base can differ. Boundary checks also cannot detect an A-to-B-to-A ref change.
Exact source acquisition
Build current base/head locators only from the guarded file collection. This keeps repositories and exact Git object IDs aligned, including forked pulls:
import {
buildPullRefFileLocators,
inspectPullFilePatch,
} from "@charlie-labs/github-api/projections";
const guardedFiles = await github.pulls.filesAtRevision({
owner: "acme",
repo: "widgets",
pullNumber: 417,
baseSha: pull.base.sha,
headSha: pull.head.sha,
maxPages: 30,
maxItems: 3_000,
});
const locations = buildPullRefFileLocators(guardedFiles);
const head = locations.files[0]?.head;
if (head?.state === "candidate") {
const content = await github.sources.getFile({
...head.locator,
maxBytes: 2 * 1024 * 1024,
maxRequests: 64,
maxPathComponents: 64,
});
consumeSource(content);
}
const patch = inspectPullFilePatch(guardedFiles.items[0]!);Current content at pull.base.sha is not necessarily the old side of GitHub's
pull diff: renames, merge-base choice, and later base-branch changes make that
alignment unprovable. Every locator therefore reports
diffAlignment: "not-guaranteed". PullRequestFile.patch is optional and has
no completeness contract; use pulls.diff when the pull-level representation
is needed.
Batch acquisition keeps component traversal and concurrency 1 as its 1.x
compatibility defaults. It preserves input order, reuses identical
commit/tree/blob work only inside that logical call, chooses the lowest failing
started input, and returns all results or throws:
const sourceFiles = await github.sources.getFiles({
owner: "acme",
repo: "widgets",
commitSha: pull.head.sha,
paths: ["src/index.ts", "package.json"],
maxBytesPerFile: 2 * 1024 * 1024,
maxTotalBytes: 4 * 1024 * 1024,
maxRequests: 128,
maxPathComponentsPerPath: 64,
maxResponseBytes: 8 * 1024 * 1024,
traversal: "adaptive",
concurrency: 4,
maxRecursiveTreeEntries: 25_000,
});traversal: "adaptive" is opt-in. For a measured deep multi-file shape it may
fetch one recursive tree after verifying the exact commit, but only uses that
tree when it is complete, non-truncated, well formed, within the caller's
entry limit, and contains every required path prefix. It otherwise falls back
to the same bounded component walk. Shallow or too-small requests skip the
recursive attempt when it is predicted to waste a request. No fallback uses a
branch or moving ref. A result that actually used the complete recursive index
sets source.traversal: "recursive"; component and fallback traces keep the
existing shape.
concurrency is an opt-in positive integer from 1 through 8. It uses the same
client physical-request scheduler and one logical request identity, deadline,
signal, and source request/byte budgets. New paths stop scheduling after a
terminal failure, already-started work settles deterministically, and no
partial array escapes. Aggregate-byte reservations prevent concurrent blobs
from racing beyond maxTotalBytes; returned repeated-blob values retain
independent mutable byte views. Queued reads are served round-robin across
logical operations and FIFO within each operation, so one batch cannot
continually retake every newly available client-local permit. Choose
concurrency from deployed tail latency and secondary-rate evidence rather than
assuming 8 is universally faster.
JSON response bytes, physical commit/tree/blob requests (including retries and revalidations), path components, each file, and the batch total have separate budgets. Duplicate or empty paths fail before I/O. No successful subset is returned after a later failure.
Source results preserve Git mode and exact bytes:
- ordinary
100644and executable100755files are classified as strict UTF-8 text, binary, or a strict Git LFS v1 pointer; 120000symlinks return their link bytes and are never followed;160000gitlinks return the submodule commit and are never cloned;- an LFS pointer exposes pointer metadata but the LFS object is never fetched;
- missing tree paths are distinct from inaccessible repositories/objects, truncated required trees, unsupported shapes, and GitHub's blob-size limit;
- removed head versions and deleted/inaccessible fork repositories remain explicit locator states and never fall back to the base repository.
The package never follows download_url, raw_url, response-provided object
URLs, archives, or other origins.
Raw escape hatch
Raw calls cover uncommon fields and endpoints while retaining the credentialed client's authentication, scheduling, budgets, cancellation, redaction, events, timeouts, and normalized errors:
const repositoryResponse = await github.raw.rest.request("GET /repos/{owner}/{repo}", {
owner: "acme",
repo: "widgets",
});
const untypedRepositoryResponse = await github.raw.rest.requestUntyped(
"GET /repos/{owner}/{repo}",
{
path: { owner: "acme", repo: "widgets" },
headers: { accept: "application/vnd.github+json" },
},
);
const customResult = await github.raw.graphql.execute(MyGeneratedDocument, {
owner: "acme",
repo: "widgets",
});
const untypedResult = await github.raw.graphql.executeUntyped(
`query RepositoryId($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) { id }
}`,
{ owner: "acme", repo: "widgets" },
);
const selected = await github.raw.graphql.executeUntyped(
`query ViewerLogin { viewer { login } }
query ViewerId { viewer { id } }`,
{},
{ operationName: "ViewerId" },
);raw.rest.request() accepts only routes from the package's pinned Octokit
endpoint map. requestUntyped() has an explicit structured path/query/body
grammar and accepts only same-origin URLs beneath the configured API base path.
A Uint8Array body is sent as exact copied bytes; every other defined body is
JSON-serialized, and GET/HEAD reject bodies. Allowed endpoint headers may be
supplied, but credentials, framing, API version, content coding, validators,
request identity, transport, base URL, and redirect behavior remain
package-owned. The separate-origin release-asset upload route is rejected
rather than having credentials rewritten or forwarded across origins.
Every raw REST or GraphQL method is authenticated-only. Anonymous clients must use the reviewed high-level REST hierarchy.
raw.graphql.execute() infers its result and variables from a generated
TypedDocumentNode. executeUntyped() parses a string document and returns
unknown; callers must validate or narrow it. A multi-operation untyped
document requires an exact operationName.
Raw calls acquire one upstream page and intentionally provide no pagination
helpers or completeness claim. Follow REST links from the full response
envelope, or pass GraphQL pageInfo.endCursor, in an explicit subsequent call.
Cancellation, timeouts, events, and errors
A caller signal governs the complete logical operation across scheduler waits, token/cache callbacks, retries, companion reads, pages, and body consumption:
const controller = new AbortController();
const pending = github.pulls.filesAtRevision({
owner: "acme",
repo: "widgets",
pullNumber: 417,
baseSha: pull.base.sha,
headSha: pull.head.sha,
signal: controller.signal,
});
controller.abort("request superseded");
await pending;responseStartTimeoutMs measures dispatch until response headers for each
physical attempt. bodyIdleTimeoutMs measures only a pending next-chunk read;
it is not a whole-operation deadline and does not run while a stream consumer
is applying backpressure. Use AbortSignal.timeout(...), or combine it with a
caller signal, for an overall deadline:
const github = createGitHubApi({
auth: { token: process.env.GITHUB_TOKEN! },
responseStartTimeoutMs: 15_000,
bodyIdleTimeoutMs: 20_000,
});
await github.pulls.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
signal: AbortSignal.timeout(60_000),
});High-level values intentionally omit validators. Read bounded, redacted ETag/Last-Modified observations from events, or use a raw REST response when full response metadata is required:
const github = createGitHubApi({
auth: { token: process.env.GITHUB_TOKEN! },
onEvent(event) {
if (event.type === "physical-request-finish") {
observeValidators(event.etag, event.lastModified);
}
},
});
const response = await github.raw.rest.request("GET /repos/{owner}/{repo}", {
owner: "acme",
repo: "widgets",
});
observeValidators(response.headers.etag, response.headers["last-modified"]);Errors expose package-owned typed fields and bounded allowlisted details, never an arbitrary upstream response body, partial GraphQL data, mutation content, authorization headers, or an unsafe transport cause:
import {
GitHubApiError,
GitHubHttpError,
GitHubRateLimitError,
} from "@charlie-labs/github-api";
try {
await github.pulls.get({
owner: "acme",
repo: "widgets",
pullNumber: 417,
});
} catch (error) {
if (error instanceof GitHubApiError) {
reportRequestCorrelation(error.requestId, error.githubRequestId);
}
if (error instanceof GitHubRateLimitError) {
if (error.retryAt !== undefined) {
scheduleAfter(error.retryAt);
} else {
report(error.rateLimitKind, error.retryAfterMs);
}
} else if (error instanceof GitHubHttpError) {
report(error.status, error.kind, error.details.documentationUrl);
} else if (error instanceof GitHubApiError) {
report(error.kind, error.operation);
} else {
throw error;
}
}requestId identifies the logical package operation across its pages, retries,
and other physical attempts. Optional githubRequestId identifies only the
GitHub response that triggered the failure and is absent when no safe response
request ID exists.
Conditional REST cache
No cache is active by default. An injected adapter stores bounded, content-decoded JSON bytes only for eligible successful REST GET responses with a validator; each physical collection page has its own entry. Every hit is conditionally revalidated, never served as fresh by a TTL.
import type {
ConditionalRestCacheAdapter,
ConditionalRestCacheEntry,
} from "@charlie-labs/github-api";
const entries = new Map<string, ConditionalRestCacheEntry>();
const adapter: ConditionalRestCacheAdapter = {
async get(key) {
return entries.get(key);
},
async set(key, entry) {
entries.set(key, entry);
},
async delete(key) {
entries.delete(key);
},
};
const github = createGitHubApi({
auth: { token: process.env.GITHUB_TOKEN! },
cache: {
adapter,
authorizationNamespace: "acme-review-bot/visibility-v3",
},
});The namespace identifies one authorization/visibility context, not just a user. Rotate it whenever repository visibility, membership, SSO authorization, permissions/scopes, or another access property could narrow or change results. Token rotation may retain it only when that context is intentionally equivalent.
Entries use the versioned github-rest-json-utf8-v1 representation with a body
SHA-256 digest, copied bytes, bounded validators, and a storage timestamp. The
digest detects accidental corruption; the adapter is a trusted component
inside the consumer's security boundary. Cached private-repository JSON is
sensitive: apply access control, encryption at rest, retention/deletion
policy, tenant isolation, and an appropriate backup policy. The package does
not encrypt entries or choose their lifetime.
GraphQL, raw REST, mutations, diffs, decoded source bytes, and Git blob bodies are never cached. Exact commit/tree JSON used during source traversal may be revalidated independently. Adapter failures fail open except caller cancellation, which stops the operation.
Installation repositories
Use the authenticated installation namespace to enumerate repositories visible
to the current GitHub App installation token without dropping to raw:
const repositories = await github.installation.repositories.all({
pageSize: 100,
maxPages: 20,
maxItems: 2_000,
});installation.repositories exposes page(), lazy pages(), flat all(), and
allWithMeta() over GET /installation/repositories. Items use the pinned
Octokit repository schema directly, including repository ID, owner login,
name, full_name, private, visibility, archived, and html_url.
GitHub's total_count is returned as page().totalCount and is used with
validated Link continuations to reject an omitted next page. A successful
allWithMeta() reports { state: "complete" }; this means the bounded
pagination traversal ended, not that multiple pages formed an atomic snapshot.
The resource requires credentialed authentication and is intended for an installation access token. Anonymous mode rejects before transport. Ordinary read retry, cancellation, response/page/item budgets, normalized errors, conditional REST cache partitioning, and test-transport behavior apply.
Checks and commit statuses
Use the exact REST resources when their endpoint-specific fields matter:
const checkRuns = await github.checks.forRef.all({
owner: "acme",
repo: "widgets",
ref: pull.head.sha,
filter: "latest",
});
const statusHistory = await github.statuses.forRef.all({
owner: "acme",
repo: "widgets",
ref: pull.head.sha,
});
const combined = await github.statuses.combinedForRef.all({
owner: "acme",
repo: "widgets",
ref: "main",
});Check-run appId accepts a positive GitHub App database ID or GitHub's exact
-1 sentinel for runs created by GitHub Actions; zero and other negative
values are rejected before authentication.
statuses.combinedForRef.all() is the sole non-flat .all() result: it keeps
GitHub's aggregate state, resolved sha, repository, and totalCount
alongside items. After the first page resolves a branch or tag, every
continuation cursor is bound to that exact SHA, including when resumed by a new
client. Aggregate status can still change while the pages are read, so the
result exposes stateChangedDuringCollection.
The package preserves GitHub's checks and statuses; it does not decide which ones are required or whether a pull is ready to merge. Check runs typically require Checks read permission, while commit status resources typically require Commit statuses read permission, subject to repository visibility and organization policy. See GitHub's endpoint documentation for check runs, listed statuses, and [combined status](htt
