Files
kandev/apps/backend/internal/github/branch_protection.go
T

142 lines
4.6 KiB
Go

package github
import (
"context"
"sync"
"time"
)
// BranchProtection captures the subset of a GitHub branch protection rule that
// the CI popover surfaces ("N approvals required to merge"). Stored values
// have a fixed TTL so changes to the protection rule on GitHub propagate
// without restarting the backend.
type BranchProtection struct {
RequiredApprovingReviewCount int
HasRule bool // false when GitHub returned 404 (no rule)
FetchedAt time.Time
}
// branchProtectionCacheTTL is how long a cached protection lookup stays
// authoritative before a refetch is allowed. One hour balances API quota
// (4-PRs-per-cycle GraphQL polling) against rule-change latency (rare).
const branchProtectionCacheTTL = time.Hour
// branchProtectionCache is an in-memory TTL cache keyed by
// "owner/repo@base_branch". Concurrent reads share the RLock; concurrent
// cache misses for the same key may each issue their own GitHub fetch and
// race to set() the result — values are deterministic so the loser's
// write is harmless, and the extra round-trips are bounded by the 1h TTL.
// Stale entries are evicted on read so the map cannot grow unbounded over
// a long-running process: callers always observe an active TTL window.
type branchProtectionCache struct {
mu sync.RWMutex
m map[string]BranchProtection
ttl time.Duration
}
func newBranchProtectionCache() *branchProtectionCache {
return &branchProtectionCache{
m: make(map[string]BranchProtection),
ttl: branchProtectionCacheTTL,
}
}
func (c *branchProtectionCache) get(key string) (BranchProtection, bool) {
c.mu.RLock()
bp, ok := c.m[key]
c.mu.RUnlock()
if !ok {
return BranchProtection{}, false
}
if time.Since(bp.FetchedAt) <= c.ttl {
return bp, true
}
// Evict the stale entry so the map doesn't grow unbounded across
// long-running deployments with many distinct (owner, repo, branch)
// keys. Recheck under the write lock so a concurrent set() that just
// landed isn't clobbered.
c.mu.Lock()
if cur, present := c.m[key]; present && time.Since(cur.FetchedAt) > c.ttl {
delete(c.m, key)
}
c.mu.Unlock()
return BranchProtection{}, false
}
func (c *branchProtectionCache) set(key string, bp BranchProtection) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key] = bp
}
func (c *branchProtectionCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.m = make(map[string]BranchProtection)
}
func branchProtectionKey(owner, repo, branch string) string {
return owner + "/" + repo + "@" + branch
}
// BranchProtectionFetcher is implemented by the live GitHub clients (PAT, gh
// CLI). The mock client returns a sentinel "no rule" so e2e tests get
// deterministic results unless they seed via the mock controller.
type BranchProtectionFetcher interface {
FetchBranchProtection(ctx context.Context, owner, repo, branch string) (BranchProtection, error)
}
// fetchRequiredReviews returns the branch-protection-derived
// RequiredApprovingReviewCount for (owner, repo, branch), or nil if:
// - the active client doesn't implement BranchProtectionFetcher,
// - the upstream call errors (token lacks scope, network down, etc.),
// - the repo has no protection rule on the base branch.
//
// Result is cached for branchProtectionCacheTTL. nil never gets cached so a
// transient error is retried on the next sync.
func (s *Service) fetchRequiredReviews(ctx context.Context, owner, repo, branch string) *int {
return s.fetchRequiredReviewsWithClient(ctx, s.client, "legacy", owner, repo, branch)
}
func (s *Service) fetchRequiredReviewsForWorkspace(
ctx context.Context, workspaceID, owner, repo, branch string,
) *int {
resolved, err := s.resolveAutomationClient(ctx, workspaceID, owner, repo)
if err != nil {
return nil
}
return s.fetchRequiredReviewsWithClient(ctx, resolved.Client, resolved.CacheScope, owner, repo, branch)
}
func (s *Service) fetchRequiredReviewsWithClient(
ctx context.Context, client Client, cacheScope, owner, repo, branch string,
) *int {
if owner == "" || repo == "" || branch == "" {
return nil
}
key := scopedCacheKey(cacheScope, branchProtectionKey(owner, repo, branch))
if bp, ok := s.protectionCache.get(key); ok {
if !bp.HasRule {
return nil
}
n := bp.RequiredApprovingReviewCount
return &n
}
fetcher, ok := client.(BranchProtectionFetcher)
if !ok {
return nil
}
bp, err := fetcher.FetchBranchProtection(ctx, owner, repo, branch)
if err != nil {
// Don't cache errors — next sync retries.
return nil
}
bp.FetchedAt = time.Now().UTC()
s.protectionCache.set(key, bp)
if !bp.HasRule {
return nil
}
n := bp.RequiredApprovingReviewCount
return &n
}