Files
multica/server/internal/integrations/ghsnapshot/refresh_test.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

215 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ghsnapshot
import (
"context"
"crypto/rand"
"crypto/rsa"
"errors"
"testing"
"time"
)
func enabledClient(t *testing.T) *Client {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
return &Client{appID: "1", privateKey: key, tokens: map[int64]cachedToken{}, now: time.Now}
}
// TestManagerDisabledNoOps is the clean-degradation guarantee (acceptance
// criterion 4): with no App key the manager touches nothing.
func TestManagerDisabledNoOps(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
if m.Enabled() {
t.Fatal("nil-client manager must be disabled")
}
m.Enqueue(1, "o", "r", 2)
m.MaybeEnqueueOnView(1, "o", "r", 2, time.Time{}, false)
if len(m.queue) != 0 {
t.Fatalf("disabled manager enqueued %d items, want 0", len(m.queue))
}
// Start must be a safe no-op (no workers, no panic).
m.Start(context.Background())
}
// TestEnqueueCoalesces proves the dedup / single-in-flight key (acceptance
// criterion 3): the same PR address enqueued repeatedly is coalesced to one
// pending item; distinct addresses are not.
func TestEnqueueCoalesces(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
// Workers are NOT started, so items accumulate in the queue for inspection.
m.Enqueue(1, "o", "r", 7)
m.Enqueue(1, "o", "r", 7)
m.Enqueue(1, "o", "r", 7)
if len(m.queue) != 1 {
t.Fatalf("same address enqueued 3× produced %d queued items, want 1", len(m.queue))
}
m.Enqueue(1, "o", "r", 8) // different PR
m.Enqueue(1, "o", "other", 7)
if len(m.queue) != 3 {
t.Fatalf("queue length = %d, want 3 distinct", len(m.queue))
}
}
// TestMaybeEnqueueOnViewRespectsTTL: a fresh snapshot is not refreshed on view;
// a stale or missing one is.
func TestMaybeEnqueueOnViewRespectsTTL(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
now := time.Unix(10000, 0)
m.now = func() time.Time { return now }
m.MaybeEnqueueOnView(1, "o", "r", 1, now.Add(-10*time.Second), true) // fresh (<60s)
if len(m.queue) != 0 {
t.Fatal("fresh snapshot should not refresh on view")
}
m.MaybeEnqueueOnView(1, "o", "r", 2, now.Add(-5*time.Minute), true) // stale
m.MaybeEnqueueOnView(1, "o", "r", 3, time.Time{}, false) // never fetched
if len(m.queue) != 2 {
t.Fatalf("stale/missing snapshots enqueued %d, want 2", len(m.queue))
}
}
// TestProcessRateLimitedSetsPause proves a rate-limited fetch records a pause
// only for that installation (acceptance criterion 3) and writes nothing.
func TestProcessRateLimitedSetsPause(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
now := time.Unix(20000, 0)
m.now = func() time.Time { return now }
m.jitter = func() time.Duration { return 0 }
// A cancelled ctx keeps the rescheduled retry from lingering after the test.
ctx, cancel := context.WithCancel(context.Background())
cancel()
m.ctx = ctx
m.fetch = func(context.Context, *Client, int64, string, string, int32) (*PRSnapshot, error) {
return nil, &RateLimitError{RetryAfter: 90 * time.Second}
}
// queries/pool are nil; the fetch errors before any DB access, proving the
// rate-limit path never touches storage.
m.process(ctx, address{InstallationID: 1, Owner: "o", Repo: "r", Number: 1})
if got := m.rateUntil[1]; !got.Equal(now.Add(90 * time.Second)) {
t.Fatalf("rateUntil = %v, want %v", got, now.Add(90*time.Second))
}
}
func TestRateLimitedInstallationDoesNotOccupyWorkers(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
m.concurrency = 12
m.sweepInterval = time.Hour
m.jitter = func() time.Duration { return 0 }
m.extendRateLimit(1, 2*time.Second)
limitedFetched := make(chan struct{}, 1)
otherFetched := make(chan struct{}, 1)
m.fetch = func(_ context.Context, _ *Client, installationID int64, _ string, _ string, _ int32) (*PRSnapshot, error) {
switch installationID {
case 1:
limitedFetched <- struct{}{}
case 2:
otherFetched <- struct{}{}
}
return nil, errors.New("stop after fetch")
}
// Fill an entire worker pool with addresses from the paused installation,
// then queue an unrelated tenant behind them.
for number := int32(1); number <= int32(m.concurrency); number++ {
m.Enqueue(1, "o", "r", number)
}
m.Enqueue(2, "o", "r", 1)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
m.Start(ctx)
select {
case <-otherFetched:
case <-time.After(500 * time.Millisecond):
t.Fatal("rate-limited installation occupied the global worker pool")
}
select {
case <-limitedFetched:
t.Fatal("paused installation fetched before Retry-After")
default:
}
}
func TestRateLimitDeadlineNeverShortens(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
now := time.Unix(21000, 0)
m.now = func() time.Time { return now }
m.extendRateLimit(1, 90*time.Second)
m.extendRateLimit(1, 30*time.Second)
if got := m.rateUntil[1]; !got.Equal(now.Add(90 * time.Second)) {
t.Fatalf("shorter Retry-After replaced deadline: %v", got)
}
m.extendRateLimit(1, 2*time.Minute)
if got := m.rateUntil[1]; !got.Equal(now.Add(2 * time.Minute)) {
t.Fatalf("later Retry-After was not retained: %v", got)
}
}
func TestRateLimitIsolatedByInstallation(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
now := time.Unix(22000, 0)
m.now = func() time.Time { return now }
m.jitter = func() time.Duration { return 0 }
m.extendRateLimit(1, time.Hour)
called := false
m.fetch = func(context.Context, *Client, int64, string, string, int32) (*PRSnapshot, error) {
called = true
return nil, errors.New("stop after fetch")
}
m.process(context.Background(), address{InstallationID: 2, Owner: "o", Repo: "r", Number: 1})
if !called {
t.Fatal("installation 1 rate limit blocked installation 2")
}
if pause := m.rateLimitPause(2); pause > 0 {
t.Fatalf("installation 2 unexpectedly paused for %v", pause)
}
}
func TestPersistentRateLimitReturnsToTTLSweep(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
m.jitter = func() time.Duration { return 0 }
m.ctx = context.Background()
m.fetch = func(context.Context, *Client, int64, string, string, int32) (*PRSnapshot, error) {
return nil, &RateLimitError{RetryAfter: time.Millisecond}
}
m.process(context.Background(), address{InstallationID: 1, Owner: "o", Repo: "r", Number: 1})
// The old implementation scheduled an unbounded direct retry here. The
// manager now records Retry-After and returns ownership to the bounded
// open/draft TTL sweep or a later external event.
time.Sleep(10 * time.Millisecond)
if len(m.queue) != 0 {
t.Fatalf("rate-limited fetch scheduled %d direct retries, want 0", len(m.queue))
}
}
// TestScheduleChaseBounded proves the chase window terminates (acceptance
// criterion 7): after maxChaseAttempts the address stops being rescheduled.
func TestScheduleChaseBounded(t *testing.T) {
m := NewManager(enabledClient(t), nil, nil, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
m.ctx = ctx
addr := address{InstallationID: 1, Owner: "o", Repo: "r", Number: 1}
for i := 0; i < maxChaseAttempts; i++ {
m.scheduleChase(addr)
if m.attempts[addr] != i+1 {
t.Fatalf("attempt %d recorded as %d", i+1, m.attempts[addr])
}
}
// One more chase past the cap clears tracking and does not reschedule.
m.scheduleChase(addr)
if _, ok := m.attempts[addr]; ok {
t.Fatal("chase past the cap must stop and clear attempts")
}
}