Files
multica/server/pkg/db/queries/workspace.sql
Bohan Jiang ecce589867 MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265)

Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL
API as the single source of truth (Plan C). Webhooks, page visits and a
bounded TTL sweep are refresh triggers only; nothing is inferred from
webhook payloads anymore.

Backend (server/internal/integrations/ghsnapshot):
- installation-token cache + GraphQL client (private key / tokens never logged)
- one paginated pullRequest query -> normalized per-check snapshot
- outbound queue: (installation,repo,PR) dedup + single in-flight per PR,
  bounded worker pool, Retry-After / rate-limit backoff, jitter
- head-SHA-guarded atomic batch replace (a slow response for an old head
  can never overwrite a newer head's snapshot)
- bounded chase window (30s->5m, stops on terminal/closed) + page-visit +
  TTL refresh; clean degradation when no App private key is configured

Removes the old suite-level webhook aggregation display path (query +
handlers + tests). check_suite / check_run / status are now pure triggers.

Frontend: PR card shows two independent tri-state elements (CI status +
mergeability). "Ready to merge" only when merge state is clean; no-checks
and unknown-mergeable never assert a positive verdict; progress strip
removed; four locales; stale marker.

Docs: github-integration + environment-variables (four languages) — now
required App private key, read-only Checks/Commit-statuses permissions,
new event subscriptions, capability boundaries and troubleshooting.

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

* fix(github): address PR snapshot review blockers

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

* fix(github): bound snapshot refresh scheduling

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

* fix(github): concurrent check-run index migration + singleflight token mint

Address Elon's third-round review on the MUL-5265 PR snapshot pipeline.

Must-fix — migration built a non-concurrent index. The
github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal)
inside CREATE TABLE, which builds a unique index synchronously and violates
the repo rule that every migration-created index (including on a new table)
use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split:
222 now creates the table without a primary key; new 223 adds the
(pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert
write path already guarantees ordinal uniqueness, so a plain unique index is
sufficient; the index also serves the pr_id-prefix list aggregation and the
workspace/PR cleanup deletes.

Nit — token mint now singleflights per installation. installationToken
released the lock before minting, so the N workers of one installation could
mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for
the same installation are now collapsed via singleflight into one HTTP mint;
added a -race concurrent-mint test asserting a single mint under 16 callers.

Verified: fresh DB migrates through 223 (table has no PK, concurrent unique
index present); ghsnapshot suite + new test pass under -race; migration lint
and handler github/workspace-delete tests pass; sqlc produced no diff;
go build / vet / gofmt / git diff --check clean.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 18:30:20 +08:00

200 lines
8.4 KiB
SQL

-- 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, w.attribution_fail_closed
FROM member m
JOIN workspace w ON w.id = m.workspace_id
WHERE m.user_id = $1
ORDER BY w.created_at ASC;
-- name: ListDaemonWorkspaces :many
-- Daemons only need the membership set and display name to discover which
-- workspaces should have local runtimes. Keep this projection intentionally
-- narrow so the periodic consistency check never reads UI-only JSON/text
-- columns such as settings, repos, or context.
SELECT w.id, w.name
FROM member m
JOIN workspace w ON w.id = m.workspace_id
WHERE m.user_id = $1
ORDER BY w.id ASC;
-- name: GetDaemonWorkspace :one
-- Workspace-scoped daemon tokens do not carry a user ID. This narrow lookup
-- lets them use the same endpoint without widening their token scope.
SELECT id, name
FROM workspace
WHERE id = $1;
-- name: GetWorkspace :one
SELECT * FROM workspace
WHERE id = $1;
-- name: GetWorkspaceBySlug :one
SELECT * FROM workspace
WHERE slug = $1;
-- name: GetWorkspaceAttributionFailClosed :one
-- Lean read of the fail-closed attribution policy for the enqueue hot path
-- (MUL-4302 §3.5), avoiding a full workspace-row fetch.
SELECT attribution_fail_closed FROM workspace
WHERE id = $1;
-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description, context, issue_prefix)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: UpdateWorkspace :one
UPDATE workspace SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
context = COALESCE(sqlc.narg('context'), context),
settings = COALESCE(sqlc.narg('settings'), settings),
repos = COALESCE(sqlc.narg('repos'), repos),
issue_prefix = COALESCE(sqlc.narg('issue_prefix'), issue_prefix),
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: IncrementIssueCounter :one
UPDATE workspace SET issue_counter = issue_counter + 1
WHERE id = $1
RETURNING issue_counter;
-- name: LockWorkspaceForDelete :one
-- Taken first by DeleteWorkspace, before it enumerates the workspace's chat
-- sessions. LockChatSessionsByWorkspace only covers sessions that exist when it
-- runs; a CreateChatSession committing during the delete window would add one
-- the lock set never saw, and a finalizer could then insert a restore for it
-- after the sweep's snapshot — orphaning the prompt (#5219).
--
-- The delete window is held closed against new sessions by an EXPLICIT protocol,
-- not the chat_session.workspace_id FK: every session creator takes
-- LockWorkspaceForChatSessionCreate (FOR KEY SHARE) on this row first, and this
-- FOR UPDATE conflicts with it. Keeping the bar in the app layer means it does
-- not silently break if that FK is ever dropped (the codebase is moving FK
-- relationships into the application layer, MUL-3515). Lock order is
-- workspace -> chat_session -> agent_task_queue; the finalizer never touches
-- workspace, so this cannot deadlock against it.
SELECT id FROM workspace WHERE id = $1 FOR UPDATE;
-- name: LockWorkspaceForChatSessionCreate :one
-- The creator half of the workspace delete/create protocol (#5219). Every
-- production path that inserts a chat_session takes this FOR KEY SHARE lock on the
-- parent workspace row, inside its transaction, before CreateChatSession. It
-- conflicts with DeleteWorkspace's FOR UPDATE (so a create is blocked while a
-- delete is in progress, and vice versa) but not with other creators (FOR KEY
-- SHARE locks share), so concurrent session creation stays unserialized. This
-- makes the mutual exclusion explicit rather than leaning on the workspace FK's
-- implicit FOR KEY SHARE, which would vanish if that FK is dropped.
SELECT id FROM workspace WHERE id = $1 FOR KEY SHARE;
-- name: DeleteWorkspace :exec
-- The channel_* tables (MUL-3515 §4), resource-label junctions, and custom issue
-- property definitions carry NO FK to workspace, so — unlike the CASCADE-backed
-- tables the DELETE below sweeps — they are not cleaned up implicitly. Remove
-- their workspace-owned rows here so they commit or roll back atomically with
-- the workspace row.
WITH ws_installations AS (
SELECT id FROM channel_installation WHERE workspace_id = $1
),
ws_agents AS (
SELECT id FROM agent WHERE workspace_id = $1
),
ws_skills AS (
SELECT id FROM skill WHERE workspace_id = $1
),
cleared_agent_label_assignments AS (
DELETE FROM agent_to_label WHERE agent_id IN (SELECT id FROM ws_agents)
),
cleared_skill_label_assignments AS (
DELETE FROM skill_to_label WHERE skill_id IN (SELECT id FROM ws_skills)
),
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_draft_restores AS (
-- chat_draft_restore is keyed by chat_session_id with no FK (MUL-3515) and has
-- no reaper, while its chat_session rows cascade away with the workspace. Reach
-- them directly through chat_session (unlike the cards above, this is not
-- limited to channel-bound sessions) or every pending restore — each holding a
-- user's prompt text — would outlive the workspace permanently (#5219).
--
-- This sweep only sees restores committed before the statement's snapshot, so
-- the caller must already hold LockChatSessionsByWorkspace: that lock is what
-- keeps FinalizeDeferredCancelledChat from inserting one behind it.
DELETE FROM chat_draft_restore
WHERE chat_session_id IN (SELECT id FROM chat_session WHERE workspace_id = $1)
),
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
),
cleared_issue_properties AS (
DELETE FROM issue_property WHERE workspace_id = $1
),
deleted_pending_check_suites AS (
DELETE FROM github_pending_check_suite WHERE workspace_id = $1
),
ws_github_prs AS (
SELECT id FROM github_pull_request WHERE workspace_id = $1
),
cleared_github_pr_check_runs AS (
-- github_pull_request_check_run intentionally has no FK. Remove its rows
-- before the workspace delete cascades away the parent PR mirrors.
DELETE FROM github_pull_request_check_run
WHERE pr_id IN (SELECT id FROM ws_github_prs)
),
-- VCS tables (migration 213) carry no FK to workspace, so they are not cascaded
-- away by the DELETE below. Sweep the workspace's connections, mirrored PRs,
-- their issue links, and CI statuses here. issue_vcs_pull_request has no
-- workspace_id, so reach it through the workspace's PRs; vcs_commit_status has
-- none either, so reach it through the workspace's connections.
ws_vcs_prs AS (
SELECT id FROM vcs_pull_request WHERE workspace_id = $1
),
ws_vcs_connections AS (
SELECT id FROM vcs_connection WHERE workspace_id = $1
),
cleared_vcs_pr_links AS (
DELETE FROM issue_vcs_pull_request
WHERE pull_request_id IN (SELECT id FROM ws_vcs_prs)
),
cleared_vcs_commit_statuses AS (
DELETE FROM vcs_commit_status
WHERE connection_id IN (SELECT id FROM ws_vcs_connections)
),
cleared_vcs_prs AS (
DELETE FROM vcs_pull_request WHERE workspace_id = $1
),
cleared_vcs_connections AS (
DELETE FROM vcs_connection WHERE workspace_id = $1
),
cleared_client_usage_workspace AS (
UPDATE client_usage_daily SET workspace_id = NULL WHERE workspace_id = $1
)
DELETE FROM workspace WHERE workspace.id = $1;