Fix high prio recommendations, add error / health screen

This commit is contained in:
2026-07-28 15:40:11 +02:00
parent 948f3e1a79
commit 7511311297
19 changed files with 1885 additions and 148 deletions

187
cache.go
View File

@@ -9,19 +9,23 @@ import (
"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
remote GitHubService
dir string
maxAge time.Duration
maxEntries int
health healthTracker
}
type cachedSnapshotService interface {
@@ -34,8 +38,18 @@ type liveGitHubService interface {
LivePullRequest(context.Context, string, string, int) (PRDetails, error)
}
func NewCachedGitHubService(remote GitHubService, dir string, maxAge time.Duration) *CachedGitHubService {
return &CachedGitHubService{remote: remote, dir: dir, maxAge: maxAge}
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(
@@ -157,7 +171,37 @@ func (c *CachedGitHubService) ListBranches(
if !ok {
return nil, errors.New("GitHub service does not support listing branches")
}
return service.ListBranches(ctx, owner, repo)
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 {
@@ -168,12 +212,27 @@ func (c *CachedGitHubService) pullRequestKey(owner, repo string, number int) str
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) error {
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
}
@@ -198,7 +257,8 @@ func (c *CachedGitHubService) write(key string, value any) error {
}
}
data, err := json.Marshal(cacheEnvelope[any]{
SavedAt: time.Now(), ContentHash: contentHash, Value: value,
Version: cacheSchemaVersion, SavedAt: time.Now(),
ContentHash: contentHash, Value: value,
})
if err != nil {
return err
@@ -220,7 +280,45 @@ func (c *CachedGitHubService) write(key string, value any) error {
if err := temp.Close(); err != nil {
return err
}
return os.Rename(name, target)
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 {
@@ -231,7 +329,20 @@ func (c *CachedGitHubService) cacheTouchInterval() time.Duration {
return interval
}
func (c *CachedGitHubService) read(key string, target any) (time.Time, error) {
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 {
@@ -241,11 +352,17 @@ func (c *CachedGitHubService) read(key string, target any) (time.Time, error) {
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()
@@ -256,9 +373,32 @@ func (c *CachedGitHubService) read(key string, target any) (time.Time, error) {
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"`
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 {
@@ -271,7 +411,20 @@ func loadReadState(path string) *readStateStore {
store := &readStateStore{path: path, Data: make(map[string]readPRState)}
data, err := os.ReadFile(path)
if err == nil {
_ = json.Unmarshal(data, &store.Data)
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
}
@@ -283,9 +436,7 @@ func (s *readStateStore) save() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(s.Data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0o600)
return atomicWriteJSON(s.path, readStateEnvelope{
Version: readStateSchemaVersion, PullRequests: s.Data,
}, 0o600)
}