feat(core): add workspace-local cache for GitHub API responses
- Add pkg/cache for file-based caching with TTL - Cache lives in workspace .core/cache/ (not home dir) - Search command now caches repo lists for 1 hour - Shows "Cache: host-uk (5s ago)" on cache hit - Use --refresh to bypass cache - Add .core/ to core-devops .gitignore Structure: .core/cache/github/<org>/repos.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
75c52e96e9
commit
936e968ad0
4 changed files with 233 additions and 35 deletions
|
|
@ -5,9 +5,13 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Core/pkg/cache"
|
||||
"github.com/Snider/Core/pkg/repos"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
|
|
@ -17,19 +21,21 @@ func AddSearchCommand(parent *clir.Cli) {
|
|||
var pattern string
|
||||
var repoType string
|
||||
var limit int
|
||||
var refresh bool
|
||||
|
||||
searchCmd := parent.NewSubCommand("search", "Search GitHub for repos by pattern")
|
||||
searchCmd.LongDescription("Searches GitHub for repositories matching a pattern.\n" +
|
||||
"Uses gh CLI for authenticated search.\n\n" +
|
||||
"Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core search --org host-uk --pattern 'core-*'\n" +
|
||||
" core search --org mycompany --pattern '*-mod-*'\n" +
|
||||
" core search --org letheanvpn --pattern '*'")
|
||||
" core search --org letheanvpn --refresh")
|
||||
|
||||
searchCmd.StringFlag("org", "GitHub organization to search (required)", &org)
|
||||
searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern)
|
||||
searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType)
|
||||
searchCmd.IntFlag("limit", "Max results (default 50)", &limit)
|
||||
searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh)
|
||||
|
||||
searchCmd.Action(func() error {
|
||||
if org == "" {
|
||||
|
|
@ -41,7 +47,7 @@ func AddSearchCommand(parent *clir.Cli) {
|
|||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
return runSearch(org, pattern, repoType, limit)
|
||||
return runSearch(org, pattern, repoType, limit, refresh)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +60,34 @@ type ghRepo struct {
|
|||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
func runSearch(org, pattern, repoType string, limit int) error {
|
||||
func runSearch(org, pattern, repoType string, limit int, refresh bool) error {
|
||||
// Initialize cache in workspace .core/ directory
|
||||
var cacheDir string
|
||||
if regPath, err := repos.FindRegistry(); err == nil {
|
||||
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
|
||||
}
|
||||
|
||||
c, err := cache.New(cacheDir, 0)
|
||||
if err != nil {
|
||||
// Cache init failed, continue without cache
|
||||
c = nil
|
||||
}
|
||||
|
||||
cacheKey := cache.GitHubReposKey(org)
|
||||
var repos []ghRepo
|
||||
var fromCache bool
|
||||
|
||||
// Try cache first (unless refresh requested)
|
||||
if c != nil && !refresh {
|
||||
if found, err := c.Get(cacheKey, &repos); found && err == nil {
|
||||
fromCache = true
|
||||
age := c.Age(cacheKey)
|
||||
fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not cached
|
||||
if !fromCache {
|
||||
if !ghAuthenticated() {
|
||||
return fmt.Errorf("gh CLI not authenticated. Run: gh auth login")
|
||||
}
|
||||
|
|
@ -65,15 +98,7 @@ func runSearch(org, pattern, repoType string, limit int) error {
|
|||
fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render(""))
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s", dimStyle.Render("Searching:"), org)
|
||||
if pattern != "" && pattern != "*" {
|
||||
fmt.Printf(" (filter: %s)", pattern)
|
||||
}
|
||||
if repoType != "" {
|
||||
fmt.Printf(" (type: %s)", repoType)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org)
|
||||
|
||||
// Always use gh repo list (more reliable than gh search repos)
|
||||
cmd := exec.Command("gh", "repo", "list", org,
|
||||
|
|
@ -82,6 +107,7 @@ func runSearch(org, pattern, repoType string, limit int) error {
|
|||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
errStr := strings.TrimSpace(string(output))
|
||||
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
|
||||
return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login")
|
||||
|
|
@ -89,11 +115,18 @@ func runSearch(org, pattern, repoType string, limit int) error {
|
|||
return fmt.Errorf("search failed: %s", errStr)
|
||||
}
|
||||
|
||||
var repos []ghRepo
|
||||
if err := json.Unmarshal(output, &repos); err != nil {
|
||||
return fmt.Errorf("failed to parse results: %w", err)
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
if c != nil {
|
||||
_ = c.Set(cacheKey, repos)
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", successStyle.Render("✓"))
|
||||
}
|
||||
|
||||
// Filter by glob pattern and type
|
||||
var filtered []ghRepo
|
||||
for _, r := range repos {
|
||||
|
|
|
|||
1
go.work
1
go.work
|
|
@ -7,6 +7,7 @@ use (
|
|||
./cmd/core-mcp
|
||||
./cmd/examples/core-static-di
|
||||
./cmd/lthn-desktop
|
||||
./pkg/cache
|
||||
./pkg/config
|
||||
./pkg/core
|
||||
./pkg/display
|
||||
|
|
|
|||
161
pkg/cache/cache.go
vendored
Normal file
161
pkg/cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// Package cache provides a file-based cache for GitHub API responses.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultTTL is the default cache expiry time.
|
||||
const DefaultTTL = 1 * time.Hour
|
||||
|
||||
// Cache represents a file-based cache.
|
||||
type Cache struct {
|
||||
baseDir string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// Entry represents a cached item with metadata.
|
||||
type Entry struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// New creates a new cache instance.
|
||||
// If baseDir is empty, uses .core/cache in current directory
|
||||
func New(baseDir string, ttl time.Duration) (*Cache, error) {
|
||||
if baseDir == "" {
|
||||
// Use .core/cache in current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDir = filepath.Join(cwd, ".core", "cache")
|
||||
}
|
||||
|
||||
if ttl == 0 {
|
||||
ttl = DefaultTTL
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Cache{
|
||||
baseDir: baseDir,
|
||||
ttl: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Path returns the full path for a cache key.
|
||||
func (c *Cache) Path(key string) string {
|
||||
return filepath.Join(c.baseDir, key+".json")
|
||||
}
|
||||
|
||||
// Get retrieves a cached item if it exists and hasn't expired.
|
||||
func (c *Cache) Get(key string, dest interface{}) (bool, error) {
|
||||
path := c.Path(key)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
var entry Entry
|
||||
if err := json.Unmarshal(data, &entry); err != nil {
|
||||
// Invalid cache file, treat as miss
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Unmarshal the actual data
|
||||
if err := json.Unmarshal(entry.Data, dest); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Set stores an item in the cache.
|
||||
func (c *Cache) Set(key string, data interface{}) error {
|
||||
path := c.Path(key)
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Marshal the data
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Data: dataBytes,
|
||||
CachedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(c.ttl),
|
||||
}
|
||||
|
||||
entryBytes, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, entryBytes, 0644)
|
||||
}
|
||||
|
||||
// Delete removes an item from the cache.
|
||||
func (c *Cache) Delete(key string) error {
|
||||
path := c.Path(key)
|
||||
err := os.Remove(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear removes all cached items.
|
||||
func (c *Cache) Clear() error {
|
||||
return os.RemoveAll(c.baseDir)
|
||||
}
|
||||
|
||||
// Age returns how old a cached item is, or -1 if not cached.
|
||||
func (c *Cache) Age(key string) time.Duration {
|
||||
path := c.Path(key)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
var entry Entry
|
||||
if err := json.Unmarshal(data, &entry); err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
return time.Since(entry.CachedAt)
|
||||
}
|
||||
|
||||
// GitHub-specific cache keys
|
||||
|
||||
// GitHubReposKey returns the cache key for an org's repo list.
|
||||
func GitHubReposKey(org string) string {
|
||||
return filepath.Join("github", org, "repos")
|
||||
}
|
||||
|
||||
// GitHubRepoKey returns the cache key for a specific repo's metadata.
|
||||
func GitHubRepoKey(org, repo string) string {
|
||||
return filepath.Join("github", org, repo, "meta")
|
||||
}
|
||||
3
pkg/cache/go.mod
vendored
Normal file
3
pkg/cache/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/Snider/Core/pkg/cache
|
||||
|
||||
go 1.24
|
||||
Loading…
Add table
Reference in a new issue