Diagnostic GitHub API
The App installation was requested and never approved
The customer says they installed the App. Your product agrees: the connection screen is green, the account is on the list, the onboarding checklist is ticked. And nothing has ever arrived — no webhook deliveries, no repositories, no first sync — and every support thread ends with somebody asking them to uninstall and install it again, which they cannot do either, because they never installed it in the first place. They asked for it. They were not an owner of the organization, so the install button quietly became a request, and it is still sitting in a queue behind somebody who has never been told it is there.
Only an organization owner can install a GitHub App. When anybody else goes through the flow, GitHub turns it into a request: the user sees a confirmation that looks like success, an owner has to approve it, and until they do the App has no installation on that account at all.
Ask the App itself. GET /app/installations with the App's JWT lists every installation the App actually has; a pending account is simply absent. GET /orgs/{org}/installation answers 404 for the same reason. Neither of those distinguishes pending from declined or from never-started, and no endpoint publishes the request queue to the App — so the finding comes from reconciling that list against your own record of who began a flow and when.
The problem in plain words
The reason this one runs for weeks is that both sides are telling the truth. The customer did complete a flow, saw a confirmation page and has an email about it. Your product recorded a connection, because the OAuth leg completed and the callback fired. The only party that disagrees is GitHub, and nobody asks GitHub, because from the product's point of view the connection already exists and the thing to debug is why it is not delivering.
So the support thread goes to webhooks. Is the delivery URL right, is the secret right, are the events subscribed, is the endpoint returning 200. All of those are answerable and all of the answers are fine, because a hook with no installation behind it has nothing to deliver and no failures to show. Then it goes to permissions, and those are fine too. Every diagnostic in the section that starts from "the App is installed" is being run against an account where it is not.
The final trap is the retry advice. "Uninstall and reinstall" is the standard remedy for a broken installation and it is unavailable here: there is nothing to uninstall, and the user going through the flow a second time produces a second request in the same queue, which is why some accounts arrive with four of them. The person who can end it is an owner who received one notification, weeks ago, about a product they had not heard of.
Why it happens
Requested is a third state, and most code has room for two. Integrations model an App as installed or not installed. This is neither: the account has expressed intent, GitHub has recorded it, and the App has no capability whatsoever. Your product's state machine almost certainly has no square for it, which is why it renders as connected — connected is the state it moves to when a flow completes, and the flow did complete.
It is a different absence from the one the section already publishes. An App that is installed on an organization but not on one repository answers 404 for that repository and works everywhere else, and GET /repos/{owner}/{repo}/installation settles it. This is the account-level absence: there is no installation to select repositories within, so the repository question cannot even be asked. Ask the account-level one instead, and expect a 404 that means "not yet" rather than "not this repo".
The API will not tell you it is pending. This is the honest limit and it shapes the whole script. There is no endpoint that lists an App's pending installation requests, so absence from GET /app/installations covers pending, declined, abandoned halfway and never attempted, all with the same silence. What separates them is information you already hold: the moment a user started a flow. Reconciling GitHub's list against your own connection records is therefore not a shortcut, it is the only method, and the script is built around it rather than pretending a single call answers this.
The reconciliation is worth doing in both directions. The obvious direction finds accounts your product calls connected that GitHub has never heard of. The other direction finds installations GitHub has that your product does not know about, which is the same bug seen from the far side: an owner approved the request three weeks after the user gave up, the App has been installed and delivering ever since, and nothing in your product ever noticed. Both are silent, and one scheduled pass over the list catches both.
An installation that exists and is suspended is a different note. If the account is on the list with a suspended_at, it was installed and then switched off, which is its own diagnosis and its own repair. The script separates that case out rather than folding it into a headline about approvals, because telling somebody to chase an approval that already happened is worse than saying nothing.
The fix, as a flow
The API can only supply half of this, and the note is built around saying which half. GET /app/installations lists what the App really has, and a per account probe confirms an absence one account at a time. Neither of them reports a pending request, because nothing does: absence covers pending, declined and never started with the same silence. The other half is your own record of who began a flow and when, and the reconciliation between the two runs in both directions, since an installation the product never noticed is the same bug seen from the far side.
How to fix it
Ask the App what it actually has
With the App's JWT, GET /app/installations lists every installation. The script pages it at 100 per page and reports how many pages it read, because a large App that reads only the first page will confidently report every account after the hundredth as missing. The list is turned into an index keyed by account login, with each installation's id, creation time and suspension state.
Bring your own record of who started a flow
Pass --record connections.json: a list of accounts, when each began an installation flow, and whether your product currently shows them as connected. This is the half GitHub cannot supply. Without it the script can only say an account is absent; with it, it can say the account is absent and your product has been showing it as connected for nineteen days.
Confirm the account-level absence directly
For each account, GET /orgs/{org}/installation under the JWT answers 200 with the installation or 404 without one. It is the same fact the list gives, asked about one account, and it is worth asking because it is the call that fails in exactly the way the pending state produces — and because it distinguishes an account that is genuinely absent from one your paging missed.
Sort the absences by what your own record says
The script reconciles each account into one state: your product says connected and GitHub has nothing, a flow started recently and is plausibly still awaiting approval, a flow started long enough ago that the request has almost certainly been forgotten, or an installation exists that your product never recorded. Suspended installations are handed to their own note instead of being counted as approvals.
Print the step; never take it
The output for a pending account is the sentence to put in front of the user: an owner of that organization has to approve the request from the organization's GitHub Apps settings. The script does not request an installation, does not approve one, and cannot — it holds a read path only. Its job is to stop the product from claiming a connection it does not have.
How to check it worked
After an owner approves the request, the account appears in the App's own installation list and the per-account probe stops answering 404.
GITHUB_APP_JWT=$JWT python3 github_app_installation_pending.py \
--record connections.json
# read cost: up to 4 request(s) against the core quota (1 app + up to 1 list
# page + 2 account probe(s))
# app: acme-sync (JWT accepted)
# installations: 37 read from 1 page(s)
# globex: HTTP 404 from GET /orgs/globex/installation
# false-connected — your record says connected since 2026-08-10 and this App
# has no installation on globex. Absence covers pending, declined and never
# started; the API publishes no request queue, so your record is what makes
# this readable.
# step: an owner of globex has to approve the pending installation request
# from the organization's GitHub Apps settings. Nothing here requests or
# approves anything.
# initech: installed 2026-07-02, and your record does not show it as connected
# unrecorded-installation — approved after the user gave up. Your product has
# been ignoring a working installation.
The full code
The live half is a paged list and one probe per account, and the script says how many pages it read because a partial list turns every account past the first hundred into a false finding. The half that produces the answer is pure and takes two inputs: what the App has, and what you believe it has. That is the shape the note argues for — the API cannot tell you a request is pending, so the reconciliation against your own record is the method rather than a convenience. Note what the reconciler does with a suspended installation: it refuses to count it either way and hands it to the note that owns it.
"""Find accounts where a GitHub App was requested and never approved.
Read only. GETs against the App's own records with the App JWT, and a local
JSON file of your own connection state. This script never requests an
installation and never approves one: approving is an organization owner's
decision, and asking for one is a write. It detects the state and prints the
step for a human to take.
The point of the note: only an owner can install an App on an organization.
Anybody else going through the flow creates a *request*, which sits in a queue
until an owner approves it. Until then the App has no installation on that
account at all, while the product that started the flow shows it as connected.
What this can and cannot see: absence. GET /app/installations does not list
pending requests, and no endpoint publishes the queue to the App, so an absent
account is pending, declined, abandoned or never attempted with the same
silence. Your own record of who began a flow and when is what separates them,
which is why this script takes one as input rather than pretending a single
call answers the question.
Environment:
GITHUB_APP_JWT the JWT your own signing code produced
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_app_installation_pending")
API = "https://api.github.com"
UA = "github-app-installation-pending/1.0"
# How long a request can sit before it is more likely forgotten than pending.
# A default rather than a rule: it decides only which sentence is printed.
STALE_AFTER_DAYS = 7
# Said once, in one place, because it is the honest core of the note and every
# state that reports an absence has to carry it.
ABSENCE_MEANING = ("absence covers pending, declined and never started; the "
"API publishes no request queue, so your record is what "
"makes this readable.")
def read_cost(accounts, pages=1):
"""Requests this run will spend against the core quota. Pure."""
return 1 + max(1, int(pages)) + len(accounts or [])
def installation_index(installations):
"""Index the App's installations by account login. Pure.
Lower-cased keys because logins are compared case-insensitively everywhere
else in this API and a record file written by hand will not match the
casing GitHub returns.
"""
index = {}
for item in installations or []:
if not isinstance(item, dict):
continue
account = item.get("account") or {}
login = account.get("login") if isinstance(account, dict) else None
if not login:
continue
index[str(login).strip().lower()] = {
"id": item.get("id"),
"created_at": item.get("created_at"),
"repository_selection": item.get("repository_selection"),
"suspended": item.get("suspended_at") not in (None, "", "null"),
}
return index
def probe_state(status):
"""What GET /orgs/{org}/installation means. Pure. (state, detail)."""
code = int(status or 0)
if code == 200:
return ("installed", "the App has an installation on this account.")
if code == 404:
return ("no-installation",
"the App has no installation on this account. " + ABSENCE_MEANING)
if code in (401, 403):
return ("unreadable",
"the JWT was refused on this probe, so nothing can be "
"concluded about the account.")
return ("unclear", "HTTP %s is not one of the answers this probe gives."
% status)
def parsed_time(text):
"""An ISO 8601 timestamp as an aware datetime, or None. Pure."""
if not text:
return None
value = str(text).strip()
if value.endswith("Z"):
value = value[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def age_days(started_at, now):
"""How long ago the flow started, in days. Pure. None if unparseable."""
start = parsed_time(started_at)
if start is None or now is None:
return None
return (now - start).total_seconds() / 86400.0
def request_age_state(days, stale_after=STALE_AFTER_DAYS):
"""Is this request plausibly still in flight. Pure. (state, detail)."""
if days is None:
return ("age-unknown",
"your record does not say when the flow started, so the "
"request cannot be aged.")
if days <= stale_after:
return ("awaiting-approval",
"the flow started %.1f day(s) ago, which is recent enough that "
"an owner may simply not have looked yet." % days)
return ("stale-request",
"the flow started %.1f day(s) ago. A request that old is more "
"likely forgotten than pending, and the owner who could approve it "
"was notified once." % days)
def reconcile(entry, installation, now, stale_after=STALE_AFTER_DAYS):
"""One account, two sources of truth. Pure. (state, detail).
entry is your own record: account, started_at, connected. installation is
what the App's own list holds for that account, or None.
"""
account = str((entry or {}).get("account") or "unknown")
connected = bool((entry or {}).get("connected"))
started_at = (entry or {}).get("started_at")
if installation:
if installation.get("suspended"):
return ("installed-but-suspended",
"an installation exists on %s and is suspended, which is a "
"different diagnosis and a different repair. Do not chase "
"an approval that already happened." % account)
if connected:
return ("agreed-connected",
"an installation exists and your record agrees. Nothing to "
"reconcile.")
return ("unrecorded-installation",
"an installation exists on %s and your record does not show it "
"as connected. An owner approved it after the fact and nothing "
"in your product noticed." % account)
if connected:
return ("false-connected",
"your record says connected%s and this App has no installation "
"on %s. %s"
% (" since " + str(started_at) if started_at else "",
account, ABSENCE_MEANING))
age_state, age_detail = request_age_state(age_days(started_at, now), stale_after)
if age_state in ("awaiting-approval", "stale-request"):
return (age_state, age_detail + " " + ABSENCE_MEANING)
return ("agreed-disconnected",
"no installation, and your record does not claim one. There is "
"nothing here to explain.")
def actionable(state):
"""Is this a state somebody has to do something about. Pure."""
return state in ("false-connected", "awaiting-approval", "stale-request",
"unrecorded-installation", "installed-but-suspended")
def printed_step(state, account):
"""The step to put in front of a human. Pure. Nothing here is executed.
This script holds a read path only. It cannot request an installation and
cannot approve one, and it should not: approving is a decision about what
reaches an organization's code, and it belongs to an owner.
"""
if state in ("false-connected", "awaiting-approval", "stale-request"):
return ("an owner of %s has to approve the pending installation "
"request from the organization's GitHub Apps settings. "
"Nothing here requests or approves anything." % account)
if state == "unrecorded-installation":
return ("reconcile your stored connection state for %s: the "
"installation is real and your product is ignoring it."
% account)
if state == "installed-but-suspended":
return ("ask an owner of %s to unsuspend the installation. The "
"approval is not what is missing." % account)
return "nothing for this account."
def product_repair(states):
"""What to change in the product, given everything seen. Pure."""
if any(s == "false-connected" for s in states):
return ("stop rendering a completed flow as a connection. Show the "
"requested state explicitly, prompt the user to ask an owner "
"to approve it, and reconcile against GET /app/installations "
"on a schedule rather than trusting the callback.")
if any(s in ("awaiting-approval", "stale-request") for s in states):
return ("surface the pending state in the product and re-check it on a "
"schedule. A request that nobody is reminded about is a "
"request that expires by neglect.")
if any(s == "unrecorded-installation" for s in states):
return ("reconcile in the other direction too: an installation "
"approved after the user gave up delivers nothing if your "
"product never records it.")
return "nothing. The App's installations and your record agree."
def load_record(path, accounts):
"""Your own connection state. Not part of the API and not a write.
Accepts a JSON list of {account, started_at, connected}. Accounts named on
the command line are added as ones you believe are connected, which is the
common case: somebody says a customer is connected and you want to know.
"""
entries = []
if path:
with open(path, "r", encoding="utf-8") as handle:
loaded = json.load(handle)
for item in loaded if isinstance(loaded, list) else []:
if isinstance(item, dict) and item.get("account"):
entries.append(item)
for account in accounts or []:
entries.append({"account": account, "connected": True})
return entries
def get(session, path):
"""One GET. Returns the response object."""
return session.get(API + path, timeout=30)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--record",
help="JSON list of {account, started_at, connected}")
parser.add_argument("--account", action="append", default=[],
help="an account you believe is connected; repeatable")
parser.add_argument("--max-pages", type=int, default=5,
help="pages of /app/installations to read")
parser.add_argument("--stale-after", type=int, default=STALE_AFTER_DAYS,
help="days after which a request is called stale")
args = parser.parse_args()
jwt = os.environ.get("GITHUB_APP_JWT")
if not jwt:
log.error("set GITHUB_APP_JWT (the JWT your own signing code produced)")
return 2
entries = load_record(args.record, args.account)
if not entries:
log.error("nothing to reconcile: pass --record or --account. This "
"script compares GitHub's list against your own, and the "
"second half is the half GitHub cannot supply.")
return 2
log.info("read cost: up to %d request(s) against the core quota "
"(1 app + up to %d list page(s) + %d account probe(s))",
read_cost(entries, args.max_pages), max(1, args.max_pages),
len(entries))
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + jwt,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
app = get(session, "/app")
if app.status_code != 200:
log.error("GET /app returned HTTP %s: the JWT was not accepted, which "
"is a different note", app.status_code)
return 2
log.info("app: %s (JWT accepted)", (app.json() or {}).get("slug"))
installations, pages = [], 0
for page in range(1, max(1, args.max_pages) + 1):
response = get(session, "/app/installations?per_page=100&page=%d" % page)
if response.status_code != 200:
log.warning("installation list page %d returned HTTP %s; the list "
"below is partial", page, response.status_code)
break
batch = response.json() or []
pages = page
installations.extend(batch)
if len(batch) < 100:
break
log.info("installations: %d read from %d page(s)", len(installations), pages)
index = installation_index(installations)
now = datetime.now(timezone.utc)
results, states = [], []
for entry in entries:
account = str(entry.get("account"))
probe = get(session, "/orgs/%s/installation" % account)
probe_result, probe_detail = probe_state(probe.status_code)
installation = index.get(account.strip().lower())
if probe_result == "installed" and installation is None:
installation = {"id": None, "created_at": None,
"repository_selection": None, "suspended": False}
if probe_result == "no-installation":
installation = None
state, detail = reconcile(entry, installation, now, args.stale_after)
log.info("%s: HTTP %s from GET /orgs/%s/installation",
account, probe.status_code, account)
log.info(" %s — %s", state, detail)
log.info(" step: %s", printed_step(state, account))
states.append(state)
results.append({
"account": account,
"probe_status": probe.status_code,
"probe_state": probe_result,
"probe_detail": probe_detail,
"installation_id": (installation or {}).get("id"),
"state": state,
"detail": detail,
"actionable": actionable(state),
"step": printed_step(state, account),
})
log.info("product repair: %s", product_repair(states))
print(json.dumps({
"installations_read": len(installations),
"pages_read": pages,
"accounts": results,
"product_repair": product_repair(states),
}, indent=2, default=str))
return 1 if any(actionable(s) for s in states) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find accounts where a GitHub App was requested and never approved.
*
* Read only. GETs against the App's own records with the App JWT, plus a local
* JSON file of your own connection state. This script never requests an
* installation and never approves one: approving belongs to an organization
* owner. It detects the state and prints the step.
*
* Only an owner can install an App on an organization. Anybody else going
* through the flow creates a request that waits in a queue, and until it is
* approved the App has no installation on that account at all.
*
* The API does not list pending requests, so an absent account is pending,
* declined, abandoned or never attempted with the same silence. Your own
* record of who began a flow is what separates them.
*
* Environment:
* GITHUB_APP_JWT the JWT your own signing code produced
* GITHUB_RECORD path to a JSON list of {account, started_at, connected}
* GITHUB_ACCOUNTS comma-separated accounts you believe are connected
*/
import { readFileSync } from 'node:fs';
const API = 'https://api.github.com';
const UA = 'github-app-installation-pending/1.0';
/** How long a request can sit before it is more likely forgotten than pending. */
export const STALE_AFTER_DAYS = 7;
/** The honest core of the note, said once. */
export const ABSENCE_MEANING = 'absence covers pending, declined and never '
+ 'started; the API publishes no request queue, so your record is what makes '
+ 'this readable.';
/** Requests this run will spend against the core quota. Pure. */
export function readCost(accounts, pages = 1) {
return 1 + Math.max(1, Number(pages) || 1) + (accounts ? accounts.length : 0);
}
/** Index the App's installations by account login. Pure. */
export function installationIndex(installations) {
const index = {};
for (const item of installations || []) {
if (!item || typeof item !== 'object') continue;
const account = item.account && typeof item.account === 'object' ? item.account : {};
const login = account.login;
if (!login) continue;
index[String(login).trim().toLowerCase()] = {
id: item.id,
created_at: item.created_at,
repository_selection: item.repository_selection,
suspended: !['', null, undefined, 'null'].includes(item.suspended_at),
};
}
return index;
}
/** What GET /orgs/{org}/installation means. Pure. [state, detail]. */
export function probeState(status) {
const code = Number(status) || 0;
if (code === 200) return ['installed', 'the App has an installation on this account.'];
if (code === 404) {
return ['no-installation', `the App has no installation on this account. ${ABSENCE_MEANING}`];
}
if ([401, 403].includes(code)) {
return ['unreadable', 'the JWT was refused on this probe, so nothing can '
+ 'be concluded about the account.'];
}
return ['unclear', `HTTP ${status} is not one of the answers this probe gives.`];
}
/** An ISO 8601 timestamp as a Date, or null. Pure. */
export function parsedTime(text) {
if (!text) return null;
const ms = Date.parse(String(text));
return Number.isNaN(ms) ? null : new Date(ms);
}
/** How long ago the flow started, in days. Pure. null if unparseable. */
export function ageDays(startedAt, now) {
const start = parsedTime(startedAt);
if (start === null || !now) return null;
return (now.getTime() - start.getTime()) / 86400000;
}
/** Is this request plausibly still in flight. Pure. [state, detail]. */
export function requestAgeState(days, staleAfter = STALE_AFTER_DAYS) {
if (days === null || days === undefined) {
return ['age-unknown', 'your record does not say when the flow started, so '
+ 'the request cannot be aged.'];
}
if (days <= staleAfter) {
return ['awaiting-approval', `the flow started ${days.toFixed(1)} day(s) `
+ 'ago, which is recent enough that an owner may simply not have looked '
+ 'yet.'];
}
return ['stale-request', `the flow started ${days.toFixed(1)} day(s) ago. A `
+ 'request that old is more likely forgotten than pending, and the owner '
+ 'who could approve it was notified once.'];
}
/** One account, two sources of truth. Pure. [state, detail]. */
export function reconcile(entry, installation, now, staleAfter = STALE_AFTER_DAYS) {
const record = entry || {};
const account = String(record.account ?? 'unknown');
const connected = Boolean(record.connected);
const startedAt = record.started_at;
if (installation) {
if (installation.suspended) {
return ['installed-but-suspended', `an installation exists on ${account} `
+ 'and is suspended, which is a different diagnosis and a different '
+ 'repair. Do not chase an approval that already happened.'];
}
if (connected) {
return ['agreed-connected', 'an installation exists and your record '
+ 'agrees. Nothing to reconcile.'];
}
return ['unrecorded-installation', `an installation exists on ${account} `
+ 'and your record does not show it as connected. An owner approved it '
+ 'after the fact and nothing in your product noticed.'];
}
if (connected) {
return ['false-connected', `your record says connected`
+ `${startedAt ? ` since ${startedAt}` : ''} and this App has no `
+ `installation on ${account}. ${ABSENCE_MEANING}`];
}
const [ageState, ageDetail] = requestAgeState(ageDays(startedAt, now), staleAfter);
if (['awaiting-approval', 'stale-request'].includes(ageState)) {
return [ageState, `${ageDetail} ${ABSENCE_MEANING}`];
}
return ['agreed-disconnected', 'no installation, and your record does not '
+ 'claim one. There is nothing here to explain.'];
}
/** Is this a state somebody has to do something about. Pure. */
export function actionable(state) {
return ['false-connected', 'awaiting-approval', 'stale-request',
'unrecorded-installation', 'installed-but-suspended'].includes(state);
}
/** The step to put in front of a human. Pure. Nothing here is executed. */
export function printedStep(state, account) {
if (['false-connected', 'awaiting-approval', 'stale-request'].includes(state)) {
return `an owner of ${account} has to approve the pending installation `
+ "request from the organization's GitHub Apps settings. Nothing here "
+ 'requests or approves anything.';
}
if (state === 'unrecorded-installation') {
return `reconcile your stored connection state for ${account}: the `
+ 'installation is real and your product is ignoring it.';
}
if (state === 'installed-but-suspended') {
return `ask an owner of ${account} to unsuspend the installation. The `
+ 'approval is not what is missing.';
}
return 'nothing for this account.';
}
/** What to change in the product, given everything seen. Pure. */
export function productRepair(states) {
const seen = states || [];
if (seen.includes('false-connected')) {
return 'stop rendering a completed flow as a connection. Show the requested '
+ 'state explicitly, prompt the user to ask an owner to approve it, and '
+ 'reconcile against GET /app/installations on a schedule rather than '
+ 'trusting the callback.';
}
if (seen.some((s) => ['awaiting-approval', 'stale-request'].includes(s))) {
return 'surface the pending state in the product and re-check it on a '
+ 'schedule. A request that nobody is reminded about is a request that '
+ 'expires by neglect.';
}
if (seen.includes('unrecorded-installation')) {
return 'reconcile in the other direction too: an installation approved '
+ 'after the user gave up delivers nothing if your product never records '
+ 'it.';
}
return "nothing. The App's installations and your record agree.";
}
function headers(jwt) {
return {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function main() {
const jwt = process.env.GITHUB_APP_JWT;
if (!jwt) {
console.error('set GITHUB_APP_JWT (the JWT your own signing code produced)');
process.exitCode = 2;
return;
}
const entries = [];
if (process.env.GITHUB_RECORD) {
const loaded = JSON.parse(readFileSync(process.env.GITHUB_RECORD, 'utf-8'));
for (const item of Array.isArray(loaded) ? loaded : []) {
if (item && item.account) entries.push(item);
}
}
for (const account of (process.env.GITHUB_ACCOUNTS || '').split(',')
.map((s) => s.trim()).filter(Boolean)) {
entries.push({ account, connected: true });
}
if (entries.length === 0) {
console.error('nothing to reconcile: set GITHUB_RECORD or GITHUB_ACCOUNTS. '
+ "This script compares GitHub's list against your own, and the second "
+ 'half is the half GitHub cannot supply.');
process.exitCode = 2;
return;
}
const maxPages = Number(process.env.GITHUB_MAX_PAGES || 5) || 5;
console.log(`read cost: up to ${readCost(entries, maxPages)} request(s) against `
+ 'the core quota');
const app = await fetch(`${API}/app`, { headers: headers(jwt) });
if (app.status !== 200) {
console.error(`GET /app returned HTTP ${app.status}: the JWT was not accepted`);
process.exitCode = 2;
return;
}
console.log(`app: ${(await app.json()).slug} (JWT accepted)`);
const installations = [];
let pages = 0;
for (let page = 1; page <= Math.max(1, maxPages); page += 1) {
const response = await fetch(`${API}/app/installations?per_page=100&page=${page}`,
{ headers: headers(jwt) });
if (response.status !== 200) {
console.warn(`installation list page ${page} returned HTTP ${response.status}; `
+ 'the list below is partial');
break;
}
const batch = await response.json();
pages = page;
installations.push(...batch);
if (batch.length < 100) break;
}
console.log(`installations: ${installations.length} read from ${pages} page(s)`);
const index = installationIndex(installations);
const now = new Date();
const results = [];
const states = [];
for (const entry of entries) {
const account = String(entry.account);
// eslint-disable-next-line no-await-in-loop
const probe = await fetch(`${API}/orgs/${account}/installation`,
{ headers: headers(jwt) });
const [probeResult] = probeState(probe.status);
let installation = index[account.trim().toLowerCase()] || null;
if (probeResult === 'no-installation') installation = null;
const [state, detail] = reconcile(entry, installation, now);
console.log(`${account}: HTTP ${probe.status} from GET /orgs/${account}/installation`);
console.log(` ${state} — ${detail}`);
console.log(` step: ${printedStep(state, account)}`);
states.push(state);
results.push({
account,
probe_status: probe.status,
probe_state: probeResult,
installation_id: installation ? installation.id : null,
state,
detail,
actionable: actionable(state),
step: printedStep(state, account),
});
}
console.log(`product repair: ${productRepair(states)}`);
console.log(JSON.stringify({
installations_read: installations.length,
pages_read: pages,
accounts: results,
product_repair: productRepair(states),
}, null, 2));
process.exitCode = states.some((s) => actionable(s)) ? 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 reconciler is the whole note, so the suite is a table of the four combinations of what GitHub has and what you believe, plus the two that do not fit in that table. The important assertions are the ones about silence: an absent account has to carry the sentence saying that absence covers pending, declined and never started, because a state name on its own invites somebody to read pending as a fact GitHub reported. A suspended installation has to come back as its own state rather than as an approval, since telling somebody to chase an approval that already happened is worse than saying nothing. And the step printed for every pending account is asserted to be a request addressed to a human, in those words, because a script in this section must never be the thing that asks GitHub for an installation.
from datetime import datetime, timezone
from github_app_installation_pending import (
ABSENCE_MEANING, actionable, age_days, installation_index, printed_step,
probe_state, product_repair, read_cost, reconcile, request_age_state,
)
NOW = datetime(2026, 8, 29, 12, 0, 0, tzinfo=timezone.utc)
INSTALLATIONS = [
{"id": 41, "account": {"login": "Initech"}, "created_at": "2026-07-02T09:00:00Z",
"repository_selection": "all", "suspended_at": None},
{"id": 42, "account": {"login": "umbrella"}, "created_at": "2026-05-01T09:00:00Z",
"repository_selection": "selected", "suspended_at": "2026-08-01T09:00:00Z"},
]
def test_the_index_is_case_insensitive_because_records_are_hand_written():
index = installation_index(INSTALLATIONS)
assert index["initech"]["id"] == 41
assert index["umbrella"]["suspended"] is True
assert index["initech"]["suspended"] is False
assert installation_index([{"id": 1}]) == {}
def test_the_probe_says_what_a_404_does_and_does_not_mean():
state, detail = probe_state(404)
assert state == "no-installation"
assert ABSENCE_MEANING in detail
assert probe_state(200)[0] == "installed"
assert probe_state(401)[0] == "unreadable"
def test_a_product_that_says_connected_against_nothing_is_the_headline():
state, detail = reconcile(
{"account": "globex", "connected": True, "started_at": "2026-08-10T00:00:00Z"},
None, NOW)
assert state == "false-connected"
assert "globex" in detail
assert ABSENCE_MEANING in detail
assert actionable(state) is True
def test_a_fresh_request_and_a_forgotten_one_are_different_sentences():
fresh, detail = reconcile(
{"account": "globex", "connected": False,
"started_at": "2026-08-27T12:00:00Z"}, None, NOW)
assert fresh == "awaiting-approval"
assert "may simply not have looked yet" in detail
stale, detail = reconcile(
{"account": "globex", "connected": False,
"started_at": "2026-07-01T12:00:00Z"}, None, NOW)
assert stale == "stale-request"
assert "notified once" in detail
def test_the_reconciliation_runs_in_the_other_direction_too():
state, detail = reconcile({"account": "initech", "connected": False},
installation_index(INSTALLATIONS)["initech"], NOW)
assert state == "unrecorded-installation"
assert "nothing in your product noticed" in detail
def test_a_suspended_installation_is_handed_to_its_own_note():
# Telling somebody to chase an approval that already happened is worse than
# saying nothing, so this never counts as a pending request.
state, detail = reconcile({"account": "umbrella", "connected": True},
installation_index(INSTALLATIONS)["umbrella"], NOW)
assert state == "installed-but-suspended"
assert "already happened" in detail
assert "unsuspend" in printed_step(state, "umbrella")
def test_agreement_in_either_direction_is_quiet():
assert reconcile({"account": "initech", "connected": True},
installation_index(INSTALLATIONS)["initech"], NOW)[0] == (
"agreed-connected")
assert reconcile({"account": "globex", "connected": False}, None, NOW)[0] == (
"agreed-disconnected")
assert actionable("agreed-connected") is False
def test_an_unaged_request_does_not_pretend_to_know_when_it_started():
state, detail = request_age_state(None)
assert state == "age-unknown"
assert "does not say when the flow started" in detail
assert age_days(None, NOW) is None
assert age_days("not-a-date", NOW) is None
assert round(age_days("2026-08-27T12:00:00Z", NOW), 1) == 2.0
def test_the_step_is_addressed_to_a_human_and_never_taken():
step = printed_step("false-connected", "globex")
assert "an owner of globex has to approve" in step
assert "Nothing here requests or approves anything" in step
assert printed_step("agreed-connected", "globex") == "nothing for this account."
def test_the_product_repair_is_about_the_state_machine_not_the_api():
fix = product_repair(["false-connected", "agreed-connected"])
assert "stop rendering a completed flow as a connection" in fix
assert "on a schedule" in fix
assert "expires by neglect" in product_repair(["stale-request"])
assert product_repair(["agreed-connected"]).startswith("nothing")
def test_the_cost_counts_the_list_pages_and_one_probe_each():
assert read_cost([{"account": "a"}, {"account": "b"}], 1) == 4
assert read_cost([{"account": "a"}], 3) == 5
assert read_cost([], 1) == 2
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ABSENCE_MEANING, actionable, ageDays, installationIndex, printedStep,
probeState, productRepair, readCost, reconcile, requestAgeState,
} from './github-app-installation-pending.mjs';
const NOW = new Date('2026-08-29T12:00:00Z');
const INSTALLATIONS = [
{
id: 41,
account: { login: 'Initech' },
created_at: '2026-07-02T09:00:00Z',
repository_selection: 'all',
suspended_at: null,
},
{
id: 42,
account: { login: 'umbrella' },
created_at: '2026-05-01T09:00:00Z',
repository_selection: 'selected',
suspended_at: '2026-08-01T09:00:00Z',
},
];
test('the index is case insensitive because records are hand written', () => {
const index = installationIndex(INSTALLATIONS);
assert.equal(index.initech.id, 41);
assert.equal(index.umbrella.suspended, true);
assert.equal(index.initech.suspended, false);
assert.deepEqual(installationIndex([{ id: 1 }]), {});
});
test('the probe says what a 404 does and does not mean', () => {
const [state, detail] = probeState(404);
assert.equal(state, 'no-installation');
assert.ok(detail.includes(ABSENCE_MEANING));
assert.equal(probeState(200)[0], 'installed');
assert.equal(probeState(401)[0], 'unreadable');
});
test('a product that says connected against nothing is the headline', () => {
const [state, detail] = reconcile(
{ account: 'globex', connected: true, started_at: '2026-08-10T00:00:00Z' },
null, NOW,
);
assert.equal(state, 'false-connected');
assert.match(detail, /globex/);
assert.ok(detail.includes(ABSENCE_MEANING));
assert.equal(actionable(state), true);
});
test('a fresh request and a forgotten one are different sentences', () => {
const [fresh, freshDetail] = reconcile(
{ account: 'globex', connected: false, started_at: '2026-08-27T12:00:00Z' },
null, NOW,
);
assert.equal(fresh, 'awaiting-approval');
assert.match(freshDetail, /may simply not have looked yet/);
const [stale, staleDetail] = reconcile(
{ account: 'globex', connected: false, started_at: '2026-07-01T12:00:00Z' },
null, NOW,
);
assert.equal(stale, 'stale-request');
assert.match(staleDetail, /notified once/);
});
test('the reconciliation runs in the other direction too', () => {
const [state, detail] = reconcile(
{ account: 'initech', connected: false },
installationIndex(INSTALLATIONS).initech, NOW,
);
assert.equal(state, 'unrecorded-installation');
assert.match(detail, /nothing in your product noticed/);
});
test('a suspended installation is handed to its own note', () => {
const [state, detail] = reconcile(
{ account: 'umbrella', connected: true },
installationIndex(INSTALLATIONS).umbrella, NOW,
);
assert.equal(state, 'installed-but-suspended');
assert.match(detail, /already happened/);
assert.match(printedStep(state, 'umbrella'), /unsuspend/);
});
test('agreement in either direction is quiet', () => {
assert.equal(reconcile({ account: 'initech', connected: true },
installationIndex(INSTALLATIONS).initech, NOW)[0], 'agreed-connected');
assert.equal(reconcile({ account: 'globex', connected: false }, null, NOW)[0],
'agreed-disconnected');
assert.equal(actionable('agreed-connected'), false);
});
test('an unaged request does not pretend to know when it started', () => {
const [state, detail] = requestAgeState(null);
assert.equal(state, 'age-unknown');
assert.match(detail, /does not say when the flow started/);
assert.equal(ageDays(null, NOW), null);
assert.equal(ageDays('not-a-date', NOW), null);
assert.equal(Math.round(ageDays('2026-08-27T12:00:00Z', NOW) * 10) / 10, 2.0);
});
test('the step is addressed to a human and never taken', () => {
const step = printedStep('false-connected', 'globex');
assert.match(step, /an owner of globex has to approve/);
assert.match(step, /Nothing here requests or approves anything/);
assert.equal(printedStep('agreed-connected', 'globex'), 'nothing for this account.');
});
test('the product repair is about the state machine not the api', () => {
const fix = productRepair(['false-connected', 'agreed-connected']);
assert.match(fix, /stop rendering a completed flow as a connection/);
assert.match(fix, /on a schedule/);
assert.match(productRepair(['stale-request']), /expires by neglect/);
assert.match(productRepair(['agreed-connected']), /^nothing/);
});
test('the cost counts the list pages and one probe each', () => {
assert.equal(readCost([{ account: 'a' }, { account: 'b' }], 1), 4);
assert.equal(readCost([{ account: 'a' }], 3), 5);
assert.equal(readCost([], 1), 2);
});
FAQ
Can the script just look up the pending request and tell me it is pending?
No, and that limit is the reason the note is shaped the way it is. There is no endpoint that lists an App's pending installation requests, so from the App's side a pending account is indistinguishable from one that declined, one that closed the tab halfway and one that never started. What you do have is the moment your product recorded the beginning of a flow. Reconciling that against <code>GET /app/installations</code> is the method, not a workaround for a missing call.
How is this different from the App not being installed on a repository?
Scope. That case is an installation that exists on the account but does not include the repository you asked about, and <code>GET /repos/{owner}/{repo}/installation</code> settles it. This is the account-level absence: there is no installation, so there are no selected repositories to be outside of. The two produce the same 404 and have completely different repairs — one is a repository being added to an existing installation, the other is an owner approving something they have not seen.
Why does the user see a success page if nothing was installed?
Because from the user's side the flow did succeed: they authorised the App and GitHub recorded their request. What follows is out of their hands, and the confirmation is not lying so much as describing a different event from the one your product cares about. This is exactly why the reconciliation belongs on a schedule rather than in the callback: the callback fires on the user's action, and the state you actually need changes later, if at all, when somebody else acts.
Some accounts have four pending requests. Does that make approval more likely?
It makes it less likely, in practice. Each attempt notifies the owners once, and repeated identical requests read as noise rather than urgency. It happens because “uninstall and reinstall” is the standard advice for a broken integration and it is unavailable here — there is nothing installed to remove — so the user does the only thing they can, which is start again. Showing the pending state, and who has to approve it, is what stops that loop.
Should the script request the installation itself if it can see one is missing?
Never, and not only because this section is read-only. Approving an App is a decision about what gets access to an organization's code, made by somebody accountable for that organization. A tool that asks on the user's behalf turns a considered approval into a notification storm, and one that could approve would be a privilege-escalation path wearing a diagnostic's clothes. The script reads, reconciles, and prints the sentence for a human to act on.
Related field notes
- The other absence: installed, but not on that repository
- When the installation exists and the stored id does not match it
- When the installation exists and has been switched off
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.
- Requesting a GitHub App from your organization owner — GitHub Docs
- Apps — GitHub REST API
- Installing a GitHub App from a third party — GitHub Docs
- Authenticating as a GitHub App — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.