Summary
processGitHubWebhook handles the installation event but has no handler for the installation_repositories event, which is the event GitHub sends when a maintainer adds or removes repositories from an already-installed app. As a result, repos added to an existing installation are never upserted with their installation linkage.
The webhook entrypoint enqueues every delivery without an event allowlist:
// src/github/webhook.ts:10-62 — no event filtering; every x-github-event is queued
const eventName = c.req.header("x-github-event") ?? null;
...
// enqueues { deliveryId, eventName, payload, installationId } for processGitHubWebhook
So installation_repositories deliveries do reach processGitHubWebhook, where they match none of the branches and fall through. The only installation-related branches are:
// src/queue/processors.ts:562 — installation deleted
if (eventName === "installation" && payload.action === "deleted" && payload.installation?.id) { ... }
// src/queue/processors.ts:577 — installation created/added
if (eventName === "installation" && (payload.action === "created" || payload.action === "added")) {
const installedRepos = payload.repositories?.map((repo) => repo.full_name)...;
...
}
// src/queue/processors.ts:593 — generic repo upsert
const installationId = getInstallationId(payload);
if (payload.repositories) {
for (const repo of payload.repositories) await upsertRepositoryFromGitHub(env, repo, installationId ?? undefined);
}
if (payload.repository) await upsertRepositoryFromGitHub(env, payload.repository, installationId ?? undefined);
Two defects:
-
Dead code: the || payload.action === "added" disjunct at processors.ts:577 can never fire. GitHub's installation event only emits actions created, deleted, suspend, unsuspend, and new_permissions_accepted — never added. "Added" repos arrive on the separate installation_repositories event.
-
Missing handler (the real bug): the installation_repositories event (actions added / removed) carries its repos in repositories_added[] / repositories_removed[], not in repositories[]. So:
- The
eventName === "installation" branches don't match (the event name is installation_repositories).
- The generic
if (payload.repositories) at processors.ts:593 is false (the data is in repositories_added).
- The added repos are never passed to
upsertRepositoryFromGitHub, so they get no row and no installationId association.
The payload type confirms the gap — GitHubWebhookPayload (src/types.ts) declares repositories? but has no repositories_added / repositories_removed fields, so even a hand-written handler couldn't read them without a type change.
Failure mode (concrete example)
- An org installs the app on repo
acme/a → GitHub sends installation/created with repositories: [acme/a] → acme/a is upserted with installationId (processors.ts:577-596). Correct.
- Later the maintainer adds
acme/b via GitHub's "Repository access" UI → GitHub sends installation_repositories with action: "added", repositories_added: [acme/b], and the same installation.id.
- Current behavior: no branch matches;
payload.repositories is undefined → acme/b is silently dropped. It has no repository row and is not associated with the installation.
- Correct behavior:
acme/b should be upserted and linked to the installation immediately, exactly as the initial created set is.
The repo only becomes visible later, by accident, if some unrelated pull_request / issue / push webhook for acme/b happens to fire and lazily upsert it — and even then it may miss the installation linkage depending on that payload.
Downstream impact
- Installation health undercounts.
refreshInstallationHealth enumerates repos by repo.installationId === installation.id (in src/github/backfill.ts); a repo never linked to the installation is excluded from health, backfill scheduling, and registration-readiness until something else upserts it.
- No usage signal. No
github_installation_* product-usage event is recorded for repos added post-install, so githubInstalledRepos / installation-growth analytics (src/db/repositories.ts:3258) under-report adoption.
- Removals are unhandled too. When a repo is removed from an installation (
action: "removed", repositories_removed[]), nothing clears its installationId / isInstalled, so the engine keeps treating a no-longer-accessible repo as installed (the inverse of markInstallationDeleted, src/db/repositories.ts:178).
Steps to reproduce
- Install the app on one repo (full
installation/created).
- Add a second repo to the same installation through GitHub's repository-access settings.
- Observe that the second repo never appears with an
installationId association (no repository row from the webhook), and refreshInstallationHealth does not count it — until an unrelated activity webhook for that repo arrives.
Expected behavior
processGitHubWebhook handles installation_repositories: on added, upsert every repo in repositories_added with the installation id (and record an installation usage event); on removed, clear the installation linkage for every repo in repositories_removed — mirroring how the initial installation/created set and markInstallationDeleted are handled.
Actual behavior
installation_repositories events are received and enqueued but match no handler, so added repos are dropped and removed repos are never unlinked. The installation/"added" disjunct that looks like it was meant to cover this is dead code against the wrong event name and the wrong payload field.
Suggested fix
- Add the missing payload fields to
GitHubWebhookPayload (src/types.ts): repositories_added?: GitHubRepositoryPayload[] and repositories_removed?: GitHubRepositoryPayload[].
- Add an
installation_repositories branch in processGitHubWebhook (src/queue/processors.ts):
action === "added": for (const repo of payload.repositories_added ?? []) await upsertRepositoryFromGitHub(env, repo, installationId ?? undefined), and record a github_installation_* product-usage event (mirroring processors.ts:581).
action === "removed": clear installationId / isInstalled for each repositories_removed repo (a small markInstallationRepositoryRemoved helper in src/db/repositories.ts, paralleling markInstallationDeleted at :178).
- Remove the dead
|| payload.action === "added" disjunct at processors.ts:577.
- Tests: a queue/processor test asserting that an
installation_repositories/added delivery upserts repositories_added with the installation id and records the usage event, and that removed clears the linkage; plus a payload-type fixture covering the new fields.
Summary
processGitHubWebhookhandles theinstallationevent but has no handler for theinstallation_repositoriesevent, which is the event GitHub sends when a maintainer adds or removes repositories from an already-installed app. As a result, repos added to an existing installation are never upserted with their installation linkage.The webhook entrypoint enqueues every delivery without an event allowlist:
So
installation_repositoriesdeliveries do reachprocessGitHubWebhook, where they match none of the branches and fall through. The only installation-related branches are:Two defects:
Dead code: the
|| payload.action === "added"disjunct atprocessors.ts:577can never fire. GitHub'sinstallationevent only emits actionscreated,deleted,suspend,unsuspend, andnew_permissions_accepted— neveradded. "Added" repos arrive on the separateinstallation_repositoriesevent.Missing handler (the real bug): the
installation_repositoriesevent (actionsadded/removed) carries its repos inrepositories_added[]/repositories_removed[], not inrepositories[]. So:eventName === "installation"branches don't match (the event name isinstallation_repositories).if (payload.repositories)atprocessors.ts:593isfalse(the data is inrepositories_added).upsertRepositoryFromGitHub, so they get no row and noinstallationIdassociation.The payload type confirms the gap —
GitHubWebhookPayload(src/types.ts) declaresrepositories?but has norepositories_added/repositories_removedfields, so even a hand-written handler couldn't read them without a type change.Failure mode (concrete example)
acme/a→ GitHub sendsinstallation/createdwithrepositories: [acme/a]→acme/ais upserted withinstallationId(processors.ts:577-596). Correct.acme/bvia GitHub's "Repository access" UI → GitHub sendsinstallation_repositorieswithaction: "added",repositories_added: [acme/b], and the sameinstallation.id.payload.repositoriesis undefined →acme/bis silently dropped. It has no repository row and is not associated with the installation.acme/bshould be upserted and linked to the installation immediately, exactly as the initialcreatedset is.The repo only becomes visible later, by accident, if some unrelated
pull_request/issue/pushwebhook foracme/bhappens to fire and lazily upsert it — and even then it may miss the installation linkage depending on that payload.Downstream impact
refreshInstallationHealthenumerates repos byrepo.installationId === installation.id(insrc/github/backfill.ts); a repo never linked to the installation is excluded from health, backfill scheduling, and registration-readiness until something else upserts it.github_installation_*product-usage event is recorded for repos added post-install, sogithubInstalledRepos/ installation-growth analytics (src/db/repositories.ts:3258) under-report adoption.action: "removed",repositories_removed[]), nothing clears itsinstallationId/isInstalled, so the engine keeps treating a no-longer-accessible repo as installed (the inverse ofmarkInstallationDeleted,src/db/repositories.ts:178).Steps to reproduce
installation/created).installationIdassociation (no repository row from the webhook), andrefreshInstallationHealthdoes not count it — until an unrelated activity webhook for that repo arrives.Expected behavior
processGitHubWebhookhandlesinstallation_repositories: onadded, upsert every repo inrepositories_addedwith the installation id (and record an installation usage event); onremoved, clear the installation linkage for every repo inrepositories_removed— mirroring how the initialinstallation/createdset andmarkInstallationDeletedare handled.Actual behavior
installation_repositoriesevents are received and enqueued but match no handler, so added repos are dropped and removed repos are never unlinked. Theinstallation/"added"disjunct that looks like it was meant to cover this is dead code against the wrong event name and the wrong payload field.Suggested fix
GitHubWebhookPayload(src/types.ts):repositories_added?: GitHubRepositoryPayload[]andrepositories_removed?: GitHubRepositoryPayload[].installation_repositoriesbranch inprocessGitHubWebhook(src/queue/processors.ts):action === "added":for (const repo of payload.repositories_added ?? []) await upsertRepositoryFromGitHub(env, repo, installationId ?? undefined), and record agithub_installation_*product-usage event (mirroringprocessors.ts:581).action === "removed": clearinstallationId/isInstalledfor eachrepositories_removedrepo (a smallmarkInstallationRepositoryRemovedhelper insrc/db/repositories.ts, parallelingmarkInstallationDeletedat:178).|| payload.action === "added"disjunct atprocessors.ts:577.installation_repositories/addeddelivery upsertsrepositories_addedwith the installation id and records the usage event, and thatremovedclears the linkage; plus a payload-type fixture covering the new fields.