package main import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "time" ) type cacheEnvelope[T any] struct { Version int `json:"version,omitempty"` SavedAt time.Time `json:"saved_at"` ContentHash string `json:"content_hash,omitempty"` Value T `json:"value"` } type CachedGitHubService struct { remote GitHubService dir string maxAge time.Duration maxEntries int health healthTracker } type cachedSnapshotService interface { CachedPullRequests(string, string, int, bool) ([]PullRequest, error) CachedPullRequest(string, string, int) (PRDetails, error) } type liveGitHubService interface { LivePullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) LivePullRequest(context.Context, string, string, int) (PRDetails, error) } const cacheSchemaVersion = 1 func NewCachedGitHubService( remote GitHubService, dir string, maxAge time.Duration, configuredMaxEntries ...int, ) *CachedGitHubService { maxEntries := 200 if len(configuredMaxEntries) > 0 && configuredMaxEntries[0] > 0 { maxEntries = configuredMaxEntries[0] } return &CachedGitHubService{ remote: remote, dir: dir, maxAge: maxAge, maxEntries: maxEntries, } } func (c *CachedGitHubService) CachedPullRequests( owner, repo string, limit int, showAll bool, ) ([]PullRequest, error) { var cached cacheEnvelope[[]PullRequest] savedAt, err := c.read(c.pullRequestsKey(owner, repo, limit, showAll), &cached) if err != nil { return nil, err } cached.SavedAt = savedAt for i := range cached.Value { cached.Value[i].FromCache, cached.Value[i].CachedAt = true, cached.SavedAt } return cached.Value, nil } func (c *CachedGitHubService) CachedPullRequest(owner, repo string, number int) (PRDetails, error) { var cached cacheEnvelope[PRDetails] savedAt, err := c.read(c.pullRequestKey(owner, repo, number), &cached) if err != nil { return PRDetails{}, err } cached.SavedAt = savedAt cached.Value.FromCache, cached.Value.CachedAt = true, cached.SavedAt return cached.Value, nil } func (c *CachedGitHubService) ListPullRequests( ctx context.Context, owner, repo string, limit int, showAll bool, ) ([]PullRequest, error) { prs, err := c.LivePullRequests(ctx, owner, repo, limit, showAll) if err == nil { return prs, nil } cached, cacheErr := c.CachedPullRequests(owner, repo, limit, showAll) if cacheErr != nil { return nil, fmt.Errorf("%w (cache unavailable: %v)", err, cacheErr) } return cached, nil } func (c *CachedGitHubService) GetPullRequest( ctx context.Context, owner, repo string, number int, ) (PRDetails, error) { details, err := c.LivePullRequest(ctx, owner, repo, number) if err == nil { return details, nil } cached, cacheErr := c.CachedPullRequest(owner, repo, number) if cacheErr != nil { return PRDetails{}, fmt.Errorf("%w (cache unavailable: %v)", err, cacheErr) } return cached, nil } func (c *CachedGitHubService) LivePullRequests( ctx context.Context, owner, repo string, limit int, showAll bool, ) ([]PullRequest, error) { prs, err := c.remote.ListPullRequests(ctx, owner, repo, limit, showAll) if err != nil { return nil, err } for i := range prs { prs[i].FromCache, prs[i].CachedAt = false, time.Time{} } _ = c.write(c.pullRequestsKey(owner, repo, limit, showAll), prs) return prs, nil } func (c *CachedGitHubService) LivePullRequest( ctx context.Context, owner, repo string, number int, ) (PRDetails, error) { details, err := c.remote.GetPullRequest(ctx, owner, repo, number) if err != nil { return PRDetails{}, err } details.FromCache, details.CachedAt = false, time.Time{} _ = c.write(c.pullRequestKey(owner, repo, number), details) return details, nil } func (c *CachedGitHubService) SetThreadResolved( ctx context.Context, threadID string, resolved bool, ) (ReviewThread, error) { writer, ok := c.remote.(GitHubWriteService) if !ok { return ReviewThread{}, errors.New("GitHub service does not support write actions") } return writer.SetThreadResolved(ctx, threadID, resolved) } func (c *CachedGitHubService) ReplyToThread( ctx context.Context, threadID, body string, ) (ReviewComment, error) { writer, ok := c.remote.(GitHubWriteService) if !ok { return ReviewComment{}, errors.New("GitHub service does not support write actions") } return writer.ReplyToThread(ctx, threadID, body) } func (c *CachedGitHubService) UpdatePullRequest( ctx context.Context, pullRequestID string, update PullRequestMetadata, ) (PullRequestMetadata, error) { writer, ok := c.remote.(GitHubPullRequestWriteService) if !ok { return PullRequestMetadata{}, errors.New("GitHub service does not support pull request updates") } return writer.UpdatePullRequest(ctx, pullRequestID, update) } func (c *CachedGitHubService) SetPullRequestAutoMerge( ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool, ) (*AutoMergeRequest, error) { writer, ok := c.remote.(GitHubMergeService) if !ok { return nil, errors.New("GitHub service does not support auto-merge") } return writer.SetPullRequestAutoMerge( ctx, pullRequestID, expectedHeadOID, mergeMethod, enabled, ) } func (c *CachedGitHubService) MergePullRequest( ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, ) (PullRequestMergeResult, error) { writer, ok := c.remote.(GitHubMergeService) if !ok { return PullRequestMergeResult{}, errors.New("GitHub service does not support merging") } return writer.MergePullRequest(ctx, pullRequestID, expectedHeadOID, mergeMethod) } func (c *CachedGitHubService) ListBranches( ctx context.Context, owner, repo string, ) ([]RepositoryBranch, error) { service, ok := c.remote.(GitHubBranchService) if !ok { return nil, errors.New("GitHub service does not support listing branches") } branches, err := service.ListBranches(ctx, owner, repo) if err == nil { _ = c.write(c.branchesKey(owner, repo), branches) return branches, nil } var cached cacheEnvelope[[]RepositoryBranch] if _, cacheErr := c.read(c.branchesKey(owner, repo), &cached); cacheErr == nil { c.health.set(HealthComponent{ Name: "branch cache", Level: healthWarning, Summary: "using cached branches", Detail: err.Error(), UpdatedAt: time.Now(), }) return cached.Value, nil } return nil, err } func (c *CachedGitHubService) EnrichPullRequest( ctx context.Context, details PRDetails, ) PRDetailsEnrichment { service, ok := c.remote.(GitHubEnrichmentService) if !ok { return PRDetailsEnrichment{ Owner: details.Owner, Repository: details.Repository, Number: details.Number, HeadOID: details.HeadOID, Issues: []DataIssue{{ Component: "PR enrichment", Message: "GitHub service does not support secondary PR data", }}, } } return service.EnrichPullRequest(ctx, details) } func (c *CachedGitHubService) pullRequestsKey(owner, repo string, limit int, showAll bool) string { return fmt.Sprintf("prs:%s/%s:%d:%t", owner, repo, limit, showAll) } func (c *CachedGitHubService) pullRequestKey(owner, repo string, number int) string { return fmt.Sprintf("pr:%s/%s:%d", owner, repo, number) } func (c *CachedGitHubService) branchesKey(owner, repo string) string { return fmt.Sprintf("branches:%s/%s", owner, repo) } func (c *CachedGitHubService) file(key string) string { sum := sha256.Sum256([]byte(key)) return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json") } func (c *CachedGitHubService) write(key string, value any) (resultErr error) { defer func() { component := HealthComponent{ Name: "disk cache", Level: healthOK, Summary: "cache write succeeded", Detail: c.dir, UpdatedAt: time.Now(), } if resultErr != nil { component.Level = healthWarning component.Summary = resultErr.Error() } c.health.set(component) }() if err := os.MkdirAll(c.dir, 0o700); err != nil { return err } valueData, err := json.Marshal(value) if err != nil { return err } sum := sha256.Sum256(valueData) contentHash := hex.EncodeToString(sum[:]) target := c.file(key) if existing, err := os.ReadFile(target); err == nil { var metadata struct { ContentHash string `json:"content_hash"` } if json.Unmarshal(existing, &metadata) == nil && metadata.ContentHash == contentHash { if info, statErr := os.Stat(target); statErr == nil && time.Since(info.ModTime()) >= c.cacheTouchInterval() { now := time.Now() _ = os.Chtimes(target, now, now) } return nil } } data, err := json.Marshal(cacheEnvelope[any]{ Version: cacheSchemaVersion, SavedAt: time.Now(), ContentHash: contentHash, Value: value, }) if err != nil { return err } temp, err := os.CreateTemp(c.dir, ".cache-*") if err != nil { return err } name := temp.Name() defer os.Remove(name) if err := temp.Chmod(0o600); err != nil { temp.Close() return err } if _, err := temp.Write(data); err != nil { temp.Close() return err } if err := temp.Close(); err != nil { return err } if err := os.Rename(name, target); err != nil { return err } return c.prune() } func (c *CachedGitHubService) prune() error { if c.maxEntries <= 0 { return nil } entries, err := os.ReadDir(c.dir) if err != nil { return err } type cacheFile struct { path string modTime time.Time } var files []cacheFile for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } info, err := entry.Info() if err != nil { continue } files = append(files, cacheFile{ path: filepath.Join(c.dir, entry.Name()), modTime: info.ModTime(), }) } sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) }) for len(files) > c.maxEntries { if err := os.Remove(files[0].path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } files = files[1:] } return nil } func (c *CachedGitHubService) cacheTouchInterval() time.Duration { interval := 24 * time.Hour if c.maxAge > 0 && c.maxAge/2 < interval { interval = c.maxAge / 2 } return interval } func (c *CachedGitHubService) read(key string, target any) ( saved time.Time, resultErr error, ) { defer func() { component := HealthComponent{ Name: "disk cache", Level: healthOK, Summary: "cache read succeeded", Detail: c.dir, UpdatedAt: time.Now(), } if resultErr != nil { component.Level = healthWarning component.Summary = resultErr.Error() } c.health.set(component) }() path := c.file(key) data, err := os.ReadFile(path) if err != nil { return time.Time{}, err } if err := json.Unmarshal(data, target); err != nil { return time.Time{}, err } var metadata struct { Version int `json:"version"` SavedAt time.Time `json:"saved_at"` } if err := json.Unmarshal(data, &metadata); err != nil { return time.Time{}, err } if metadata.Version != 0 && metadata.Version != cacheSchemaVersion { return time.Time{}, fmt.Errorf( "unsupported cache schema version %d", metadata.Version, ) } savedAt := metadata.SavedAt if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(savedAt) { savedAt = info.ModTime() } if c.maxAge > 0 && time.Since(savedAt) > c.maxAge { return time.Time{}, errors.New("cached data expired") } return savedAt, nil } func (c *CachedGitHubService) HealthReport() []HealthComponent { components := c.health.report() if provider, ok := c.remote.(healthProvider); ok { components = append(components, provider.HealthReport()...) } return components } func (c *CachedGitHubService) RateLimit() RateLimitSnapshot { if provider, ok := c.remote.(healthProvider); ok { return provider.RateLimit() } return RateLimitSnapshot{} } type readStateStore struct { path string Data map[string]readPRState `json:"pull_requests"` loadErr error } const readStateSchemaVersion = 1 type readStateEnvelope struct { Version int `json:"version"` PullRequests map[string]readPRState `json:"pull_requests"` } type readPRState struct { Initialized bool `json:"initialized"` Threads map[string]bool `json:"threads"` Comments map[string]bool `json:"comments"` } func loadReadState(path string) *readStateStore { store := &readStateStore{path: path, Data: make(map[string]readPRState)} data, err := os.ReadFile(path) if err == nil { var envelope readStateEnvelope if json.Unmarshal(data, &envelope) == nil && envelope.Version == readStateSchemaVersion && envelope.PullRequests != nil { store.Data = envelope.PullRequests } else { // Backward-compatible migration from the original unversioned map. if migrationErr := json.Unmarshal(data, &store.Data); migrationErr != nil { store.Data = make(map[string]readPRState) store.loadErr = fmt.Errorf("read state is corrupt: %w", migrationErr) } } } else if !errors.Is(err, os.ErrNotExist) { store.loadErr = err } return store } func (s *readStateStore) save() error { if s == nil || s.path == "" { return nil } if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { return err } return atomicWriteJSON(s.path, readStateEnvelope{ Version: readStateSchemaVersion, PullRequests: s.Data, }, 0o600) }