Skip to content

Diagnostic GitHub API

the repository was renamed and every call now 301s

Somebody renamed the repository on a Tuesday. Nothing broke, which is the problem: GitHub left a redirect at the old path and most clients follow it without mentioning it, so the integration kept working against a name that no longer exists. The config still says acme/platform-api. The repository is called acme/core-api. Every request the job makes takes two round trips instead of one, and has done for eight months.

Read-only token Python and Node.js Tests included
A store filled with lots of shelves filled with items
Photo by Oxana Melis on Unsplash
The short answer

Renaming or transferring a repository leaves a permanent redirect at the old path. GET /repos/{owner}/{repo} on the stale name answers 301 with a Location pointing at the repository's canonical URL, usually https://api.github.com/repositories/{id} rather than the new name.

A 301 is an instruction to update your code, not something to keep following. Read the new full_name from the response you get after following it once, write that into your configuration, and key any persistent state on the repository's id or node_id, both of which survive every future rename. A 302 or 307 is different and should just be followed.

The problem in plain words

The two ways this shows up look nothing alike, which is why it gets diagnosed twice.

If your client does not follow redirects — a hand-rolled fetch wrapper, a webhook receiver, anything with redirect: 'manual' set for good security reasons — you get a 301 with an empty body. The code checks the status against 200, or checks whether the parsed body is truthy, and reports the repository as missing. Then somebody spends an afternoon on the token, because "cannot see the repository" is a permissions shape of problem and permissions are where those investigations start.

If your client does follow redirects, which most do, nothing fails at all. That is worse in the long run. Every single call to that repository now costs two requests instead of one: one to be told where it went, one to go there. Your hourly quota drains at double the rate on that path, every latency measurement includes a round trip nobody asked for, and the configuration slowly rots because nothing is ever forcing anyone to correct it. Eight months later the old name means nothing to anyone on the team and it is still what the job is asking for.

The rename itself is invisible from your side. It happened in an org you may not be in, announced in a channel you are not in, and it left no trace in your logs because the API went out of its way to keep working.

Repo is renamedin an org, on aTuesdayOld pathanswers 301with the canonicaladdressClient followsitquietly, andsucceedsConfig is neverfixedthe stale namestill worksEvery callcosts twoquota drains atdoubleand the redirect is paid for again
Nothing fails anywhere in this chain, which is why it survives for months. The integration is paying twice for every call it makes.

Why it happens

301 means update, 302 and 307 mean follow. GitHub documents the distinction and it matters. A permanent redirect is the API telling you that the resource has a new address and that continuing to ask for the old one is your choice, not its problem. A temporary one is routing, and writing it into your config would be the actual mistake. The check treats them as two different states with two opposite repairs, because a script that says "follow it" for both is wrong half the time.

The Location usually names an id, not a name. The canonical URL is typically https://api.github.com/repositories/{id}, so the header hands you the durable key rather than the new string. That is a hint about the real repair: names are labels people change, ids are not. Follow it once, read full_name out of the body for the configuration a human reads, and key your database rows, caches and mappings on the numeric id or the node_id so the next rename costs nothing at all.

Redirect behaviour is not uniform across methods or clients. Reads are followed by nearly every HTTP library. Anything that is not a read is treated differently by different clients: some refuse to follow at all, some drop the request body, some silently change the request into a read. All of the scripts in this section are read-only so none of them can demonstrate that for you, but it is the reason a stale name is a latent failure rather than a permanent success — the day the integration starts doing something other than reading, the redirect stops being free.

This is not a 404. The 404 note is about an answer that means four different things at once: no such repository, no permission, no installation, dead token. A 301 has none of that ambiguity; it is the most informative answer in this whole section, because it contains the address of the thing you were looking for. The failure is entirely in not reading it. If your check reports a 404 on a name you believe in, that is the other note and a different set of probes.

Case is not a rename. Repository names are matched case-insensitively, so acme/Platform and acme/platform are the same repository and neither one is stale. A comparison of full_name against the configured string with the wrong case sensitivity manufactures a rename that never happened, which is a false alarm shipped to somebody who then goes and edits a config that was fine. The check reports it as its own state and tells you there is nothing to do.

What a read-only script can prove here is unusually complete. Most notes in this section can only show that a trap is set. This one can show the whole thing: the old path answers 301, here is where it points, here is the current full_name, and here is the id to key on instead. The only part it cannot see is whether your client follows redirects, so it reports both consequences and lets you recognise yours.

The fix, as a flow

One GET with automatic redirect following turned off, because that is the only way the 301 is visible at all, and one more to follow it where there is something to follow. The classification has more states than a two-branch problem needs: a permanent redirect and a temporary one carry opposite repairs, a difference of capitalisation is not a rename however much it looks like one, and a 404 is a different note with a different set of probes. Each of those is one wrong config edit that never gets made.

One probe, no followingstatus, Location and full_name301 with a Locationstale name, key on the id200 under another namefollowed silently, still stale302 or 307follow it, write nothing downDiffers only in casethe same repositoryName matches, no hopnothing to do
Only the top two rows are anything to fix, and the bottom two exist so that nobody edits a configuration that was already correct.

How to fix it

Ask for the configured name with redirects turned off

One GET /repos/{owner}/{repo} with automatic redirects disabled. This is the only way to see the 301 at all; with following enabled your client swallows it and hands you a 200 from a different URL, which is precisely how this has been invisible for months.

Read the status and the Location together

301 or 308 means the name in your configuration is stale. 302 or 307 means follow it and change nothing. The Location usually points at /repositories/{id}, which is the durable identifier rather than the new name, so it is worth keeping even after you have looked up the label.

Follow it once to get the current full_name

A second request, to the address you were given, returns the repository object. full_name in that body is what goes into your configuration; id and node_id are what should go into anything persistent. Two requests total per repository, which the script prints before it spends them.

Compare names case-insensitively

A difference in capitalisation is not a rename and must not be reported as one. Compare the configured string against full_name with case folded, and only call it stale when the letters themselves differ. The script has a state for the case-only result so that nobody edits a config that was already correct.

Write down the id, not just the new name

Updating the name fixes today. Keying persistent state on id or node_id fixes every future rename, including the ones nobody will tell you about. If your integration stores a mapping from repository to anything at all — queue, config, dashboard row — the string is the wrong key and always was.

How to check it worked

After the configuration is updated, the first request is the only request, and the state changes from a redirect to a name that matches.

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_repo_renamed.py --repo acme/platform-api
# read cost: at most 2 request(s) per repository against the core hourly quota
# acme/platform-api: 301 -> https://api.github.com/repositories/1300192
# renamed-permanent: the configured name is stale and GitHub is redirecting it
# permanently to repository id 1300192, now called acme/core-api
# repair: update the stored name to acme/core-api and key persistent state on
# id 1300192 or node_id R_kgDOE..., which survive the next rename too
# a client that follows this pays 1 extra request per call: 1200 calls an hour
# becomes 2400

GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_repo_renamed.py --repo acme/core-api
# current: the configured name matches full_name and the request was answered
# without a redirect

The full code

One GET with automatic redirects disabled, which is the only way the 301 is visible at all, and one more to follow it where there is something to follow. The classification afterwards is pure and has more states than you would expect from a two-branch problem, because a permanent redirect and a temporary one have opposite repairs, a difference of capitalisation is not a rename, and a 404 belongs to another note entirely. Each of those distinctions is one wrong config edit avoided.

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_repo_renamed.py
"""Find repositories whose configured name is a redirect to somewhere else.

Read only. At most two GETs per repository: one with redirects disabled, and
one to follow the redirect where there is one. Nothing is written and the
repair is printed rather than performed.

Renaming or transferring a repository leaves a permanent redirect at the old
path. A client that does not follow redirects sees a 301 with an empty body and
reports the repository as missing; a client that does follow them works
perfectly and pays an extra round trip on every call, forever, while the
configured name rots.

What this can and cannot see: whether your own client follows redirects is
invisible from here, so both consequences are reported and you recognise yours.
Everything else about this failure is fully readable, which is unusual.

Environment:

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

import requests

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

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

# 301 and 308 say the address changed. 302 and 307 say the routing changed.
# The difference is the whole reason this script has more than two states.
PERMANENT = (301, 308)
TEMPORARY = (302, 307)

# The canonical URL is usually the numeric form, which hands you the durable
# key rather than the new name.
LOC_ID = re.compile(r"/repositories/(\d+)")
LOC_FULL = re.compile(r"/repos/([^/?#]+)/([^/?#]+)")


def is_redirect(status):
    """Whether this status moves you somewhere else. Pure."""
    try:
        return int(status) in PERMANENT + TEMPORARY
    except (TypeError, ValueError):
        return False


def is_permanent(status):
    """Whether this status means update your code rather than follow it. Pure."""
    try:
        return int(status) in PERMANENT
    except (TypeError, ValueError):
        return False


def repo_from_location(location):
    """What the Location header points at. Pure. Returns (kind, value) or None.

    Two shapes, and the numeric one is the common answer: GitHub redirects a
    renamed repository to /repositories/{id}, which is the identifier that
    survives every future rename.
    """
    if not location:
        return None
    text = str(location)
    m = LOC_ID.search(text)
    if m:
        return ("id", m.group(1))
    m = LOC_FULL.search(text)
    if m:
        return ("full_name", "%s/%s" % (m.group(1), m.group(2)))
    return None


def same_repo(a, b):
    """Whether two owner/name strings name the same repository. Pure.

    Case-insensitively, because GitHub matches names that way and a comparison
    that does not manufactures a rename nobody performed.
    """
    if not a or not b:
        return False
    return str(a).strip().lower() == str(b).strip().lower()


def verdict(asked, status, location=None, full_name=None):
    """Classify one probe of a configured repository name. Pure."""
    try:
        code = int(status)
    except (TypeError, ValueError):
        return ("unknown", "the probe produced no readable status.")

    if is_permanent(code):
        target = repo_from_location(location)
        if not target:
            return ("renamed-permanent",
                    "%d says the configured name is stale, but the response "
                    "carried no usable Location, so the new name has to be read "
                    "from the body after following it once." % code)
        kind, value = target
        named = (", now called %s" % full_name) if full_name else ""
        if kind == "id":
            return ("renamed-permanent",
                    "the configured name is stale and GitHub is redirecting it "
                    "permanently to repository id %s%s." % (value, named))
        return ("renamed-permanent",
                "the configured name is stale and GitHub is redirecting it "
                "permanently to %s%s." % (value, named))

    if is_redirect(code):
        return ("moved-temporary",
                "%d is a temporary redirect, so follow it and change nothing. "
                "Writing this address into your configuration is the mistake "
                "here, not the fix." % code)

    if code == 404:
        return ("not-found",
                "404 is not a rename. It means no repository, no permission, no "
                "installation or a dead token, and separating those four is a "
                "different check.")

    if code != 200:
        return ("unknown",
                "%d is neither a redirect nor a readable repository." % code)

    if not full_name:
        return ("unknown",
                "the repository was returned without a full_name, so there is "
                "nothing to compare the configured name against.")

    if str(asked).strip() == str(full_name).strip():
        return ("current",
                "the configured name matches full_name and the request was "
                "answered without a redirect.")

    if same_repo(asked, full_name):
        return ("case-only",
                "the configured name differs from %s only in capitalisation. "
                "GitHub matches names case-insensitively, so this is the same "
                "repository and there is nothing to do." % full_name)

    return ("renamed-followed",
            "the request was answered as %s rather than as the name that was "
            "asked for, so a redirect was followed somewhere between here and "
            "GitHub and nobody was told." % full_name)


def durable_key(repo):
    """The identifiers that survive a rename. Pure. None when absent."""
    if not isinstance(repo, dict):
        return None
    key = {k: repo.get(k) for k in ("id", "node_id") if repo.get(k) is not None}
    return key or None


def extra_round_trips(calls):
    """Requests a followed redirect adds over a period. Pure.

    One per call: the redirect itself is a request, and it buys nothing except
    an address you could have written down once.
    """
    try:
        return max(0, int(calls))
    except (TypeError, ValueError):
        return 0


def repair(state):
    """The sentence a reader has to act on. Pure."""
    if state == "renamed-permanent":
        return ("update the stored owner/name to the value in the Location or "
                "in full_name, and key persistent state on the repository id or "
                "node_id, which survive the next rename too.")
    if state == "renamed-followed":
        return ("your client is following a redirect silently. Update the "
                "configured name to the full_name that came back, and key "
                "persistent state on id or node_id so the next rename is free.")
    if state == "moved-temporary":
        return ("follow it and change nothing. A temporary redirect is routing "
                "and does not belong in your configuration.")
    if state == "case-only":
        return ("nothing. The names differ only in capitalisation and GitHub "
                "matches them case-insensitively.")
    if state == "not-found":
        return ("triage the 404 rather than assuming a rename: check the token, "
                "the scopes and the installation before the name.")
    if state == "current":
        return "nothing."
    return "point the check at a repository this token can read."


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

    An upper bound: one probe per repository, plus one more only where there is
    a redirect to follow.
    """
    return 2 * len(repos or [])


def probe(session, full_name):
    """One GET with redirects disabled. Returns (status, location)."""
    r = session.get(API + "/repos/" + full_name, allow_redirects=False, 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")
    return r.status_code, r.headers.get("Location")


def resolve(session, url):
    """Follow one redirect and read the repository object. Returns dict or None."""
    r = session.get(url, timeout=30)
    if r.status_code != 200:
        return None
    try:
        body = r.json()
    except ValueError:
        return None
    return body if isinstance(body, dict) else None


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", action="append", required=True,
                    help="owner/name as your configuration has it. Repeatable.")
    ap.add_argument("--calls-per-hour", type=int, default=0,
                    help="how often your integration calls this repository, so "
                         "the cost of a followed redirect can be stated in "
                         "requests rather than in adjectives")
    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: at most %d request(s) per repository against the core "
             "hourly quota", 2)
    log.info("read cost: %d request(s) in total at most", read_cost(args.repo))

    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, location = probe(session, name)
        repo = None
        if is_redirect(status) and location:
            log.info("%s: %d -> %s", name, status, location)
            repo = resolve(session, location)
        elif status == 200:
            repo = resolve(session, API + "/repos/" + name)

        full_name = (repo or {}).get("full_name")
        state, detail = verdict(name, status, location, full_name)
        log.info("%s: %s", state, detail)
        log.info("repair: %s", repair(state))
        if state in ("renamed-permanent", "renamed-followed") and args.calls_per_hour:
            log.info("a client that follows this pays 1 extra request per call: "
                     "%d calls an hour becomes %d", args.calls_per_hour,
                     args.calls_per_hour + extra_round_trips(args.calls_per_hour))

        findings.append({
            "configured": name,
            "status": status,
            "location": location,
            "location_points_at": repo_from_location(location),
            "full_name": full_name,
            "durable_key": durable_key(repo),
            "extra_requests_per_hour": (
                extra_round_trips(args.calls_per_hour)
                if state in ("renamed-permanent", "renamed-followed") else 0),
            "state": state,
            "detail": detail,
            "repair": repair(state),
        })

    print(json.dumps({"requests_spent_at_most": read_cost(args.repo),
                      "findings": findings}, indent=2, default=str))
    bad = {"renamed-permanent", "renamed-followed"}
    return 1 if any(f["state"] in bad for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
github-repo-renamed.mjs
/**
 * Find repositories whose configured name is a redirect to somewhere else.
 *
 * Read only. At most two GETs per repository: one with redirects disabled, and
 * one to follow the redirect where there is one. Nothing is written and the
 * repair is printed rather than performed.
 *
 * Environment:
 *   GITHUB_TOKEN     a token with read access to the repository
 *   GITHUB_REPOS     comma-separated owner/name values as your config has them
 *   GITHUB_CALLS     calls an hour your integration makes, optional
 */
const API = 'https://api.github.com';
const UA = 'github-repo-renamed/1.0';

/** 301 and 308 say the address changed. 302 and 307 say the routing changed. */
export const PERMANENT = [301, 308];
export const TEMPORARY = [302, 307];

const LOC_ID = /\/repositories\/(\d+)/;
const LOC_FULL = /\/repos\/([^/?#]+)\/([^/?#]+)/;

/** A finite number, or null. Pure. Number(null) is 0, which would lie here. */
function toNumber(value) {
  if (value === null || value === undefined || value === '') return null;
  const n = Number(value);
  return Number.isFinite(n) ? n : null;
}

/** Whether this status moves you somewhere else. Pure. */
export function isRedirect(status) {
  const n = toNumber(status);
  return n !== null && (PERMANENT.includes(n) || TEMPORARY.includes(n));
}

/** Whether this status means update your code rather than follow it. Pure. */
export function isPermanent(status) {
  const n = toNumber(status);
  return n !== null && PERMANENT.includes(n);
}

/** What the Location header points at. Pure. Returns [kind, value] or null. */
export function repoFromLocation(location) {
  if (!location) return null;
  const text = String(location);
  const byId = LOC_ID.exec(text);
  if (byId) return ['id', byId[1]];
  const byName = LOC_FULL.exec(text);
  if (byName) return ['full_name', `${byName[1]}/${byName[2]}`];
  return null;
}

/** Whether two owner/name strings name the same repository. Pure. */
export function sameRepo(a, b) {
  if (!a || !b) return false;
  return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
}

/** Classify one probe of a configured repository name. Pure. */
export function verdict(asked, status, location = null, fullName = null) {
  const code = toNumber(status);
  if (code === null) return ['unknown', 'the probe produced no readable status.'];

  if (isPermanent(code)) {
    const target = repoFromLocation(location);
    if (!target) {
      return ['renamed-permanent',
        `${code} says the configured name is stale, but the response carried no `
        + 'usable Location, so the new name has to be read from the body after '
        + 'following it once.'];
    }
    const [kind, value] = target;
    const named = fullName ? `, now called ${fullName}` : '';
    if (kind === 'id') {
      return ['renamed-permanent',
        'the configured name is stale and GitHub is redirecting it permanently '
        + `to repository id ${value}${named}.`];
    }
    return ['renamed-permanent',
      'the configured name is stale and GitHub is redirecting it permanently to '
      + `${value}${named}.`];
  }

  if (isRedirect(code)) {
    return ['moved-temporary',
      `${code} is a temporary redirect, so follow it and change nothing. `
      + 'Writing this address into your configuration is the mistake here, not '
      + 'the fix.'];
  }

  if (code === 404) {
    return ['not-found',
      '404 is not a rename. It means no repository, no permission, no '
      + 'installation or a dead token, and separating those four is a different '
      + 'check.'];
  }

  if (code !== 200) {
    return ['unknown', `${code} is neither a redirect nor a readable repository.`];
  }

  if (!fullName) {
    return ['unknown',
      'the repository was returned without a full_name, so there is nothing to '
      + 'compare the configured name against.'];
  }

  if (String(asked).trim() === String(fullName).trim()) {
    return ['current',
      'the configured name matches full_name and the request was answered '
      + 'without a redirect.'];
  }

  if (sameRepo(asked, fullName)) {
    return ['case-only',
      `the configured name differs from ${fullName} only in capitalisation. `
      + 'GitHub matches names case-insensitively, so this is the same repository '
      + 'and there is nothing to do.'];
  }

  return ['renamed-followed',
    `the request was answered as ${fullName} rather than as the name that was `
    + 'asked for, so a redirect was followed somewhere between here and GitHub '
    + 'and nobody was told.'];
}

/** The identifiers that survive a rename. Pure. null when absent. */
export function durableKey(repo) {
  if (!repo || typeof repo !== 'object') return null;
  const key = {};
  for (const k of ['id', 'node_id']) {
    if (repo[k] !== undefined && repo[k] !== null) key[k] = repo[k];
  }
  return Object.keys(key).length ? key : null;
}

/** Requests a followed redirect adds over a period. Pure. */
export function extraRoundTrips(calls) {
  const n = toNumber(calls);
  if (n === null) return 0;
  return Math.max(0, Math.trunc(n));
}

/** The sentence a reader has to act on. Pure. */
export function repair(state) {
  if (state === 'renamed-permanent') {
    return 'update the stored owner/name to the value in the Location or in '
      + 'full_name, and key persistent state on the repository id or node_id, '
      + 'which survive the next rename too.';
  }
  if (state === 'renamed-followed') {
    return 'your client is following a redirect silently. Update the configured '
      + 'name to the full_name that came back, and key persistent state on id or '
      + 'node_id so the next rename is free.';
  }
  if (state === 'moved-temporary') {
    return 'follow it and change nothing. A temporary redirect is routing and '
      + 'does not belong in your configuration.';
  }
  if (state === 'case-only') {
    return 'nothing. The names differ only in capitalisation and GitHub matches '
      + 'them case-insensitively.';
  }
  if (state === 'not-found') {
    return 'triage the 404 rather than assuming a rename: check the token, the '
      + 'scopes and the installation before the name.';
  }
  if (state === 'current') return 'nothing.';
  return 'point the check at a repository this token can read.';
}

/** Requests this run will spend against the core quota, as an upper bound. Pure. */
export function readCost(repos) {
  return 2 * (Array.isArray(repos) ? repos.length : 0);
}

function headersFor(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 repos = (process.env.GITHUB_REPOS || '').split(',').map((s) => s.trim()).filter(Boolean);
  if (!token || repos.length === 0) {
    console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_REPOS=owner/name');
    process.exitCode = 2;
    return;
  }
  const calls = Number(process.env.GITHUB_CALLS || 0);
  console.log(`read cost: at most ${readCost(repos)} request(s) against the core hourly quota`);

  const findings = [];
  for (const name of repos) {
    // Manual, because with following enabled the client swallows the 301 and
    // hands back a 200 from an address nobody looked at.
    const res = await fetch(`${API}/repos/${name}`, {
      headers: headersFor(token),
      redirect: 'manual',
    });
    const location = res.headers.get('location');
    let repo = null;
    if (isRedirect(res.status) && location) {
      console.log(`${name}: ${res.status} -> ${location}`);
      const followed = await fetch(location, { headers: headersFor(token) });
      if (followed.ok) { try { repo = await followed.json(); } catch { repo = null; } }
    } else if (res.status === 200) {
      try { repo = await res.json(); } catch { repo = null; }
    }

    const fullName = repo ? repo.full_name : null;
    const [state, detail] = verdict(name, res.status, location, fullName);
    console.log(`${state}: ${detail}`);
    console.log(`repair: ${repair(state)}`);
    if (['renamed-permanent', 'renamed-followed'].includes(state) && calls) {
      console.log(`a client that follows this pays 1 extra request per call: `
        + `${calls} calls an hour becomes ${calls + extraRoundTrips(calls)}`);
    }

    findings.push({
      configured: name,
      status: res.status,
      location,
      location_points_at: repoFromLocation(location),
      full_name: fullName,
      durable_key: durableKey(repo),
      extra_requests_per_hour: ['renamed-permanent', 'renamed-followed'].includes(state)
        ? extraRoundTrips(calls) : 0,
      state,
      detail,
      repair: repair(state),
    });
  }

  console.log(JSON.stringify({ requests_spent_at_most: readCost(repos), findings }, null, 2));
  const bad = ['renamed-permanent', 'renamed-followed'];
  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 states are the point, so the tests are mostly about keeping them apart: a permanent redirect and a temporary one must never produce the same repair, a difference of capitalisation must not be reported as a rename, and a 404 must be handed to the note that owns it rather than guessed at here. The Location parser gets its own attention because the header usually names an id rather than a name, and that id is the durable key the repair is really about. The read cost is asserted as the upper bound it claims to be.

test_github_repo_renamed.py
from github_repo_renamed import (
    PERMANENT, TEMPORARY, durable_key, extra_round_trips, is_permanent,
    is_redirect, read_cost, repair, repo_from_location, same_repo, verdict,
)

BY_ID = "https://api.github.com/repositories/1300192"
BY_NAME = "https://api.github.com/repos/acme/core-api"


def test_permanent_and_temporary_redirects_are_kept_apart():
    assert is_redirect(301) and is_permanent(301)
    assert is_redirect(308) and is_permanent(308)
    assert is_redirect(302) and not is_permanent(302)
    assert is_redirect(307) and not is_permanent(307)
    assert not is_redirect(200)
    assert not is_redirect(None)
    assert set(PERMANENT).isdisjoint(TEMPORARY)


def test_the_location_usually_names_an_id_rather_than_a_name():
    assert repo_from_location(BY_ID) == ("id", "1300192")
    assert repo_from_location(BY_NAME) == ("full_name", "acme/core-api")
    assert repo_from_location("/repos/acme/core-api") == ("full_name", "acme/core-api")
    assert repo_from_location("https://example.test/nothing") is None
    assert repo_from_location(None) is None


def test_names_are_compared_the_way_github_compares_them():
    assert same_repo("Acme/Platform", "acme/platform")
    assert same_repo(" acme/platform ", "acme/platform")
    assert not same_repo("acme/platform", "acme/core-api")
    assert not same_repo(None, "acme/platform")


def test_a_permanent_redirect_is_the_finding_and_names_the_target():
    state, detail = verdict("acme/platform-api", 301, BY_ID, "acme/core-api")
    assert state == "renamed-permanent"
    assert "1300192" in detail
    assert "acme/core-api" in detail


def test_a_permanent_redirect_without_a_location_is_still_a_finding():
    state, detail = verdict("acme/platform-api", 301, None, None)
    assert state == "renamed-permanent"
    assert "no usable Location" in detail


def test_a_temporary_redirect_must_not_be_written_into_a_config():
    state, detail = verdict("acme/platform-api", 302, BY_NAME, None)
    assert state == "moved-temporary"
    assert "change nothing" in detail
    assert "change nothing" in repair(state)


def test_a_followed_redirect_is_caught_by_the_name_that_came_back():
    state, detail = verdict("acme/platform-api", 200, None, "acme/core-api")
    assert state == "renamed-followed"
    assert "nobody was told" in detail


def test_capitalisation_is_not_a_rename():
    state, detail = verdict("Acme/Platform", 200, None, "acme/platform")
    assert state == "case-only"
    assert "capitalisation" in detail
    assert repair(state).startswith("nothing.")


def test_a_matching_name_is_not_a_finding():
    assert verdict("acme/core-api", 200, None, "acme/core-api")[0] == "current"
    assert repair("current") == "nothing."


def test_a_404_is_handed_to_the_note_that_owns_it():
    state, detail = verdict("acme/gone", 404, None, None)
    assert state == "not-found"
    assert "not a rename" in detail
    assert "triage the 404" in repair(state)


def test_an_unreadable_probe_is_never_reported_as_a_rename():
    assert verdict("acme/x", None, None, None)[0] == "unknown"
    assert verdict("acme/x", 500, None, None)[0] == "unknown"
    assert verdict("acme/x", 200, None, None)[0] == "unknown"


def test_the_durable_key_is_what_the_repair_is_really_about():
    assert durable_key({"id": 1300192, "node_id": "R_kgDOE", "name": "core-api"}) == {
        "id": 1300192, "node_id": "R_kgDOE"}
    assert durable_key({"name": "core-api"}) is None
    assert durable_key(None) is None


def test_a_followed_redirect_doubles_the_requests_on_that_path():
    assert extra_round_trips(1200) == 1200
    assert extra_round_trips(0) == 0
    assert extra_round_trips(-5) == 0
    assert extra_round_trips(None) == 0


def test_the_two_rename_repairs_both_point_at_the_id():
    assert "node_id" in repair("renamed-permanent")
    assert "node_id" in repair("renamed-followed")
    assert "following a redirect silently" in repair("renamed-followed")


def test_the_cost_is_stated_as_the_upper_bound_it_is():
    assert read_cost(["a/b", "c/d"]) == 4
    assert read_cost(["a/b"]) == 2
    assert read_cost([]) == 0
    assert read_cost(None) == 0
github-repo-renamed.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  PERMANENT, TEMPORARY, durableKey, extraRoundTrips, isPermanent, isRedirect,
  readCost, repair, repoFromLocation, sameRepo, verdict,
} from './github-repo-renamed.mjs';

const BY_ID = 'https://api.github.com/repositories/1300192';
const BY_NAME = 'https://api.github.com/repos/acme/core-api';

test('permanent and temporary redirects are kept apart', () => {
  assert.ok(isRedirect(301) && isPermanent(301));
  assert.ok(isRedirect(308) && isPermanent(308));
  assert.ok(isRedirect(302) && !isPermanent(302));
  assert.ok(isRedirect(307) && !isPermanent(307));
  assert.ok(!isRedirect(200));
  assert.ok(!isRedirect(null));
  assert.ok(!PERMANENT.some((c) => TEMPORARY.includes(c)));
});

test('the Location usually names an id rather than a name', () => {
  assert.deepEqual(repoFromLocation(BY_ID), ['id', '1300192']);
  assert.deepEqual(repoFromLocation(BY_NAME), ['full_name', 'acme/core-api']);
  assert.deepEqual(repoFromLocation('/repos/acme/core-api'), ['full_name', 'acme/core-api']);
  assert.equal(repoFromLocation('https://example.test/nothing'), null);
  assert.equal(repoFromLocation(null), null);
});

test('names are compared the way GitHub compares them', () => {
  assert.ok(sameRepo('Acme/Platform', 'acme/platform'));
  assert.ok(sameRepo(' acme/platform ', 'acme/platform'));
  assert.ok(!sameRepo('acme/platform', 'acme/core-api'));
  assert.ok(!sameRepo(null, 'acme/platform'));
});

test('a permanent redirect is the finding and names the target', () => {
  const [state, detail] = verdict('acme/platform-api', 301, BY_ID, 'acme/core-api');
  assert.equal(state, 'renamed-permanent');
  assert.match(detail, /1300192/);
  assert.match(detail, /acme\/core-api/);
});

test('a permanent redirect without a Location is still a finding', () => {
  const [state, detail] = verdict('acme/platform-api', 301, null, null);
  assert.equal(state, 'renamed-permanent');
  assert.match(detail, /no usable Location/);
});

test('a temporary redirect must not be written into a config', () => {
  const [state, detail] = verdict('acme/platform-api', 302, BY_NAME, null);
  assert.equal(state, 'moved-temporary');
  assert.match(detail, /change nothing/);
  assert.match(repair(state), /change nothing/);
});

test('a followed redirect is caught by the name that came back', () => {
  const [state, detail] = verdict('acme/platform-api', 200, null, 'acme/core-api');
  assert.equal(state, 'renamed-followed');
  assert.match(detail, /nobody was told/);
});

test('capitalisation is not a rename', () => {
  const [state, detail] = verdict('Acme/Platform', 200, null, 'acme/platform');
  assert.equal(state, 'case-only');
  assert.match(detail, /capitalisation/);
  assert.ok(repair(state).startsWith('nothing.'));
});

test('a matching name is not a finding', () => {
  assert.equal(verdict('acme/core-api', 200, null, 'acme/core-api')[0], 'current');
  assert.equal(repair('current'), 'nothing.');
});

test('a 404 is handed to the note that owns it', () => {
  const [state, detail] = verdict('acme/gone', 404, null, null);
  assert.equal(state, 'not-found');
  assert.match(detail, /not a rename/);
  assert.match(repair(state), /triage the 404/);
});

test('an unreadable probe is never reported as a rename', () => {
  assert.equal(verdict('acme/x', null, null, null)[0], 'unknown');
  assert.equal(verdict('acme/x', 500, null, null)[0], 'unknown');
  assert.equal(verdict('acme/x', 200, null, null)[0], 'unknown');
});

test('the durable key is what the repair is really about', () => {
  assert.deepEqual(durableKey({ id: 1300192, node_id: 'R_kgDOE', name: 'core-api' }),
    { id: 1300192, node_id: 'R_kgDOE' });
  assert.equal(durableKey({ name: 'core-api' }), null);
  assert.equal(durableKey(null), null);
});

test('a followed redirect doubles the requests on that path', () => {
  assert.equal(extraRoundTrips(1200), 1200);
  assert.equal(extraRoundTrips(0), 0);
  assert.equal(extraRoundTrips(-5), 0);
  assert.equal(extraRoundTrips(null), 0);
});

test('the two rename repairs both point at the id', () => {
  assert.match(repair('renamed-permanent'), /node_id/);
  assert.match(repair('renamed-followed'), /node_id/);
  assert.match(repair('renamed-followed'), /following a redirect silently/);
});

test('the cost is stated as the upper bound it is', () => {
  assert.equal(readCost(['a/b', 'c/d']), 4);
  assert.equal(readCost(['a/b']), 2);
  assert.equal(readCost([]), 0);
  assert.equal(readCost(null), 0);
});

FAQ

Should I just follow the redirect and move on?

You can, and your client probably already does, which is why nobody noticed. The cost is that every call to that repository takes two requests instead of one: one to be told where it went and one to go there. On a job making a thousand calls an hour that is a thousand requests an hour of your quota spent re-learning a fact you could have written down once. GitHub's own guidance is that a 301 means update your code, and it means it.

Why does the Location point at a number instead of the new name?

Because the number is the repository's canonical identity and the name is a label somebody chose. The redirect resolves to https://api.github.com/repositories/{id}, which is stable across every rename and every transfer that will ever happen to that repository. That is a hint worth taking: update the name in the configuration a human reads, and key anything persistent on id or node_id so the next rename does not reach your code at all.

How is this different from getting a 404?

A 404 on this API is deliberately ambiguous: no such repository, no permission, not in the installation, or a dead token, all with the same status and no way to tell them apart from the response alone. A 301 is the opposite, the most informative answer in this section, because it contains the address of what you asked for. If your probe returns 404 on a name you are sure about, stop reading this note and go and triage the 404 instead.

Does the redirect work for everything, not just reads?

Not dependably, and that is what makes a stale name a latent problem rather than a permanent workaround. HTTP libraries treat redirects on non-read requests very differently: some refuse them, some drop the body, some quietly turn the request into a read. Every script in this section is read only so none of them can demonstrate it for you, but the day your integration stops only reading is the day the redirect stops being free.

What if the name only differs in capitalisation?

Then nothing happened. GitHub matches repository names case-insensitively, so acme/Platform and acme/platform are one repository and neither name is stale. A comparison that does not fold case will report a rename that nobody performed, and somebody will go and edit a configuration that was already right. The check has a state for this specifically so a false alarm never reaches a reader.

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.