@@ -6,17 +6,21 @@ import (
66"log/slog"
77"strings"
88"sync"
9+ "sync/atomic"
910"time"
1011
12+ "github.com/muesli/cache2go"
1113"github.com/shurcooL/githubv4"
1214)
1315
16+ var cacheNameCounter atomic.Uint64
17+
1418// RepoAccessCache caches repository metadata related to lockdown checks so that
1519// multiple tools can reuse the same access information safely across goroutines.
1620type RepoAccessCache struct {
1721client * githubv4.Client
1822mu sync.Mutex
19- cache map [ string ] * repoAccessCacheEntry
23+ cache * cache2go. CacheTable
2024ttl time.Duration
2125logger * slog.Logger
2226}
@@ -25,7 +29,6 @@ type repoAccessCacheEntry struct {
2529isPrivate bool
2630knownUsers map [string ]bool // normalized login -> has push access
2731ready bool
28- timer * time.Timer
2932}
3033
3134const defaultRepoAccessTTL = 5 * time .Minute
@@ -51,9 +54,11 @@ func WithLogger(logger *slog.Logger) RepoAccessOption {
5154// NewRepoAccessCache returns a cache bound to the provided GitHub GraphQL
5255// client. The cache is safe for concurrent use.
5356func NewRepoAccessCache (client * githubv4.Client ,opts ... RepoAccessOption )* RepoAccessCache {
57+ // Use a unique cache name for each instance to avoid sharing state between tests
58+ cacheName := fmt .Sprintf ("repoAccess-%d" ,cacheNameCounter .Add (1 ))
5459c := & RepoAccessCache {
5560client :client ,
56- cache :make ( map [ string ] * repoAccessCacheEntry ),
61+ cache :cache2go . Cache ( cacheName ),
5762ttl :defaultRepoAccessTTL ,
5863}
5964for _ ,opt := range opts {
@@ -72,8 +77,19 @@ func (c *RepoAccessCache) SetTTL(ttl time.Duration) {
7277defer c .mu .Unlock ()
7378c .ttl = ttl
7479c .logInfo ("repo access cache TTL updated" ,"ttl" ,ttl )
75- for key ,entry := range c .cache {
76- entry .scheduleExpiry (c ,key )
80+
81+ // Collect all current entries
82+ entries := make (map [interface {}]* repoAccessCacheEntry )
83+ c .cache .Foreach (func (key interface {},item * cache2go.CacheItem ) {
84+ entries [key ]= item .Data ().(* repoAccessCacheEntry )
85+ })
86+
87+ // Flush the cache
88+ c .cache .Flush ()
89+
90+ // Re-add all entries with the new TTL
91+ for key ,entry := range entries {
92+ c .cache .Add (key ,ttl ,entry )
7793}
7894}
7995
@@ -103,69 +119,46 @@ func (c *RepoAccessCache) GetRepoAccessInfo(ctx context.Context, username, owner
103119userKey := strings .ToLower (username )
104120c .mu .Lock ()
105121defer c .mu .Unlock ()
106- entry := c .ensureEntry (key )
107- if entry .ready {
108- if cachedHasPush ,known := entry .knownUsers [userKey ];known {
109- entry .scheduleExpiry (c ,key )
110- c .logDebug ("repo access cache hit" ,"owner" ,owner ,"repo" ,repo ,"user" ,username )
111- cachedPrivate := entry .isPrivate
112- return cachedPrivate ,cachedHasPush ,nil
122+
123+ // Try to get entry from cache - this will keep the item alive if it exists
124+ cacheItem ,err := c .cache .Value (key )
125+ if err == nil {
126+ entry := cacheItem .Data ().(* repoAccessCacheEntry )
127+ if entry .ready {
128+ if cachedHasPush ,known := entry .knownUsers [userKey ];known {
129+ c .logDebug ("repo access cache hit" ,"owner" ,owner ,"repo" ,repo ,"user" ,username )
130+ return entry .isPrivate ,cachedHasPush ,nil
131+ }
113132}
133+ // Entry exists but user not in knownUsers, need to query
114134}
115135c .logDebug ("repo access cache miss" ,"owner" ,owner ,"repo" ,repo ,"user" ,username )
116136
117- isPrivate ,hasPush ,err := c .queryRepoAccessInfo (ctx ,username ,owner ,repo )
118- if err != nil {
119- return false ,false ,err
137+ isPrivate ,hasPush ,queryErr := c .queryRepoAccessInfo (ctx ,username ,owner ,repo )
138+ if queryErr != nil {
139+ return false ,false ,queryErr
120140}
121141
122- entry = c .ensureEntry (key )
123- entry .ready = true
124- entry .isPrivate = isPrivate
125- entry .knownUsers [userKey ]= hasPush
126- entry .scheduleExpiry (c ,key )
127-
128- return isPrivate ,hasPush ,nil
129- }
130-
131- func (c * RepoAccessCache )ensureEntry (key string )* repoAccessCacheEntry {
132- if c .cache == nil {
133- c .cache = make (map [string ]* repoAccessCacheEntry )
134- }
135- entry ,ok := c .cache [key ]
136- if ! ok {
142+ // Get or create entry - don't use Value() here to avoid keeping alive unnecessarily
143+ var entry * repoAccessCacheEntry
144+ if err == nil && cacheItem != nil {
145+ // Entry already existed, just update it
146+ entry = cacheItem .Data ().(* repoAccessCacheEntry )
147+ }else {
148+ // Create new entry
137149entry = & repoAccessCacheEntry {
138150knownUsers :make (map [string ]bool ),
139151}
140- c .cache [key ]= entry
141152}
142- return entry
143- }
144-
145- func (entry * repoAccessCacheEntry )scheduleExpiry (c * RepoAccessCache ,key string ) {
146- if entry .timer != nil {
147- entry .timer .Stop ()
148- entry .timer = nil
149- }
150-
151- dur := c .ttl
152- if dur <= 0 {
153- return
154- }
155-
156- owner ,repo := splitKey (key )
157- entry .timer = time .AfterFunc (dur ,func () {
158- c .mu .Lock ()
159- defer c .mu .Unlock ()
160-
161- current ,ok := c .cache [key ]
162- if ! ok || current != entry {
163- return
164- }
153+
154+ entry .ready = true
155+ entry .isPrivate = isPrivate
156+ entry .knownUsers [userKey ]= hasPush
157+
158+ // Add or update the entry in cache with TTL
159+ c .cache .Add (key ,c .ttl ,entry )
165160
166- delete (c .cache ,key )
167- c .logDebug ("repo access cache entry evicted" ,"owner" ,owner ,"repo" ,repo )
168- })
161+ return isPrivate ,hasPush ,nil
169162}
170163
171164func (c * RepoAccessCache )queryRepoAccessInfo (ctx context.Context ,username ,owner ,repo string ) (bool ,bool ,error ) {