443 lines
12 KiB
Go
443 lines
12 KiB
Go
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) 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)
|
|
}
|