test(github): out-of-order fan-out test + drop dead query + document multi-workspace delivery (MUL-4343)

Addresses review feedback on the webhook fan-out change:

- Add TestWebhook_CheckSuite_OutOfOrderFansOutToBoundWorkspaces: a check_suite
  that arrives before the PR must stash a pending row per bound workspace, and
  each workspace must drain its own row when the PR fans out.
- Remove the now-unused ListWorkspacesWithRepos query (its only caller was the
  deleted resolveWorkspaceForRepo) and regenerate sqlc; fix the stale
  "picks the target workspace via the repos registry" comment on
  ListGitHubInstallationsByInstallationID.
- Document multi-workspace event delivery in the GitHub integration docs
  (en + zh), including an explicit self-host upgrade note: delivery is now
  keyed on the GitHub connection, so a workspace that relied on the
  code-repository list alone (without connecting GitHub) must connect the
  installation to keep receiving events. This is an intentional, documented
  behavior change — the PR description's earlier "single-binding behavior is
  unchanged" claim was inaccurate and has been corrected.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-07-10 17:54:39 +08:00
parent 8498aa24e3
commit 3235b87bf9
7 changed files with 144 additions and 44 deletions

View File

@@ -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.
<Callout type="warning">
**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**.
</Callout>
## How identifiers are matched
The webhook extracts identifiers from three fields, in this order: **PR head branch**, **PR title**, **PR body**. The matcher is:

View File

@@ -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 里不需要再单独逐仓库勾选。
<Callout type="warning">
**升级 self-host 部署时注意:** 事件投递以 GitHub 连接为准,而不是工作区的代码仓库列表。如果某个工作区之前是在**没有**连接 GitHub 的情况下、仅靠代码仓库列表里登记该仓库来接收 PR 事件,升级后它将不再收到事件。要恢复投递,需在该工作区的 **Settings → GitHub** 里**连接同一个 GitHub installation**。
</Callout>
## 编号是怎么匹配的
Webhook 从三个字段抽取编号,顺序是:**PR head 分支** → **PR 标题** → **PR 正文**。匹配规则:

View File

@@ -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

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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;

View File

@@ -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