| name | github-api-integration |
| description | Class-level playbook for integrating an app against the GitHub REST + GraphQL APIs — query-complexity ("Resource limits exceeded") fan-out limits, OAuth-App vs GitHub-App scope behavior, read:enterprise / SAML-SSO null-node redaction, pagination correctness, REST rate limits, and turning raw API errors into actionable UX. Use when building or debugging anything that calls api.github.com / api.github.ghe.com (GraphQL or REST), enumerating enterprises/orgs/repos, wiring GitHub sign-in (OAuth App, GitHub App, device-code flow), or seeing GraphQL "Resource limits for this query exceeded", null enterprise/org nodes, or scope/SSO errors. |
| author | skill-review |
github-api-integration
Durable gotchas for talking to GitHub's APIs from an app (works for both
github.com and *.ghe.com). These are the failure modes that look like bugs
in your code but are actually GitHub-side behaviors with known workarounds.
Reach for this before inventing a theory about why a query "randomly" fails.
GraphQL: "Resource limits for this query exceeded"
GitHub's GraphQL endpoint enforces a query-complexity budget, not just a
rate limit. The classic trigger is a fan-out subquery: asking for one nested
field per item in a list — e.g. repositories(first:0){totalCount} under
every org in a single query. With ~140 orgs that's ~140 nested connections in
one request and you get one "Resource limits for this query exceeded" error per
item.
Fix: do not fan out. Split into per-item lightweight queries and run
them sequentially (top-down), caching results. One org's count query —
organization(login:"x"){repositories(first:0){totalCount}} — is cheap and well
under budget when issued alone. Sequential prefetch also avoids head-of-line
blocking: don't let one giant org (thousands of repos = dozens of paginated
round-trips) stall the queue for smaller orgs behind it; fetch a cheap count
first, defer the full list.
OAuth App vs GitHub App: scopes behave differently
- OAuth Apps honor the
scope= parameter at sign-in. Adding a scope (e.g.
read:enterprise) to the authorize URL and re-authing Just Works. The consent
screen shows real scope names ("Full control of your repositories").
- GitHub Apps ignore the
scope= param entirely. Permissions are
pre-declared on the App settings and granted at install/authorize time.
Tell-tale signs you're dealing with a GitHub App: generic consent wording
("Verify your identity / Act on your behalf") and "has not been installed on
any accounts." Changing your scope= string changes nothing — you must edit
the App's declared permissions, then re-authorize.
Diagnose which one you have first; otherwise you'll chase a scope bug that
your code can't fix.
viewer.enterprises returns null nodes (scope/SSO redaction)
Querying viewer.enterprises can return a non-zero count while every
EnterpriseNode in nodes is null — e.g.
{"viewer":{"enterprises":{"nodes":[null,null,null,null]}}}. This is not a
parse bug; it's GitHub's "you can see that it exists but not read it" response.
Two causes:
- Missing
read:enterprise scope (OAuth App) or missing enterprise
permission (GitHub App).
- SAML SSO authorization not granted on the OAuth App for the enterprise's
underlying orgs — nodes stay
null even with read:enterprise.
Handle it gracefully: detect count > 0 && all nodes null and surface a
clear "your sign-in is missing read:enterprise (or SSO authorization) — sign
out and back in to re-authorize" message, not a raw JSON-parse error. The same
null-node pattern appears for individual organization(...) nodes behind SSO.
404 on private resources hides the auth-vs-permission distinction
GitHub returns HTTP 404 (not 401/403) for both unauthenticated and
unauthorized access to a private resource, deliberately, so it never discloses
that the resource exists. On a private web or JSON endpoint, a logged-out session
and a signed-in-but-unpermissioned session both look like 404 — so status code
alone cannot distinguish an expired session from a missing permission.
To disambiguate, probe a known-accessible resource first: hit an endpoint the
current identity is definitely entitled to (e.g. an authenticated root page whose
HTML carries a user-login marker). If that probe shows you're authenticated, a
later 404/403 on a locked-down endpoint means permission-denied, not session
expiry; if the probe itself fails, the session is logged out/expired.
Make API errors actionable, not scary
Several GitHub error families are normal operating conditions, not breakage —
catch and humanize them at the UI boundary instead of dumping raw strings:
- IP allow list:
the 'X' organization has an IP allow list enabled, and your IP address is not permitted... — org policy, per-org. Show a 🔒 "IP
restricted" badge, not a wall of GraphQL errors. (Note: returns HTTP 200 with
errors in the body, so check the GraphQL errors array even on 200.)
- Unauthorized: Invalid or expired token: usually a token minted for the
wrong host (a
*.ghe.com token sent to api.github.com or vice versa) — keep
host and token strictly paired.
- GraphQL JSON parse error: before theorizing, surface the actual response
body (URL + first ~500 chars) so you can see whether GitHub returned HTML, a
different shape, or null nodes. Most "parse errors" are really redacted nulls.
Pagination correctness (REST)
/orgs/{org}/repos (and similar) paginate at up to 100 per page; a 3,800-
repo org is ~38 round-trips. Budget for it or defer it.
- Watch for a paginator that hard-codes
per_page (e.g. 50) and silently
overrides a caller's 100 — max_pages * per_page then caps results lower than
intended (5×50 = 250, not 500), and any "truncated?" check keyed on a higher
threshold never fires. Keep per_page, max_pages, and the truncation
threshold consistent across the call path.
- REST search API is rate-limited to 30 req/min per user and matches on
name only (no description/topic) — fine for a picker, not for exhaustive
enumeration. "Instant" search results over a huge org usually mean you're only
searching the locally-cached/first-page subset, not live.
Enumerating Copilot seats, licenses & agent activity (enterprise)
GitHub's enterprise Copilot enumeration endpoints have unstable ordering and
no total-consistent pagination — walking them naively both duplicates and
drops rows. Reconcile to a known total; never trust a single page walk.
/enterprises/{ent}/copilot/billing/seats has unstable page-offset
pagination: naive page=1..ceil(total/100) yields duplicates AND misses ~10%
of seats. You must follow Link rel=next to exhaustion; even then expect
only ~97% coverage — mark the result incomplete honestly. Per-seat fields
include organization.login, assigning_team (enterprise team slug ent:...),
last_activity_at, plan_type, pending_cancellation_date. The org-level
/orgs/{org}/copilot/billing/seats 404s when Copilot billing is
enterprise-managed.
- The
all_member_licenses endpoint has NO sort param and its seat query is
an unordered UNION ALL, so page order is unstable across refetches — the
client must reconcile-to-total, not page once.
- Copilot coding-agent Actions runs are attributed to actor
{login:"Copilot", id:198982749, type:"Bot"} — a distinct identity from
the PR-author copilot-swe-agent[bot], which is never the Actions-run actor.
Don't conflate them when attributing runs.
- No single API lists all agentic activity enterprise-wide. Live "running
now" is only repo/user-scoped (requires fan-out); enterprise breadth is
historical only (audit log
actor:Copilot, or copilot/usage-records over
a trailing 48h window).
/enterprises/{ent}/copilot/custom-agents needs the copilot OAuth scope
plus an enterprise role — without them it's a 403, not a 404. It
returns only a {name, file_path, url} inventory; there's no run/session
provenance to join onto live activity.
- Audit-log scope differs by host. On github.com,
GET /orgs/{org}/audit-log
needs admin:org and org-owner rights (read:org gets a 403); on GHES the
same endpoint accepts read:org for org admins — so audit-log breadth works on
a GHE enterprise but not on a dotcom sub-org you don't own.
Inside the github/github monorepo (internal)
Facts specific to developing inside the github/github Rails monorepo (not the
public API):
- Enumerate every internal JSON REST URL by joining
bin/rails routes output
with a static scan of controllers (app/ + packages/*/app/controllers) for
actions doing render json: / format.json. Neither source alone is complete.
Configuration#set! / #delete commit OUTSIDE the enclosing ActiveRecord
transaction — ActiveRecord::Rollback will not undo Copilot policy
writes. In tests, use explicit teardown (reset to nil / DEFAULTS) rather
than relying on transactional rollback to clean up.
- Read policy slices with
config.raw, not config.get. get coerces a
stored "false" string into a boolean, which breaks a downstream JSON.parse
/ to_i. (Copilot SWE-agent policy readers are registered on Organization in
packages/orgs/app/models/organization/configuration_dependency.rb.)
Configuration::Entry caps each stored value at 255 chars (keys at 80).
For a JSON-blob policy, store one entry per item (e.g. a key per tier) or use a
GitHub::KV store — never a single multi-item blob.
- Run one package's tests with
bin/rails test <path/to/_test.rb>. It boots
in "dotcom mode" and takes minutes to load.
- The rulesets engine lives in
packages/branch_protections. A new rule type
means: add a *_rule.rb under app/models/rule_engine/rules/, wire it into the
target mixins in app/models/ruleset_definitions/, gate it with a
feature_flag:, and regenerate the GraphQL enum via script/dump-rules-schema
— the Copilot SWE-agent and code-review rules are existing precedent.
For desktop/CLI apps without a redirect URI, use the device-code flow:
request device+user codes, show the user_code + verification_uri, then poll.
Pitfalls seen in the wild:
- If the user closes the code popup without entering it, stop polling —
otherwise the UI hangs on an "authenticating" spinner forever. Tie the poll
loop's lifetime to the popup.
- On macOS, persisting the token can fail with
keychain write failed: ... item already exists (stale entry from a prior attempt) or User interaction is not allowed (locked keychain / no UI session). Delete-then-write the keychain
item, and treat "already exists" as "overwrite," not a hard failure.
Org repo ownership & enumerating who has admin
A repo's creation path decides its initial ownership — and that bites later
when someone needs admin:
- A repo created via the API /
gh CLI under an org (e.g.
gh repo create org/name) is ownerless by default — no owning team is
assigned, and the human creator is not automatically an admin. Admin then
lives only in the org's owner/admin teams (or whoever an org owner later
grants). The creator can end up with mere push/triage via some inherited
team and be unable to reach Settings → Collaborators and teams at all.
- Creating the same repo via the web UI forces an owner-team picker at
creation time, so ownership is explicit from the start. If you want a clear
owner, create in the browser (or assign a team immediately after a CLI create).
Enumerating who has admin as a non-admin is mostly blocked by the API. The
team/collaborator/branch-protection endpoints (/repos/{o}/{r}/teams,
/orgs/{o}/teams, admin collaborator lists, org-owners list) 404 or redact
for a viewer without admin/read:org. Don't read those 404s as "no admins
exist" — they mean "you can't see them."
- Web-UI diagnostic fallback (not a stable automation path): when the admin
APIs are hidden for your token, the authenticated Insights → Access page
(and the repo's Settings sidebar visibility) can still reveal admin/role
badges the API won't return. Use it as a one-off human diagnostic to answer
"who do I ask?", not as something to script — it depends on UI markup, your
viewer permissions, and org settings.
Enterprise & org policy: which surfaces have an API
- Enterprise Actions policy is fully REST-scriptable at
/enterprises/{ent}/actions/permissions/* — the allowed-actions allowlist,
sha_pinning_required, fork-PR approval, and the default read-only
GITHUB_TOKEN setting. But the classic member-privilege enterprise policies
(repo creation / visibility / base permissions) are UI-only — no API.
- GitHub's native policy engine is rulesets + custom properties. Rulesets
carry a conditions engine (
ref_name / repo_name / repo custom-property, plus
org targeting at the enterprise level) and an evaluate (dry-run) mode;
custom properties act as targeting labels. All of it is GA via REST / Terraform.
Pitfalls
- Reading repo-admin 404s as "no admins." Team/owner endpoints redact for
non-admin viewers; absence of a result is missing permission, not missing data.
- Assuming a CLI-created org repo has an owner. API/
gh-created org repos
are ownerless by default; the creator may not be an admin. Assign a team early.
- Assuming GraphQL limits are rate limits. Complexity budget is separate;
the fix is restructuring the query (de-fan-out), not backing off in time.
- Trusting HTTP 200. GraphQL returns 200 with an
errors array for IP
allow-list, resource-limit, and partial-permission cases. Always inspect
errors.
- Reading a null node as an error. Null = redacted (scope/SSO), not absent.
Distinguish "doesn't exist" from "can't read it."
- Mixing host and token. A
*.ghe.com token is invalid against
api.github.com; pair them per host.
- Reading a
/users/{login} 404 as "no such account." Accounts with a
hidden/private profile 404 on GET /users/<login> yet remain valid recipients
for a PUT /repos/{o}/{r}/collaborators/<login> invite — 404 there means "not
visible," not "nonexistent."