mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
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:
292
server/pkg/db/generated/github_snapshot.sql.go
Normal file
292
server/pkg/db/generated/github_snapshot.sql.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user