Files
multica/server/pkg/db/generated/github_snapshot.sql.go
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

329 lines
10 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: github_snapshot.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteGitHubPRCheckRuns = `-- name: DeleteGitHubPRCheckRuns :exec
DELETE FROM github_pull_request_check_run WHERE pr_id = $1
`
// First half of the atomic per-check replace. Runs inside the same transaction
// as UpdateGitHubPRSnapshot and the inserts below.
func (q *Queries) DeleteGitHubPRCheckRuns(ctx context.Context, prID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteGitHubPRCheckRuns, prID)
return err
}
const getGitHubPullRequestByID = `-- name: GetGitHubPullRequestByID :one
SELECT id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files, api_mergeable, api_merge_state_status, checks_rollup_state, snapshot_head_sha, snapshot_fetched_at FROM github_pull_request WHERE id = $1
`
func (q *Queries) GetGitHubPullRequestByID(ctx context.Context, id pgtype.UUID) (GithubPullRequest, error) {
row := q.db.QueryRow(ctx, getGitHubPullRequestByID, id)
var i GithubPullRequest
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.InstallationID,
&i.RepoOwner,
&i.RepoName,
&i.PrNumber,
&i.Title,
&i.State,
&i.HtmlUrl,
&i.Branch,
&i.AuthorLogin,
&i.AuthorAvatarUrl,
&i.MergedAt,
&i.ClosedAt,
&i.PrCreatedAt,
&i.PrUpdatedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.HeadSha,
&i.MergeableState,
&i.Additions,
&i.Deletions,
&i.ChangedFiles,
&i.ApiMergeable,
&i.ApiMergeStateStatus,
&i.ChecksRollupState,
&i.SnapshotHeadSha,
&i.SnapshotFetchedAt,
)
return i, err
}
const insertGitHubPRCheckRun = `-- name: InsertGitHubPRCheckRun :exec
INSERT INTO github_pull_request_check_run (
pr_id, head_sha, ordinal, name, status, conclusion, details_url, is_status_context
) VALUES (
$1, $2, $3, $4, $5, $7, $8, $6
)
`
type InsertGitHubPRCheckRunParams struct {
PrID pgtype.UUID `json:"pr_id"`
HeadSha string `json:"head_sha"`
Ordinal int32 `json:"ordinal"`
Name string `json:"name"`
Status string `json:"status"`
IsStatusContext bool `json:"is_status_context"`
Conclusion pgtype.Text `json:"conclusion"`
DetailsUrl pgtype.Text `json:"details_url"`
}
func (q *Queries) InsertGitHubPRCheckRun(ctx context.Context, arg InsertGitHubPRCheckRunParams) error {
_, err := q.db.Exec(ctx, insertGitHubPRCheckRun,
arg.PrID,
arg.HeadSha,
arg.Ordinal,
arg.Name,
arg.Status,
arg.IsStatusContext,
arg.Conclusion,
arg.DetailsUrl,
)
return err
}
const listGitHubPRNumbersByHeadSHA = `-- name: ListGitHubPRNumbersByHeadSHA :many
SELECT DISTINCT pr_number
FROM github_pull_request
WHERE installation_id = $1 AND repo_owner = $2 AND repo_name = $3 AND head_sha = $4
`
type ListGitHubPRNumbersByHeadSHAParams struct {
InstallationID int64 `json:"installation_id"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
HeadSha string `json:"head_sha"`
}
// Resolves a commit SHA to the PR numbers whose head it is. `status` webhook
// events (legacy commit statuses) carry a SHA + repo but no PR number, so we
// map back through the mirrored head_sha to find which PR(s) to refresh.
func (q *Queries) ListGitHubPRNumbersByHeadSHA(ctx context.Context, arg ListGitHubPRNumbersByHeadSHAParams) ([]int32, error) {
rows, err := q.db.Query(ctx, listGitHubPRNumbersByHeadSHA,
arg.InstallationID,
arg.RepoOwner,
arg.RepoName,
arg.HeadSha,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []int32{}
for rows.Next() {
var pr_number int32
if err := rows.Scan(&pr_number); err != nil {
return nil, err
}
items = append(items, pr_number)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listGitHubPRRowsByAddress = `-- name: ListGitHubPRRowsByAddress :many
SELECT id, workspace_id, head_sha, state
FROM github_pull_request
WHERE installation_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4
`
type ListGitHubPRRowsByAddressParams struct {
InstallationID int64 `json:"installation_id"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
PrNumber int32 `json:"pr_number"`
}
type ListGitHubPRRowsByAddressRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
HeadSha string `json:"head_sha"`
State string `json:"state"`
}
// =====================
// GitHub API snapshot (MUL-5265, Plan C)
//
// These queries back the API-snapshot refresh pipeline. The GitHub GraphQL
// response is the single source of truth; each successful fetch is written as
// one atomic batch replace (guarded update of the PR row + full replace of the
// per-check rows) inside a single transaction.
// =====================
// One (installation, owner, repo, number) address can map to several
// github_pull_request rows — the same installation can be bound to multiple
// workspaces (#4823/#4855), each mirroring its own row. A single API fetch is
// applied to every matching row (each guarded by its own head_sha).
func (q *Queries) ListGitHubPRRowsByAddress(ctx context.Context, arg ListGitHubPRRowsByAddressParams) ([]ListGitHubPRRowsByAddressRow, error) {
rows, err := q.db.Query(ctx, listGitHubPRRowsByAddress,
arg.InstallationID,
arg.RepoOwner,
arg.RepoName,
arg.PrNumber,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListGitHubPRRowsByAddressRow{}
for rows.Next() {
var i ListGitHubPRRowsByAddressRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.HeadSha,
&i.State,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listStaleUndecidedGitHubPRs = `-- name: ListStaleUndecidedGitHubPRs :many
WITH candidates AS (
SELECT installation_id, repo_owner, repo_name, pr_number
FROM github_pull_request AS pr
WHERE state IN ('open', 'draft')
AND (snapshot_fetched_at IS NULL OR snapshot_fetched_at < $6)
AND (
snapshot_fetched_at IS NULL
OR api_mergeable IS NULL
OR api_mergeable = 'UNKNOWN'
OR checks_rollup_state IN ('PENDING', 'EXPECTED')
OR EXISTS (
SELECT 1
FROM github_pull_request_check_run AS cr
WHERE cr.pr_id = pr.id AND cr.status <> 'completed'
)
)
GROUP BY installation_id, repo_owner, repo_name, pr_number
)
SELECT installation_id, repo_owner, repo_name, pr_number
FROM candidates
ORDER BY (
ROW(installation_id, repo_owner, repo_name, pr_number) >
ROW(
$1::BIGINT,
$2::TEXT,
$3::TEXT,
$4::INTEGER
)
) DESC,
installation_id, repo_owner, repo_name, pr_number
LIMIT $5
`
type ListStaleUndecidedGitHubPRsParams struct {
AfterInstallationID int64 `json:"after_installation_id"`
AfterRepoOwner string `json:"after_repo_owner"`
AfterRepoName string `json:"after_repo_name"`
AfterPrNumber int32 `json:"after_pr_number"`
MaxRows int32 `json:"max_rows"`
OlderThan pgtype.Timestamptz `json:"older_than"`
}
type ListStaleUndecidedGitHubPRsRow struct {
InstallationID int64 `json:"installation_id"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
PrNumber int32 `json:"pr_number"`
}
// TTL / safety-net sweep source. Returns distinct addresses of open/draft PRs
// whose snapshot is both stale and undecided. A decided snapshot leaves the
// periodic refresh set; later webhook or view activity can still refresh it.
// The caller advances an address cursor after each bounded batch. Rows after
// the cursor sort first, followed by a wrap to the start, so even perpetually
// failing addresses cannot pin the same first LIMIT rows forever.
func (q *Queries) ListStaleUndecidedGitHubPRs(ctx context.Context, arg ListStaleUndecidedGitHubPRsParams) ([]ListStaleUndecidedGitHubPRsRow, error) {
rows, err := q.db.Query(ctx, listStaleUndecidedGitHubPRs,
arg.AfterInstallationID,
arg.AfterRepoOwner,
arg.AfterRepoName,
arg.AfterPrNumber,
arg.MaxRows,
arg.OlderThan,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListStaleUndecidedGitHubPRsRow{}
for rows.Next() {
var i ListStaleUndecidedGitHubPRsRow
if err := rows.Scan(
&i.InstallationID,
&i.RepoOwner,
&i.RepoName,
&i.PrNumber,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateGitHubPRSnapshot = `-- name: UpdateGitHubPRSnapshot :execrows
UPDATE github_pull_request
SET api_mergeable = $1,
api_merge_state_status = $2,
checks_rollup_state = $3,
snapshot_head_sha = $4,
snapshot_fetched_at = $5,
updated_at = now()
WHERE id = $6 AND head_sha = $4
`
type UpdateGitHubPRSnapshotParams struct {
ApiMergeable pgtype.Text `json:"api_mergeable"`
ApiMergeStateStatus pgtype.Text `json:"api_merge_state_status"`
ChecksRollupState pgtype.Text `json:"checks_rollup_state"`
HeadSha string `json:"head_sha"`
FetchedAt pgtype.Timestamptz `json:"fetched_at"`
PrID pgtype.UUID `json:"pr_id"`
}
// Head-SHA anti-stale write (acceptance criterion 1): the snapshot is written
// only when the row's current head_sha still equals the head the snapshot was
// fetched for. If the head advanced (a newer push landed while this request was
// in flight, mirrored by the pull_request webhook), 0 rows are updated and the
// caller discards the whole response — the per-check replace is skipped too.
func (q *Queries) UpdateGitHubPRSnapshot(ctx context.Context, arg UpdateGitHubPRSnapshotParams) (int64, error) {
result, err := q.db.Exec(ctx, updateGitHubPRSnapshot,
arg.ApiMergeable,
arg.ApiMergeStateStatus,
arg.ChecksRollupState,
arg.HeadSha,
arg.FetchedAt,
arg.PrID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}