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

206 lines
6.2 KiB
Go

package ghsnapshot
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func newTestClient(t *testing.T, apiBase string) *Client {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
return &Client{
appID: "123",
privateKey: key,
apiBase: apiBase,
httpClient: &http.Client{Timeout: 5 * time.Second},
now: time.Now,
tokens: map[int64]cachedToken{},
}
}
// TestInstallationTokenCaches proves the token is minted once and reused within
// the renew skew — the cache the whole pipeline relies on to stay under
// GitHub's App-JWT budget.
func TestInstallationTokenCaches(t *testing.T) {
var mints int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/access_tokens") {
atomic.AddInt32(&mints, 1)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"token":"ghs_secret","expires_at":"` +
time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + `"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
ctx := context.Background()
for i := 0; i < 3; i++ {
tok, err := c.installationToken(ctx, 42)
if err != nil {
t.Fatalf("installationToken: %v", err)
}
if tok != "ghs_secret" {
t.Fatalf("token = %q", tok)
}
}
if got := atomic.LoadInt32(&mints); got != 1 {
t.Fatalf("minted %d times, want 1 (cache miss)", got)
}
}
// TestInstallationTokenSingleflight proves concurrent callers for the same
// installation on a cold cache collapse into a single mint (Elon review nit):
// the N workers of one installation must not each hit the token endpoint.
func TestInstallationTokenSingleflight(t *testing.T) {
var mints int32
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/access_tokens") {
atomic.AddInt32(&mints, 1)
// Hold the first mint open so concurrent callers pile into the
// in-flight singleflight rather than serializing behind the cache.
<-release
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"token":"ghs_secret","expires_at":"` +
time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + `"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
const n = 16
var wg sync.WaitGroup
toks := make([]string, n)
errs := make([]error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
toks[i], errs[i] = c.installationToken(context.Background(), 42)
}(i)
}
// Give every goroutine time to enter singleflight, then release the one mint.
time.Sleep(100 * time.Millisecond)
close(release)
wg.Wait()
if got := atomic.LoadInt32(&mints); got != 1 {
t.Fatalf("minted %d times under %d concurrent callers, want 1", got, n)
}
for i := 0; i < n; i++ {
if errs[i] != nil || toks[i] != "ghs_secret" {
t.Fatalf("caller %d: token=%q err=%v", i, toks[i], errs[i])
}
}
}
// TestGraphQLRateLimited maps a 403 with Retry-After to a *RateLimitError so the
// refresh manager can back off (acceptance criterion 3).
func TestGraphQLRateLimited(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/access_tokens") {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"token":"ghs_secret","expires_at":"` +
time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + `"}`))
return
}
w.Header().Set("Retry-After", "42")
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
c := newTestClient(t, srv.URL)
_, err := c.graphQL(context.Background(), 1, "query{}", nil)
rl, ok := err.(*RateLimitError)
if !ok {
t.Fatalf("err = %T (%v), want *RateLimitError", err, err)
}
if rl.RetryAfter != 42*time.Second {
t.Fatalf("RetryAfter = %s, want 42s", rl.RetryAfter)
}
}
func TestRateLimitFromResponse(t *testing.T) {
now := time.Unix(1000, 0)
cases := []struct {
name string
headers map[string]string
want time.Duration
}{
{"retry-after wins", map[string]string{"Retry-After": "30", "X-RateLimit-Reset": "5000"}, 30 * time.Second},
{"reset fallback", map[string]string{"X-RateLimit-Reset": "1090"}, 90 * time.Second},
{"default", map[string]string{}, time.Minute},
{"clamped to 5m", map[string]string{"Retry-After": "99999"}, 5 * time.Minute},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
for k, v := range tc.headers {
resp.Header.Set(k, v)
}
if got := rateLimitFromResponse(resp, now).RetryAfter; got != tc.want {
t.Fatalf("wait = %s, want %s", got, tc.want)
}
})
}
}
// TestNewClientFromEnv covers the three configuration outcomes, including the
// clean-degradation case (acceptance criterion 4): no key → nil client, no error.
func TestNewClientFromEnv(t *testing.T) {
t.Run("unconfigured yields nil client no error", func(t *testing.T) {
t.Setenv("GITHUB_APP_ID", "")
t.Setenv("GITHUB_APP_PRIVATE_KEY", "")
c, err := NewClientFromEnv()
if err != nil || c != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", c, err)
}
if c.Enabled() {
t.Fatal("nil client must report disabled")
}
})
t.Run("malformed key is an error", func(t *testing.T) {
t.Setenv("GITHUB_APP_ID", "1")
t.Setenv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\nnope\n-----END RSA PRIVATE KEY-----")
c, err := NewClientFromEnv()
if err == nil {
t.Fatal("want error for malformed key")
}
// The error must not echo the key material.
if strings.Contains(err.Error(), "nope") {
t.Fatal("error leaked key material")
}
if c != nil {
t.Fatal("want nil client on error")
}
})
t.Run("valid key enables the client", func(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
t.Setenv("GITHUB_APP_ID", "1")
t.Setenv("GITHUB_APP_PRIVATE_KEY", string(pemBytes))
c, err := NewClientFromEnv()
if err != nil || !c.Enabled() {
t.Fatalf("got (%v, %v), want enabled client", c, err)
}
})
}