diff --git a/apps/docs/content/docs/github-integration.mdx b/apps/docs/content/docs/github-integration.mdx
index 43e3392bc6..5d5b1095fc 100644
--- a/apps/docs/content/docs/github-integration.mdx
+++ b/apps/docs/content/docs/github-integration.mdx
@@ -20,6 +20,19 @@ There is no per-issue setup. The whole flow is identifier-driven.
Only the PR itself is mirrored. Commits, branch refs without an open PR, and CI check states are **not** modeled. The integration is intentionally narrow.
+## Multiple workspaces
+
+One GitHub App installation can be connected in **several** workspaces at once — for example when different teams in the same organization run separate workspaces. When it is, every connected workspace receives each repository's `pull_request` events **independently**:
+
+- Each workspace mirrors the PR and auto-links it against **its own** [issue prefix](/workspaces) and GitHub feature switches. A PR that references `MUL-1` and `ENG-2` links `MUL-1` in the workspace whose prefix is `MUL` and `ENG-2` in the one whose prefix is `ENG` — neither sees the other's issues.
+- Disconnecting the installation from one workspace only stops **that** workspace from receiving events; the others keep working.
+
+Repository scope is whatever you granted the GitHub App (all repositories, or a chosen subset). Connecting the installation in a workspace is itself the subscription — there is no separate per-repository selection inside Multica.
+
+
+**Upgrading a self-host deployment:** event delivery is keyed on the GitHub connection, not on a workspace's code-repository list. If a workspace previously received a repo's PRs *without* connecting GitHub in that workspace (by listing the repo in its code-repositories entry alone), it will stop receiving events after this upgrade. To restore delivery, **connect the same GitHub installation** in that workspace under **Settings → GitHub**.
+
+
## How identifiers are matched
The webhook extracts identifiers from three fields, in this order: **PR head branch**, **PR title**, **PR body**. The matcher is:
diff --git a/apps/docs/content/docs/github-integration.zh.mdx b/apps/docs/content/docs/github-integration.zh.mdx
index 5d563cdf4b..3d35ace5ff 100644
--- a/apps/docs/content/docs/github-integration.zh.mdx
+++ b/apps/docs/content/docs/github-integration.zh.mdx
@@ -20,6 +20,19 @@ import { Callout } from "fumadocs-ui/components/callout";
只镜像 PR 本身。Commit、没开 PR 的分支、CI 检查状态都**不**入库——集成有意保持窄边界。
+## 多个工作区
+
+同一个 GitHub App installation 可以同时连接到**多个**工作区——比如同一组织下不同团队各自用独立工作区。此时每个已连接的工作区都会**各自独立**收到每个仓库的 `pull_request` 事件:
+
+- 每个工作区各自镜像 PR,并按**自己的** [issue 前缀](/workspaces) 和 GitHub 功能开关做自动关联。一个同时引用 `MUL-1` 和 `ENG-2` 的 PR,会在前缀为 `MUL` 的工作区关联 `MUL-1`、在前缀为 `ENG` 的工作区关联 `ENG-2`,两边互不可见对方的 issue。
+- 从某个工作区断开连接,只会让**该**工作区停止接收事件,其它工作区照常工作。
+
+仓库范围由你授予 GitHub App 的权限决定(全部仓库或指定子集)。在工作区连接这个 installation 本身就是一次订阅——Multica 里不需要再单独逐仓库勾选。
+
+
+**升级 self-host 部署时注意:** 事件投递以 GitHub 连接为准,而不是工作区的代码仓库列表。如果某个工作区之前是在**没有**连接 GitHub 的情况下、仅靠代码仓库列表里登记该仓库来接收 PR 事件,升级后它将不再收到事件。要恢复投递,需在该工作区的 **Settings → GitHub** 里**连接同一个 GitHub installation**。
+
+
## 编号是怎么匹配的
Webhook 从三个字段抽取编号,顺序是:**PR head 分支** → **PR 标题** → **PR 正文**。匹配规则:
diff --git a/server/internal/handler/github_test.go b/server/internal/handler/github_test.go
index 36e6a4b519..071ce4e7fd 100644
--- a/server/internal/handler/github_test.go
+++ b/server/internal/handler/github_test.go
@@ -3022,6 +3022,120 @@ func TestWebhook_CheckSuite_FansOutToBoundWorkspaces(t *testing.T) {
assertRecorded("B", prB.ID)
}
+// TestWebhook_CheckSuite_OutOfOrderFansOutToBoundWorkspaces covers the most
+// error-prone multi-workspace path: a check_suite that arrives BEFORE the PR is
+// mirrored. Each bound workspace must stash its own pending row, and when the PR
+// event fans out, each workspace must drain its own pending row and record the
+// suite — one workspace's stash/drain must not stand in for another's.
+func TestWebhook_CheckSuite_OutOfOrderFansOutToBoundWorkspaces(t *testing.T) {
+ if testHandler == nil || testPool == nil {
+ t.Skip("handler test fixture not initialized (no DB?)")
+ }
+ ctx := context.Background()
+ secret := "fanout-cs-ooo-secret"
+ t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
+
+ const repo = "fanout-ooo-repo"
+ const prNumber int32 = 4345
+ const installationID int64 = 778899103
+ const suiteID int64 = 90019002
+ head := "ooosha7654321"
+
+ testPool.Exec(ctx, `DELETE FROM github_installation WHERE installation_id = $1`, installationID)
+ testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, "fanout-ooo-ws-a")
+ testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, "fanout-ooo-ws-b")
+ wsA, err := testHandler.Queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
+ Name: "fanout-ooo-ws-a", Slug: "fanout-ooo-ws-a", IssuePrefix: "OOA",
+ })
+ if err != nil {
+ t.Fatalf("CreateWorkspace A: %v", err)
+ }
+ wsB, err := testHandler.Queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
+ Name: "fanout-ooo-ws-b", Slug: "fanout-ooo-ws-b", IssuePrefix: "OOB",
+ })
+ if err != nil {
+ t.Fatalf("CreateWorkspace B: %v", err)
+ }
+
+ if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
+ WorkspaceID: wsA.ID, InstallationID: installationID, AccountLogin: "acme", AccountType: "User",
+ }); err != nil {
+ t.Fatalf("CreateGitHubInstallation A: %v", err)
+ }
+ if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
+ WorkspaceID: wsB.ID, InstallationID: installationID, AccountLogin: "acme", AccountType: "User",
+ }); err != nil {
+ t.Fatalf("CreateGitHubInstallation B: %v", err)
+ }
+
+ t.Cleanup(func() {
+ testPool.Exec(ctx, `DELETE FROM github_pull_request_check_suite WHERE pr_id IN (SELECT id FROM github_pull_request WHERE repo_owner = 'acme' AND repo_name = $1)`, repo)
+ testPool.Exec(ctx, `DELETE FROM github_pending_check_suite WHERE repo_owner = 'acme' AND repo_name = $1`, repo)
+ testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE repo_owner = 'acme' AND repo_name = $1`, repo)
+ testPool.Exec(ctx, `DELETE FROM github_installation WHERE installation_id = $1`, installationID)
+ testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsA.ID)
+ testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsB.ID)
+ })
+
+ pendingCount := func(wsID any) int {
+ var n int
+ if err := testPool.QueryRow(ctx,
+ `SELECT count(*) FROM github_pending_check_suite WHERE workspace_id = $1 AND repo_owner = 'acme' AND repo_name = $2 AND pr_number = $3 AND suite_id = $4`,
+ wsID, repo, prNumber, suiteID).Scan(&n); err != nil {
+ t.Fatalf("count pending: %v", err)
+ }
+ return n
+ }
+ suiteCount := func(prID any) int {
+ var n int
+ if err := testPool.QueryRow(ctx,
+ `SELECT count(*) FROM github_pull_request_check_suite WHERE pr_id = $1 AND suite_id = $2`,
+ prID, suiteID).Scan(&n); err != nil {
+ t.Fatalf("count suites: %v", err)
+ }
+ return n
+ }
+
+ // 1. check_suite arrives BEFORE any PR mirror: each bound workspace stashes
+ // its own pending row.
+ fireCheckSuiteWebhook(t, secret, installationID, repo, []int32{prNumber}, suiteID, 7200, head, "failure", "2026-05-02T00:00:00Z")
+ if got := pendingCount(wsA.ID); got != 1 {
+ t.Fatalf("workspace A: expected 1 pending check_suite, got %d", got)
+ }
+ if got := pendingCount(wsB.ID); got != 1 {
+ t.Fatalf("workspace B: expected 1 pending check_suite, got %d", got)
+ }
+
+ // 2. The PR arrives and fans out: each workspace drains its own pending row
+ // and records the suite against its own PR mirror.
+ firePullRequestWebhookWithHead(t, secret, "OOX-1", installationID, repo, prNumber, "opened", head, "")
+
+ prA, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
+ WorkspaceID: wsA.ID, RepoOwner: "acme", RepoName: repo, PrNumber: prNumber,
+ })
+ if err != nil {
+ t.Fatalf("workspace A: expected PR mirrored: %v", err)
+ }
+ prB, err := testHandler.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
+ WorkspaceID: wsB.ID, RepoOwner: "acme", RepoName: repo, PrNumber: prNumber,
+ })
+ if err != nil {
+ t.Fatalf("workspace B: expected PR mirrored: %v", err)
+ }
+ if got := suiteCount(prA.ID); got != 1 {
+ t.Fatalf("workspace A: expected 1 recorded check_suite after drain, got %d", got)
+ }
+ if got := suiteCount(prB.ID); got != 1 {
+ t.Fatalf("workspace B: expected 1 recorded check_suite after drain, got %d", got)
+ }
+ if got := pendingCount(wsA.ID); got != 0 {
+ t.Fatalf("workspace A: expected pending drained to 0, got %d", got)
+ }
+ if got := pendingCount(wsB.ID); got != 0 {
+ t.Fatalf("workspace B: expected pending drained to 0, got %d", got)
+ }
+}
+
// TestSecondWorkspaceBindDoesNotUnbindFirst is the #4823 regression: binding
// the same GitHub App installation in a second workspace must NOT overwrite the
// first workspace's binding. Both bindings coexist, and re-binding an existing
diff --git a/server/pkg/db/generated/github.sql.go b/server/pkg/db/generated/github.sql.go
index d6a9aa19d2..e68786b4d8 100644
--- a/server/pkg/db/generated/github.sql.go
+++ b/server/pkg/db/generated/github.sql.go
@@ -386,8 +386,8 @@ ORDER BY created_at ASC, id ASC
`
// 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]).
+// every binding and fans the event out to each bound workspace. Ordered oldest
+// first so processing is deterministic and replay-stable.
func (q *Queries) ListGitHubInstallationsByInstallationID(ctx context.Context, installationID int64) ([]GithubInstallation, error) {
rows, err := q.db.Query(ctx, listGitHubInstallationsByInstallationID, installationID)
if err != nil {
diff --git a/server/pkg/db/generated/workspace.sql.go b/server/pkg/db/generated/workspace.sql.go
index b82dcb61b5..63cd26f60f 100644
--- a/server/pkg/db/generated/workspace.sql.go
+++ b/server/pkg/db/generated/workspace.sql.go
@@ -208,39 +208,6 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor
return items, nil
}
-const listWorkspacesWithRepos = `-- name: ListWorkspacesWithRepos :many
-SELECT id, repos FROM workspace
-WHERE repos IS NOT NULL AND repos <> '[]'::jsonb
-ORDER BY id
-`
-
-type ListWorkspacesWithReposRow struct {
- ID pgtype.UUID `json:"id"`
- Repos []byte `json:"repos"`
-}
-
-// Workspaces with a non-empty repo registry, to route a webhook to the repo's
-// owning workspace. ORDER BY id keeps the resolver's tie-break stable on replay.
-func (q *Queries) ListWorkspacesWithRepos(ctx context.Context) ([]ListWorkspacesWithReposRow, error) {
- rows, err := q.db.Query(ctx, listWorkspacesWithRepos)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListWorkspacesWithReposRow{}
- for rows.Next() {
- var i ListWorkspacesWithReposRow
- if err := rows.Scan(&i.ID, &i.Repos); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
const updateWorkspace = `-- name: UpdateWorkspace :one
UPDATE workspace SET
name = COALESCE($2, name),
diff --git a/server/pkg/db/queries/github.sql b/server/pkg/db/queries/github.sql
index d250e37184..0b3fda2a41 100644
--- a/server/pkg/db/queries/github.sql
+++ b/server/pkg/db/queries/github.sql
@@ -9,8 +9,8 @@ ORDER BY created_at ASC;
-- 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]).
+-- every binding and fans the event out to each bound workspace. Ordered oldest
+-- first so processing is deterministic and replay-stable.
SELECT * FROM github_installation
WHERE installation_id = $1
ORDER BY created_at ASC, id ASC;
diff --git a/server/pkg/db/queries/workspace.sql b/server/pkg/db/queries/workspace.sql
index a1d15cc7f5..c1eff1d711 100644
--- a/server/pkg/db/queries/workspace.sql
+++ b/server/pkg/db/queries/workspace.sql
@@ -33,13 +33,6 @@ UPDATE workspace SET
WHERE id = $1
RETURNING *;
--- name: ListWorkspacesWithRepos :many
--- Workspaces with a non-empty repo registry, to route a webhook to the repo's
--- owning workspace. ORDER BY id keeps the resolver's tie-break stable on replay.
-SELECT id, repos FROM workspace
-WHERE repos IS NOT NULL AND repos <> '[]'::jsonb
-ORDER BY id;
-
-- name: IncrementIssueCounter :one
UPDATE workspace SET issue_counter = issue_counter + 1
WHERE id = $1