fix(github): allow one installation to bind multiple workspaces

Connecting the same GitHub App installation in a second workspace silently
overwrote the first workspace's binding: github_installation was
UNIQUE(installation_id) and CreateGitHubInstallation's upsert overwrote
workspace_id on conflict (#4823).

Widen the uniqueness key to (workspace_id, installation_id) so each workspace
keeps its own binding row, and teach the webhook/lifecycle paths to handle N
bindings per installation_id:

- CreateGitHubInstallation upserts per (workspace_id, installation_id).
- Webhook lookup lists all bindings; PR/check_suite routing uses the oldest
  binding as the deterministic fallback and still routes per-repo via the
  existing workspace.repos registry.
- installation.deleted/suspend drops every workspace binding and broadcasts to
  each affected workspace.
- installation.created/unsuspend refreshes account metadata across all bindings.
- Add a standalone index on installation_id (the dropped unique constraint was
  the only index behind the webhook lookup).

MUL-3950

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-07-02 17:12:31 +08:00
parent 2cf5297814
commit bc53ef7fe6
6 changed files with 423 additions and 96 deletions

View File

@@ -7,9 +7,13 @@ SELECT * FROM github_installation
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: GetGitHubInstallationByInstallationID :one
-- name: ListGitHubInstallationsByInstallationID :many
-- One installation_id can be bound to several workspaces; webhook routing lists
-- every binding and picks the target workspace via the repos registry. Ordered
-- so the oldest binding is the deterministic routing fallback (insts[0]).
SELECT * FROM github_installation
WHERE installation_id = $1;
WHERE installation_id = $1
ORDER BY created_at ASC, id ASC;
-- name: GetGitHubInstallationByID :one
SELECT * FROM github_installation
@@ -21,8 +25,7 @@ INSERT INTO github_installation (
) VALUES (
$1, $2, $3, $4, sqlc.narg('account_avatar_url'), sqlc.narg('connected_by_id')
)
ON CONFLICT (installation_id) DO UPDATE SET
workspace_id = EXCLUDED.workspace_id,
ON CONFLICT (workspace_id, installation_id) DO UPDATE SET
account_login = EXCLUDED.account_login,
account_type = EXCLUDED.account_type,
account_avatar_url = EXCLUDED.account_avatar_url,
@@ -33,10 +36,25 @@ RETURNING *;
-- name: DeleteGitHubInstallation :exec
DELETE FROM github_installation WHERE id = $1 AND workspace_id = $2;
-- name: DeleteGitHubInstallationByInstallationID :one
-- name: DeleteGitHubInstallationByInstallationID :many
-- GitHub-side uninstall/suspend removes trust in the installation entirely, so
-- drop every workspace binding. Returns one row per deleted binding so the
-- handler can broadcast to each affected workspace.
DELETE FROM github_installation WHERE installation_id = $1
RETURNING id, workspace_id;
-- name: UpdateGitHubInstallationAccountByInstallationID :many
-- Refresh the GitHub account display metadata across every workspace binding of
-- an installation (fired by installation.created/new_permissions_accepted/
-- unsuspend). Leaves workspace_id and connected_by_id untouched.
UPDATE github_installation
SET account_login = $2,
account_type = $3,
account_avatar_url = sqlc.narg('account_avatar_url'),
updated_at = now()
WHERE installation_id = $1
RETURNING *;
-- name: UpsertPendingGitHubInstallation :one
INSERT INTO github_pending_installation (
installation_id, account_login, account_type, account_avatar_url