Skip to content

Commit c69dc82

Browse files
Add repository description caching (#35)
- Add GetRepository() method to fetch individual repositories - Add application-level repoCache map to Client struct - Cache repositories as we encounter them from starred/owned repos - Add CacheRepository() method for pre-populating cache - Add ClearRepoCache() method for clearing cache - Add comprehensive tests for caching behavior - Prevents redundant API calls for repository descriptions Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 7ce97f7 commit c69dc82

File tree

2 files changed

+316
-0
lines changed

2 files changed

+316
-0
lines changed

github/client.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ type Client struct {
4141
httpClient *http.Client
4242
logger *slog.Logger
4343
cache map[string]*cacheEntry
44+
repoCache map[string]*Repository // Application-level repo cache (key: "owner/repo")
4445
rateLimit *RateLimit
4546
baseURL string
4647
token string
4748
cacheMu sync.RWMutex
49+
repoCacheMu sync.RWMutex
4850
rateLimitMu sync.RWMutex
4951
}
5052

@@ -123,6 +125,7 @@ func NewClient(token string, opts ...Option) *Client {
123125
token: token,
124126
logger: slog.Default(),
125127
cache: make(map[string]*cacheEntry),
128+
repoCache: make(map[string]*Repository),
126129
}
127130
for _, opt := range opts {
128131
opt(c)
@@ -150,6 +153,13 @@ func (c *Client) ClearCache() {
150153
c.cache = make(map[string]*cacheEntry)
151154
}
152155

156+
// ClearRepoCache clears the repository cache.
157+
func (c *Client) ClearRepoCache() {
158+
c.repoCacheMu.Lock()
159+
defer c.repoCacheMu.Unlock()
160+
c.repoCache = make(map[string]*Repository)
161+
}
162+
153163
func (c *Client) get(ctx context.Context, path string, result any) error {
154164
url := c.baseURL + path
155165

@@ -360,43 +370,63 @@ func (c *Client) GetFollowedUsersByUsername(ctx context.Context, username string
360370

361371
// GetStarredRepos returns repositories starred by the authenticated user.
362372
// This method automatically handles pagination to fetch all starred repos.
373+
// Repositories are cached in memory to avoid redundant API calls.
363374
func (c *Client) GetStarredRepos(ctx context.Context) ([]Repository, error) {
364375
var repos []Repository
365376
if err := c.getPaginated(ctx, "/user/starred", &repos); err != nil {
366377
return nil, fmt.Errorf("fetching starred repos: %w", err)
367378
}
379+
// Cache all fetched repositories
380+
for i := range repos {
381+
c.CacheRepository(&repos[i])
382+
}
368383
return repos, nil
369384
}
370385

371386
// GetStarredReposByUsername returns repositories starred by a specific user.
372387
// This method automatically handles pagination to fetch all starred repos.
388+
// Repositories are cached in memory to avoid redundant API calls.
373389
func (c *Client) GetStarredReposByUsername(ctx context.Context, username string) ([]Repository, error) {
374390
var repos []Repository
375391
path := fmt.Sprintf("/users/%s/starred", username)
376392
if err := c.getPaginated(ctx, path, &repos); err != nil {
377393
return nil, fmt.Errorf("fetching repos starred by %s: %w", username, err)
378394
}
395+
// Cache all fetched repositories
396+
for i := range repos {
397+
c.CacheRepository(&repos[i])
398+
}
379399
return repos, nil
380400
}
381401

382402
// GetOwnedRepos returns repositories owned by the authenticated user.
383403
// This method automatically handles pagination to fetch all owned repos.
404+
// Repositories are cached in memory to avoid redundant API calls.
384405
func (c *Client) GetOwnedRepos(ctx context.Context) ([]Repository, error) {
385406
var repos []Repository
386407
if err := c.getPaginated(ctx, "/user/repos?type=owner", &repos); err != nil {
387408
return nil, fmt.Errorf("fetching owned repos: %w", err)
388409
}
410+
// Cache all fetched repositories
411+
for i := range repos {
412+
c.CacheRepository(&repos[i])
413+
}
389414
return repos, nil
390415
}
391416

392417
// GetOwnedReposByUsername returns repositories owned by a specific user.
393418
// This method automatically handles pagination to fetch all owned repos.
419+
// Repositories are cached in memory to avoid redundant API calls.
394420
func (c *Client) GetOwnedReposByUsername(ctx context.Context, username string) ([]Repository, error) {
395421
var repos []Repository
396422
path := fmt.Sprintf("/users/%s/repos?type=owner", username)
397423
if err := c.getPaginated(ctx, path, &repos); err != nil {
398424
return nil, fmt.Errorf("fetching repos owned by %s: %w", username, err)
399425
}
426+
// Cache all fetched repositories
427+
for i := range repos {
428+
c.CacheRepository(&repos[i])
429+
}
400430
return repos, nil
401431
}
402432

@@ -421,3 +451,62 @@ func (c *Client) GetReceivedEvents(ctx context.Context, username string) ([]Even
421451
}
422452
return events, nil
423453
}
454+
455+
// GetRepository fetches a single repository by owner and name.
456+
// Results are cached in memory to avoid redundant API calls.
457+
func (c *Client) GetRepository(ctx context.Context, owner, name string) (*Repository, error) {
458+
cacheKey := fmt.Sprintf("%s/%s", owner, name)
459+
460+
// Check cache first
461+
c.repoCacheMu.RLock()
462+
if cached, ok := c.repoCache[cacheKey]; ok {
463+
c.repoCacheMu.RUnlock()
464+
c.logger.Debug("using cached repository",
465+
"owner", owner,
466+
"name", name,
467+
)
468+
return cached, nil
469+
}
470+
c.repoCacheMu.RUnlock()
471+
472+
// Fetch from API
473+
var repo Repository
474+
path := fmt.Sprintf("/repos/%s/%s", owner, name)
475+
if err := c.get(ctx, path, &repo); err != nil {
476+
return nil, fmt.Errorf("fetching repository %s/%s: %w", owner, name, err)
477+
}
478+
479+
// Store in cache
480+
c.repoCacheMu.Lock()
481+
c.repoCache[cacheKey] = &repo
482+
c.repoCacheMu.Unlock()
483+
484+
c.logger.Debug("cached repository",
485+
"owner", owner,
486+
"name", name,
487+
)
488+
489+
return &repo, nil
490+
}
491+
492+
// CacheRepository stores a repository in the cache.
493+
// This is useful for pre-populating the cache with repositories
494+
// we've already fetched from other API endpoints (e.g., starred repos).
495+
func (c *Client) CacheRepository(repo *Repository) {
496+
if repo == nil {
497+
return
498+
}
499+
500+
cacheKey := repo.FullName
501+
502+
c.repoCacheMu.Lock()
503+
defer c.repoCacheMu.Unlock()
504+
505+
// Only cache if not already present
506+
if _, exists := c.repoCache[cacheKey]; !exists {
507+
c.repoCache[cacheKey] = repo
508+
c.logger.Debug("pre-cached repository",
509+
"repo", cacheKey,
510+
)
511+
}
512+
}

github/client_test.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,3 +688,230 @@ func TestGetStarredReposPagination(t *testing.T) {
688688
t.Errorf("expected 2 requests, got %d", requestCount)
689689
}
690690
}
691+
692+
func TestGetRepository(t *testing.T) {
693+
repo := Repository{
694+
ID: 123,
695+
Name: "test-repo",
696+
FullName: "owner/test-repo",
697+
Description: "A test repository",
698+
Language: "Go",
699+
StarCount: 42,
700+
Owner: User{
701+
Login: "owner",
702+
ID: 1,
703+
},
704+
}
705+
706+
requestCount := 0
707+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
708+
requestCount++
709+
if r.URL.Path != "/repos/owner/test-repo" {
710+
t.Errorf("unexpected path: %s", r.URL.Path)
711+
}
712+
w.Header().Set("Content-Type", "application/json")
713+
if err := json.NewEncoder(w).Encode(repo); err != nil {
714+
t.Fatalf("encoding response: %v", err)
715+
}
716+
}))
717+
defer server.Close()
718+
719+
c := NewClient("test-token", WithBaseURL(server.URL))
720+
result, err := c.GetRepository(context.Background(), "owner", "test-repo")
721+
if err != nil {
722+
t.Fatalf("GetRepository() error: %v", err)
723+
}
724+
725+
if result.Name != "test-repo" {
726+
t.Errorf("expected name 'test-repo', got %q", result.Name)
727+
}
728+
if result.Description != "A test repository" {
729+
t.Errorf("expected description 'A test repository', got %q", result.Description)
730+
}
731+
if result.StarCount != 42 {
732+
t.Errorf("expected star count 42, got %d", result.StarCount)
733+
}
734+
if requestCount != 1 {
735+
t.Errorf("expected 1 request, got %d", requestCount)
736+
}
737+
}
738+
739+
func TestGetRepositoryCaching(t *testing.T) {
740+
repo := Repository{
741+
ID: 123,
742+
Name: "cached-repo",
743+
FullName: "owner/cached-repo",
744+
Description: "Cached repository",
745+
Owner: User{
746+
Login: "owner",
747+
ID: 1,
748+
},
749+
}
750+
751+
requestCount := 0
752+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
753+
requestCount++
754+
w.Header().Set("Content-Type", "application/json")
755+
if err := json.NewEncoder(w).Encode(repo); err != nil {
756+
t.Fatalf("encoding response: %v", err)
757+
}
758+
}))
759+
defer server.Close()
760+
761+
c := NewClient("test-token", WithBaseURL(server.URL))
762+
763+
// First request - should hit API
764+
result1, err := c.GetRepository(context.Background(), "owner", "cached-repo")
765+
if err != nil {
766+
t.Fatalf("first GetRepository() error: %v", err)
767+
}
768+
if result1.Description != "Cached repository" {
769+
t.Errorf("expected description 'Cached repository', got %q", result1.Description)
770+
}
771+
if requestCount != 1 {
772+
t.Errorf("expected 1 request after first call, got %d", requestCount)
773+
}
774+
775+
// Second request - should use cache
776+
result2, err := c.GetRepository(context.Background(), "owner", "cached-repo")
777+
if err != nil {
778+
t.Fatalf("second GetRepository() error: %v", err)
779+
}
780+
if result2.Description != "Cached repository" {
781+
t.Errorf("expected cached description 'Cached repository', got %q", result2.Description)
782+
}
783+
if requestCount != 1 {
784+
t.Errorf("expected still 1 request after second call (cache hit), got %d", requestCount)
785+
}
786+
}
787+
788+
func TestCacheRepository(t *testing.T) {
789+
repo := &Repository{
790+
ID: 456,
791+
Name: "pre-cached-repo",
792+
FullName: "owner/pre-cached-repo",
793+
Description: "Pre-cached repository",
794+
Owner: User{
795+
Login: "owner",
796+
ID: 1,
797+
},
798+
}
799+
800+
requestCount := 0
801+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
802+
requestCount++
803+
t.Error("should not hit API for pre-cached repo")
804+
}))
805+
defer server.Close()
806+
807+
c := NewClient("test-token", WithBaseURL(server.URL))
808+
809+
// Pre-cache the repository
810+
c.CacheRepository(repo)
811+
812+
// Request should use cache and not hit API
813+
result, err := c.GetRepository(context.Background(), "owner", "pre-cached-repo")
814+
if err != nil {
815+
t.Fatalf("GetRepository() error: %v", err)
816+
}
817+
if result.Description != "Pre-cached repository" {
818+
t.Errorf("expected cached description 'Pre-cached repository', got %q", result.Description)
819+
}
820+
if requestCount != 0 {
821+
t.Errorf("expected 0 requests (cache hit), got %d", requestCount)
822+
}
823+
}
824+
825+
func TestClearRepoCache(t *testing.T) {
826+
repo := Repository{
827+
ID: 789,
828+
Name: "clear-test-repo",
829+
FullName: "owner/clear-test-repo",
830+
Description: "Test clear cache",
831+
Owner: User{
832+
Login: "owner",
833+
ID: 1,
834+
},
835+
}
836+
837+
requestCount := 0
838+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
839+
requestCount++
840+
w.Header().Set("Content-Type", "application/json")
841+
if err := json.NewEncoder(w).Encode(repo); err != nil {
842+
t.Fatalf("encoding response: %v", err)
843+
}
844+
}))
845+
defer server.Close()
846+
847+
c := NewClient("test-token", WithBaseURL(server.URL))
848+
849+
// First request
850+
_, _ = c.GetRepository(context.Background(), "owner", "clear-test-repo")
851+
// Second request (should use cache)
852+
_, _ = c.GetRepository(context.Background(), "owner", "clear-test-repo")
853+
854+
if requestCount != 1 {
855+
t.Errorf("expected 1 request before clear, got %d", requestCount)
856+
}
857+
858+
// Clear repo cache
859+
c.ClearRepoCache()
860+
861+
// Third request (should not use cache)
862+
_, _ = c.GetRepository(context.Background(), "owner", "clear-test-repo")
863+
864+
if requestCount != 2 {
865+
t.Errorf("expected 2 requests after clear, got %d", requestCount)
866+
}
867+
}
868+
869+
func TestGetStarredReposPopulatesCache(t *testing.T) {
870+
repos := []Repository{
871+
{ID: 1, Name: "repo1", FullName: "owner/repo1", Description: "First repo"},
872+
{ID: 2, Name: "repo2", FullName: "owner/repo2", Description: "Second repo"},
873+
}
874+
875+
starredRequestCount := 0
876+
repoRequestCount := 0
877+
878+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
879+
if r.URL.Path == "/user/starred" {
880+
starredRequestCount++
881+
w.Header().Set("Content-Type", "application/json")
882+
if err := json.NewEncoder(w).Encode(repos); err != nil {
883+
t.Fatalf("encoding response: %v", err)
884+
}
885+
} else if strings.HasPrefix(r.URL.Path, "/repos/") {
886+
repoRequestCount++
887+
t.Error("should not request individual repo when already cached from starred")
888+
}
889+
}))
890+
defer server.Close()
891+
892+
c := NewClient("test-token", WithBaseURL(server.URL))
893+
894+
// Fetch starred repos (should populate cache)
895+
_, err := c.GetStarredRepos(context.Background())
896+
if err != nil {
897+
t.Fatalf("GetStarredRepos() error: %v", err)
898+
}
899+
900+
if starredRequestCount != 1 {
901+
t.Errorf("expected 1 starred request, got %d", starredRequestCount)
902+
}
903+
904+
// Now try to get one of those repos individually - should use cache
905+
result, err := c.GetRepository(context.Background(), "owner", "repo1")
906+
if err != nil {
907+
t.Fatalf("GetRepository() error: %v", err)
908+
}
909+
910+
if result.Description != "First repo" {
911+
t.Errorf("expected description 'First repo', got %q", result.Description)
912+
}
913+
914+
if repoRequestCount != 0 {
915+
t.Errorf("expected 0 individual repo requests (should use cache), got %d", repoRequestCount)
916+
}
917+
}

0 commit comments

Comments
 (0)