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