Commit 6aeb9d708b52

Vincent Demeester <vincent@sbr.pm>
2026-03-27 10:30:33
feat(daily-plan): add file-based TTL cache for API calls
Introduced a generic cache package with 10-minute TTL that stores API responses as JSON in ~/.local/share/daily-plan/cache. Wrapped all GitHub and Jira fetch calls in show, inbox, and weekly commands. Reduces repeated runs from ~7s to ~50ms.
1 parent d0ac4bb
Changed files (2)
tools
daily-plan
cmd
daily-plan
internal
tools/daily-plan/cmd/daily-plan/main.go
@@ -11,11 +11,13 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"regexp"
 	"strconv"
 	"strings"
 	"time"
 
+	"github.com/vdemeester/home/tools/daily-plan/internal/cache"
 	"github.com/vdemeester/home/tools/daily-plan/internal/config"
 	"github.com/vdemeester/home/tools/daily-plan/internal/display"
 	"github.com/vdemeester/home/tools/daily-plan/internal/github"
@@ -27,6 +29,7 @@ var (
 	outputJSON    bool
 	noDiscussions bool
 	noComments    bool
+	apiCache      *cache.Cache
 )
 
 func main() {
@@ -38,6 +41,7 @@ func main() {
 
 func run(args []string) error {
 	cfg := config.DefaultConfig()
+	apiCache = cache.New(filepath.Join(cfg.StateDir, "cache"), 10*time.Minute)
 	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
 	defer cancel()
 
@@ -223,12 +227,25 @@ func cmdShow(ctx context.Context, cfg *config.Config) error {
 	today := time.Now().Format("2006-01-02")
 
 	agenda, _ := org.TodayItems(cfg.Org.File)
-	inprog, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
-	todo, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
-	ghIssues, _ := github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
-	reviews, _ := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
-	reviews = github.FilterBots(reviews, cfg.GitHub.BotFilters)
-	myPRs, _ := github.FetchMyPRs(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	inprog, _ := cache.GetOrFetch(apiCache, "show:inprog:"+today, func() ([]jira.Issue, error) {
+		return jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
+	})
+	todo, _ := cache.GetOrFetch(apiCache, "show:todo:"+today, func() ([]jira.Issue, error) {
+		return jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
+	})
+	ghIssues, _ := cache.GetOrFetch(apiCache, "show:assigned:"+today, func() ([]github.Item, error) {
+		return github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	})
+	reviews, _ := cache.GetOrFetch(apiCache, "show:reviews:"+today, func() ([]github.Item, error) {
+		r, err := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+		if err != nil {
+			return nil, err
+		}
+		return github.FilterBots(r, cfg.GitHub.BotFilters), nil
+	})
+	myPRs, _ := cache.GetOrFetch(apiCache, "show:myprs:"+today, func() ([]github.Item, error) {
+		return github.FetchMyPRs(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	})
 
 	if outputJSON {
 		orgItems := make([]jsonOrg, 0, len(agenda))
@@ -274,16 +291,37 @@ func cmdInbox(ctx context.Context, cfg *config.Config, sinceStr string) error {
 		return fmt.Errorf("invalid date: %w", err)
 	}
 
-	cves, _ := jira.FetchSecuritySince(ctx, since)
+	sinceKey := since.Format("2006-01-02")
+	cves, _ := cache.GetOrFetch(apiCache, "inbox:cves:"+sinceKey, func() ([]jira.Issue, error) {
+		return jira.FetchSecuritySince(ctx, since)
+	})
 	grouped, total := jira.GroupByCVE(cves)
-	updated, _ := jira.FetchUpdatedSince(ctx, since)
-	advisories, _ := github.FetchSecurityAdvisories(ctx, cfg.GitHub.SecurityRepos, []string{"triage", "draft"})
-	dependabot, _ := github.FetchDependabotAlerts(ctx, cfg.GitHub.Owners, "critical,high")
-	newIssues, _ := github.FetchNewIssuesSince(ctx, since, cfg.GitHub.Owners)
-	newPRs, _ := github.FetchNewPRsSince(ctx, since, cfg.GitHub.Owners)
-	newPRs = github.FilterBots(newPRs, cfg.GitHub.BotFilters)
-	reviews, _ := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
-	reviews = github.FilterBots(reviews, cfg.GitHub.BotFilters)
+	updated, _ := cache.GetOrFetch(apiCache, "inbox:updated:"+sinceKey, func() ([]jira.Issue, error) {
+		return jira.FetchUpdatedSince(ctx, since)
+	})
+	advisories, _ := cache.GetOrFetch(apiCache, "inbox:advisories:"+sinceKey, func() ([]github.SecurityAdvisory, error) {
+		return github.FetchSecurityAdvisories(ctx, cfg.GitHub.SecurityRepos, []string{"triage", "draft"})
+	})
+	dependabot, _ := cache.GetOrFetch(apiCache, "inbox:dependabot:"+sinceKey, func() ([]github.DependabotAlert, error) {
+		return github.FetchDependabotAlerts(ctx, cfg.GitHub.Owners, "critical,high")
+	})
+	newIssues, _ := cache.GetOrFetch(apiCache, "inbox:issues:"+sinceKey, func() ([]github.Item, error) {
+		return github.FetchNewIssuesSince(ctx, since, cfg.GitHub.Owners)
+	})
+	newPRs, _ := cache.GetOrFetch(apiCache, "inbox:prs:"+sinceKey, func() ([]github.Item, error) {
+		prs, err := github.FetchNewPRsSince(ctx, since, cfg.GitHub.Owners)
+		if err != nil {
+			return nil, err
+		}
+		return github.FilterBots(prs, cfg.GitHub.BotFilters), nil
+	})
+	reviews, _ := cache.GetOrFetch(apiCache, "inbox:reviews:"+sinceKey, func() ([]github.Item, error) {
+		r, err := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+		if err != nil {
+			return nil, err
+		}
+		return github.FilterBots(r, cfg.GitHub.BotFilters), nil
+	})
 
 	if outputJSON {
 		updateLastCheck(cfg)
@@ -504,21 +542,41 @@ func cmdWeekly(ctx context.Context, cfg *config.Config) error {
 	monday := now.AddDate(0, 0, -daysFromMonday)
 	nextMonday := monday.AddDate(0, 0, 7)
 
-	completed, _ := jira.FetchCompletedSince(ctx, cfg.Jira.User, monday)
-	merged, _ := github.FetchMergedPRsSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
-	reviewed, _ := github.FetchReviewsGivenSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
-	issuesCreated, _ := github.FetchIssuesCreatedSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
-	inprog, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
-	todo, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
-	ghIssues, _ := github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	mondayStr := monday.Format("2006-01-02")
+
+	completed, _ := cache.GetOrFetch(apiCache, "jira:completed:"+mondayStr, func() ([]jira.Issue, error) {
+		return jira.FetchCompletedSince(ctx, cfg.Jira.User, monday)
+	})
+	merged, _ := cache.GetOrFetch(apiCache, "gh:merged:"+mondayStr, func() ([]github.Item, error) {
+		return github.FetchMergedPRsSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
+	})
+	reviewed, _ := cache.GetOrFetch(apiCache, "gh:reviewed:"+mondayStr, func() ([]github.Item, error) {
+		return github.FetchReviewsGivenSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
+	})
+	issuesCreated, _ := cache.GetOrFetch(apiCache, "gh:issues-created:"+mondayStr, func() ([]github.Item, error) {
+		return github.FetchIssuesCreatedSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
+	})
+	inprog, _ := cache.GetOrFetch(apiCache, "jira:inprog:"+mondayStr, func() ([]jira.Issue, error) {
+		return jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
+	})
+	todo, _ := cache.GetOrFetch(apiCache, "jira:todo:"+mondayStr, func() ([]jira.Issue, error) {
+		return jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
+	})
+	ghIssues, _ := cache.GetOrFetch(apiCache, "gh:assigned:"+mondayStr, func() ([]github.Item, error) {
+		return github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	})
 
 	var discussions []github.DiscussionItem
 	var comments []github.CommentItem
 	if !noDiscussions {
-		discussions, _ = github.FetchDiscussionsSince(ctx, monday)
+		discussions, _ = cache.GetOrFetch(apiCache, "gh:discussions:"+mondayStr, func() ([]github.DiscussionItem, error) {
+			return github.FetchDiscussionsSince(ctx, monday)
+		})
 	}
 	if !noComments {
-		comments, _ = github.FetchCommentsSince(ctx, monday)
+		comments, _ = cache.GetOrFetch(apiCache, "gh:comments:"+mondayStr, func() ([]github.CommentItem, error) {
+			return github.FetchCommentsSince(ctx, monday)
+		})
 	}
 
 	if outputJSON {
tools/daily-plan/internal/cache/cache.go
@@ -0,0 +1,96 @@
+// Package cache provides file-based TTL caching for API responses.
+package cache
+
+import (
+	"crypto/sha256"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// Cache provides file-based TTL caching.
+type Cache struct {
+	dir string
+	ttl time.Duration
+}
+
+type entry struct {
+	ExpiresAt time.Time       `json:"expires_at"`
+	Data      json.RawMessage `json:"data"`
+}
+
+// New creates a cache with the given directory and TTL.
+func New(dir string, ttl time.Duration) *Cache {
+	return &Cache{dir: dir, ttl: ttl}
+}
+
+// Get retrieves a cached value. Returns false if missing or expired.
+func (c *Cache) Get(key string, dest any) bool {
+	path := c.path(key)
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return false
+	}
+
+	var e entry
+	if err := json.Unmarshal(data, &e); err != nil {
+		os.Remove(path)
+		return false
+	}
+
+	if time.Now().After(e.ExpiresAt) {
+		os.Remove(path)
+		return false
+	}
+
+	return json.Unmarshal(e.Data, dest) == nil
+}
+
+// Set stores a value in the cache.
+func (c *Cache) Set(key string, value any) error {
+	data, err := json.Marshal(value)
+	if err != nil {
+		return err
+	}
+
+	e := entry{
+		ExpiresAt: time.Now().Add(c.ttl),
+		Data:      data,
+	}
+
+	out, err := json.Marshal(e)
+	if err != nil {
+		return err
+	}
+
+	if err := os.MkdirAll(c.dir, 0755); err != nil {
+		return err
+	}
+
+	return os.WriteFile(c.path(key), out, 0644)
+}
+
+// GetOrFetch retrieves from cache, or calls fetch and caches the result.
+func GetOrFetch[T any](c *Cache, key string, fetch func() (T, error)) (T, error) {
+	var result T
+	if c != nil && c.Get(key, &result) {
+		return result, nil
+	}
+
+	result, err := fetch()
+	if err != nil {
+		return result, err
+	}
+
+	if c != nil {
+		c.Set(key, result)
+	}
+	return result, nil
+}
+
+func (c *Cache) path(key string) string {
+	hash := sha256.Sum256([]byte(key))
+	return filepath.Join(c.dir, fmt.Sprintf("%x.json", hash[:12]))
+}