Skip to content

Diagnostic GitHub API

a pull request's files and commits lists are both capped

The review bot posts its summary on a nine-hundred-file pull request and says three files changed. Nobody reads past the first line, because a bot that has been right for a year is not a thing people audit. The pull request object knew the real number the whole time. It is just that changed_files lives on the pull request and the file list lives one URL further down, and nothing in either response mentions the other.

Read-only token Python and Node.js Tests included
Man loading boxes from a truck onto a dolly.
Photo by chansu shin on Unsplash
The short answer

GET /repos/{owner}/{repo}/pulls/{n}/files returns at most 3,000 files, and GET /repos/{owner}/{repo}/pulls/{n}/commits at most 250. Both paginate at 30 an page by default, and both answer 200 with a shorter array rather than saying they stopped.

The counters that contradict them are on the pull request itself: changed_files and commits on GET /repos/{owner}/{repo}/pulls/{n}. Fetch those first, paginate the lists at per_page=100, and assert the collected count against the counter, failing loudly on a mismatch. Where changed_files exceeds 3,000 no amount of correct pagination will reach the end, and the diff has to come from the application/vnd.github.diff media type instead.

The problem in plain words

Two numbers describe the same pull request and they are served by different endpoints, which is the entire mechanism. A bot that only ever calls .../files has no way to know what it is missing, because the response that is short does not carry the number it is short of. The bot is not ignoring a warning. There is no warning to ignore.

The default page size does most of the damage before either cap is reached. A client that reads one page of files at the default 30, decides that is the diff and moves on, is wrong on every pull request with 31 changed files — a much lower bar than 3,000, and one your repository crossed long ago. The cap is the ceiling; the default is the floor, and a bot can be losing data at both ends of the same endpoint.

What makes it stay hidden is that the truncation is plausible. Three files changed is a normal-looking answer. Two hundred and fifty commits is a normal-looking answer for a long-lived branch. Nothing in the output reads as broken, so the failure shows up as a judgment call nobody questions: the security bot that did not flag a file, the changelog that skipped a fix, the reviewer requirement that matched no owner because the owned path was on page four.

Bot lists PRfilesone endpoint,default page30 of 900arrive200, no flag, noheaderCounter neverreadchanged_files iselsewhereSummary ispostedthree fileschangedNobody auditsitthe bot has beenrightand the next big pull request is truncated too
The bot is not ignoring a warning. The response that is short does not carry the number it is short of, and the number lives one URL away.

Why it happens

The ground truth is at a different URL from the list. changed_files, commits, additions and deletions are fields on the pull request object. The lists that enumerate those things are separate endpoints with their own pagination and their own ceilings. Any check worth running therefore costs at least two requests, because it is a comparison between two responses and there is no single response that contains both sides of it.

Two different caps, two different repairs. Files stop at 3,000, commits at 250. Above the commit cap the answer is a different endpoint: GET /repos/{owner}/{repo}/commits with a sha and a range paginates conventionally and has no such ceiling. Above the file cap the answer is not an endpoint at all but a media type — request the pull request with application/vnd.github.diff and parse the diff, because the JSON list is simply not going to hand you file 3,001.

This is not the compare endpoint's 250-commit cap. That note is about /compare/{base}...{head}, where total_commits arrives in the same body as the truncated array, so the finding is arithmetic you can do on one response. Here the counter is on another resource entirely, and a client that never fetches it cannot detect the truncation at all. Same number, 250, in both places; completely different detection.

Pagination is necessary and not sufficient. A client that follows rel="next" to the end of .../files is doing the right thing and still stops at 3,000 on a big enough pull request, with no rel="next" left to warn it. That is why the check compares against a counter rather than against the shape of the last page: the header is telling the truth about the endpoint, and the endpoint is not telling the whole truth about the pull request.

The pull requests that trip it are the ones that matter. Vendored dependency bumps, generated clients, framework upgrades, the merge of a long-running branch. These are exactly the changes a review bot exists to look at, and exactly the ones where its answer is truncated. A bot that is right on small pull requests and quietly wrong on large ones is worse than no bot, because it is trusted in inverse proportion to how correct it is.

The API cannot see your collector, so the script measures the endpoint. Nothing GitHub returns says how many pages your bot read. What a read-only script can do is state the declared counts, ask each list endpoint how far it is willing to go by reading its Link header at per_page=100, and report where those two things cannot be reconciled. That is the gap; whether your code falls into it is one loop away and the script prints the assertion to add.

The fix, as a flow

Three GETs per pull request, and they are three rather than one for the same reason the bug exists: the counter and the list it describes live at different URLs. One request reads changed_files and commits off the pull request object, and the other two ask each list endpoint how far it is prepared to go by reading its Link header at the maximum page size. Everything after that is arithmetic over two numbers from two responses, so the rule is pure and the tests never touch the network.

PR counter and listdeclared count against page countAbove the endpoint capunreachable at any page sizePages cannot hold itsomething truncated the listMore than one pageone page at 30 loses the restCounter matches pagescollected count can be asserted
The top row is the only one no page size can repair. The second is a list that stopped somewhere the header cannot explain.

How to fix it

Fetch the pull request object first, not the lists

One request to GET /repos/{owner}/{repo}/pulls/{n} gives you changed_files and commits. This is the only place those numbers exist, and every later judgment is a comparison against them. A collector that skips this call has nothing to check itself against and will believe whatever the list endpoints hand it.

Ask each list endpoint how far it will go

One page of .../files and one of .../commits at per_page=100, read for the Link header rather than for the items. A rel="last" gives the endpoint's own page count; the absence of both next and last means one page is all there is. Either way you now know the widest band of items the endpoint is prepared to serve.

Reconcile the counter against the band

changed_files should land inside the range the page count implies. Above 3,000 files or 250 commits it cannot, and no page size will change that. Below the cap but outside the band, something else truncated the list and the mismatch is the finding either way.

Set per_page to 100 and assert on the collected count

The default of 30 is where most of this is lost, long before either ceiling. Collect to the end of the Link header at 100, then compare what you collected against changed_files and commits, and raise rather than log when they differ. An assertion that fires on the one pull request in a thousand is what turns this from a silent wrong answer into a ticket.

Take the big ones through the diff instead

Above 3,000 files, request the pull request with the application/vnd.github.diff media type and parse the diff; above 250 commits, walk GET /repos/{owner}/{repo}/commits with a sha and a date range. Both are more work than the JSON list and both are the only ways to see the whole thing. The audit itself costs three requests per pull request against the hourly core quota.

How to check it worked

Once the collector paginates at 100 and asserts against the counters, the audit turns into a description of the pull request rather than a warning about it.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_pr_truncation.py --repo acme/monorepo --pr 4821
# read cost: 3 request(s) against the core hourly quota
# pull 4821 declares 4200 changed file(s) and 61 commit(s)
# files: beyond-cap - 4200 file(s) are declared and the endpoint stops at 3000,
# so 1200 of them cannot be read through it at any page size
# repair: request the pull request with the application/vnd.github.diff media
# type and parse the diff; the JSON list will not return file 3001
# commits: multi-page - 61 commit(s) across 1 page(s) at per_page=100. A client
# reading one page at the default 30 sees 30 of them and misses 31

# a normal pull request, after the collector was fixed
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_pr_truncation.py --repo acme/monorepo --pr 4830
# files: single-page - 7 file(s) fit in one page at any page size
# commits: single-page - 2 commit(s) fit in one page at any page size

The full code

Three GETs per pull request: the pull request object for its counters, then one page each of files and commits at the maximum page size, read for their Link headers rather than for their contents. Everything after that is arithmetic over two numbers that came from two different responses, which is the whole point of the note, so all of it is pure and all of it is tested offline. The cost is computed and printed before the first request, because a section full of quota notes should not ship a diagnostic that surprises you.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 101 GitHub API fixes, free and open source.
github_pr_truncation.py
"""Compare a pull request's own counters against what its lists can return.

Read only. Three GETs per pull request. Nothing is written and the repair is
printed rather than performed.

A pull request object carries changed_files and commits. The two list endpoints
hanging off it do not: .../files stops at 3,000 files and .../commits at 250,
and both answer 200 with a shorter array rather than saying they stopped. The
counters are therefore the only ground truth a client has, and they live at a
different URL from the lists they describe.

What this can and cannot see: the API has no idea how many pages your collector
read. It can state what the pull request declares, how far each list endpoint is
prepared to go, and where those two things cannot be reconciled.

Environment:

    GITHUB_TOKEN    a token with read access to the repository
"""
import argparse
import json
import logging
import math
import os
import re
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_pr_truncation")

API = "https://api.github.com"
UA = "github-pr-truncation/1.0"

MAX_PER_PAGE = 100
# What you get when nobody sets per_page, which is where most of the loss
# happens: long before either ceiling, on any pull request over thirty files.
DEFAULT_PER_PAGE = 30

# The documented ceilings on the two lists hanging off a pull request. Named
# together because the note is about the pair of them and their repairs differ.
CAPS = {"files": 3000, "commits": 250}

# Anchored on the angle brackets rather than split on commas: a pagination URL
# can carry a comma of its own and splitting on it breaks the link in half.
LINK = re.compile(r'<([^>]+)>\s*;\s*rel="([^"]+)"')
PAGE = re.compile(r'[?&]page=(\d+)')


def parse_link(header):
    """Parse a Link header into {rel: url}. Pure."""
    if not header:
        return {}
    return {rel: url for url, rel in LINK.findall(header)}


def page_of(url):
    """The page number inside a pagination URL, or None. Pure."""
    if not url:
        return None
    m = PAGE.search(str(url))
    return int(m.group(1)) if m else None


def cap_for(kind):
    """The documented ceiling on this list, or None if there isn't one. Pure."""
    return CAPS.get(kind)


def pages_needed(total, per_page):
    """Pages required to hold this many items. Pure. None for nonsense input."""
    try:
        n, size = int(total), int(per_page)
    except (TypeError, ValueError):
        return None
    if n < 0 or size < 1:
        return None
    return int(math.ceil(n / float(size)))


def reachable(kind, declared):
    """How many of the declared items the list endpoint can actually hand over."""
    cap = cap_for(kind)
    try:
        n = int(declared)
    except (TypeError, ValueError):
        return None
    if cap is None:
        return n
    return min(n, cap)


def beyond_cap(kind, declared):
    """How many items are unreachable through this endpoint. Pure. 0 when fine."""
    cap = cap_for(kind)
    try:
        n = int(declared)
    except (TypeError, ValueError):
        return 0
    if cap is None:
        return 0
    return max(0, n - cap)


def bounds_from_last(last_page, per_page):
    """The item count a rel=last page number implies, as (low, high). Pure.

    A last page of 3 at per_page=100 means somewhere between 201 and 300 items:
    the final page holds at least one and at most a full page. That band is the
    widest honest statement the header supports, so the counter is only called a
    disagreement when it falls outside it.
    """
    try:
        last, size = int(last_page), int(per_page)
    except (TypeError, ValueError):
        return None
    if last < 1 or size < 1:
        return None
    return ((last - 1) * size + 1, last * size)


def counter_outside_bounds(declared, bounds):
    """Whether the pull request's own count contradicts the page count. Pure."""
    if not bounds:
        return False
    try:
        n = int(declared)
    except (TypeError, ValueError):
        return False
    return n < bounds[0] or n > bounds[1]


def one_page_shortfall(declared, per_page=DEFAULT_PER_PAGE):
    """Items a client reading a single page never sees. Pure."""
    try:
        n, size = int(declared), int(per_page)
    except (TypeError, ValueError):
        return 0
    return max(0, n - max(0, size))


def verdict(kind, declared, last_page=None, per_page=MAX_PER_PAGE):
    """Classify one list against the counter that describes it. Pure.

    Returns (state, detail). The states keep three unreconcilable things apart:
    a count above the endpoint's ceiling, a count the endpoint's own page count
    cannot contain, and a count that is fine but needs more than one page.
    """
    cap = cap_for(kind)
    if cap is None:
        return ("unknown", "%r is not a list this check knows a ceiling for." % kind)
    try:
        n = int(declared)
    except (TypeError, ValueError):
        return ("unknown",
                "the pull request did not report a count for %s, so there is "
                "nothing to reconcile the list against." % kind)
    if n < 0:
        return ("unknown", "a negative count for %s is not a number this check "
                           "can use." % kind)

    over = beyond_cap(kind, n)
    if over:
        return ("beyond-cap",
                "%d %s are declared and the endpoint stops at %d, so %d of them "
                "cannot be read through it at any page size."
                % (n, kind, cap, over))

    bounds = bounds_from_last(last_page, per_page)
    if counter_outside_bounds(n, bounds):
        return ("counter-disagrees",
                "the pull request declares %d %s and the Link header stops at "
                "page %s, which can hold between %d and %d, so the list is "
                "shorter than the counter and something truncated it."
                % (n, kind, last_page, bounds[0], bounds[1]))

    if n > DEFAULT_PER_PAGE:
        return ("multi-page",
                "%d %s across %d page(s) at per_page=%d. A client reading one "
                "page at the default %d sees %d of them and misses %d."
                % (n, kind, pages_needed(n, per_page) or 1, int(per_page),
                   DEFAULT_PER_PAGE, min(n, DEFAULT_PER_PAGE),
                   one_page_shortfall(n)))

    return ("single-page",
            "%d %s fit in one page at any page size, so nothing here is being "
            "truncated today." % (n, kind))


def repair(state, kind):
    """The sentence a reader has to act on. Pure."""
    if state == "beyond-cap" and kind == "files":
        return ("request the pull request with the application/vnd.github.diff "
                "media type and parse the diff. The JSON list will not return "
                "file 3001 however you paginate it.")
    if state == "beyond-cap" and kind == "commits":
        return ("read the branch through GET /repos/{owner}/{repo}/commits with "
                "a sha and a date range, which paginates conventionally and has "
                "no ceiling of its own.")
    if state == "counter-disagrees":
        return ("collect the whole list at per_page=100 and compare the count "
                "you collected against changed_files and commits on the pull "
                "request object, raising rather than logging on a mismatch.")
    if state == "multi-page":
        return ("set per_page=100, follow rel=next to the end, and assert the "
                "collected count against the counter on the pull request "
                "object. The default page size of 30 is where this is lost.")
    if state == "single-page":
        return ("nothing on this pull request. Run the same check against your "
                "largest ones, which are the ones a review bot is trusted on.")
    return "point the check at a pull request this token can read."


def read_cost(prs):
    """Requests this run will spend against the core quota. Pure.

    Three per pull request: the object for its counters, and one page each of
    files and commits for their Link headers.
    """
    return 3 * len(prs or [])


def get(session, path, params=None):
    """One GET. Returns (status, parsed-body-or-None, links)."""
    r = session.get(API + path, params=params or {}, timeout=30)
    links = parse_link(r.headers.get("Link"))
    if r.status_code == 401:
        raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
                         "or revoked")
    if r.status_code == 403 and "rate limit" in r.text.lower():
        raise SystemExit("403 rate limited. GET /rate_limit reports the reset "
                         "time and does not itself consume quota")
    if r.status_code != 200:
        log.info("%s returned %d; skipping it", path, r.status_code)
        return r.status_code, None, links
    try:
        return r.status_code, r.json(), links
    except ValueError:
        return r.status_code, None, links


def last_page_from(links):
    """The endpoint's own page count, or 1 when it says there is only one. Pure.

    None where there is a next page but no last, because then the page count is
    genuinely unknown and guessing it is how the other notes in this section
    start.
    """
    last = page_of(links.get("last"))
    if last:
        return last
    if "next" in links:
        return None
    return 1


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", required=True, help="owner/name")
    ap.add_argument("--pr", action="append", type=int, required=True,
                    help="pull request number. Repeatable.")
    ap.add_argument("--per-page", type=int, default=MAX_PER_PAGE,
                    help="page size used to probe the lists. 100 is the maximum "
                         "and there is no reason to probe with less.")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (a read-only token is enough)")
        return 2

    log.info("read cost: %d request(s) against the core hourly quota",
             read_cost(args.pr))

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        # GitHub rejects requests with no User-Agent outright.
        "User-Agent": UA,
    })

    findings = []
    for number in args.pr:
        base = "/repos/%s/pulls/%d" % (args.repo, number)
        _status, pr, _links = get(session, base)
        if not isinstance(pr, dict):
            continue
        log.info("pull %d declares %s changed file(s) and %s commit(s)",
                 number, pr.get("changed_files"), pr.get("commits"))

        for kind, declared in (("files", pr.get("changed_files")),
                               ("commits", pr.get("commits"))):
            _s, _body, links = get(session, base + "/" + kind,
                                   {"per_page": args.per_page})
            last = last_page_from(links)
            state, detail = verdict(kind, declared, last, args.per_page)
            log.info("%s: %s - %s", kind, state, detail)
            log.info("repair: %s", repair(state, kind))
            findings.append({
                "pull_request": number,
                "list": kind,
                "declared": declared,
                "cap": cap_for(kind),
                "reachable": reachable(kind, declared),
                "unreachable": beyond_cap(kind, declared),
                "endpoint_last_page": last,
                "implied_bounds": bounds_from_last(last, args.per_page),
                "missed_by_one_default_page": one_page_shortfall(declared),
                "state": state,
                "detail": detail,
                "repair": repair(state, kind),
            })

    print(json.dumps({"requests_spent": read_cost(args.pr),
                      "findings": findings}, indent=2, default=str))
    bad = {"beyond-cap", "counter-disagrees", "multi-page"}
    return 1 if any(f["state"] in bad for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
github-pr-truncation.mjs
/**
 * Compare a pull request's own counters against what its lists can return.
 *
 * Read only. Three GETs per pull request. Nothing is written and the repair is
 * printed rather than performed.
 *
 * Environment:
 *   GITHUB_TOKEN     a token with read access to the repository
 *   GITHUB_REPO      owner/name
 *   GITHUB_PRS       comma-separated pull request numbers
 *   GITHUB_PER_PAGE  page size used to probe the lists, default 100
 */
const API = 'https://api.github.com';
const UA = 'github-pr-truncation/1.0';

export const MAX_PER_PAGE = 100;
/** What you get when nobody sets per_page, and where most of the loss happens. */
export const DEFAULT_PER_PAGE = 30;

/** The documented ceilings on the two lists hanging off a pull request. */
export const CAPS = { files: 3000, commits: 250 };

// Anchored on the angle brackets rather than split on commas: a pagination URL
// can carry a comma of its own and splitting on it breaks the link in half.
const LINK = /<([^>]+)>\s*;\s*rel="([^"]+)"/g;
const PAGE = /[?&]page=(\d+)/;

/**
 * A finite number, or null. Pure.
 *
 * Written out because Number(null) is 0 in JavaScript and Number('') is 0 too,
 * which is exactly how a missing counter turns into a confident zero and a
 * diagnostic starts reporting a truncation nobody has.
 */
function toNumber(value) {
  if (value === null || value === undefined || value === '') return null;
  const n = Number(value);
  return Number.isFinite(n) ? n : null;
}

/** Parse a Link header into {rel: url}. Pure. */
export function parseLink(header) {
  const out = {};
  if (!header) return out;
  for (const m of String(header).matchAll(LINK)) out[m[2]] = m[1];
  return out;
}

/** The page number inside a pagination URL, or null. Pure. */
export function pageOf(url) {
  if (!url) return null;
  const m = PAGE.exec(String(url));
  return m ? Number(m[1]) : null;
}

/** The documented ceiling on this list, or null. Pure. */
export function capFor(kind) {
  return Object.prototype.hasOwnProperty.call(CAPS, kind) ? CAPS[kind] : null;
}

/** Pages required to hold this many items. Pure. null for nonsense input. */
export function pagesNeeded(total, perPage) {
  const n = toNumber(total);
  const size = toNumber(perPage);
  if (n === null || size === null || n < 0 || size < 1) return null;
  return Math.ceil(n / size);
}

/** How many of the declared items the endpoint can actually hand over. Pure. */
export function reachable(kind, declared) {
  const cap = capFor(kind);
  const n = toNumber(declared);
  if (n === null) return null;
  return cap === null ? n : Math.min(n, cap);
}

/** How many items are unreachable through this endpoint. Pure. */
export function beyondCap(kind, declared) {
  const cap = capFor(kind);
  const n = toNumber(declared);
  if (n === null || cap === null) return 0;
  return Math.max(0, n - cap);
}

/**
 * The item count a rel=last page number implies, as [low, high]. Pure.
 *
 * A last page of 3 at per_page=100 means between 201 and 300 items. That band
 * is the widest honest statement the header supports.
 */
export function boundsFromLast(lastPage, perPage) {
  const last = toNumber(lastPage);
  const size = toNumber(perPage);
  if (last === null || size === null || last < 1 || size < 1) return null;
  return [(last - 1) * size + 1, last * size];
}

/** Whether the pull request's own count contradicts the page count. Pure. */
export function counterOutsideBounds(declared, bounds) {
  if (!bounds) return false;
  const n = toNumber(declared);
  if (n === null) return false;
  return n < bounds[0] || n > bounds[1];
}

/** Items a client reading a single page never sees. Pure. */
export function onePageShortfall(declared, perPage = DEFAULT_PER_PAGE) {
  const n = toNumber(declared);
  const size = toNumber(perPage);
  if (n === null || size === null) return 0;
  return Math.max(0, n - Math.max(0, size));
}

/** Classify one list against the counter that describes it. Pure. */
export function verdict(kind, declared, lastPage = null, perPage = MAX_PER_PAGE) {
  const cap = capFor(kind);
  if (cap === null) {
    return ['unknown', `${kind} is not a list this check knows a ceiling for.`];
  }
  const n = toNumber(declared);
  if (n === null) {
    return ['unknown',
      `the pull request did not report a count for ${kind}, so there is nothing `
      + 'to reconcile the list against.'];
  }
  if (n < 0) {
    return ['unknown', `a negative count for ${kind} is not a number this check can use.`];
  }

  const over = beyondCap(kind, n);
  if (over) {
    return ['beyond-cap',
      `${n} ${kind} are declared and the endpoint stops at ${cap}, so ${over} of `
      + 'them cannot be read through it at any page size.'];
  }

  const bounds = boundsFromLast(lastPage, perPage);
  if (counterOutsideBounds(n, bounds)) {
    return ['counter-disagrees',
      `the pull request declares ${n} ${kind} and the Link header stops at page `
      + `${lastPage}, which can hold between ${bounds[0]} and ${bounds[1]}, so `
      + 'the list is shorter than the counter and something truncated it.'];
  }

  if (n > DEFAULT_PER_PAGE) {
    return ['multi-page',
      `${n} ${kind} across ${pagesNeeded(n, perPage) || 1} page(s) at `
      + `per_page=${Number(perPage)}. A client reading one page at the default `
      + `${DEFAULT_PER_PAGE} sees ${Math.min(n, DEFAULT_PER_PAGE)} of them and `
      + `misses ${onePageShortfall(n)}.`];
  }

  return ['single-page',
    `${n} ${kind} fit in one page at any page size, so nothing here is being `
    + 'truncated today.'];
}

/** The sentence a reader has to act on. Pure. */
export function repair(state, kind) {
  if (state === 'beyond-cap' && kind === 'files') {
    return 'request the pull request with the application/vnd.github.diff media '
      + 'type and parse the diff. The JSON list will not return file 3001 '
      + 'however you paginate it.';
  }
  if (state === 'beyond-cap' && kind === 'commits') {
    return 'read the branch through GET /repos/{owner}/{repo}/commits with a sha '
      + 'and a date range, which paginates conventionally and has no ceiling of '
      + 'its own.';
  }
  if (state === 'counter-disagrees') {
    return 'collect the whole list at per_page=100 and compare the count you '
      + 'collected against changed_files and commits on the pull request object, '
      + 'raising rather than logging on a mismatch.';
  }
  if (state === 'multi-page') {
    return 'set per_page=100, follow rel=next to the end, and assert the '
      + 'collected count against the counter on the pull request object. The '
      + 'default page size of 30 is where this is lost.';
  }
  if (state === 'single-page') {
    return 'nothing on this pull request. Run the same check against your '
      + 'largest ones, which are the ones a review bot is trusted on.';
  }
  return 'point the check at a pull request this token can read.';
}

/** Requests this run will spend against the core quota. Pure. */
export function readCost(prs) {
  return 3 * (Array.isArray(prs) ? prs.length : 0);
}

/** The endpoint's own page count, or null when it cannot be known. Pure. */
export function lastPageFrom(links) {
  const last = pageOf((links || {}).last);
  if (last) return last;
  if (links && Object.prototype.hasOwnProperty.call(links, 'next')) return null;
  return 1;
}

function headers(token) {
  return {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
}

async function get(token, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const res = await fetch(url, { headers: headers(token) });
  const links = parseLink(res.headers.get('link'));
  if (!res.ok) return { status: res.status, body: null, links };
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  return { status: res.status, body, links };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const repo = process.env.GITHUB_REPO;
  const prs = (process.env.GITHUB_PRS || '').split(',').map((s) => s.trim()).filter(Boolean);
  if (!token || !repo || prs.length === 0) {
    console.error('set GITHUB_TOKEN (read-only is enough), GITHUB_REPO=owner/name '
      + 'and GITHUB_PRS=4821,4830');
    process.exitCode = 2;
    return;
  }
  const perPage = Number(process.env.GITHUB_PER_PAGE || MAX_PER_PAGE);
  console.log(`read cost: ${readCost(prs)} request(s) against the core hourly quota`);

  const findings = [];
  for (const number of prs) {
    const base = `/repos/${repo}/pulls/${number}`;
    const { body: pr } = await get(token, base);
    if (!pr || typeof pr !== 'object') continue;
    console.log(`pull ${number} declares ${pr.changed_files} changed file(s) and `
      + `${pr.commits} commit(s)`);

    for (const [kind, declared] of [['files', pr.changed_files], ['commits', pr.commits]]) {
      const { links } = await get(token, `${base}/${kind}`, { per_page: perPage });
      const last = lastPageFrom(links);
      const [state, detail] = verdict(kind, declared, last, perPage);
      console.log(`${kind}: ${state} - ${detail}`);
      console.log(`repair: ${repair(state, kind)}`);
      findings.push({
        pull_request: number,
        list: kind,
        declared,
        cap: capFor(kind),
        reachable: reachable(kind, declared),
        unreachable: beyondCap(kind, declared),
        endpoint_last_page: last,
        implied_bounds: boundsFromLast(last, perPage),
        missed_by_one_default_page: onePageShortfall(declared),
        state,
        detail,
        repair: repair(state, kind),
      });
    }
  }

  console.log(JSON.stringify({ requests_spent: readCost(prs), findings }, null, 2));
  const bad = ['beyond-cap', 'counter-disagrees', 'multi-page'];
  process.exitCode = findings.some((f) => bad.includes(f.state)) ? 1 : 0;
}

// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The arithmetic gets the attention, because the arithmetic is the finding: a count above the ceiling, a count the endpoint's own page count cannot contain, and a count that merely needs more than one page are three different states with three different repairs, and the tests pin all three at their boundaries. After that, the band a rel=last implies, the shortfall a single default page leaves behind, the two repairs that must not be swapped between files and commits, and the read cost, which is asserted so a diagnostic in a section about quota cannot quietly start spending more of it.

test_github_pr_truncation.py
from github_pr_truncation import (
    CAPS, DEFAULT_PER_PAGE, MAX_PER_PAGE, beyond_cap, bounds_from_last, cap_for,
    counter_outside_bounds, last_page_from, one_page_shortfall, page_of,
    pages_needed, parse_link, read_cost, reachable, repair, verdict,
)


def test_the_two_ceilings_are_different_numbers():
    assert cap_for("files") == 3000
    assert cap_for("commits") == 250
    assert cap_for("comments") is None
    assert CAPS["files"] > CAPS["commits"]


def test_pages_needed_rounds_up_and_refuses_nonsense():
    assert pages_needed(3000, 100) == 30
    assert pages_needed(901, 100) == 10
    assert pages_needed(1, 100) == 1
    assert pages_needed(0, 100) == 0
    assert pages_needed(250, 30) == 9
    assert pages_needed(10, 0) is None
    assert pages_needed(None, 100) is None


def test_what_is_reachable_stops_at_the_ceiling():
    assert reachable("files", 4200) == 3000
    assert reachable("files", 12) == 12
    assert reachable("commits", 812) == 250
    assert reachable("files", None) is None


def test_what_is_beyond_the_ceiling_is_counted_exactly():
    assert beyond_cap("files", 4200) == 1200
    assert beyond_cap("files", 3000) == 0
    assert beyond_cap("commits", 251) == 1
    assert beyond_cap("commits", None) == 0


def test_a_last_page_implies_a_band_rather_than_a_number():
    assert bounds_from_last(3, 100) == (201, 300)
    assert bounds_from_last(1, 100) == (1, 100)
    assert bounds_from_last(0, 100) is None
    assert bounds_from_last(None, 100) is None
    assert bounds_from_last(3, 0) is None


def test_the_counter_is_only_wrong_when_it_leaves_the_band():
    assert counter_outside_bounds(150, (1, 100))
    assert counter_outside_bounds(0, (1, 100))
    assert not counter_outside_bounds(100, (1, 100))
    assert not counter_outside_bounds(250, (201, 300))
    assert not counter_outside_bounds(150, None)


def test_one_default_page_is_where_most_of_it_is_lost():
    assert one_page_shortfall(900) == 900 - DEFAULT_PER_PAGE
    assert one_page_shortfall(31) == 1
    assert one_page_shortfall(30) == 0
    assert one_page_shortfall(7) == 0
    assert one_page_shortfall(900, 100) == 800


def test_a_count_above_the_ceiling_is_unreachable_at_any_page_size():
    state, detail = verdict("files", 4200, 30, MAX_PER_PAGE)
    assert state == "beyond-cap"
    assert "1200" in detail
    assert "any page size" in detail
    assert verdict("commits", 812, 3, MAX_PER_PAGE)[0] == "beyond-cap"


def test_a_page_count_that_cannot_hold_the_counter_is_its_own_finding():
    state, detail = verdict("files", 150, 1, MAX_PER_PAGE)
    assert state == "counter-disagrees"
    assert "between 1 and 100" in detail


def test_a_reconcilable_multi_page_list_names_what_one_page_misses():
    state, detail = verdict("files", 900, 9, MAX_PER_PAGE)
    assert state == "multi-page"
    assert "misses 870" in detail


def test_a_small_pull_request_is_not_a_finding():
    assert verdict("files", 7, 1, MAX_PER_PAGE)[0] == "single-page"
    assert verdict("commits", 30, 1, MAX_PER_PAGE)[0] == "single-page"


def test_a_missing_counter_is_reported_rather_than_assumed():
    assert verdict("files", None, 1, MAX_PER_PAGE)[0] == "unknown"
    assert verdict("files", "several", 1, MAX_PER_PAGE)[0] == "unknown"
    assert verdict("comments", 12, 1, MAX_PER_PAGE)[0] == "unknown"


def test_an_unknown_page_count_does_not_manufacture_a_disagreement():
    # rel=next with no rel=last: the page count is genuinely unknown, so the
    # only honest verdict is the one the counter alone supports.
    assert verdict("files", 900, None, MAX_PER_PAGE)[0] == "multi-page"


def test_the_two_repairs_are_not_interchangeable():
    assert "vnd.github.diff" in repair("beyond-cap", "files")
    assert "vnd.github.diff" not in repair("beyond-cap", "commits")
    assert "/commits" in repair("beyond-cap", "commits")
    assert "per_page=100" in repair("multi-page", "files")
    assert "changed_files" in repair("counter-disagrees", "files")
    assert repair("single-page", "files").startswith("nothing on this")


def test_the_page_count_is_read_from_the_header_not_guessed():
    header = ('<https://api.github.com/repos/o/n/pulls/1/files?page=2>; rel="next", '
              '<https://api.github.com/repos/o/n/pulls/1/files?page=9>; rel="last"')
    links = parse_link(header)
    assert page_of(links["last"]) == 9
    assert last_page_from(links) == 9
    assert last_page_from({"next": "https://api.github.com/x?page=2"}) is None
    assert last_page_from({}) == 1
    assert page_of(None) is None


def test_the_run_says_what_it_will_spend():
    assert read_cost([1, 2]) == 6
    assert read_cost([4821]) == 3
    assert read_cost([]) == 0
    assert read_cost(None) == 0
github-pr-truncation.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  CAPS, DEFAULT_PER_PAGE, MAX_PER_PAGE, beyondCap, boundsFromLast, capFor,
  counterOutsideBounds, lastPageFrom, onePageShortfall, pageOf, pagesNeeded,
  parseLink, readCost, reachable, repair, verdict,
} from './github-pr-truncation.mjs';

test('the two ceilings are different numbers', () => {
  assert.equal(capFor('files'), 3000);
  assert.equal(capFor('commits'), 250);
  assert.equal(capFor('comments'), null);
  assert.ok(CAPS.files > CAPS.commits);
});

test('pagesNeeded rounds up and refuses nonsense', () => {
  assert.equal(pagesNeeded(3000, 100), 30);
  assert.equal(pagesNeeded(901, 100), 10);
  assert.equal(pagesNeeded(1, 100), 1);
  assert.equal(pagesNeeded(0, 100), 0);
  assert.equal(pagesNeeded(250, 30), 9);
  assert.equal(pagesNeeded(10, 0), null);
  assert.equal(pagesNeeded(null, 100), null);
});

test('what is reachable stops at the ceiling', () => {
  assert.equal(reachable('files', 4200), 3000);
  assert.equal(reachable('files', 12), 12);
  assert.equal(reachable('commits', 812), 250);
  assert.equal(reachable('files', null), null);
});

test('what is beyond the ceiling is counted exactly', () => {
  assert.equal(beyondCap('files', 4200), 1200);
  assert.equal(beyondCap('files', 3000), 0);
  assert.equal(beyondCap('commits', 251), 1);
  assert.equal(beyondCap('commits', null), 0);
});

test('a last page implies a band rather than a number', () => {
  assert.deepEqual(boundsFromLast(3, 100), [201, 300]);
  assert.deepEqual(boundsFromLast(1, 100), [1, 100]);
  assert.equal(boundsFromLast(0, 100), null);
  assert.equal(boundsFromLast(null, 100), null);
  assert.equal(boundsFromLast(3, 0), null);
});

test('the counter is only wrong when it leaves the band', () => {
  assert.ok(counterOutsideBounds(150, [1, 100]));
  assert.ok(counterOutsideBounds(0, [1, 100]));
  assert.ok(!counterOutsideBounds(100, [1, 100]));
  assert.ok(!counterOutsideBounds(250, [201, 300]));
  assert.ok(!counterOutsideBounds(150, null));
});

test('one default page is where most of it is lost', () => {
  assert.equal(onePageShortfall(900), 900 - DEFAULT_PER_PAGE);
  assert.equal(onePageShortfall(31), 1);
  assert.equal(onePageShortfall(30), 0);
  assert.equal(onePageShortfall(7), 0);
  assert.equal(onePageShortfall(900, 100), 800);
});

test('a count above the ceiling is unreachable at any page size', () => {
  const [state, detail] = verdict('files', 4200, 30, MAX_PER_PAGE);
  assert.equal(state, 'beyond-cap');
  assert.match(detail, /1200/);
  assert.match(detail, /any page size/);
  assert.equal(verdict('commits', 812, 3, MAX_PER_PAGE)[0], 'beyond-cap');
});

test('a page count that cannot hold the counter is its own finding', () => {
  const [state, detail] = verdict('files', 150, 1, MAX_PER_PAGE);
  assert.equal(state, 'counter-disagrees');
  assert.match(detail, /between 1 and 100/);
});

test('a reconcilable multi-page list names what one page misses', () => {
  const [state, detail] = verdict('files', 900, 9, MAX_PER_PAGE);
  assert.equal(state, 'multi-page');
  assert.match(detail, /misses 870/);
});

test('a small pull request is not a finding', () => {
  assert.equal(verdict('files', 7, 1, MAX_PER_PAGE)[0], 'single-page');
  assert.equal(verdict('commits', 30, 1, MAX_PER_PAGE)[0], 'single-page');
});

test('a missing counter is reported rather than assumed', () => {
  assert.equal(verdict('files', null, 1, MAX_PER_PAGE)[0], 'unknown');
  assert.equal(verdict('files', 'several', 1, MAX_PER_PAGE)[0], 'unknown');
  assert.equal(verdict('comments', 12, 1, MAX_PER_PAGE)[0], 'unknown');
});

test('an unknown page count does not manufacture a disagreement', () => {
  assert.equal(verdict('files', 900, null, MAX_PER_PAGE)[0], 'multi-page');
});

test('the two repairs are not interchangeable', () => {
  assert.match(repair('beyond-cap', 'files'), /vnd\.github\.diff/);
  assert.ok(!repair('beyond-cap', 'commits').includes('vnd.github.diff'));
  assert.match(repair('beyond-cap', 'commits'), /\/commits/);
  assert.match(repair('multi-page', 'files'), /per_page=100/);
  assert.match(repair('counter-disagrees', 'files'), /changed_files/);
  assert.ok(repair('single-page', 'files').startsWith('nothing on this'));
});

test('the page count is read from the header, not guessed', () => {
  const header = '<https://api.github.com/repos/o/n/pulls/1/files?page=2>; rel="next", '
    + '<https://api.github.com/repos/o/n/pulls/1/files?page=9>; rel="last"';
  const links = parseLink(header);
  assert.equal(pageOf(links.last), 9);
  assert.equal(lastPageFrom(links), 9);
  assert.equal(lastPageFrom({ next: 'https://api.github.com/x?page=2' }), null);
  assert.equal(lastPageFrom({}), 1);
  assert.equal(pageOf(null), null);
});

test('the run says what it will spend', () => {
  assert.equal(readCost([1, 2]), 6);
  assert.equal(readCost([4821]), 3);
  assert.equal(readCost([]), 0);
  assert.equal(readCost(null), 0);
});

FAQ

Is this the same as the compare endpoint's 250-commit cap?

No, though the number 250 appears in both. On /compare/{base}...{head} the true count arrives as total_commits in the same body as the truncated array, so a client can detect the truncation from one response with one subtraction. Here the counters live on the pull request object and the lists live on separate endpoints, so a client that only calls .../files has nothing in hand to compare against. Same shape of failure, different detection, and a collector can be correct about one and wrong about the other.

Does paginating properly fix it?

It fixes the part that matters most and not the whole thing. Paginating at per_page=100 to the end of the Link header gets you every file up to 3,000 and every commit up to 250, which covers almost every pull request anybody opens. Past those numbers the endpoint stops handing pages over and there is no rel=next left to tell you so, which is why the check compares against changed_files rather than against the shape of the last page.

How do I read a pull request with more than 3,000 files?

Ask for the diff rather than the list. Requesting the pull request with the application/vnd.github.diff media type returns the unified diff, which you parse yourself. It is more work and it is the only route that does not stop at the cap. For commits above 250, GET /repos/{owner}/{repo}/commits with a sha and a date range paginates conventionally, so a long-lived branch can be walked in full there.

Why does the check need three requests per pull request?

Because the comparison has two sides and they live in different places. One request gets the pull request object and its counters; the other two ask each list endpoint how far it is prepared to go, at per_page=100, reading the Link header rather than the items. There is no single response that contains both a counter and the list it describes, which is the mechanism of the bug and therefore also the cost of the check. The script prints that cost before it spends any of it.

Can the script tell whether my bot is truncating?

Only by proxy, and it says so. The API cannot see how many pages your collector read or whether it read any. What the script establishes is what the pull request declares, what the endpoint is willing to serve, and where those two cannot be reconciled. If they cannot, any client is truncated there. If they can, yours might still be, and the assertion the script prints against changed_files is the one line that would have caught it.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.