Diagnostic GitHub API
Every call succeeds and reports on a fork, not the upstream
The quarterly report says the platform repository had four merged pull requests, no releases and eleven open issues. It is a repository with nine thousand issues. Nothing failed to produce that report: every call returned 200, the pagination was followed properly, the retries never fired, the JSON parsed. The configuration was copied a year ago from an engineer's personal fork, and a fork is a different repository with its own issues, its own releases and its own branches. The integration has been right about the wrong object for four quarters.
Read fork on GET /repos/{owner}/{repo}. When it is true, the same response carries parent.full_name — the repository this one was forked from — and source.full_name, the root of the network. If your integration treats a repository with fork: true as canonical, that is the bug, and source.full_name names what it should have been reading.
There is nothing to catch here. A fork answers every endpoint with a 200 and correct data about itself, so no status code, retry or alert will ever fire. The finding has to come from a reading you take deliberately, before the audit rather than after it.
Then key your stored state on the repository's numeric id rather than on owner/name. A name can come to point at a different object — a repository is deleted and somebody's fork is renamed into the gap, or a rename frees a name that is then taken. The id never moves, so a mismatch between the stored id and the live one is the same bug arriving without anybody having edited a configuration.
The problem in plain words
Everything that normally surfaces a misconfiguration is absent. There is no error, so nothing pages anybody. The data is internally consistent, so no validation trips. The numbers are low rather than zero, which is worse than zero would have been: zero looks broken, and four looks like a quiet quarter. And the report is delivered on time every time, which builds confidence in it.
The ways integrations end up here are all mundane. Somebody sets it up from their own fork because that is the URL in their browser while they are working. A repository is transferred to an organization and the old personal copy stays behind, still reachable, still answering. A vendor's onboarding form is filled in by copying a clone URL out of a terminal. A repository gets deleted and a fork is renamed into the same path, so a configuration nobody touched now resolves to a different object.
What finally exposes it is usually an argument about a number. Someone says the release cadence is monthly and the dashboard says there have been no releases at all, and half a day goes into the dashboard's release-fetching code before anybody checks which repository it is fetching them from. By then the report has been believed for a year, which is the real cost: not a failed run, but a stack of accepted conclusions that have to be withdrawn.
Why it happens
A fork is a repository, not a view of one. It has its own issue list, its own releases, its own labels, milestones, webhooks and branch protection. The only things it shares with its parent are commits and the upstream pointers in the API response. So an integration aimed at a fork is not seeing a partial or stale version of the upstream; it is seeing a different, complete, correct repository that happens to have similar code in it.
Forks are created with issues switched off. That is why the symptom so often arrives as an empty or erroring issue list rather than as a wrong count — a fork whose owner never enabled issues answers 410 Gone on those endpoints, which sends the investigation towards disabled features and away from the fact that this was never the right repository. Both notes are true at once here, and only one of them is the root cause.
parent and source are different fields and they can disagree. parent is what this repository was forked directly from; source is the root of the whole network. For a fork of a fork they differ, and it is source you almost always want. Reading parent and stopping there repoints the integration one hop closer and leaves it still wrong, which is a particularly annoying way to fix something.
Names are mutable and ids are not. owner/name is a lookup key that GitHub is free to reassign: renames free the old name, deletions free the whole path, and a rename leaves a redirect that only lasts as long as nothing else claims the name. The numeric id and the node_id are permanent. Storing the id alongside the name costs nothing and turns an undetectable substitution into a mismatch your code can raise.
The size difference is the corroboration a human recognises. Nobody looks at fork: true and feels anything. Everybody looks at "this repository has 41 stars, the one it was forked from has 12,400, and it was last pushed to nineteen months ago" and knows immediately. So the script reads the upstream too and prints the gap, because the point is not only to be correct but to be believed quickly.
Nothing is written to establish any of this. That is easy here and worth saying anyway: the whole finding is two repository reads and a comparison of integers, and the repair — repoint the configuration, store the id — happens in your configuration rather than through the API at all.
The fix, as a flow
There is no failure to catch here, so the script compares two repositories instead of classifying one response. The fork boolean and source.full_name come off a single read and settle which object the configuration is pointed at; a second read of the upstream turns that into a size difference the reader can recognise, in stars, in open issues, in how long ago each was pushed to. The stored id is checked against the live one in the same pass, because a name that resolves to a different object than it did last month is the same bug arriving without anybody editing the configuration.
How to fix it
Ask the repository whether it is one
One read of GET /repos/{owner}/{repo} returns fork, and when that is true, parent.full_name and source.full_name beside it. The script reports all three and names source.full_name as the repository the configuration should hold. It prefers source over parent and says so when the two differ, which is the fork-of-a-fork case.
Read the upstream too and print the gap
A second read fetches the repository named by source and prints the difference in stars, open issues, forks and how far behind the fork's last push is. This is the output you can put in front of somebody who set the integration up and get agreement in ten seconds, which is worth one extra request against a quota of five thousand.
Check the stored id against the live one
Pass --expect-id 1296269 with whatever your state store recorded and the script compares it to the id the name resolves to now. A mismatch means the name has moved to a different object since you last looked, which is a bug nobody introduced and nothing else will detect. If you have no id stored, the script prints the one to store.
Collect the symptoms that were blamed on something else
The script lists the things about this repository that would make an audit look quiet: issues disabled, no releases, no open issues, a stale pushed_at, a default branch that differs from the upstream's. Seeing them gathered under one cause is what stops the next person debugging the release-fetching code.
Repoint the configuration, and key it on the id
The printed repair names the repository to switch to and the id to store next to it. Nothing is changed by the script — the fix lives in your configuration, not in the API — and once the id is stored, the same run catches the next silent substitution instead of reporting a quiet quarter.
How to check it worked
After repointing, the same run reports a root repository whose id matches the one you stored.
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_fork_or_upstream.py \
jsmith/platform-core --expect-id 20221
# read cost: 2 request(s) against the core hourly quota
# jsmith/platform-core: fork=True id=904113 pushed_at=2025-01-14T09:22:10Z
# parent=octo-org/platform-core source=octo-org/platform-core
# fork-as-canonical: this repository has fork=true, so it is a separate
# repository with its own issues, releases and branches. The integration is
# reading it as if it were octo-org/platform-core.
# id-drift: the stored id is 20221 and this name now resolves to 904113.
# gap against octo-org/platform-core: stars 41 vs 12400, open issues 0 vs 9134,
# forks 0 vs 1875, last push 592 day(s) behind
# quiet-audit-explained: issues are disabled on this fork; no open issues; no
# releases; pushed_at is over a year old
# repair: point the integration at octo-org/platform-core and store its id
# 20221 beside the name, so a future rename or substitution is a mismatch
# rather than a quiet quarter.
The full code
There is no error to classify here, so the script compares objects instead of responses. One read establishes what the configured repository is; a second reads the repository source names, and the difference between them is printed in units a person recognises rather than in booleans. The id comparison is the part worth keeping in your own code afterwards: it is three lines, it costs nothing, and it is the only thing that catches the version of this bug where the configuration was right when it was written.
"""Say whether the configured repository is a fork of the one you meant.
Read only. GET requests and nothing else. There is nothing to attempt here in
any case: the failure mode of this bug is that everything succeeds, so the
finding comes from reading two repositories and comparing them rather than from
catching anything.
The point of the note: a fork is a separate repository with its own issues,
releases and branches. An integration pointed at one answers every call with a
200 and is accurate about the wrong object, so no status code, retry or alert
will ever fire.
What this can and cannot see: the API says whether a repository is a fork and
what it was forked from. It cannot say what you intended, so the verdict is
"this is a fork and here is the root of its network", and the decision to
repoint stays with you. Nor can it tell you when a name started resolving to a
different object; it can only compare the id you stored against the id now.
Environment:
GITHUB_TOKEN a token with read access to the repositories
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_fork_or_upstream")
API = "https://api.github.com"
UA = "github-fork-or-upstream/1.0"
# Gaps large enough that a human recognises the mistake immediately. These are
# presentation thresholds, not truth: fork=true is the finding either way.
STAR_RATIO_OBVIOUS = 10
PUSH_DAYS_OBVIOUS = 90
# GitHub timestamps are RFC 3339 in UTC with a literal Z. Python 3.9's
# fromisoformat does not accept the Z, so parse the exact shape rather than
# depending on a version difference nobody will reproduce locally.
TS_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
def read_cost(with_upstream=True, with_releases=False):
"""Requests this run will spend against the core quota. Pure."""
cost = 1
if with_upstream:
cost += 1
if with_releases:
cost += 2 if with_upstream else 1
return cost
def parse_ts(value):
"""One GitHub timestamp to a datetime, or None. Pure."""
try:
return datetime.strptime(str(value), TS_FORMAT)
except (TypeError, ValueError):
return None
def days_between(earlier, later):
"""Whole days from one timestamp to another, or None. Pure."""
a, b = parse_ts(earlier), parse_ts(later)
if a is None or b is None:
return None
return (b - a).days
def is_fork(repo):
"""The one boolean the whole note turns on. Pure."""
return bool((repo or {}).get("fork"))
def upstream_of(repo):
"""The repository this one should probably have been. Pure.
source is the root of the fork network and parent is one hop up. They
differ for a fork of a fork, and it is source you almost always want:
repointing at parent moves the integration one hop closer and leaves it
wrong, which is a maddening way to fix something.
"""
repo = repo or {}
source = (repo.get("source") or {}).get("full_name")
parent = (repo.get("parent") or {}).get("full_name")
return source or parent or None
def fork_chain(repo):
"""parent and source as they were reported, for the fork-of-fork case. Pure."""
repo = repo or {}
return {
"parent": (repo.get("parent") or {}).get("full_name"),
"source": (repo.get("source") or {}).get("full_name"),
}
def classify(repo, expected_id=None):
"""Sort the configured repository. Pure. Returns (state, detail).
id drift is checked before the fork question because it is the case where
nobody changed anything: a name that used to resolve to one object now
resolves to another, and no amount of reading the configuration finds it.
"""
repo = repo or {}
live_id = repo.get("id")
if expected_id not in (None, "") and live_id is not None:
try:
if int(expected_id) != int(live_id):
return ("id-drift",
"the stored id is %s and this name now resolves to %s. "
"The name has moved to a different object since you "
"last looked, which nothing else will detect."
% (expected_id, live_id))
except (TypeError, ValueError):
pass
if not repo:
return ("unknown", "no repository object was read.")
chain = fork_chain(repo)
if is_fork(repo):
if chain["parent"] and chain["source"] and chain["parent"] != chain["source"]:
return ("fork-of-fork",
"this is a fork of %s, which is itself a fork. The root of "
"the network is %s and that is almost certainly the "
"repository you want."
% (chain["parent"], chain["source"]))
return ("fork-as-canonical",
"this repository has fork=true, so it is a separate repository "
"with its own issues, releases and branches. Every call against "
"it succeeds and describes it rather than %s."
% (upstream_of(repo) or "the upstream"))
return ("canonical",
"fork=false, so this is a root repository and not a copy of "
"something else.")
def divergence(fork, source):
"""The size difference between two repositories, in units people feel. Pure."""
fork, source = fork or {}, source or {}
def gap(key):
a, b = fork.get(key), source.get(key)
if a is None or b is None:
return None
return {"fork": a, "upstream": b, "difference": b - a}
behind = days_between(fork.get("pushed_at"), source.get("pushed_at"))
stars_fork = fork.get("stargazers_count") or 0
stars_up = source.get("stargazers_count") or 0
return {
"stargazers_count": gap("stargazers_count"),
"open_issues_count": gap("open_issues_count"),
"forks_count": gap("forks_count"),
"pushed_days_behind": behind,
"default_branch": {"fork": fork.get("default_branch"),
"upstream": source.get("default_branch")},
"obvious": bool(
(stars_up >= STAR_RATIO_OBVIOUS * max(1, stars_fork))
or (behind is not None and behind >= PUSH_DAYS_OBVIOUS)),
}
def quiet_audit_reasons(repo, releases=None):
"""Why an audit of this repository would look uneventful. Pure.
Every one of these gets blamed on something else when it arrives alone. The
value is in seeing them gathered under one cause.
"""
repo = repo or {}
reasons = []
if repo.get("has_issues") is False:
reasons.append("issues are disabled on this fork, so issue endpoints "
"answer 410 rather than an empty list")
if (repo.get("open_issues_count") or 0) == 0:
reasons.append("no open issues")
if releases == 0:
reasons.append("no releases")
if (repo.get("forks_count") or 0) == 0:
reasons.append("nothing has forked it")
if repo.get("archived"):
reasons.append("the repository is archived")
return reasons
def repair(state, repo, expected_id=None):
"""The sentence a reader has to act on. Pure."""
upstream = upstream_of(repo)
live_id = (repo or {}).get("id")
if state in ("fork-as-canonical", "fork-of-fork"):
return ("point the integration at %s and store its id beside the name, "
"so a future rename or substitution is a mismatch rather than a "
"quiet quarter." % (upstream or "the repository named by source"))
if state == "id-drift":
return ("stop trusting the name. It resolves to id %s today and your "
"store says %s, so confirm which object you meant and rekey the "
"state on the id." % (live_id, expected_id))
if state == "canonical":
return ("nothing on the fork question. Store id %s alongside the name "
"anyway; it is the only key that survives a rename."
% (live_id,))
return "read the repository first; there is nothing to judge yet."
def get(session, path):
"""One GET. Returns the response object."""
r = session.get(API + path, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
"or revoked")
return r
def release_count(session, full_name):
"""0, 1-or-more, or None if it could not be read. Cheap on purpose."""
r = get(session, "/repos/%s/releases?per_page=1" % full_name)
if r.status_code != 200:
return None
try:
return len(r.json())
except ValueError:
return None
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("repo", help="owner/name the integration is configured with")
ap.add_argument("--expect-id", default="",
help="the repository id your state store recorded")
ap.add_argument("--no-upstream", action="store_true",
help="skip the second read of the upstream repository")
ap.add_argument("--releases", action="store_true",
help="also check whether either repository has releases")
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
if "/" not in args.repo:
log.error("repo should be owner/name")
return 2
log.info("read cost: %d request(s) against the core hourly quota",
read_cost(not args.no_upstream, args.releases))
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,
})
response = get(session, "/repos/" + args.repo)
if response.status_code != 200:
log.error("%s: HTTP %s reading the repository", args.repo,
response.status_code)
return 2
repo = response.json()
chain = fork_chain(repo)
log.info("%s: fork=%s id=%s pushed_at=%s", args.repo, repo.get("fork"),
repo.get("id"), repo.get("pushed_at"))
log.info(" parent=%s source=%s", chain["parent"], chain["source"])
state, detail = classify(repo, args.expect_id or None)
log.info("%s: %s", state, detail)
gaps = None
upstream = upstream_of(repo)
if upstream and not args.no_upstream:
up = get(session, "/repos/" + upstream)
if up.status_code == 200:
gaps = divergence(repo, up.json())
log.info("gap against %s: stars %s vs %s, open issues %s vs %s, "
"forks %s vs %s, last push %s day(s) behind", upstream,
(gaps["stargazers_count"] or {}).get("fork"),
(gaps["stargazers_count"] or {}).get("upstream"),
(gaps["open_issues_count"] or {}).get("fork"),
(gaps["open_issues_count"] or {}).get("upstream"),
(gaps["forks_count"] or {}).get("fork"),
(gaps["forks_count"] or {}).get("upstream"),
gaps["pushed_days_behind"])
else:
log.warning("could not read %s: HTTP %s", upstream, up.status_code)
releases = release_count(session, args.repo) if args.releases else None
reasons = quiet_audit_reasons(repo, releases)
if reasons:
log.info("quiet-audit-explained: %s", "; ".join(reasons))
log.info("repair: %s", repair(state, repo, args.expect_id or None))
print(json.dumps({
"configured": args.repo,
"id": repo.get("id"),
"node_id": repo.get("node_id"),
"fork": repo.get("fork"),
"chain": chain,
"upstream": upstream,
"state": state,
"detail": detail,
"divergence": gaps,
"quiet_audit_reasons": reasons,
"repair": repair(state, repo, args.expect_id or None),
}, indent=2, default=str))
return 1 if state in ("fork-as-canonical", "fork-of-fork", "id-drift") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say whether the configured repository is a fork of the one you meant.
*
* Read only. GET requests and nothing else, and there is nothing to attempt in
* any case: the failure mode of this bug is that everything succeeds. A fork is
* a separate repository with its own issues, releases and branches, so an
* integration pointed at one answers every call with a 200 and is accurate
* about the wrong object.
*
* Environment:
* GITHUB_TOKEN a token with read access to the repositories
* GITHUB_REPO owner/name the integration is configured with
* GITHUB_EXPECT_ID the repository id your state store recorded
*/
const API = 'https://api.github.com';
const UA = 'github-fork-or-upstream/1.0';
/** Gaps large enough that a human recognises the mistake immediately. */
export const STAR_RATIO_OBVIOUS = 10;
export const PUSH_DAYS_OBVIOUS = 90;
const DAY_MS = 86400000;
/** Requests this run will spend against the core quota. Pure. */
export function readCost(withUpstream = true, withReleases = false) {
let cost = 1;
if (withUpstream) cost += 1;
if (withReleases) cost += withUpstream ? 2 : 1;
return cost;
}
/** One GitHub timestamp to milliseconds, or null. Pure. */
export function parseTs(value) {
const ms = Date.parse(String(value ?? ''));
return Number.isFinite(ms) ? ms : null;
}
/** Whole days from one timestamp to another, or null. Pure. */
export function daysBetween(earlier, later) {
const a = parseTs(earlier);
const b = parseTs(later);
if (a === null || b === null) return null;
return Math.floor((b - a) / DAY_MS);
}
/** The one boolean the whole note turns on. Pure. */
export function isFork(repo) {
return Boolean((repo || {}).fork);
}
/** parent and source as they were reported. Pure. */
export function forkChain(repo) {
const r = repo || {};
return {
parent: (r.parent || {}).full_name ?? null,
source: (r.source || {}).full_name ?? null,
};
}
/** The repository this one should probably have been. Pure. */
export function upstreamOf(repo) {
const chain = forkChain(repo);
return chain.source || chain.parent || null;
}
/** Sort the configured repository. Pure. [state, detail]. */
export function classify(repo, expectedId = null) {
const r = repo || {};
const liveId = r.id;
if (expectedId !== null && expectedId !== undefined && expectedId !== ''
&& liveId !== undefined && liveId !== null
&& Number(expectedId) !== Number(liveId)) {
return ['id-drift', `the stored id is ${expectedId} and this name now `
+ `resolves to ${liveId}. The name has moved to a different object since `
+ 'you last looked, which nothing else will detect.'];
}
if (Object.keys(r).length === 0) return ['unknown', 'no repository object was read.'];
const chain = forkChain(r);
if (isFork(r)) {
if (chain.parent && chain.source && chain.parent !== chain.source) {
return ['fork-of-fork', `this is a fork of ${chain.parent}, which is `
+ `itself a fork. The root of the network is ${chain.source} and that is `
+ 'almost certainly the repository you want.'];
}
return ['fork-as-canonical', 'this repository has fork=true, so it is a '
+ 'separate repository with its own issues, releases and branches. Every '
+ `call against it succeeds and describes it rather than ${upstreamOf(r) || 'the upstream'}.`];
}
return ['canonical', 'fork=false, so this is a root repository and not a copy '
+ 'of something else.'];
}
/** The size difference between two repositories. Pure. */
export function divergence(fork, source) {
const f = fork || {};
const s = source || {};
const gap = (key) => {
const a = f[key];
const b = s[key];
if (a === undefined || a === null || b === undefined || b === null) return null;
return { fork: a, upstream: b, difference: b - a };
};
const behind = daysBetween(f.pushed_at, s.pushed_at);
const starsFork = f.stargazers_count || 0;
const starsUp = s.stargazers_count || 0;
return {
stargazers_count: gap('stargazers_count'),
open_issues_count: gap('open_issues_count'),
forks_count: gap('forks_count'),
pushed_days_behind: behind,
default_branch: { fork: f.default_branch ?? null, upstream: s.default_branch ?? null },
obvious: Boolean(starsUp >= STAR_RATIO_OBVIOUS * Math.max(1, starsFork)
|| (behind !== null && behind >= PUSH_DAYS_OBVIOUS)),
};
}
/** Why an audit of this repository would look uneventful. Pure. */
export function quietAuditReasons(repo, releases = null) {
const r = repo || {};
const reasons = [];
if (r.has_issues === false) {
reasons.push('issues are disabled on this fork, so issue endpoints answer '
+ '410 rather than an empty list');
}
if ((r.open_issues_count || 0) === 0) reasons.push('no open issues');
if (releases === 0) reasons.push('no releases');
if ((r.forks_count || 0) === 0) reasons.push('nothing has forked it');
if (r.archived) reasons.push('the repository is archived');
return reasons;
}
/** The sentence a reader has to act on. Pure. */
export function repair(state, repo, expectedId = null) {
const upstream = upstreamOf(repo);
const liveId = (repo || {}).id;
if (state === 'fork-as-canonical' || state === 'fork-of-fork') {
return `point the integration at ${upstream || 'the repository named by source'} `
+ 'and store its id beside the name, so a future rename or substitution is '
+ 'a mismatch rather than a quiet quarter.';
}
if (state === 'id-drift') {
return `stop trusting the name. It resolves to id ${liveId} today and your `
+ `store says ${expectedId}, so confirm which object you meant and rekey `
+ 'the state on the id.';
}
if (state === 'canonical') {
return `nothing on the fork question. Store id ${liveId} alongside the name `
+ 'anyway; it is the only key that survives a rename.';
}
return 'read the repository first; there is nothing to judge yet.';
}
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 repoName = process.env.GITHUB_REPO;
if (!token || !repoName) {
console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_REPO');
process.exitCode = 2;
return;
}
const expectId = process.env.GITHUB_EXPECT_ID || null;
console.log(`read cost: ${readCost(true)} request(s) against the core hourly quota`);
const res = await fetch(`${API}/repos/${repoName}`, { headers: headers(token) });
if (res.status !== 200) {
console.error(`${repoName}: HTTP ${res.status} reading the repository`);
process.exitCode = 2;
return;
}
const repo = await res.json();
const chain = forkChain(repo);
console.log(`${repoName}: fork=${repo.fork} id=${repo.id} pushed_at=${repo.pushed_at}`);
console.log(` parent=${chain.parent} source=${chain.source}`);
const [state, detail] = classify(repo, expectId);
console.log(`${state}: ${detail}`);
let gaps = null;
const upstream = upstreamOf(repo);
if (upstream) {
const up = await fetch(`${API}/repos/${upstream}`, { headers: headers(token) });
if (up.status === 200) {
gaps = divergence(repo, await up.json());
console.log(`gap against ${upstream}: stars `
+ `${gaps.stargazers_count?.fork} vs ${gaps.stargazers_count?.upstream}, `
+ `last push ${gaps.pushed_days_behind} day(s) behind`);
}
}
const reasons = quietAuditReasons(repo);
if (reasons.length) console.log(`quiet-audit-explained: ${reasons.join('; ')}`);
console.log(`repair: ${repair(state, repo, expectId)}`);
console.log(JSON.stringify({
configured: repoName,
id: repo.id,
node_id: repo.node_id,
fork: repo.fork,
chain,
upstream,
state,
detail,
divergence: gaps,
quiet_audit_reasons: reasons,
repair: repair(state, repo, expectId),
}, null, 2));
process.exitCode = ['fork-as-canonical', 'fork-of-fork', 'id-drift'].includes(state) ? 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
The fork-of-a-fork case is asserted early because it is where the obvious fix is wrong: repointing at parent when parent and source disagree moves the integration one hop and leaves it reading a fork. Then id drift, which is checked ahead of the fork question in the classifier and has to stay that way, since it is the only case where nobody edited anything and the fork boolean is no help. The divergence tests assert differences rather than states, because the output that convinces a human is a pair of numbers, and the last group is the list of symptoms that get blamed on something else when they arrive one at a time.
from github_fork_or_upstream import (
PUSH_DAYS_OBVIOUS, classify, days_between, divergence, fork_chain, is_fork,
parse_ts, quiet_audit_reasons, read_cost, repair, upstream_of,
)
FORK = {
"id": 904113,
"node_id": "R_kgDONjA",
"fork": True,
"has_issues": False,
"open_issues_count": 0,
"forks_count": 0,
"stargazers_count": 41,
"pushed_at": "2025-01-14T09:22:10Z",
"default_branch": "master",
"parent": {"full_name": "octo-org/platform-core"},
"source": {"full_name": "octo-org/platform-core"},
}
FORK_OF_FORK = dict(FORK, parent={"full_name": "acme/platform-core"},
source={"full_name": "octo-org/platform-core"})
UPSTREAM = {
"id": 20221,
"fork": False,
"has_issues": True,
"open_issues_count": 9134,
"forks_count": 1875,
"stargazers_count": 12400,
"pushed_at": "2026-08-28T17:03:44Z",
"default_branch": "main",
}
def test_a_fork_is_a_separate_repository_and_that_is_the_finding():
assert is_fork(FORK) is True
state, detail = classify(FORK)
assert state == "fork-as-canonical"
assert "own issues, releases and branches" in detail
assert classify(UPSTREAM)[0] == "canonical"
def test_source_is_preferred_over_parent():
# Repointing at parent when the two disagree moves the integration one hop
# closer and leaves it reading a fork, which is the annoying half-fix.
assert upstream_of(FORK_OF_FORK) == "octo-org/platform-core"
assert fork_chain(FORK_OF_FORK)["parent"] == "acme/platform-core"
state, detail = classify(FORK_OF_FORK)
assert state == "fork-of-fork"
assert "root of the network is octo-org/platform-core" in detail
def test_a_repository_with_no_upstream_reports_none():
assert upstream_of(UPSTREAM) is None
assert fork_chain({}) == {"parent": None, "source": None}
def test_id_drift_is_checked_before_the_fork_question():
# This is the case where nobody changed anything: the name now resolves to
# a different object, and fork=false is no help at all.
state, detail = classify(dict(UPSTREAM, id=88), expected_id=20221)
assert state == "id-drift"
assert "20221" in detail and "88" in detail
assert classify(UPSTREAM, expected_id=20221)[0] == "canonical"
assert classify(UPSTREAM, expected_id="")[0] == "canonical"
def test_the_gap_is_reported_in_units_a_person_recognises():
gaps = divergence(FORK, UPSTREAM)
assert gaps["stargazers_count"] == {"fork": 41, "upstream": 12400,
"difference": 12359}
assert gaps["open_issues_count"]["difference"] == 9134
assert gaps["default_branch"] == {"fork": "master", "upstream": "main"}
assert gaps["obvious"] is True
def test_a_close_copy_is_not_reported_as_obvious():
near = dict(FORK, stargazers_count=12000, pushed_at="2026-08-27T10:00:00Z")
gaps = divergence(near, UPSTREAM)
assert gaps["obvious"] is False
assert gaps["pushed_days_behind"] < PUSH_DAYS_OBVIOUS
def test_timestamps_are_parsed_and_differenced():
assert parse_ts("2026-08-28T17:03:44Z") is not None
assert parse_ts("not a date") is None
assert parse_ts(None) is None
assert days_between("2026-08-01T00:00:00Z", "2026-08-28T00:00:00Z") == 27
assert days_between("nope", "2026-08-28T00:00:00Z") is None
def test_the_quiet_symptoms_are_gathered_under_one_cause():
reasons = quiet_audit_reasons(FORK, releases=0)
joined = " ".join(reasons)
assert "issues are disabled" in joined
assert "no open issues" in joined
assert "no releases" in joined
assert "nothing has forked it" in joined
assert quiet_audit_reasons(UPSTREAM, releases=3) == []
def test_disabled_issues_on_a_fork_answers_410_not_an_empty_list():
# The symptom that sends people to the disabled-feature note instead of to
# the fact that this was never the right repository.
assert "410" in " ".join(quiet_audit_reasons(FORK))
def test_the_repair_names_the_upstream_and_the_id_to_store():
fix = repair("fork-as-canonical", FORK)
assert "octo-org/platform-core" in fix
assert "store its id" in fix
drift = repair("id-drift", dict(UPSTREAM, id=88), expected_id=20221)
assert "88" in drift and "20221" in drift
assert "survives a rename" in repair("canonical", UPSTREAM)
def test_the_run_costs_two_reads_by_default():
assert read_cost() == 2
assert read_cost(False) == 1
assert read_cost(True, True) == 4
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
PUSH_DAYS_OBVIOUS, classify, daysBetween, divergence, forkChain, isFork,
parseTs, quietAuditReasons, readCost, repair, upstreamOf,
} from './github-fork-or-upstream.mjs';
const FORK = {
id: 904113,
node_id: 'R_kgDONjA',
fork: true,
has_issues: false,
open_issues_count: 0,
forks_count: 0,
stargazers_count: 41,
pushed_at: '2025-01-14T09:22:10Z',
default_branch: 'master',
parent: { full_name: 'octo-org/platform-core' },
source: { full_name: 'octo-org/platform-core' },
};
const FORK_OF_FORK = {
...FORK,
parent: { full_name: 'acme/platform-core' },
source: { full_name: 'octo-org/platform-core' },
};
const UPSTREAM = {
id: 20221,
fork: false,
has_issues: true,
open_issues_count: 9134,
forks_count: 1875,
stargazers_count: 12400,
pushed_at: '2026-08-28T17:03:44Z',
default_branch: 'main',
};
test('a fork is a separate repository and that is the finding', () => {
assert.equal(isFork(FORK), true);
const [state, detail] = classify(FORK);
assert.equal(state, 'fork-as-canonical');
assert.match(detail, /own issues, releases and branches/);
assert.equal(classify(UPSTREAM)[0], 'canonical');
});
test('source is preferred over parent', () => {
assert.equal(upstreamOf(FORK_OF_FORK), 'octo-org/platform-core');
assert.equal(forkChain(FORK_OF_FORK).parent, 'acme/platform-core');
const [state, detail] = classify(FORK_OF_FORK);
assert.equal(state, 'fork-of-fork');
assert.match(detail, /root of the network is octo-org\/platform-core/);
});
test('a repository with no upstream reports none', () => {
assert.equal(upstreamOf(UPSTREAM), null);
assert.deepEqual(forkChain({}), { parent: null, source: null });
});
test('id drift is checked before the fork question', () => {
const [state, detail] = classify({ ...UPSTREAM, id: 88 }, 20221);
assert.equal(state, 'id-drift');
assert.match(detail, /20221/);
assert.match(detail, /88/);
assert.equal(classify(UPSTREAM, 20221)[0], 'canonical');
assert.equal(classify(UPSTREAM, '')[0], 'canonical');
});
test('the gap is reported in units a person recognises', () => {
const gaps = divergence(FORK, UPSTREAM);
assert.deepEqual(gaps.stargazers_count,
{ fork: 41, upstream: 12400, difference: 12359 });
assert.equal(gaps.open_issues_count.difference, 9134);
assert.deepEqual(gaps.default_branch, { fork: 'master', upstream: 'main' });
assert.equal(gaps.obvious, true);
});
test('a close copy is not reported as obvious', () => {
const near = { ...FORK, stargazers_count: 12000, pushed_at: '2026-08-27T10:00:00Z' };
const gaps = divergence(near, UPSTREAM);
assert.equal(gaps.obvious, false);
assert.ok(gaps.pushed_days_behind < PUSH_DAYS_OBVIOUS);
});
test('timestamps are parsed and differenced', () => {
assert.notEqual(parseTs('2026-08-28T17:03:44Z'), null);
assert.equal(parseTs('not a date'), null);
assert.equal(daysBetween('2026-08-01T00:00:00Z', '2026-08-28T00:00:00Z'), 27);
assert.equal(daysBetween('nope', '2026-08-28T00:00:00Z'), null);
});
test('the quiet symptoms are gathered under one cause', () => {
const reasons = quietAuditReasons(FORK, 0).join(' ');
assert.match(reasons, /issues are disabled/);
assert.match(reasons, /no open issues/);
assert.match(reasons, /no releases/);
assert.match(reasons, /nothing has forked it/);
assert.deepEqual(quietAuditReasons(UPSTREAM, 3), []);
});
test('disabled issues on a fork answers 410 not an empty list', () => {
assert.match(quietAuditReasons(FORK).join(' '), /410/);
});
test('the repair names the upstream and the id to store', () => {
const fix = repair('fork-as-canonical', FORK);
assert.match(fix, /octo-org\/platform-core/);
assert.match(fix, /store its id/);
const drift = repair('id-drift', { ...UPSTREAM, id: 88 }, 20221);
assert.match(drift, /88/);
assert.match(drift, /20221/);
assert.match(repair('canonical', UPSTREAM), /survives a rename/);
});
test('the run costs two reads by default', () => {
assert.equal(readCost(), 2);
assert.equal(readCost(false), 1);
assert.equal(readCost(true, true), 4);
});
FAQ
If everything returns 200, how would anyone ever notice?
Usually from an argument about a number, weeks later. That is the whole problem with this one: there is no failure to alert on, no retry to exhaust, no status code to sort. The report is well formed and internally consistent, so the only thing that exposes it is somebody who knows the repository disagreeing with the output. The defence is to check <code>fork</code> once when the integration is configured and then again on every run, because it costs one field on a call you are already making and it is the only signal the API gives you.
Why prefer source over parent when they both name a repository?
Because <code>parent</code> is one hop and <code>source</code> is the root. For a plain fork they are identical and it does not matter; for a fork of a fork, repointing at <code>parent</code> takes you to another fork, and you get to discover the same bug again with slightly better numbers. Read <code>source.full_name</code>, and treat a disagreement between the two as worth logging: it tells you the repository you were given is at least two hops from the thing anybody meant.
Should the integration just refuse to run against a fork?
That depends on what it does, and the script deliberately does not decide for you. Plenty of legitimate integrations target forks — a CI bot for a contributor's copy, a mirror checker, a security scanner sweeping every fork in a network. What is never legitimate is treating a fork as the canonical repository without knowing that is what you are doing. Reporting <code>fork: true</code> alongside the result, so the reader can see which object the numbers describe, is a smaller and more honest change than a refusal.
The configuration has not changed and it used to be right. How is that possible?
Names are reassignable and ids are not. A repository can be renamed, freeing its old name for anyone to claim; a repository can be deleted, freeing its whole path; and a fork can then be renamed into the gap. Your configuration still says <code>owner/name</code>, that name still resolves, and it now resolves to a different object. Nothing in the response announces the substitution. Store the numeric <code>id</code> next to the name, compare it on every run, and this becomes a mismatch you can raise instead of a quarter you have to withdraw.
Why does the fork have no issues at all rather than a shorter list?
Because forks are created with issues switched off. The endpoints then answer <code>410 Gone</code> rather than an empty array, which reads like a deprecated API and sends the investigation somewhere else entirely. It is worth knowing that both things are true at once here: the feature really is disabled, and the deeper cause is that this was never the repository you wanted. Fixing the feature would give you an empty issue list on a fork, which is a more convincing wrong answer than the error was.
Related field notes
- The other way a repository name stops meaning what it did
- When the upstream disappears from an anonymous reader
- An audit that reports its own coverage
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.
- Repositories — GitHub REST API
- About forks — GitHub Docs
- Forks — GitHub REST API
- About permissions and visibility of forks — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.