mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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 正文**。匹配规则:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user