mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +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> * 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>
215 lines
7.2 KiB
Go
215 lines
7.2 KiB
Go
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")
|
||
}
|
||
}
|