Skip to content

Diagnostic GitHub API

the repository is disabled and behaves like a ghost

The weekly platform report says the organisation has one repository with no branch protection, no webhooks, no open pull requests and no contributors. It is not a new repository and it is not empty; it shipped for three years. Every call the report makes against it either 404s or comes back with nothing in it, while the repository itself reads fine and sits in the organisation listing next to everything else. One boolean on the repository object explains all of it, and it is not archived.

Read-only token Python and Node.js Tests included
Two people riding electric scooters on a street.
Photo by Felix yu on Unsplash
The short answer

GET /repos/{owner}/{repo} returns disabled next to archived, private and fork. A disabled repository is one GitHub has switched off — typically for a billing problem, a terms violation, or because the account that owns it is suspended. It keeps appearing in organisation listings and its repository object keeps reading, and most of its sub-resources stop working.

That combination is the danger: the repository is present enough to be counted and absent enough to contribute nothing, so an organisation-wide sweep records it as zero of everything. Report disabled: true as its own finding, separate from archived, and exclude those repositories from aggregates rather than letting them contribute false zeroes. The remedy is an account or billing matter with GitHub, not a change to your integration.

The problem in plain words

The failure has no single symptom, which is why nothing gets diagnosed. One endpoint 404s, another returns an empty list, a third returns a perfectly normal object. Each of those on its own is a plausible thing for a real repository to do: a repository with no webhooks returns an empty list, a repository with no protected branches has none, a path can 404 because it does not exist. Read one at a time, none of them is evidence of anything.

So the investigation goes after each symptom separately and each one dead-ends. The 404 gets attributed to permissions, which is the usual and usually correct reading, and a wider token changes nothing. The empty lists get attributed to a genuinely quiet repository. The whole thing has the flavour of a repository that was abandoned rather than one that was switched off, and there is nothing in any individual response that says otherwise.

Meanwhile the aggregate at the bottom of the report is wrong and looks right. If one repository in two hundred contributes zero open pull requests, zero webhooks, zero protected branches and zero contributors, the totals barely move and the averages shift a little. Nobody notices a number that is a fraction of a per cent out. What they notice, months later, is that the coverage percentage on some compliance metric is not what the same query returns when run against a smaller set, and reconciling those two numbers is a very long afternoon.

And it is nobody's bug, which is the last thing that makes it durable. The team that owns the repository did not disable it. The team that owns the report cannot fix it. It is set on GitHub's side and stays set until the underlying account or billing problem is resolved by whoever owns that relationship.

Org sweep of212 reposone row readsoddlyBranches andcommits 404languages answersfineBlamed on thetokenwidened twice, nochangeCounted as zerohooks, checks,reviewsCoverage lookshealthyand is quietlywrongand next week it is zero again
Each symptom on its own is something a healthy repository does. The aggregate at the bottom moves by half a per cent and looks right.

Why it happens

Disabled is not archived, and the difference matters twice. An archived repository is fully readable and was frozen deliberately by somebody on your side; its reads all work and only writes are refused. A disabled repository is not fully readable, was not frozen by you, and refuses much more than writes. They are two booleans in the same response and they conflate easily, but the report they belong in and the person who can fix them are different, so the check keeps them apart.

The repository object still reads, and that is what hides it. Because GET /repos/{owner}/{repo} answers 200 with a normal-looking object, and because the organisation listing still includes it, every mechanism you might use to notice a missing repository keeps saying it is present. A deleted repository disappears from the listing and 404s cleanly, which is loud. This is quiet in exactly the way that survives a code review.

A zero from a disabled repository is not a zero. This is the practical damage and it is a data-quality problem rather than an availability one. “Zero webhooks” is a fact about a normal repository and an artefact about a disabled one, and once both have been written into the same column nothing downstream can tell them apart. The right move is to exclude those repositories from the denominator and report their count separately, which is a one-line filter as long as somebody knows to write it.

A 409 is a different explanation and a common false positive. An empty repository — created and never pushed to — answers 409 with “Git Repository is empty” on the endpoints that need a commit, and it will otherwise look exactly like the ghost you are hunting. The check treats 409 as its own state so that a brand new repository is never reported as disabled.

No credential changes anything here. The 404s a disabled repository produces are not permission 404s and cannot be resolved by widening a token, adding an App permission or fixing an installation. That is worth stating plainly because the 404 triage note is the right instinct for almost every other 404 on this API, and it will send you round a loop of credential experiments that all come back the same. Reading the boolean first is one request and settles it.

What a read-only script can prove, and what it cannot. The boolean is exact and the pattern of sub-resource answers is real evidence. Why the repository was disabled is not exposed anywhere in the API: billing, a terms violation and a suspended owning account all present identically, and the script says so rather than guessing. That question is answered by whoever holds the account relationship with GitHub.

The fix, as a flow

One read settles it and four cheap probes make the evidence legible. The boolean is the finding; the probes exist because the symptoms arrived as a scatter of unrelated 404s and empty lists, and seeing them explained line by line is what makes the state recognisable next time. Two shortcuts are refused on purpose: a 409 is an empty repository rather than a ghost, and a failure on a repository that is not disabled is handed to the credential triage rather than absorbed. The output that matters is the last one, which decides whether a row may enter a total at all.

Each repository, classifiedbefore it enters a totaldisabled trueexclude: the zeroes are artefacts409 on commitsan empty repository, not a ghostarchived truefully readable, so the values are realActive and answeringcounts, and a zero is a zero
The second row is the false positive worth avoiding. A repository made ten minutes ago looks like a ghost from a distance.

How to fix it

Read the repository object and both booleans

One GET /repos/{owner}/{repo}. disabled and archived arrive together and are classified into four states, because a repository can be both and the two have different remedies. This single field is the finding; everything after it is corroboration and reporting.

Probe a few sub-resources at per_page=1

Branches, commits, contributors and languages, one cheap request each. The point is not to discover anything new — the boolean already told you — but to produce the evidence in the shape the confusing symptoms arrived in, so the pattern in your logs is explained line by line rather than in the abstract.

Keep the empty repository out of it

A repository created and never pushed to answers 409 Git Repository is empty on anything that needs a commit, and looks like a ghost from a distance. The script gives that its own state. Reporting a new repository as disabled is the one false positive this check can produce and it is worth the extra branch to avoid it.

Decide what the row does to your aggregates

For every repository the script prints whether it may be counted, and for the disabled ones it prints the reason it may not: the zeroes it would contribute are artefacts. Feed that into the sweep as an exclusion and report the excluded count next to the total, so the denominator is honest and the missing repositories are visible rather than silently averaged away.

Send the remedy to the right person

Nothing in your integration fixes a disabled repository. The script names the owner of the remedy — GitHub, through the billing or support relationship for that account — rather than printing a code change, and it says explicitly that the API does not expose which of the possible reasons applies. In the meantime the exclusion keeps the reporting honest.

How to check it worked

Once the exclusion is in place, the disabled repository stops contributing zeroes and starts being reported as what it is.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_disabled_repo_probe.py \
    --repo acme/payments-legacy --repo acme/platform-api
# read cost: 5 request(s) per repository against the core hourly quota
# read cost: 10 request(s) in total
# acme/payments-legacy: disabled=True archived=False
# ghost-confirmed: the repository object reads and 3 of 4 sub-resource(s) do
# not, which is what disabled looks like from the outside
#   /branches 404 explained by the disabled state
#   /commits 404 explained by the disabled state
#   /contributors 404 explained by the disabled state
#   /languages 200 answered
#   aggregates: exclude. Every zero this repository contributes is an artefact
#   of the disabled state rather than a measurement.
#   remedy owner: GitHub, through the billing or support relationship for this
#   account. The API does not say which reason applies.
# acme/platform-api: disabled=False archived=False
# healthy: the repository reads and every sub-resource answered
#
# summary: 2 repositories, 1 disabled, 0 archived, 1 countable

The full code

One read settles it and four more make the evidence legible, which is the right ratio for a problem whose difficulty was never detection but recognition. The classification is pure and deliberately refuses two shortcuts: a 409 from an empty repository is its own state rather than a ghost, and a sub-resource failure on a repository that is not disabled is handed to the 404 triage rather than explained away. The aggregate functions are the part worth stealing — they decide whether a row may enter a denominator, which is the actual damage this state does.

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_disabled_repo_probe.py
"""Recognise a disabled repository and keep its zeroes out of your aggregates.

Read only. One GET for the repository object and one cheap GET per probed
sub-resource, all at per_page=1. Nothing is written, and no write is attempted
to characterise the state: disabled is a boolean on the repository object and
the sub-resource probes are reads that would have happened anyway in the sweep
this note is about.

The point of the note: a disabled repository -- switched off by GitHub for a
billing problem, a terms violation or a suspended owning account -- keeps
appearing in organisation listings and keeps serving its own repository object
while most of its sub-resources stop answering. It is therefore present enough
to be counted and absent enough to contribute nothing, so every org-wide
aggregate silently records it as zero of everything.

What this can and cannot see: the boolean is exact and the pattern of answers
is real evidence. Why the repository was disabled is not exposed anywhere in
the API; billing, a terms violation and a suspended account are
indistinguishable from here, so the script names the owner of the remedy
rather than the reason.

Environment:

    GITHUB_TOKEN    a token with read access to the repositories
"""
import argparse
import json
import logging
import os
import sys

import requests

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

API = "https://api.github.com"
UA = "github-disabled-repo-probe/1.0"

# Cheap reads that a repository normally answers. per_page=1 everywhere: the
# question is whether the endpoint answers at all, not what is in it.
DEFAULT_PROBES = ("/branches?per_page=1", "/commits?per_page=1",
                  "/contributors?per_page=1", "/languages")

# An empty repository answers this on anything that needs a commit. It is the
# one false positive this check can produce, so it gets its own state.
EMPTY_REPOSITORY = 409


def platform_state(repo):
    """Which platform state this repository is in. Pure.

    disabled and archived are separate booleans on the same object with
    different owners and different remedies, so they make four states rather
    than one flag with two names.
    """
    if not isinstance(repo, dict):
        return "unknown"
    disabled = bool(repo.get("disabled"))
    archived = bool(repo.get("archived"))
    if disabled and archived:
        return "disabled-and-archived"
    if disabled:
        return "disabled"
    if archived:
        return "archived"
    return "active"


def is_disabled(state):
    """Whether the disabled boolean is set, in either combination. Pure."""
    return state in ("disabled", "disabled-and-archived")


def explains_subresource(state, status):
    """Whether the repository state accounts for this answer. Pure.

    Returns (explained, why). A failure on a repository that is not disabled
    is deliberately not explained away: that is a credential triage and it
    belongs to another note.
    """
    try:
        code = int(status)
    except (TypeError, ValueError):
        return (False, "no readable status for this probe.")
    if 200 <= code < 300:
        return (True, "answered")
    if code == EMPTY_REPOSITORY:
        return (False, "409, which is an empty repository rather than a "
                       "disabled one")
    if is_disabled(state) and code in (403, 404, 451):
        return (True, "explained by the disabled state")
    if state == "archived" and code in (403, 404):
        return (False, "not explained by archiving, which leaves reads working")
    return (False, "not explained by the repository state")


def probe_verdict(state, probes):
    """Classify a repository from its state and its probe answers. Pure.

    probes: [{"path": str, "status": int}, ...]
    """
    rows = [p for p in (probes or []) if isinstance(p, dict)]
    failing = [p for p in rows
               if not explains_subresource(state, p.get("status"))[0]
               or not (200 <= int(p.get("status") or 0) < 300)]
    empty = [p for p in rows if str(p.get("status")) == str(EMPTY_REPOSITORY)]

    if state == "unknown":
        return ("repository-unreadable",
                "the repository object itself did not come back, so this is a "
                "credential or name problem rather than a platform state.")
    if is_disabled(state):
        if failing:
            return ("ghost-confirmed",
                    "the repository object reads and %d of %d sub-resource(s) "
                    "do not, which is what disabled looks like from the outside."
                    % (len(failing), len(rows)))
        return ("disabled-but-answering",
                "disabled is set and every probe answered anyway. Trust the "
                "boolean: the repository is switched off and must still be "
                "excluded from aggregates.")
    if empty:
        return ("empty-repository",
                "%d probe(s) answered 409 Git Repository is empty. This "
                "repository has never been pushed to and is not disabled."
                % len(empty))
    if state == "archived":
        return ("archived-not-disabled",
                "archived rather than disabled. Reads work and only writes are "
                "refused, which is a different note.")
    if failing:
        return ("not-explained-by-state",
                "%d sub-resource(s) failed on a repository that is neither "
                "disabled nor archived, so the repository state does not "
                "explain it." % len(failing))
    return ("healthy", "the repository reads and every sub-resource answered.")


def aggregate_safety(state):
    """Whether this repository may enter an org-wide aggregate. Pure."""
    if is_disabled(state):
        return ("exclude",
                "every zero this repository contributes is an artefact of the "
                "disabled state rather than a measurement.")
    if state == "unknown":
        return ("exclude",
                "the repository could not be read, so it has no values to "
                "contribute and its absence should be visible in the report.")
    if state == "archived":
        return ("include",
                "an archived repository is fully readable, so its values are "
                "real. Only its writes are refused.")
    return ("include", "nothing here disqualifies this repository from a count.")


def is_real_zero(state, value):
    """Whether a zero measured on this repository means anything. Pure.

    Returns True for a genuine zero, False for an artefact, None where the
    value is not a zero at all.
    """
    try:
        number = int(value)
    except (TypeError, ValueError):
        return None
    if number != 0:
        return None
    if is_disabled(state) or state == "unknown":
        return False
    return True


def aggregate_impact(rows):
    """What a sweep should report alongside its total. Pure."""
    counted, excluded, false_zeroes = 0, 0, 0
    for row in rows or []:
        state = (row or {}).get("state")
        decision, _ = aggregate_safety(state)
        if decision == "exclude":
            excluded += 1
            if is_disabled(state):
                false_zeroes += 1
        else:
            counted += 1
    return {"counted": counted, "excluded": excluded,
            "false_zeroes_avoided": false_zeroes}


def remedy_owner(state):
    """Who can actually change this state. Pure."""
    if is_disabled(state):
        return ("GitHub, through the billing or support relationship for this "
                "account. The API does not say which reason applies.")
    if state == "archived":
        return ("whoever owns the repository, by unarchiving it. That is a "
                "decision about whether it is still in use.")
    if state == "unknown":
        return "nobody yet: the repository could not be read."
    return "no remedy needed."


def repair(state):
    """The sentence a reader has to act on. Pure."""
    if state in ("ghost-confirmed", "disabled-but-answering"):
        return ("exclude this repository from org-wide aggregates and report it "
                "separately. Nothing in your integration can re-enable it; that "
                "is a billing or account matter with GitHub.")
    if state == "empty-repository":
        return ("nothing. A repository that has never been pushed to answers "
                "409 on anything needing a commit, and that is not this "
                "problem.")
    if state == "archived-not-disabled":
        return ("see /github/repo-archived-writes-403/ -- reads work there and "
                "only writes are refused.")
    if state == "not-explained-by-state":
        return ("triage the failures as a credential problem: the repository "
                "state does not account for them.")
    if state == "repository-unreadable":
        return ("check the name, the visibility and the installation before "
                "anything else. A 404 on the repository means several things.")
    return "nothing on the platform state."


def read_cost(repos, probes=DEFAULT_PROBES):
    """Requests this run will spend against the core quota. Pure."""
    per_repo = 1 + len(probes or ())
    return per_repo * len(repos or [])


def get_repo(session, full_name):
    """One GET of a repository object. Returns (status, dict-or-None)."""
    r = session.get(API + "/repos/" + full_name, 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")
    try:
        body = r.json()
    except ValueError:
        body = None
    return r.status_code, (body if isinstance(body, dict) else None)


def probe(session, full_name, path):
    """One cheap GET of a sub-resource. Returns its status only."""
    r = session.get(API + "/repos/" + full_name + path, timeout=30)
    return r.status_code


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", action="append", required=True,
                    help="owner/name to check. Repeatable.")
    ap.add_argument("--probe", action="append", default=[],
                    help="sub-resource path to probe, defaulting to branches, "
                         "commits, contributors and languages")
    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

    probes = tuple(args.probe) or DEFAULT_PROBES
    log.info("read cost: %d request(s) per repository against the core hourly "
             "quota", 1 + len(probes))
    log.info("read cost: %d request(s) in total", read_cost(args.repo, probes))

    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 name in args.repo:
        status, repo = get_repo(session, name)
        state = platform_state(repo) if status == 200 else "unknown"
        results = []
        if status == 200:
            for path in probes:
                code = probe(session, name, path)
                explained, why = explains_subresource(state, code)
                results.append({"path": path.split("?")[0], "status": code,
                                "explained": explained, "why": why})

        verdict, detail = probe_verdict(state, results)
        decision, reason = aggregate_safety(state)

        log.info("%s: disabled=%s archived=%s", name,
                 bool((repo or {}).get("disabled")),
                 bool((repo or {}).get("archived")))
        log.info("%s: %s", verdict, detail)
        for row in results:
            log.info("  %s %s %s", row["path"], row["status"], row["why"])
        log.info("  aggregates: %s. %s", decision, reason)
        log.info("  remedy owner: %s", remedy_owner(state))
        log.info("  repair: %s", repair(verdict))

        findings.append({
            "repository": name,
            "repository_status": status,
            "platform_state": state,
            "probes": results,
            "state": verdict,
            "detail": detail,
            "aggregate_decision": decision,
            "aggregate_reason": reason,
            "remedy_owner": remedy_owner(state),
            "repair": repair(verdict),
        })

    impact = aggregate_impact([{"state": f["platform_state"]} for f in findings])
    disabled = sum(1 for f in findings if is_disabled(f["platform_state"]))
    archived = sum(1 for f in findings
                   if f["platform_state"] in ("archived", "disabled-and-archived"))
    log.info("summary: %d repositories, %d disabled, %d archived, %d countable",
             len(findings), disabled, archived, impact["counted"])

    print(json.dumps({
        "requests_spent": read_cost(args.repo, probes),
        "aggregate_impact": impact,
        "findings": findings,
    }, indent=2, default=str))
    return 1 if disabled else 0


if __name__ == "__main__":
    sys.exit(main())
github-disabled-repo-probe.mjs
/**
 * Recognise a disabled repository and keep its zeroes out of your aggregates.
 *
 * Read only. One GET for the repository object and one cheap GET per probed
 * sub-resource, all at per_page=1. Nothing is written and no write is attempted
 * to characterise the state.
 *
 * A disabled repository keeps appearing in organisation listings and keeps
 * serving its own repository object while most of its sub-resources stop
 * answering, so an org-wide sweep records it as zero of everything.
 *
 * Environment:
 *   GITHUB_TOKEN      a token with read access to the repositories
 *   GITHUB_REPOS      comma-separated owner/name values
 */
const API = 'https://api.github.com';
const UA = 'github-disabled-repo-probe/1.0';

/** Cheap reads a repository normally answers. */
export const DEFAULT_PROBES = ['/branches?per_page=1', '/commits?per_page=1',
  '/contributors?per_page=1', '/languages'];

/** An empty repository answers this on anything that needs a commit. */
export const EMPTY_REPOSITORY = 409;

/** Which platform state this repository is in. Pure. */
export function platformState(repo) {
  if (!repo || typeof repo !== 'object') return 'unknown';
  const disabled = Boolean(repo.disabled);
  const archived = Boolean(repo.archived);
  if (disabled && archived) return 'disabled-and-archived';
  if (disabled) return 'disabled';
  if (archived) return 'archived';
  return 'active';
}

/** Whether the disabled boolean is set, in either combination. Pure. */
export function isDisabled(state) {
  return state === 'disabled' || state === 'disabled-and-archived';
}

/** Whether the repository state accounts for this answer. Pure. */
export function explainsSubresource(state, status) {
  const code = Number(status);
  if (!Number.isFinite(code)) return [false, 'no readable status for this probe.'];
  if (code >= 200 && code < 300) return [true, 'answered'];
  if (code === EMPTY_REPOSITORY) {
    return [false, '409, which is an empty repository rather than a disabled one'];
  }
  if (isDisabled(state) && [403, 404, 451].includes(code)) {
    return [true, 'explained by the disabled state'];
  }
  if (state === 'archived' && [403, 404].includes(code)) {
    return [false, 'not explained by archiving, which leaves reads working'];
  }
  return [false, 'not explained by the repository state'];
}

/** Classify a repository from its state and its probe answers. Pure. */
export function probeVerdict(state, probes) {
  const rows = (probes || []).filter((p) => p && typeof p === 'object');
  const failing = rows.filter((p) => {
    const code = Number(p.status);
    return !explainsSubresource(state, p.status)[0]
      || !(Number.isFinite(code) && code >= 200 && code < 300);
  });
  const empty = rows.filter((p) => Number(p.status) === EMPTY_REPOSITORY);

  if (state === 'unknown') {
    return ['repository-unreadable', 'the repository object itself did not come '
      + 'back, so this is a credential or name problem rather than a platform state.'];
  }
  if (isDisabled(state)) {
    if (failing.length) {
      return ['ghost-confirmed', `the repository object reads and ${failing.length} `
        + `of ${rows.length} sub-resource(s) do not, which is what disabled looks `
        + 'like from the outside.'];
    }
    return ['disabled-but-answering', 'disabled is set and every probe answered '
      + 'anyway. Trust the boolean: the repository is switched off and must still '
      + 'be excluded from aggregates.'];
  }
  if (empty.length) {
    return ['empty-repository', `${empty.length} probe(s) answered 409 Git `
      + 'Repository is empty. This repository has never been pushed to and is '
      + 'not disabled.'];
  }
  if (state === 'archived') {
    return ['archived-not-disabled', 'archived rather than disabled. Reads work '
      + 'and only writes are refused, which is a different note.'];
  }
  if (failing.length) {
    return ['not-explained-by-state', `${failing.length} sub-resource(s) failed `
      + 'on a repository that is neither disabled nor archived, so the '
      + 'repository state does not explain it.'];
  }
  return ['healthy', 'the repository reads and every sub-resource answered.'];
}

/** Whether this repository may enter an org-wide aggregate. Pure. */
export function aggregateSafety(state) {
  if (isDisabled(state)) {
    return ['exclude', 'every zero this repository contributes is an artefact of '
      + 'the disabled state rather than a measurement.'];
  }
  if (state === 'unknown') {
    return ['exclude', 'the repository could not be read, so it has no values to '
      + 'contribute and its absence should be visible in the report.'];
  }
  if (state === 'archived') {
    return ['include', 'an archived repository is fully readable, so its values '
      + 'are real. Only its writes are refused.'];
  }
  return ['include', 'nothing here disqualifies this repository from a count.'];
}

/** Whether a zero measured on this repository means anything. Pure. */
export function isRealZero(state, value) {
  const number = Number(value);
  if (!Number.isFinite(number) || value === null || value === '') return null;
  if (number !== 0) return null;
  if (isDisabled(state) || state === 'unknown') return false;
  return true;
}

/** What a sweep should report alongside its total. Pure. */
export function aggregateImpact(rows) {
  let counted = 0;
  let excluded = 0;
  let falseZeroes = 0;
  for (const row of rows || []) {
    const state = (row || {}).state;
    const [decision] = aggregateSafety(state);
    if (decision === 'exclude') {
      excluded += 1;
      if (isDisabled(state)) falseZeroes += 1;
    } else {
      counted += 1;
    }
  }
  return { counted, excluded, false_zeroes_avoided: falseZeroes };
}

/** Who can actually change this state. Pure. */
export function remedyOwner(state) {
  if (isDisabled(state)) {
    return 'GitHub, through the billing or support relationship for this '
      + 'account. The API does not say which reason applies.';
  }
  if (state === 'archived') {
    return 'whoever owns the repository, by unarchiving it. That is a decision '
      + 'about whether it is still in use.';
  }
  if (state === 'unknown') return 'nobody yet: the repository could not be read.';
  return 'no remedy needed.';
}

/** The sentence a reader has to act on. Pure. */
export function repair(state) {
  if (['ghost-confirmed', 'disabled-but-answering'].includes(state)) {
    return 'exclude this repository from org-wide aggregates and report it '
      + 'separately. Nothing in your integration can re-enable it; that is a '
      + 'billing or account matter with GitHub.';
  }
  if (state === 'empty-repository') {
    return 'nothing. A repository that has never been pushed to answers 409 on '
      + 'anything needing a commit, and that is not this problem.';
  }
  if (state === 'archived-not-disabled') {
    return 'see /github/repo-archived-writes-403/ -- reads work there and only '
      + 'writes are refused.';
  }
  if (state === 'not-explained-by-state') {
    return 'triage the failures as a credential problem: the repository state '
      + 'does not account for them.';
  }
  if (state === 'repository-unreadable') {
    return 'check the name, the visibility and the installation before anything '
      + 'else. A 404 on the repository means several things.';
  }
  return 'nothing on the platform state.';
}

/** Requests this run will spend against the core quota. Pure. */
export function readCost(repos, probes = DEFAULT_PROBES) {
  const perRepo = 1 + (probes || []).length;
  return perRepo * ((repos || []).length);
}

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

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const names = (process.env.GITHUB_REPOS || '').split(',')
    .map((n) => n.trim()).filter(Boolean);
  if (!token || !names.length) {
    console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_REPOS');
    process.exitCode = 2;
    return;
  }

  console.log(`read cost: ${1 + DEFAULT_PROBES.length} request(s) per repository `
    + 'against the core hourly quota');
  console.log(`read cost: ${readCost(names)} request(s) in total`);

  const findings = [];
  for (const name of names) {
    const res = await fetch(`${API}/repos/${name}`, { headers: headers(token) });
    let repo = null;
    try { repo = await res.json(); } catch { repo = null; }
    const state = res.status === 200 ? platformState(repo) : 'unknown';

    const probes = [];
    if (res.status === 200) {
      for (const path of DEFAULT_PROBES) {
        const p = await fetch(`${API}/repos/${name}${path}`, { headers: headers(token) });
        const [explained, why] = explainsSubresource(state, p.status);
        probes.push({ path: path.split('?')[0], status: p.status, explained, why });
      }
    }

    const [verdict, detail] = probeVerdict(state, probes);
    const [decision, reason] = aggregateSafety(state);

    console.log(`${name}: disabled=${Boolean((repo || {}).disabled)} `
      + `archived=${Boolean((repo || {}).archived)}`);
    console.log(`${verdict}: ${detail}`);
    for (const row of probes) console.log(`  ${row.path} ${row.status} ${row.why}`);
    console.log(`  aggregates: ${decision}. ${reason}`);
    console.log(`  remedy owner: ${remedyOwner(state)}`);
    console.log(`  repair: ${repair(verdict)}`);

    findings.push({
      repository: name,
      repository_status: res.status,
      platform_state: state,
      probes,
      state: verdict,
      detail,
      aggregate_decision: decision,
      aggregate_reason: reason,
      remedy_owner: remedyOwner(state),
      repair: repair(verdict),
    });
  }

  const impact = aggregateImpact(findings.map((f) => ({ state: f.platform_state })));
  const disabled = findings.filter((f) => isDisabled(f.platform_state)).length;
  console.log(`summary: ${findings.length} repositories, ${disabled} disabled, `
    + `${impact.counted} countable`);

  console.log(JSON.stringify({
    requests_spent: readCost(names),
    aggregate_impact: impact,
    findings,
  }, null, 2));
  process.exitCode = disabled ? 1 : 0;
}

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

Add a test

Two of these tests exist to stop the check being wrong in the direction that would matter. A brand new repository answering 409 must never be reported as a ghost, and a sub-resource failure on a repository that is neither disabled nor archived must be handed to the credential triage rather than explained away by a state that is not set. The rest pin the four platform states, the aggregate decision that is the actual output of the note, and the small function that decides whether a zero measured on a repository means anything at all.

test_github_disabled_repo_probe.py
from github_disabled_repo_probe import (
    DEFAULT_PROBES, EMPTY_REPOSITORY, aggregate_impact, aggregate_safety,
    explains_subresource, is_disabled, is_real_zero, platform_state,
    probe_verdict, read_cost, remedy_owner, repair,
)

DISABLED = {"full_name": "acme/payments-legacy", "disabled": True, "archived": False}
ARCHIVED = {"full_name": "acme/legacy-billing", "disabled": False, "archived": True}
BOTH = {"full_name": "acme/gone", "disabled": True, "archived": True}
ACTIVE = {"full_name": "acme/platform-api", "disabled": False, "archived": False}

GHOST_PROBES = [{"path": "/branches", "status": 404},
                {"path": "/commits", "status": 404},
                {"path": "/contributors", "status": 404},
                {"path": "/languages", "status": 200}]
ALL_FINE = [{"path": "/branches", "status": 200},
            {"path": "/commits", "status": 200}]
NEW_REPO = [{"path": "/branches", "status": 200},
            {"path": "/commits", "status": EMPTY_REPOSITORY}]


def test_the_two_booleans_make_four_platform_states():
    assert platform_state(DISABLED) == "disabled"
    assert platform_state(ARCHIVED) == "archived"
    assert platform_state(BOTH) == "disabled-and-archived"
    assert platform_state(ACTIVE) == "active"
    assert platform_state(None) == "unknown"
    assert is_disabled("disabled-and-archived") is True
    assert is_disabled("archived") is False


def test_a_failure_is_only_explained_when_the_state_explains_it():
    assert explains_subresource("disabled", 404)[0] is True
    assert explains_subresource("disabled", 403)[0] is True
    assert explains_subresource("disabled", 200)[0] is True
    explained, why = explains_subresource("active", 404)
    assert explained is False
    assert "not explained by the repository state" in why


def test_archiving_does_not_explain_a_failed_read():
    explained, why = explains_subresource("archived", 404)
    assert explained is False
    assert "leaves reads working" in why


def test_an_empty_repository_is_never_reported_as_a_ghost():
    state, detail = probe_verdict("active", NEW_REPO)
    assert state == "empty-repository"
    assert "never been pushed to" in detail
    assert explains_subresource("disabled", EMPTY_REPOSITORY)[0] is False
    assert repair(state).startswith("nothing.")


def test_the_ghost_is_the_repository_object_reading_and_nothing_else_doing():
    state, detail = probe_verdict("disabled", GHOST_PROBES)
    assert state == "ghost-confirmed"
    assert "3 of 4 sub-resource(s)" in detail
    assert "billing or account matter" in repair(state)


def test_a_disabled_repository_that_answers_is_still_disabled():
    state, detail = probe_verdict("disabled", ALL_FINE)
    assert state == "disabled-but-answering"
    assert "Trust the boolean" in detail


def test_failures_without_a_state_to_explain_them_go_to_the_other_note():
    state, detail = probe_verdict("active", [{"path": "/branches", "status": 404}])
    assert state == "not-explained-by-state"
    assert "neither disabled nor archived" in detail
    assert "credential problem" in repair(state)


def test_archived_and_unreadable_are_handed_on_rather_than_absorbed():
    assert probe_verdict("archived", ALL_FINE)[0] == "archived-not-disabled"
    assert "repo-archived-writes-403" in repair("archived-not-disabled")
    assert probe_verdict("unknown", [])[0] == "repository-unreadable"
    assert probe_verdict("active", ALL_FINE)[0] == "healthy"


def test_the_aggregate_decision_is_the_output_that_matters():
    decision, reason = aggregate_safety("disabled")
    assert decision == "exclude"
    assert "artefact" in reason
    assert aggregate_safety("archived")[0] == "include"
    assert aggregate_safety("active")[0] == "include"
    assert aggregate_safety("unknown")[0] == "exclude"


def test_a_zero_from_a_disabled_repository_is_not_a_zero():
    assert is_real_zero("disabled", 0) is False
    assert is_real_zero("unknown", 0) is False
    assert is_real_zero("active", 0) is True
    assert is_real_zero("archived", 0) is True
    assert is_real_zero("disabled", 4) is None
    assert is_real_zero("active", None) is None


def test_the_sweep_reports_what_it_left_out():
    impact = aggregate_impact([{"state": "disabled"}, {"state": "active"},
                               {"state": "archived"}, {"state": "unknown"}])
    assert impact == {"counted": 2, "excluded": 2, "false_zeroes_avoided": 1}
    assert aggregate_impact([]) == {"counted": 0, "excluded": 0,
                                    "false_zeroes_avoided": 0}


def test_the_remedy_is_addressed_to_whoever_can_apply_it():
    assert "GitHub" in remedy_owner("disabled")
    assert "does not say which reason" in remedy_owner("disabled")
    assert "unarchiving" in remedy_owner("archived")
    assert remedy_owner("active") == "no remedy needed."


def test_the_cost_is_worked_out_before_anything_is_fetched():
    assert len(DEFAULT_PROBES) == 4
    assert read_cost(["a", "b"]) == 10
    assert read_cost(["a"], ("/languages",)) == 2
    assert read_cost([]) == 0
    assert read_cost(None) == 0
github-disabled-repo-probe.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  DEFAULT_PROBES, EMPTY_REPOSITORY, aggregateImpact, aggregateSafety,
  explainsSubresource, isDisabled, isRealZero, platformState, probeVerdict,
  readCost, remedyOwner, repair,
} from './github-disabled-repo-probe.mjs';

const DISABLED = { full_name: 'acme/payments-legacy', disabled: true, archived: false };
const ARCHIVED = { full_name: 'acme/legacy-billing', disabled: false, archived: true };
const BOTH = { full_name: 'acme/gone', disabled: true, archived: true };
const ACTIVE = { full_name: 'acme/platform-api', disabled: false, archived: false };

const GHOST_PROBES = [
  { path: '/branches', status: 404 },
  { path: '/commits', status: 404 },
  { path: '/contributors', status: 404 },
  { path: '/languages', status: 200 },
];
const ALL_FINE = [
  { path: '/branches', status: 200 }, { path: '/commits', status: 200 },
];
const NEW_REPO = [
  { path: '/branches', status: 200 }, { path: '/commits', status: EMPTY_REPOSITORY },
];

test('the two booleans make four platform states', () => {
  assert.equal(platformState(DISABLED), 'disabled');
  assert.equal(platformState(ARCHIVED), 'archived');
  assert.equal(platformState(BOTH), 'disabled-and-archived');
  assert.equal(platformState(ACTIVE), 'active');
  assert.equal(platformState(null), 'unknown');
  assert.equal(isDisabled('disabled-and-archived'), true);
  assert.equal(isDisabled('archived'), false);
});

test('a failure is only explained when the state explains it', () => {
  assert.equal(explainsSubresource('disabled', 404)[0], true);
  assert.equal(explainsSubresource('disabled', 403)[0], true);
  assert.equal(explainsSubresource('disabled', 200)[0], true);
  const [explained, why] = explainsSubresource('active', 404);
  assert.equal(explained, false);
  assert.match(why, /not explained by the repository state/);
});

test('archiving does not explain a failed read', () => {
  const [explained, why] = explainsSubresource('archived', 404);
  assert.equal(explained, false);
  assert.match(why, /leaves reads working/);
});

test('an empty repository is never reported as a ghost', () => {
  const [state, detail] = probeVerdict('active', NEW_REPO);
  assert.equal(state, 'empty-repository');
  assert.match(detail, /never been pushed to/);
  assert.equal(explainsSubresource('disabled', EMPTY_REPOSITORY)[0], false);
  assert.ok(repair(state).startsWith('nothing.'));
});

test('the ghost is the repository object reading and nothing else doing', () => {
  const [state, detail] = probeVerdict('disabled', GHOST_PROBES);
  assert.equal(state, 'ghost-confirmed');
  assert.match(detail, /3 of 4 sub-resource\(s\)/);
  assert.match(repair(state), /billing or account matter/);
});

test('a disabled repository that answers is still disabled', () => {
  const [state, detail] = probeVerdict('disabled', ALL_FINE);
  assert.equal(state, 'disabled-but-answering');
  assert.match(detail, /Trust the boolean/);
});

test('failures without a state to explain them go to the other note', () => {
  const [state, detail] = probeVerdict('active', [{ path: '/branches', status: 404 }]);
  assert.equal(state, 'not-explained-by-state');
  assert.match(detail, /neither disabled nor archived/);
  assert.match(repair(state), /credential problem/);
});

test('archived and unreadable are handed on rather than absorbed', () => {
  assert.equal(probeVerdict('archived', ALL_FINE)[0], 'archived-not-disabled');
  assert.match(repair('archived-not-disabled'), /repo-archived-writes-403/);
  assert.equal(probeVerdict('unknown', [])[0], 'repository-unreadable');
  assert.equal(probeVerdict('active', ALL_FINE)[0], 'healthy');
});

test('the aggregate decision is the output that matters', () => {
  const [decision, reason] = aggregateSafety('disabled');
  assert.equal(decision, 'exclude');
  assert.match(reason, /artefact/);
  assert.equal(aggregateSafety('archived')[0], 'include');
  assert.equal(aggregateSafety('active')[0], 'include');
  assert.equal(aggregateSafety('unknown')[0], 'exclude');
});

test('a zero from a disabled repository is not a zero', () => {
  assert.equal(isRealZero('disabled', 0), false);
  assert.equal(isRealZero('unknown', 0), false);
  assert.equal(isRealZero('active', 0), true);
  assert.equal(isRealZero('archived', 0), true);
  assert.equal(isRealZero('disabled', 4), null);
  assert.equal(isRealZero('active', null), null);
});

test('the sweep reports what it left out', () => {
  const impact = aggregateImpact([{ state: 'disabled' }, { state: 'active' },
    { state: 'archived' }, { state: 'unknown' }]);
  assert.deepEqual(impact, { counted: 2, excluded: 2, false_zeroes_avoided: 1 });
  assert.deepEqual(aggregateImpact([]),
    { counted: 0, excluded: 0, false_zeroes_avoided: 0 });
});

test('the remedy is addressed to whoever can apply it', () => {
  assert.match(remedyOwner('disabled'), /GitHub/);
  assert.match(remedyOwner('disabled'), /does not say which reason/);
  assert.match(remedyOwner('archived'), /unarchiving/);
  assert.equal(remedyOwner('active'), 'no remedy needed.');
});

test('the cost is worked out before anything is fetched', () => {
  assert.equal(DEFAULT_PROBES.length, 4);
  assert.equal(readCost(['a', 'b']), 10);
  assert.equal(readCost(['a'], ['/languages']), 2);
  assert.equal(readCost([]), 0);
  assert.equal(readCost(null), 0);
});

FAQ

How is disabled different from archived?

Archiving is a decision somebody on your side made and it leaves the repository completely readable; only writes are refused. Disabling is done by GitHub, usually over billing, a terms violation or a suspended owning account, and it takes most of the repository's sub-resources with it. The two booleans arrive in the same response and are easy to conflate, but they need different columns in a report because the person who can undo them is different: one is a team decision, the other is an account relationship with GitHub.

Why does the repository object still read?

Because the repository record itself is intact; what has been switched off is the machinery around it. That is exactly why this is hard to spot, since every mechanism you would normally use to detect a missing repository keeps reporting it as present: it stays in the organisation listing, its metadata is current, and a plain read of it returns 200. A deleted repository disappears and 404s cleanly, which is loud and gets noticed the same day.

What does the API say about why it was disabled?

Nothing. There is no field naming the reason and no endpoint that exposes it, so billing, a terms violation and a suspended owning account are indistinguishable from a script's point of view. This is a genuine blind spot rather than a matter of permissions, and the script says so rather than guessing: it names the owner of the remedy, which is whoever holds the billing or support relationship for that account, and leaves the reason to them.

Could this just be an empty repository?

It could, and the check separates them deliberately. A repository created and never pushed to answers <code>409 Git Repository is empty</code> on anything that needs a commit, which from a distance looks like the same ghost. The script gives 409 its own state and never reports a new repository as disabled. If your own sweep does not make that distinction, it will eventually raise an alarm about a repository somebody created ten minutes earlier.

What should the sweep do with a disabled repository?

Exclude it from the aggregates and report the excluded count next to the total. The damage here is not a failed request, it is a zero that looks like a measurement: zero webhooks, zero protected branches, zero open pull requests, all of which are artefacts of the repository being switched off. Leaving those in the denominator quietly moves every coverage percentage in the report, and the discrepancy usually surfaces months later when two people run the same query over different sets.

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.