feat(github): connect multiple GitHub organizations to one Paradev organization
Summary
- A Paradev organization can now connect several GitHub organizations or accounts. Migration
0191drops thegithub_installations_org_uniqueconstraint, and every server read now covers all of the organization's installations. - UI: the web GitHub integration page lists every connected account, each with its own Disconnect, plus an Add GitHub organization button. Desktop Integrations settings show every connected account. The CLI
integrations github status / connect / disconnect --accounthandles several accounts. - tRPC changes are additive: new
integration.github.listInstallations, an optionalinstallationIdondisconnectandtriggerSync, andgetInstallationkept as@deprecated.
Why / Context
A Team-plan customer cancelled about 1h45m after subscribing: "only 1 github org, too limiting".
The limit was structural, not billing. There is no plan or Autumn limit on the number of orgs; packages/shared/src/billing.ts only gates GitHub at pro. What limited us:
github_installationshadunique("github_installations_org_unique").on(organizationId)./api/github/callbackupserted withtarget: organizationId, so connecting a second GitHub account overwrote the first.- About ten read paths did
githubInstallations.findFirst({ where: organizationId }).
How It Works
- Schema (
0191_github_installations_multiple_per_org.sql):DROP CONSTRAINT github_installations_org_unique, thenCREATE INDEX github_installations_org_id_idx.installation_idstays globally unique, so one GitHub installation still belongs to exactly one Paradev org. - Callback (
saveInstallation):INSERT … ON CONFLICT (installation_id) DO UPDATE … WHERE organization_id = $org.- A new GitHub account adds a row.
- Reinstalling or reconfiguring the same installation updates its row.
- An installation owned by another org returns no row, so the callback exits with
save_failed. The existingalready_connectedpre-check still runs first.
- Reads:
- Repos and PRs filter on
github_repositories.organization_id, which already existed (denormalized for Electric). findInstalledRepositoryloads the repo's own installation, so a PR on an org-B repo uses org B's token.- The people picker,
triggerSync, and the plan-upgrade backfill run per installation. connectionStatusreports connected if any installation is active.
- Repos and PRs filter on
organizationInstallations(orgId)orders active installations first, then oldest first. Released clients that still callgetInstallationtherefore get a stable account, and an active one when one exists.- Unchanged: webhooks, suspension, the suspend job, and initial-sync already key on the installation.
Testing
-
bun testpasses:Package Tests packages/trpc868, including the new find-installed-repository.test.tspackages/cli481 packages/i18n169 packages/db23 packages/auth91 -
bun run typecheckis clean in db, trpc, auth, cli, api, web, and desktop. -
bun run check:i18nis clean. Translations for the six new strings are written for all 17 locales. -
biome checkis clean on the changed files. -
Migration applied to this workspace's own Neon branch (
br-winter-art-afjsvz0n, a non-default child branch), using the productionPGOPTIONS(lock_timeout=5s,statement_timeout=60s). -
saveInstallationagainst real Postgres on that branch:- A third account was added.
- A reinstall updated the same row.
- Another org claiming the installation was refused, and the row was unchanged.
- A per-account delete left the other accounts in place.
-
Running dev server (web :3000 + API :3001): signed in as the dev user and verified:
/integrations/githubshows two accounts and repos from both.- The per-account disconnect dialog appears.
- "Add GitHub organization" returns a 302 to
github.com/apps/superset-app-dev/installations/new?state=…&redirect_url=…/api/github/callback.
Screenshots and verification: https://app.superset.sh/page/multi-org-github-running-on-the-dev-server-pr-9396-x02nsh
-
Not run: a real GitHub install round-trip. This workspace's
GH_APP_PRIVATE_KEYholds only its first line, so the API cannot mint GitHub tokens here, and the headless browser could not reach github.com. Demo installations were seeded on the dev branch instead.
Independent review: findings and fixes
A separate review agent read the diff and searched the rest of the repo. It found no cross-org security hole and no drizzle semantics mistake. It confirmed the callback upsert: a race between two orgs ends in save_failed, and the installation is not taken over.
Fixed in de42b587:
- Released CLI
disconnectdeleted every account. Old CLIs confirm the accountgetInstallationshows, then senddisconnect({ organizationId })with no id. That call now removes only that account.- Verified against the dev API: with 3 accounts connected, the old-shape call removed only the one
getInstallationreturned. - Verified: another org's installation id with your own org returns
No installation found, and that row is untouched.
- Verified against the dev API: with 3 accounts connected, the old-shape call removed only the one
- Initial sync left a repo on its old installation. When a repo moved between accounts, the sync updated its
organization_idbut kept itsinstallation_id. Initial sync and the dev sync now update both.
Open, and not fixed here:
- CLI
connect: if the user picks an account that is already installed, GitHub sends back the same installation id. The CLI waits for a new one and times out after 10 minutes. The web flow is unaffected. - Desktop "Configure" link: in the new-workspace repo picker it opens only the first account's GitHub App settings.
- Stale rows: a reinstall used to overwrite the org's single row. Now, if GitHub's uninstall webhook is missed, a dead row stays until someone disconnects it in the UI.
- Pre-existing: the callback trusts
installation_idfrom the URL, so an org member can claim an installation that no Paradev org owns yet.
Migration safety (db-migrations checklist)
- Size:
github_installationsis 1.8 MB with 3,277 rows on the production-copy branch, one per org. - Locks: no column type change and no FK change.
DROP CONSTRAINTtakes a brief ACCESS EXCLUSIVE lock on this small table only, and no parent tables are locked. The table is not in the hot list. No load rehearsal was run, per the skill: it touches only a small table that no job holds open. - Ordering:
deploy-apianddeploy-webbothneeds: deploy-database, so the code that inserts a second row never runs before the constraint is gone. - Deviation from repo rules, at the requester's ask: the migration ships in the same PR as the tRPC additions, against
packages/trpc/AGENTS.mdrule 10. The tRPC changes are additive only, so a revert of the code alone stays safe (see Rollback).
tRPC compatibility
- Inputs: only new optional fields. Released clients send none and get the old whole-org behavior.
- Outputs: shapes, columns, and error codes are unchanged.
getInstallationis kept, and still used by released desktop and CLI builds. They keep working, and see one account.- New
listInstallations: called by web (deploys with the API), desktop settings, and CLI. Desktop and CLI pick it up in their next release, after this API is live.
Known Limitations / Follow-ups
- Cloud workspaces spanning two GitHub accounts (
installationTokenForinpackages/trpc/src/lib/sandbox/repositories.ts): when the creator has not connected their own GitHub, the box gets one installation token. That token comes from the first repo's account, and private repos from a second account in the same workspace will not clone. The creator's own token (the default) is unaffected. The fix needs per-owner credentials at the egress firewall. - Sidebar PR chips across orgs:
getByBranchesacross orgs is covered by the organization-wide repo filter, but not exercised against real GitHub data. - Docs:
apps/docsCLI reference is not updated fordisconnect --account. The docs site documents only released CLI behavior, so update it with the next CLI release. - Dev only:
POST /api/github/sync(development only) still syncs one installation.
Risks / Rollout / Rollback
- Risk: low to medium.
- With one installation per org, which is every org today, every changed query returns the same rows.
- New exposure: orgs can now hold several installations, so the multi-installation paths above are live.
- Rollback:
- Revert the code first. The old code still works with the constraint gone, but its callback upsert targets
organization_id, which has no unique constraint after 0191. That makes the old callback fail, though nothing else breaks. - If a code revert is needed, re-add the constraint only after removing extra rows per org. Do not re-add it while any org has two rows.
- Prefer a forward fix.
- Revert the code first. The old code still works with the constraint gone, but its callback upsert targets
Summary by CodeRabbit
- New Features
- Connect multiple GitHub organizations or personal accounts to one workspace, and view each installation’s status.
- Add or disconnect accounts individually. The CLI can target a specific account when disconnecting.
- Browse repositories and people across connected accounts, and sync one or all installations.
- Improvements
- Suspended installations no longer count as active connections.
- Disconnecting an account stops syncing its repositories without affecting other connected accounts.
- Translations
- Updated GitHub integration guidance and account-specific messaging across supported languages.