Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

243 lines
7.3 KiB
Go

package git
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/mattn/go-sqlite3"
)
// Cache is a small TTL-backed key-value store for git aggregation results.
//
// Reads and writes go through the provided *sql.DB. The caller is expected
// to have created the `git_cache` table (see internal/db/schema.sql) before
// using the cache; Cache itself never issues DDL.
type Cache struct {
db *sql.DB
write bool
}
// NewCache wraps db as a git TTL cache. db must be a handle on a SQLite
// database that already contains the `git_cache` table.
func NewCache(db *sql.DB) *Cache {
return &Cache{db: db, write: true}
}
// NewReadOnlyCache wraps db as a TTL cache that reads existing rows but does
// not attempt to persist freshly computed values. It is used by commands that
// intentionally open SQLite read-only.
func NewReadOnlyCache(db *sql.DB) *Cache {
return &Cache{db: db}
}
// CacheKey returns a hex-encoded sha256 digest of the (kind, repo, author,
// since, until) tuple. Fields are encoded as JSON before hashing so any
// field can contain arbitrary bytes (including '|', tabs, or newlines)
// without colliding with another tuple. Any change to any field produces a
// different key.
func CacheKey(kind, repo, author, since, until string) string {
encoded, err := json.Marshal([]string{
kind, repo, author, since, until,
})
if err != nil {
// json.Marshal cannot fail for []string; fall back to a
// length-prefixed concatenation for robustness if it ever does.
encoded = fmt.Appendf(nil,
"%d:%s|%d:%s|%d:%s|%d:%s|%d:%s",
len(kind), kind,
len(repo), repo,
len(author), author,
len(since), since,
len(until), until,
)
}
h := sha256.Sum256(encoded)
return hex.EncodeToString(h[:])
}
// GetOrCompute returns the cached payload for key when present and within
// ttl. On miss or stale entry, compute is invoked and its result is stored
// with INSERT OR REPLACE. compute is called at most once per invocation,
// and only on miss. Errors from compute propagate unchanged and do NOT
// write a cache row.
//
// kind is persisted alongside the payload as a debugging aid; it is not
// used for lookup (the key already encodes kind).
func (c *Cache) GetOrCompute(
ctx context.Context,
key, kind string,
ttl time.Duration,
compute func() ([]byte, error),
) ([]byte, error) {
payload, ok, err := c.lookup(ctx, key, ttl)
if err != nil {
if !c.write && isMissingCacheTable(err) {
return compute()
}
return nil, err
}
if ok {
return payload, nil
}
fresh, err := compute()
if err != nil {
return nil, err
}
if !c.write {
return fresh, nil
}
if err := c.store(ctx, key, kind, fresh); err != nil {
return nil, fmt.Errorf("git_cache store: %w", err)
}
return fresh, nil
}
func isMissingCacheTable(err error) bool {
var sqliteErr sqlite3.Error
if !errors.As(err, &sqliteErr) || sqliteErr.Code != sqlite3.ErrError {
return false
}
return strings.Contains(err.Error(), "no such table: git_cache")
}
// lookup returns (payload, true, nil) when a fresh row exists, (nil, false,
// nil) on miss or stale entry, and (nil, false, err) on a query error.
// Rows whose computed_at cannot be parsed are treated as stale.
func (c *Cache) lookup(
ctx context.Context, key string, ttl time.Duration,
) ([]byte, bool, error) {
var payload, computedAt string
err := c.db.QueryRowContext(
ctx,
`SELECT payload, computed_at FROM git_cache WHERE cache_key = ?`,
key,
).Scan(&payload, &computedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("git_cache lookup: %w", err)
}
t, parseErr := time.Parse(time.RFC3339Nano, computedAt)
if parseErr != nil {
// Malformed timestamp: treat as stale so compute runs and overwrites.
return nil, false, nil
}
if time.Since(t) > ttl {
return nil, false, nil
}
return []byte(payload), true, nil
}
// tokenIdentity returns a stable hex digest of ghToken suitable for
// inclusion in a cache key. The raw token is never written into the key —
// only this digest, which CacheKey hashes again. The result depends only
// on the token bytes, so the same account/token always produces the same
// identity slot.
func tokenIdentity(ghToken string) string {
h := sha256.Sum256([]byte(ghToken))
return hex.EncodeToString(h[:])
}
// store upserts a cache row with the current time as computed_at.
func (c *Cache) store(
ctx context.Context, key, kind string, payload []byte,
) error {
_, err := c.db.ExecContext(
ctx,
`INSERT OR REPLACE INTO git_cache
(cache_key, kind, payload, computed_at)
VALUES (?, ?, ?, ?)`,
key, kind, string(payload),
time.Now().UTC().Format(time.RFC3339Nano),
)
return err
}
// AggregateLogCached wraps AggregateLog with a TTL-bounded read-through
// cache. The cache key is derived from ("log", repo, author, since, until).
// On a cache miss, AggregateLog is invoked; its LogResult is JSON-encoded
// before being stored. Errors from AggregateLog propagate unchanged.
func AggregateLogCached(
ctx context.Context,
cache *Cache,
repo, author, since, until string,
ttl time.Duration,
) (LogResult, error) {
key := CacheKey("log", repo, author, since, until)
payload, err := cache.GetOrCompute(
ctx, key, "log", ttl,
func() ([]byte, error) {
res, err := AggregateLog(ctx, repo, author, since, until)
if err != nil {
return nil, err
}
return json.Marshal(res)
},
)
if err != nil {
return LogResult{}, err
}
var out LogResult
if err := json.Unmarshal(payload, &out); err != nil {
return LogResult{}, fmt.Errorf("git_cache decode log payload: %w", err)
}
return out, nil
}
// AggregatePRsCached wraps AggregatePRs with a TTL-bounded cache.
//
// `--author=@me` resolves against the GitHub identity behind ghToken, so
// the token effectively partitions the result set. To prevent one
// account's PR counts from leaking into another's cache (after `gh auth
// switch` or a token swap), the cache key includes a SHA-256 digest of
// the token. The token itself never lands on disk — only its digest
// appears in the key, which is itself hashed again by CacheKey.
//
// When ghToken == "", AggregatePRs returns (nil, nil) and this wrapper
// mirrors that behavior without touching the cache — a nil PRResult is
// ambiguous ("gh unavailable") and we don't want to memoize it.
func AggregatePRsCached(
ctx context.Context,
cache *Cache,
repo, since, until, ghToken string,
ttl time.Duration,
) (*PRResult, error) {
if ghToken == "" {
return AggregatePRs(ctx, repo, since, until, ghToken)
}
key := CacheKey("pr", repo, tokenIdentity(ghToken), since, until)
payload, err := cache.GetOrCompute(
ctx, key, "pr", ttl,
func() ([]byte, error) {
res, err := AggregatePRs(ctx, repo, since, until, ghToken)
if err != nil {
return nil, err
}
if res == nil {
// Defensive: AggregatePRs only returns nil when ghToken
// is empty, which we short-circuit above. Guard anyway
// so a future refactor can't accidentally cache nil.
return nil, errors.New("git_cache: AggregatePRs returned nil with non-empty token")
}
return json.Marshal(res)
},
)
if err != nil {
return nil, err
}
var out PRResult
if err := json.Unmarshal(payload, &out); err != nil {
return nil, fmt.Errorf("git_cache decode pr payload: %w", err)
}
return &out, nil
}