Files
multica/server/pkg/db/generated/workspace.sql.go
Bohan Jiang 53f05cca5e feat(github): fan out PR/check_suite webhooks to all bound workspaces (MUL-4343) (#5218)
* feat(github): fan out PR/check_suite webhooks to all bound workspaces (MUL-4343)

One GitHub App installation can be bound to several workspaces (#4855), but
pull_request and check_suite webhooks were still routed to a single workspace
via resolveWorkspaceForRepo (workspace.repos registry + oldest-binding
fallback). Every workspace but one silently received nothing for a shared repo,
with no way to opt in.

Deliver each repo event to every workspace bound to the installation. Repo
scope is whatever GitHub authorized the installation for; we no longer gate on
the workspace.repos registry (that list means "code the agent clones", not a
webhook subscription). Each workspace independently mirrors the PR, auto-links
against its own issue prefix + github toggles, records check suites against its
own PR mirror, and gets its own realtime broadcast.

- Extract mirrorPullRequestForWorkspace / recordCheckSuiteForWorkspace and loop
  over all installation bindings instead of resolving one workspace.
- Remove the now-dead resolveWorkspaceForRepo / repoIdentityFromURL routing.
- Replace the registry-routing tests with PR + check_suite fan-out tests.

Co-authored-by: multica-agent <github@multica.ai>

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 20:10:25 +08:00

264 lines
7.3 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: workspace.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createWorkspace = `-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description, context, issue_prefix)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url
`
type CreateWorkspaceParams struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description pgtype.Text `json:"description"`
Context pgtype.Text `json:"context"`
IssuePrefix string `json:"issue_prefix"`
}
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
row := q.db.QueryRow(ctx, createWorkspace,
arg.Name,
arg.Slug,
arg.Description,
arg.Context,
arg.IssuePrefix,
)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
&i.AvatarUrl,
)
return i, err
}
const deleteWorkspace = `-- name: DeleteWorkspace :exec
WITH ws_installations AS (
SELECT id FROM channel_installation WHERE workspace_id = $1
),
cleared_chat_sessions AS (
DELETE FROM channel_chat_session_binding WHERE installation_id IN (SELECT id FROM ws_installations)
RETURNING chat_session_id
),
cleared_outbound_cards AS (
-- channel_outbound_card_message is keyed by chat_session_id (no FK); its own
-- chat_session rows cascade away with the workspace, so reach the cards through
-- the just-removed chat-session bindings, which still carry the id.
DELETE FROM channel_outbound_card_message
WHERE chat_session_id IN (SELECT chat_session_id FROM cleared_chat_sessions)
),
cleared_inbound_dedup AS (
DELETE FROM channel_inbound_message_dedup WHERE installation_id IN (SELECT id FROM ws_installations)
),
cleared_audit AS (
-- Purge, don't detach: the workspace is gone and channel_inbound_audit has no
-- workspace_id and no reaper, so a detached (NULL) row would be permanently
-- unattributable. (Reclaim, where the workspace survives, still detaches.)
DELETE FROM channel_inbound_audit WHERE installation_id IN (SELECT id FROM ws_installations)
),
cleared_user_bindings AS (
DELETE FROM channel_user_binding WHERE workspace_id = $1
),
cleared_binding_tokens AS (
DELETE FROM channel_binding_token WHERE workspace_id = $1
),
cleared_installations AS (
DELETE FROM channel_installation WHERE workspace_id = $1
),
deleted_pending_check_suites AS (
DELETE FROM github_pending_check_suite WHERE workspace_id = $1
)
DELETE FROM workspace WHERE workspace.id = $1
`
// The channel_* tables carry NO FK to workspace (MUL-3515 §4), so — unlike the
// CASCADE-backed tables the DELETE below sweeps — they are not cleaned up
// implicitly. Remove this workspace's channel installations, and every dependent
// row of each, here so a deleted workspace never leaves an orphaned installation
// occupying its bot's (channel_type, config->>'app_id') routing slot, which would
// make that bot un-rebindable anywhere until an operator hand-deletes the row
// (#4810). All in one statement so it commits atomically with the workspace row.
func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspace, id)
return err
}
const getWorkspace = `-- name: GetWorkspace :one
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url FROM workspace
WHERE id = $1
`
func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace, error) {
row := q.db.QueryRow(ctx, getWorkspace, id)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
&i.AvatarUrl,
)
return i, err
}
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url FROM workspace
WHERE slug = $1
`
func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspace, error) {
row := q.db.QueryRow(ctx, getWorkspaceBySlug, slug)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
&i.AvatarUrl,
)
return i, err
}
const incrementIssueCounter = `-- name: IncrementIssueCounter :one
UPDATE workspace SET issue_counter = issue_counter + 1
WHERE id = $1
RETURNING issue_counter
`
func (q *Queries) IncrementIssueCounter(ctx context.Context, id pgtype.UUID) (int32, error) {
row := q.db.QueryRow(ctx, incrementIssueCounter, id)
var issue_counter int32
err := row.Scan(&issue_counter)
return issue_counter, err
}
const listWorkspaces = `-- name: ListWorkspaces :many
SELECT w.id, w.name, w.slug, w.description, w.settings,
w.created_at, w.updated_at, w.context, w.repos,
w.issue_prefix, w.issue_counter, w.avatar_url
FROM member m
JOIN workspace w ON w.id = m.workspace_id
WHERE m.user_id = $1
ORDER BY w.created_at ASC
`
func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Workspace, error) {
rows, err := q.db.Query(ctx, listWorkspaces, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Workspace{}
for rows.Next() {
var i Workspace
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
&i.AvatarUrl,
); 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),
description = COALESCE($3, description),
context = COALESCE($4, context),
settings = COALESCE($5, settings),
repos = COALESCE($6, repos),
issue_prefix = COALESCE($7, issue_prefix),
avatar_url = COALESCE($8, avatar_url),
updated_at = now()
WHERE id = $1
RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter, avatar_url
`
type UpdateWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Context pgtype.Text `json:"context"`
Settings []byte `json:"settings"`
Repos []byte `json:"repos"`
IssuePrefix pgtype.Text `json:"issue_prefix"`
AvatarUrl pgtype.Text `json:"avatar_url"`
}
func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
row := q.db.QueryRow(ctx, updateWorkspace,
arg.ID,
arg.Name,
arg.Description,
arg.Context,
arg.Settings,
arg.Repos,
arg.IssuePrefix,
arg.AvatarUrl,
)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
&i.Repos,
&i.IssuePrefix,
&i.IssueCounter,
&i.AvatarUrl,
)
return i, err
}