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>
This commit is contained in:
Bohan-J
2026-07-24 16:22:30 +08:00
parent 00e658401b
commit c7b40246fd
37 changed files with 3242 additions and 1612 deletions

View File

@@ -0,0 +1,292 @@
// 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 listStaleOpenGitHubPRs = `-- name: ListStaleOpenGitHubPRs :many
SELECT DISTINCT installation_id, repo_owner, repo_name, pr_number
FROM github_pull_request
WHERE state IN ('open', 'draft')
AND (snapshot_fetched_at IS NULL OR snapshot_fetched_at < $1)
ORDER BY installation_id, repo_owner, repo_name, pr_number
LIMIT $2
`
type ListStaleOpenGitHubPRsParams struct {
OlderThan pgtype.Timestamptz `json:"older_than"`
MaxRows int32 `json:"max_rows"`
}
type ListStaleOpenGitHubPRsRow 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 missing or older than the TTL cutoff. Bounded by LIMIT so
// one sweep can never fan out unbounded. Open PRs whose base branch advanced
// (a conflict-producing event that emits NO pull_request webhook on this PR)
// are recovered here without needing Contents:read. Merged/closed PRs are
// excluded — a settled PR leaves the refresh set.
func (q *Queries) ListStaleOpenGitHubPRs(ctx context.Context, arg ListStaleOpenGitHubPRsParams) ([]ListStaleOpenGitHubPRsRow, error) {
rows, err := q.db.Query(ctx, listStaleOpenGitHubPRs, arg.OlderThan, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListStaleOpenGitHubPRsRow{}
for rows.Next() {
var i ListStaleOpenGitHubPRsRow
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
}