Skip to content

Diagnostic GitHub API

the compare endpoint stops at 250 commits and says nothing

The release-notes generator diffs v4.2.0...v4.3.0 and produces a changelog that looks entirely plausible. It has 250 entries. The release contains 812 commits, and the ones it dropped are not the boring ones at the end — the shape of what came back is not what the code assumed at all.

Read-only token Python and Node.js Tests included
A network device
Photo by Elimende Inagella on Unsplash
The short answer

Compare total_commits against len(commits) in the same response. GET /repos/{owner}/{repo}/compare/{base}...{head} returns at most 250 commits when called without pagination parameters, and says so nowhere except in that arithmetic.

Where total_commits exceeds what you received, paginate with per_page and page — noting that files comes back only on the first page — or switch to GET /repos/{owner}/{repo}/commits, which paginates conventionally through the Link header.

The problem in plain words

A truncated changelog is a specific kind of bad. It is not empty, so no alarm fires; it is not obviously short, because nobody knows how many commits a release should have; and it is wrong in a way that only the author of a missing commit will notice, weeks later, when their fix is not in the notes and nobody can say whether it shipped.

There is a second, sharper problem underneath. In the unpaginated response the final element of commits is the most recent commit of the entire comparison rather than the 250th — the array is not a contiguous prefix. Any code that reasons about boundaries from the list, such as taking the first entry as the merge base or assuming commits[i] and commits[i+1] are adjacent in history, is drawing conclusions from a sequence with a hole in it.

Job comparestwo tagsno page parameters812 commits inrangetotal_commits saysso250 come back200, and no flagNotes builtfrom 250entirely plausible562 neverlistednoticed weekslater
There is no truncation flag anywhere in the response. The only evidence is that two numbers in the same JSON body disagree.

Why it happens

The cap applies to the unpaginated call specifically. Ask without per_page or page and you get up to 250 commits, whatever the comparison contains. There is no truncated: true field to check and no warning header; the only evidence is that total_commits is larger than the array you were handed.

Pagination changes the semantics as well as the size. Once you page the endpoint, files is returned on the first page only, so a job that collects changed files from every page ends up with the file list of page one and nothing else. Code written for the unpaginated shape does not simply become slower when you add paging; it changes what it collects.

250 is a plausible number for a real release. Unlike 30, which people learn to distrust, a 250-commit release is entirely believable for a busy repository. That is what lets this survive: the failure produces a defensible-looking artefact rather than an error.

The comparison is against the merge base, which people forget. base...head compares head against the common ancestor, so total_commits is the count of commits on head that are not on base. A long-lived branch behind on base produces a much larger number than the diff a human has in mind, and pushes past 250 sooner than expected.

The fix, as a flow

The script calls compare without page parameters on purpose, because that is the call the cap applies to, and reproducing it is the only way to measure what an unpaginated client is actually missing.

total_commits vs commitsone unpaginated GETThe two counts agreethe comparison is wholeExactly 250 of 812the unpaginated cap, exactly100 of 812mid walk, so keep pagingNo total_commits fieldcannot judge, do not assume
A missing total_commits is not a complete comparison. Defaulting it to zero would reproduce the bug inside the checker written to catch it.

How to fix it

Read total_commits before you read commits

It is in every compare response and it is the true count. If it exceeds the number of items in commits, the list in your hands is a truncated list, whatever it looks like.

Treat exactly 250 as the signature

A response with 250 commits and a larger total_commits is the unpaginated cap, precisely. It is not a coincidence and it is not a network problem; the next commit was never sent.

Do not read the last element as the oldest commit

In the capped response the final entry is the head of the comparison, not the 250th commit from the base. Anything that walks the array as a contiguous history — computing a previous-release boundary, diffing adjacent pairs — is reading across a gap that is invisible in the JSON.

Paginate, and collect files from page one only

Add per_page=100 and walk pages until you have total_commits commits. Keep files from the first page and ignore the field afterwards; that is where it is, and re-reading it per page gives you nothing but confusion.

Or use the commits list instead

GET /repos/{owner}/{repo}/commits?sha={head}&since=... paginates through the Link header like every other list endpoint and has no 250-item ceiling. It does not compute a merge base for you, which is the trade: you get complete data and you do the ancestry yourself.

How to check it worked

Re-run against the same pair of refs. The script should report complete, or tell you precisely how many commits are missing from an unpaginated read.

GITHUB_TOKEN=... python3 github_compare_truncation.py --repo octocat/hello-world --base v4.2.0 --head v4.3.0
# complete  v4.2.0...v4.3.0  18 commit(s), all present

The full code

One GET against the compare endpoint, deliberately without pagination parameters, because the point is to see what an unpaginated client sees. The verdict is a pure function over the response so the four outcomes can be exercised offline: a comparison inside the cap, one truncated at exactly 250, a partial page from a paginated read, and a response with no total_commits at all, which must not be mistaken for a complete one.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 GitHub API fixes, free and open source.
github_compare_truncation.py
"""Report whether a compare response was silently truncated at 250 commits.

Read only. One GET, no writes: a token with read access to the repository is
enough. The repair is printed, never performed.

The request is deliberately made without per_page or page, because that is the
call whose 250-commit cap is invisible, and reproducing it is the only way to
measure what an unpaginated client is missing.
"""
import argparse
import logging
import os
import sys

import requests

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

API = "https://api.github.com"

CAP = 250


def verdict(compare):
    """Classify one compare response. Pure. Returns (state, detail).

    `compare` is the parsed JSON: total_commits, commits and files.

    A missing total_commits is its own state rather than a default of zero.
    Defaulting it would report a truncated comparison as complete, which is the
    exact failure this script exists to catch.
    """
    total = compare.get("total_commits")
    if total is None:
        return ("unknown",
                "no total_commits in the response, so completeness cannot be "
                "judged. Do not treat this as complete.")

    total = int(total)
    commits = compare.get("commits") or []
    received = len(commits)
    files = len(compare.get("files") or [])

    if total == 0:
        return ("empty", "no commits between these refs; head is not ahead of base")

    if received >= total:
        return ("complete",
                "%d commit(s), all present%s."
                % (total, " (%d changed file(s))" % files if files else ""))

    if received == CAP:
        return ("capped",
                "total_commits is %d and %d came back: the unpaginated 250-commit "
                "cap, so %d commit(s) are missing. The last entry in this list is "
                "the head of the comparison, not the 250th commit from the base, "
                "so the array is not a contiguous history."
                % (total, received, total - received))

    return ("truncated",
            "total_commits is %d and %d came back, so %d commit(s) are missing. "
            "This is what a paginated read looks like mid-walk; keep paging until "
            "the counts agree." % (total, received, total - received))


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    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 == 404:
        raise SystemExit("404 on %s: check the repository and that both refs exist" % path)
    r.raise_for_status()
    return r.json()


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", required=True, help="owner/name")
    ap.add_argument("--base", required=True, help="base ref, tag or sha")
    ap.add_argument("--head", required=True, help="head ref, tag or sha")
    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

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

    # No per_page and no page on purpose: this is the call the cap applies to.
    path = "/repos/%s/compare/%s...%s" % (args.repo, args.base, args.head)
    body = get(session, path)
    state, detail = verdict(body)

    line = "%-10s %s...%s  %s" % (state, args.base, args.head, detail)
    if state in ("complete", "empty"):
        log.info(line)
        return 0

    log.warning(line)
    log.warning("  repair: read total_commits first, then page this endpoint with "
                "per_page=100 and page=N until you have that many commits, keeping "
                "files from the first page only. Or read "
                "/repos/%s/commits?sha=%s, which paginates through the Link "
                "header and has no 250-commit ceiling.", args.repo, args.head)
    return 1


if __name__ == "__main__":
    sys.exit(main())
github-compare-truncation.mjs
/**
 * Report whether a compare response was silently truncated at 250 commits.
 *
 * Read only. One GET, no writes: a token with read access is enough. The repair
 * is printed, never performed.
 *
 * The request deliberately omits per_page and page, because that is the call the
 * 250-commit cap applies to.
 */
const API = 'https://api.github.com';

const CAP = 250;

/**
 * Classify one compare response. Pure. Returns [state, detail].
 *
 * A missing total_commits is its own state rather than a default of zero:
 * defaulting it would report a truncated comparison as complete.
 */
export function verdict(compare) {
  const raw = compare.total_commits;
  if (raw === undefined || raw === null) {
    return ['unknown',
      'no total_commits in the response, so completeness cannot be judged. Do ' +
      'not treat this as complete.'];
  }

  const total = Number(raw);
  const commits = compare.commits ?? [];
  const received = commits.length;
  const files = (compare.files ?? []).length;

  if (total === 0) {
    return ['empty', 'no commits between these refs; head is not ahead of base'];
  }

  if (received >= total) {
    return ['complete',
      `${total} commit(s), all present` +
      (files ? ` (${files} changed file(s))` : '') + '.'];
  }

  if (received === CAP) {
    return ['capped',
      `total_commits is ${total} and ${received} came back: the unpaginated ` +
      `250-commit cap, so ${total - received} commit(s) are missing. The last ` +
      'entry in this list is the head of the comparison, not the 250th commit ' +
      'from the base, so the array is not a contiguous history.'];
  }

  return ['truncated',
    `total_commits is ${total} and ${received} came back, so ${total - received} ` +
    'commit(s) are missing. This is what a paginated read looks like mid-walk; ' +
    'keep paging until the counts agree.'];
}

function arg(name) {
  const i = process.argv.indexOf(`--${name}`);
  return i === -1 ? undefined : process.argv[i + 1];
}

async function get(token, path) {
  const res = await fetch(API + path, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': 'github-compare-truncation',
    },
  });
  if (res.status === 401) {
    throw new Error('401 from GitHub: GITHUB_TOKEN is missing, malformed or revoked');
  }
  if (res.status === 403) {
    throw new Error('403 from GitHub. If this is a rate limit, GET /rate_limit ' +
                    'reports the reset and does not itself consume quota');
  }
  if (res.status === 404) {
    throw new Error(`404 on ${path}: check the repository and that both refs exist`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${path}`);
  return res.json();
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const repo = arg('repo');
  const base = arg('base');
  const head = arg('head');
  if (!token || !repo || !base || !head) {
    console.error('set GITHUB_TOKEN and pass --repo owner/name --base X --head Y');
    process.exitCode = 2;
    return;
  }

  const path = `/repos/${repo}/compare/${base}...${head}`;
  const [state, detail] = verdict(await get(token, path));

  const line = `${state.padEnd(10)} ${base}...${head}  ${detail}`;
  if (state === 'complete' || state === 'empty') {
    console.log(line);
    return;
  }

  console.warn(line);
  console.warn('  repair: read total_commits first, then page this endpoint with ' +
               'per_page=100 and page=N until you have that many commits, keeping ' +
               'files from the first page only. Or read ' +
               `/repos/${repo}/commits?sha=${head}, which paginates through the ` +
               'Link header and has no 250-commit ceiling.');
  process.exitCode = 1;
}

// Only run when invoked directly, so the test file can import verdict without
// main() running and failing the suite on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Four responses, four different things to say about them. The one that would otherwise slip through is a response with no total_commits at all: the tempting default is zero, and a zero there turns "I cannot tell" into "everything is present", which is the failure this whole note is about, reproduced inside the checker meant to catch it.

test_github_compare_truncation.py
from github_compare_truncation import verdict


def compare(total, received, files=0):
    return {"total_commits": total,
            "commits": [{"sha": "%040x" % i} for i in range(received)],
            "files": [{"filename": "f%d" % i} for i in range(files)]}


def test_a_small_comparison_is_complete():
    state, detail = verdict(compare(18, 18, files=42))
    assert state == "complete"
    assert "18 commit(s)" in detail
    assert "42 changed file(s)" in detail


def test_exactly_250_with_more_to_come_is_the_cap():
    state, detail = verdict(compare(812, 250))
    assert state == "capped"
    assert "562 commit(s) are missing" in detail
    # The sharp edge: the array is not a contiguous prefix of the history.
    assert "not the 250th commit" in detail


def test_a_partial_page_is_not_the_same_finding_as_the_cap():
    state, detail = verdict(compare(812, 100))
    assert state == "truncated"
    assert "712 commit(s) are missing" in detail


def test_no_commits_between_the_refs_is_not_a_failure():
    assert verdict(compare(0, 0))[0] == "empty"


def test_a_missing_total_commits_is_never_reported_as_complete():
    # Defaulting the count to zero here would call a truncated comparison
    # complete, which is precisely the bug being hunted.
    state, _ = verdict({"commits": [{"sha": "abc"}]})
    assert state == "unknown"
github-compare-truncation.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './github-compare-truncation.mjs';

const compare = (total, received, files = 0) => ({
  total_commits: total,
  commits: Array.from({ length: received }, (_, i) => ({ sha: String(i) })),
  files: Array.from({ length: files }, (_, i) => ({ filename: `f${i}` })),
});

test('a small comparison is complete', () => {
  const [state, detail] = verdict(compare(18, 18, 42));
  assert.equal(state, 'complete');
  assert.match(detail, /18 commit\(s\)/);
  assert.match(detail, /42 changed file\(s\)/);
});

test('exactly 250 with more to come is the cap', () => {
  const [state, detail] = verdict(compare(812, 250));
  assert.equal(state, 'capped');
  assert.match(detail, /562 commit\(s\) are missing/);
  assert.match(detail, /not the 250th commit/);
});

test('a partial page is not the same finding as the cap', () => {
  const [state, detail] = verdict(compare(812, 100));
  assert.equal(state, 'truncated');
  assert.match(detail, /712 commit\(s\) are missing/);
});

test('no commits between the refs is not a failure', () => {
  assert.equal(verdict(compare(0, 0))[0], 'empty');
});

test('a missing total_commits is never reported as complete', () => {
  assert.equal(verdict({ commits: [{ sha: 'abc' }] })[0], 'unknown');
});

FAQ

Where is the flag that says the commit list was truncated?

There isn't one. The response is a 200 with a shorter array, and the only evidence is that total_commits is larger than the number of commits you received. That comparison is the check; nothing else in the response mentions it.

Why is 250 the number?

It is the documented ceiling on the unpaginated compare response. Paginating the endpoint with per_page and page gets past it, at the cost of a different response shape.

What changes when I paginate the compare endpoint?

The files array is returned on the first page only. Code that gathers changed files from every page silently ends up with page one's files, which for a large release is a small and misleading subset.

Is the truncated list the first 250 commits in order?

Not exactly, and this is the part that catches people. The last entry in the capped list is the most recent commit of the whole comparison rather than the 250th, so the array is not a contiguous slice of history and adjacent entries are not necessarily adjacent commits.

What should a release-notes job use instead?

Either page the compare endpoint properly, or read GET /repos/{owner}/{repo}/commits with a sha and a since, which paginates through the Link header and has no cap. The compare endpoint's advantage is that it computes the merge base for you; the commits list makes you do that yourself in exchange for complete data.

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.