feat(github): connect multiple GitHub organizations to one Paradev organization

HA
14h ago
+18113-447

Summary

  • A Paradev organization can now connect several GitHub organizations or accounts. Migration 0191 drops the github_installations_org_unique constraint, 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 --account handles several accounts.
  • tRPC changes are additive: new integration.github.listInstallations, an optional installationId on disconnect and triggerSync, and getInstallation kept 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_installations had unique("github_installations_org_unique").on(organizationId).
  • /api/github/callback upserted with target: 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, then CREATE INDEX github_installations_org_id_idx. installation_id stays 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 existing already_connected pre-check still runs first.
  • Reads:
    • Repos and PRs filter on github_repositories.organization_id, which already existed (denormalized for Electric).
    • findInstalledRepository loads 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.
    • connectionStatus reports connected if any installation is active.
  • organizationInstallations(orgId) orders active installations first, then oldest first. Released clients that still call getInstallation therefore 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 test passes:

    PackageTests
    packages/trpc868, including the new find-installed-repository.test.ts
    packages/cli481
    packages/i18n169
    packages/db23
    packages/auth91
  • bun run typecheck is clean in db, trpc, auth, cli, api, web, and desktop.

  • bun run check:i18n is clean. Translations for the six new strings are written for all 17 locales.

  • biome check is 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 production PGOPTIONS (lock_timeout=5s, statement_timeout=60s).

  • saveInstallation against 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/github shows 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_KEY holds 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 disconnect deleted every account. Old CLIs confirm the account getInstallation shows, then send disconnect({ 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 getInstallation returned.
    • Verified: another org's installation id with your own org returns No installation found, and that row is untouched.
  • Initial sync left a repo on its old installation. When a repo moved between accounts, the sync updated its organization_id but kept its installation_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_id from the URL, so an org member can claim an installation that no Paradev org owns yet.

Migration safety (db-migrations checklist)

  • Size: github_installations is 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 CONSTRAINT takes 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-api and deploy-web both needs: 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.md rule 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.
  • getInstallation is 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 (installationTokenFor in packages/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: getByBranches across orgs is covered by the organization-wide repo filter, but not exercised against real GitHub data.
  • Docs: apps/docs CLI reference is not updated for disconnect --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.

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.