gitcrawl/internal/cli/gh_shim_cache_test.go
2026-05-05 22:00:07 +01:00

1026 lines
35 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/openclaw/gitcrawl/internal/config"
"github.com/openclaw/gitcrawl/internal/store"
)
func TestGHShimCachesReadOnlyFallbackCommands(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
echo "call-$count:$*"
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
t.Setenv("GH_REPO", "cache-test/"+filepath.Base(dir))
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
args := []string{"--config", configPath, "gh", "run", "view", "123", "-R", "openclaw/openclaw", "--json", "status"}
if err := run.Run(ctx, args); err != nil {
t.Fatalf("first cached read: %v", err)
}
first := stdout.String()
stdout.Reset()
if err := run.Run(ctx, args); err != nil {
t.Fatalf("second cached read: %v", err)
}
if second := stdout.String(); second != first {
t.Fatalf("cached output changed: first=%q second=%q", first, second)
}
countData, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count: %v", err)
}
if strings.TrimSpace(string(countData)) != "1" {
t.Fatalf("fake gh call count = %q, want 1", countData)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
t.Fatalf("xcache stats: %v", err)
}
var stats map[string]any
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
}
if int(stats["entries"].(float64)) != 1 {
t.Fatalf("stats = %#v", stats)
}
counters := stats["counters"].(map[string]any)
if int(counters["backend_misses"].(float64)) != 1 || int(counters["fallback_hits"].(float64)) != 1 {
t.Fatalf("counters = %#v", counters)
}
if stats["hit_rate_percent"].(float64) != 50 {
t.Fatalf("hit rate = %#v", stats["hit_rate_percent"])
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "reset", "--json"}); err != nil {
t.Fatalf("xcache reset: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
t.Fatalf("xcache stats after reset: %v", err)
}
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
t.Fatalf("decode reset stats: %v\n%s", err, stdout.String())
}
counters = stats["counters"].(map[string]any)
if int(counters["backend_misses"].(float64)) != 0 || int(counters["fallback_hits"].(float64)) != 0 {
t.Fatalf("reset counters = %#v", counters)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "keys", "--json"}); err != nil {
t.Fatalf("xcache keys: %v", err)
}
var keys []map[string]any
if err := json.Unmarshal(stdout.Bytes(), &keys); err != nil {
t.Fatalf("decode keys: %v\n%s", err, stdout.String())
}
if len(keys) != 1 || keys[0]["command"] != "run view" {
t.Fatalf("keys = %#v", keys)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "flush", "--json"}); err != nil {
t.Fatalf("xcache flush: %v", err)
}
var flushed map[string]any
if err := json.Unmarshal(stdout.Bytes(), &flushed); err != nil {
t.Fatalf("decode flush: %v\n%s", err, stdout.String())
}
if int(flushed["removed"].(float64)) != 1 {
t.Fatalf("flushed = %#v", flushed)
}
}
func TestGHXCacheCommandsReportAndCleanCacheState(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
app := New()
app.configPath = configPath
var stdout bytes.Buffer
app.Stdout = &stdout
dir, err := app.ghCommandCacheDir()
if err != nil {
t.Fatalf("cache dir: %v", err)
}
now := time.Now()
freshPath := filepath.Join(dir, "fresh.json")
expiredPath := filepath.Join(dir, "expired.json")
if err := writeGHCommandCache(freshPath, ghCommandCacheEntry{CreatedAt: now.Add(-time.Minute), Args: []string{"api", "users/octocat"}, ExitCode: 0, Stdout: "{}"}); err != nil {
t.Fatalf("write fresh cache: %v", err)
}
if err := writeGHCommandCache(expiredPath, ghCommandCacheEntry{CreatedAt: now.Add(-8 * 24 * time.Hour), Args: []string{"api", "users/octocat"}, ExitCode: 0, Stdout: "{}"}); err != nil {
t.Fatalf("write expired cache: %v", err)
}
lockPath := filepath.Join(dir, "stale.lock")
if err := os.WriteFile(lockPath, []byte("123\n"), 0o600); err != nil {
t.Fatalf("write lock: %v", err)
}
old := now.Add(-3 * time.Minute)
if err := os.Chtimes(lockPath, old, old); err != nil {
t.Fatalf("age lock: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{"), 0o600); err != nil {
t.Fatalf("write broken entry: %v", err)
}
if _, ok := ghCommandCacheKeyInfoFromDirEntry(dir, mustDirEntry(t, dir, "broken.json")); ok {
t.Fatal("broken cache entry should be ignored")
}
if err := app.incrementGHXCacheCounter("local_hits"); err != nil {
t.Fatalf("increment hit: %v", err)
}
if err := app.incrementGHXCacheBackendMiss([]string{"api", "repos/openclaw/gitcrawl/actions/runs/1/jobs"}); err != nil {
t.Fatalf("increment miss: %v", err)
}
if err := app.runGHXCache([]string{"stats", "--since", "2h"}); err != nil {
t.Fatalf("stats: %v", err)
}
statsText := stdout.String()
if !strings.Contains(statsText, "hit rate") || !strings.Contains(statsText, "Backend Misses by Route") {
t.Fatalf("stats output = %q", statsText)
}
stdout.Reset()
if err := app.runGHXCache([]string{"keys"}); err != nil {
t.Fatalf("keys: %v", err)
}
if !strings.Contains(stdout.String(), "api users/octocat") {
t.Fatalf("keys output = %q", stdout.String())
}
stdout.Reset()
if err := app.runGHXCache([]string{"snapshot", "--reset"}); err != nil {
t.Fatalf("snapshot: %v", err)
}
if !strings.Contains(stdout.String(), "Reset xcache counters") {
t.Fatalf("snapshot output = %q", stdout.String())
}
stdout.Reset()
if err := app.runGHXCache([]string{"gc"}); err != nil {
t.Fatalf("gc: %v", err)
}
if !strings.Contains(stdout.String(), "Removed 1 expired entrie(s), 1 stale lock(s)") {
t.Fatalf("gc output = %q", stdout.String())
}
stdout.Reset()
if err := app.runGHXCache([]string{"flush"}); err != nil {
t.Fatalf("flush: %v", err)
}
if !strings.Contains(stdout.String(), "Flushed") {
t.Fatalf("flush output = %q", stdout.String())
}
if err := app.clearGHCommandCache(); err != nil {
t.Fatalf("clear cache: %v", err)
}
if err := app.runGHXCache([]string{}); err == nil {
t.Fatal("missing xcache command should fail")
}
if err := app.runGHXCache([]string{"stats", "--since", "nope"}); err == nil {
t.Fatal("invalid since should fail")
}
if err := app.runGHXCache([]string{"mystery"}); err == nil {
t.Fatal("unknown xcache command should fail")
}
}
func mustDirEntry(t *testing.T, dir, name string) os.DirEntry {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir: %v", err)
}
for _, entry := range entries {
if entry.Name() == name {
return entry
}
}
t.Fatalf("missing dir entry %s", name)
return nil
}
func TestGHShimCachesGHXStyleReadOnlyFallbackCommands(t *testing.T) {
for _, args := range [][]string{
{"gh", "release", "view", "v1.2.3", "-R", "openclaw/openclaw"},
{"gh", "workflow", "view", "ci.yml", "-R", "openclaw/openclaw"},
{"gh", "secret", "list", "-R", "openclaw/openclaw"},
{"gh", "variable", "list", "-R", "openclaw/openclaw"},
{"gh", "ruleset", "list", "-R", "openclaw/openclaw"},
} {
if !cacheableGHRead(args[1:]) {
t.Fatalf("%v should be cacheable", args)
}
}
}
func TestGHShimCommandAwareCacheTTLs(t *testing.T) {
t.Setenv("GITCRAWL_GH_CACHE_TTL", "")
if got := ghCommandCacheTTL([]string{"api", "users/octocat"}); got != 7*24*time.Hour {
t.Fatalf("user api ttl = %s, want 7d", got)
}
if got := ghCommandCacheTTL([]string{"api", "graphql", "-f", "query={ viewer { login } }"}); got != 6*time.Hour {
t.Fatalf("graphql api ttl = %s, want 6h", got)
}
if got := ghCommandCacheTTL([]string{"run", "view", "123", "--log"}); got != 12*time.Hour {
t.Fatalf("run log ttl = %s, want 12h", got)
}
if got := ghCommandCacheTTL([]string{"run", "view", "123", "--job", "456"}); got != time.Minute {
t.Fatalf("run job ttl = %s, want 1m", got)
}
if got := ghCommandCacheTTL([]string{"run", "list", "-R", "openclaw/openclaw"}); got != 30*time.Second {
t.Fatalf("run list ttl = %s, want 30s", got)
}
if got := ghCommandCacheTTL([]string{"search", "issues", "cache"}); got != 15*time.Minute {
t.Fatalf("search ttl = %s, want 15m", got)
}
if got := ghCommandCacheTTL([]string{"api", "-i", "repos/openclaw/openclaw/actions/runs/123/logs"}); got != 12*time.Hour {
t.Fatalf("actions log api ttl = %s, want 12h", got)
}
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/actions/runs/123"}); got != 30*time.Second {
t.Fatalf("actions run api ttl = %s, want 30s", got)
}
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/pages"}); got != 30*time.Minute {
t.Fatalf("pages api ttl = %s, want 30m", got)
}
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=v0.2.0"}); got != 7*24*time.Hour {
t.Fatalf("tagged contents api ttl = %s, want 7d", got)
}
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=refs%2Ftags%2Fv0.2.0"}); got != 7*24*time.Hour {
t.Fatalf("refs/tags contents api ttl = %s, want 7d", got)
}
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=0123456789abcdef0123456789abcdef01234567"}); got != 7*24*time.Hour {
t.Fatalf("sha contents api ttl = %s, want 7d", got)
}
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=vnext"}); got != 30*time.Minute {
t.Fatalf("mutable vnext contents api ttl = %s, want 30m", got)
}
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=refs%2Fheads%2Fv0.2.0"}); got != 30*time.Minute {
t.Fatalf("v-prefixed branch contents api ttl = %s, want 30m", got)
}
if got := normalizeGHAPIRoute([]string{"repos/openclaw/openclaw/actions/runs?per_page=1"}); got != "api repos/:owner/:repo/actions/runs" {
t.Fatalf("normalized actions route = %q", got)
}
if got := normalizeGHAPIRoute([]string{"--paginate", "repos/openclaw/openclaw/issues?state=all&creator=octocat", "--jq", ".[].number"}); got != "api repos/:owner/:repo/issues" {
t.Fatalf("normalized paginated issues route = %q", got)
}
if got := normalizeGHAPIRoute([]string{"repos/openclaw/openclaw/contents/.github/workflows/ci.yml?ref=main"}); got != "api repos/:owner/:repo/contents/:path" {
t.Fatalf("normalized contents route = %q", got)
}
entry := ghCommandCacheEntry{CreatedAt: time.Now().Add(-3 * time.Minute), ExitCode: 1, Stderr: "HTTP 403: API rate limit exceeded"}
if ttl := ghCommandCacheEntryTTL(entry, 12*time.Hour); ttl != 2*time.Minute {
t.Fatalf("rate-limit error ttl = %s, want 2m", ttl)
}
completedRun := ghCommandCacheEntry{
Args: []string{"run", "view", "123", "-R", "openclaw/openclaw", "--json", "status,conclusion"},
ExitCode: 0,
Stdout: `{"status":"completed","conclusion":"success"}`,
}
if ttl := ghCommandCacheEntryTTL(completedRun, 2*time.Minute); ttl != 12*time.Hour {
t.Fatalf("completed run ttl = %s, want 12h", ttl)
}
completedRuns := ghCommandCacheEntry{
Args: []string{"run", "list", "-R", "openclaw/openclaw", "--json", "status,conclusion"},
ExitCode: 0,
Stdout: `[{"status":"completed","conclusion":"success"}]`,
}
if ttl := ghCommandCacheEntryTTL(completedRuns, 2*time.Minute); ttl != 30*time.Minute {
t.Fatalf("completed run list ttl = %s, want 30m", ttl)
}
completedJobs := ghCommandCacheEntry{
Args: []string{"api", "repos/openclaw/openclaw/actions/runs/123/jobs"},
ExitCode: 0,
Stdout: `{"jobs":[{"status":"completed","conclusion":"success"}]}`,
}
if ttl := ghCommandCacheEntryTTL(completedJobs, time.Minute); ttl != 12*time.Hour {
t.Fatalf("completed jobs ttl = %s, want 12h", ttl)
}
}
func TestGHShimCanonicalizesEquivalentCacheKeys(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
a := New()
a.configPath = configPath
t.Setenv("GH_HOST", "")
t.Setenv("GH_REPO", "")
first := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/openclaw", "--json", "status,conclusion"})
second := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "--json", "conclusion,status", "--repo", "openclaw/openclaw"})
if first != second {
t.Fatalf("equivalent command keys differ: %s != %s", first, second)
}
}
func TestGHShimGraphQLReadOnlyDetection(t *testing.T) {
if !cacheableGHRead([]string{"api", "graphql", "-f", "login=octocat", "-f", "query=query { viewer { login } }"}) {
t.Fatalf("graphql query should be cacheable")
}
if !cacheableGHRead([]string{"api", "graphql", "-f", "query={ viewer { login } }"}) {
t.Fatalf("anonymous graphql query should be cacheable")
}
if cacheableGHRead([]string{"api", "graphql", "-f", "query=mutation { addStar(input:{starrableId:\"x\"}) { clientMutationId } }"}) {
t.Fatalf("graphql mutation should not be cacheable")
}
if cacheableGHRead([]string{"api", "graphql", "-X", "PATCH", "-f", "query={ viewer { login } }"}) {
t.Fatalf("graphql non-read method should not be cacheable")
}
if cacheableGHRead([]string{"api", "graphql", "-f", "query=@query.graphql"}) {
t.Fatalf("graphql file-backed query should not be cacheable")
}
if cacheableGHRead([]string{"api", "repos/openclaw/openclaw/issues", "-f", "title=x"}) {
t.Fatalf("REST API fields should not be cacheable")
}
}
func TestGHShimExplicitCacheKeysAreCwdIndependent(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
a := New()
a.configPath = configPath
t.Setenv("GH_REPO", "")
t.Setenv("GH_HOST", "")
original, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
defer func() { _ = os.Chdir(original) }()
firstDir := t.TempDir()
secondDir := t.TempDir()
if err := os.Chdir(firstDir); err != nil {
t.Fatalf("chdir first: %v", err)
}
apiFirst := a.ghCommandCacheKey(ctx, []string{"api", "users/octocat"})
repoFirst := a.ghCommandCacheKey(ctx, []string{"repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"})
runFirst := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/gitcrawl", "--json", "status"})
implicitFirst := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"})
if err := os.Chdir(secondDir); err != nil {
t.Fatalf("chdir second: %v", err)
}
if apiSecond := a.ghCommandCacheKey(ctx, []string{"api", "users/octocat"}); apiSecond != apiFirst {
t.Fatalf("explicit api key changed across cwd: %s != %s", apiSecond, apiFirst)
}
if repoSecond := a.ghCommandCacheKey(ctx, []string{"repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"}); repoSecond != repoFirst {
t.Fatalf("explicit repo key changed across cwd: %s != %s", repoSecond, repoFirst)
}
if runSecond := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/gitcrawl", "--json", "status"}); runSecond != runFirst {
t.Fatalf("explicit -R key changed across cwd: %s != %s", runSecond, runFirst)
}
if implicitSecond := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"}); implicitSecond == implicitFirst {
t.Fatalf("implicit repo key did not include cwd")
}
if err := os.Setenv("GH_REPO", "openclaw/other"); err != nil {
t.Fatalf("set GH_REPO: %v", err)
}
if apiWithEnv := a.ghCommandCacheKey(ctx, []string{"api", "users/octocat"}); apiWithEnv != apiFirst {
t.Fatalf("explicit api key changed across GH_REPO: %s != %s", apiWithEnv, apiFirst)
}
if repoWithEnv := a.ghCommandCacheKey(ctx, []string{"repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"}); repoWithEnv != repoFirst {
t.Fatalf("explicit repo key changed across GH_REPO: %s != %s", repoWithEnv, repoFirst)
}
if runWithEnv := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/gitcrawl", "--json", "status"}); runWithEnv != runFirst {
t.Fatalf("explicit -R key changed across GH_REPO: %s != %s", runWithEnv, runFirst)
}
}
func TestGHShimGHRepoScopedCacheKeysAreCwdIndependent(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
a := New()
a.configPath = configPath
t.Setenv("GH_HOST", "")
t.Setenv("GH_REPO", "")
original, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
defer func() { _ = os.Chdir(original) }()
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir first: %v", err)
}
if err := os.Setenv("GH_REPO", "openclaw/one"); err != nil {
t.Fatalf("set GH_REPO one: %v", err)
}
first := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"})
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir second: %v", err)
}
second := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"})
if second != first {
t.Fatalf("GH_REPO-scoped key changed across cwd: %s != %s", second, first)
}
if err := os.Setenv("GH_REPO", "openclaw/two"); err != nil {
t.Fatalf("set GH_REPO two: %v", err)
}
if otherRepo := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"}); otherRepo == first {
t.Fatalf("GH_REPO-scoped key ignored GH_REPO change")
}
}
func TestGHShimTracksBackendMissesByCommandAndRoute(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
ghPath := filepath.Join(dir, "gh")
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho api:$*\n"), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_REPO", "miss-test/"+filepath.Base(dir))
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
args := []string{"--config", configPath, "gh", "api", "-i", "repos/openclaw/openclaw/actions/runs/123/logs"}
if err := run.Run(ctx, args); err != nil {
t.Fatalf("api read: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
t.Fatalf("xcache stats: %v", err)
}
var stats ghCommandCacheStats
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
}
if stats.Counters.BackendMissesByCommand["api"] != 1 {
t.Fatalf("backend misses by command = %#v", stats.Counters.BackendMissesByCommand)
}
if stats.Counters.BackendMissesByRoute["api repos/:owner/:repo/actions/runs/:id/logs"] != 1 {
t.Fatalf("backend misses by route = %#v", stats.Counters.BackendMissesByRoute)
}
if stats.Counters.BackendMissesByKey["api repos/openclaw/openclaw/actions/runs/123/logs -i"] != 1 {
t.Fatalf("backend misses by key = %#v", stats.Counters.BackendMissesByKey)
}
}
func TestGHShimXCacheStatsSinceAndSnapshot(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
ghPath := filepath.Join(dir, "gh")
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho repo:$*\n"), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_REPO", "stats-since/"+filepath.Base(dir))
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
args := []string{"--config", configPath, "gh", "repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"}
if err := run.Run(ctx, args); err != nil {
t.Fatalf("repo view: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--since", "1h", "--json"}); err != nil {
t.Fatalf("xcache stats --since: %v", err)
}
var stats ghCommandCacheStats
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
}
if stats.Since != "1h0m0s" || stats.CumulativeCounters == nil || stats.Counters.BackendMisses != 1 {
t.Fatalf("since stats = %+v", stats)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "snapshot", "--reset", "--json"}); err != nil {
t.Fatalf("xcache snapshot: %v", err)
}
var snap ghCommandCacheSnapshotResult
if err := json.Unmarshal(stdout.Bytes(), &snap); err != nil {
t.Fatalf("decode snapshot: %v\n%s", err, stdout.String())
}
if snap.SnapshotPath == "" || !snap.Reset {
t.Fatalf("snapshot result = %+v", snap)
}
if _, err := os.Stat(snap.SnapshotPath); err != nil {
t.Fatalf("snapshot file: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
t.Fatalf("xcache stats after snapshot reset: %v", err)
}
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
t.Fatalf("decode reset stats: %v\n%s", err, stdout.String())
}
if stats.Counters.BackendMisses != 0 {
t.Fatalf("snapshot reset counters = %+v", stats.Counters)
}
}
func TestGHShimCachesReadOnlyFallbackErrors(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
echo "missing-$count:$*" >&2
exit 42
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
t.Setenv("GH_REPO", "error-cache/"+filepath.Base(dir))
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
args := []string{"--config", configPath, "gh", "release", "view", "missing", "-R", "openclaw/openclaw"}
for i := 0; i < 2; i++ {
run := New()
var stderr bytes.Buffer
run.Stderr = &stderr
err := run.Run(ctx, args)
if err == nil {
t.Fatalf("run %d unexpectedly succeeded", i)
}
if !strings.Contains(stderr.String(), "missing-1:release view missing") {
t.Fatalf("stderr %d = %q", i, stderr.String())
}
}
countData, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count: %v", err)
}
if strings.TrimSpace(string(countData)) != "1" {
t.Fatalf("fake gh call count = %q, want 1", countData)
}
}
func TestGHShimServesExpiredSuccessOnRateLimit(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
if [ "$count" = "1" ]; then
echo "release-ok"
exit 0
fi
echo "HTTP 403: API rate limit exceeded" >&2
exit 1
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1ns")
args := []string{"--config", configPath, "gh", "release", "view", "v1", "-R", "openclaw/openclaw"}
run := New()
var stdout, stderr bytes.Buffer
run.Stdout = &stdout
run.Stderr = &stderr
if err := run.Run(ctx, args); err != nil {
t.Fatalf("first read: %v", err)
}
stdout.Reset()
stderr.Reset()
if err := run.Run(ctx, args); err != nil {
t.Fatalf("stale read should succeed: %v", err)
}
if strings.TrimSpace(stdout.String()) != "release-ok" {
t.Fatalf("stale stdout = %q", stdout.String())
}
if !strings.Contains(stderr.String(), "serving stale cached gh response") {
t.Fatalf("stderr missing stale warning: %q", stderr.String())
}
countData, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count: %v", err)
}
if strings.TrimSpace(string(countData)) != "2" {
t.Fatalf("fake gh call count = %q, want 2", countData)
}
stdout.Reset()
stderr.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
t.Fatalf("xcache stats: %v", err)
}
var stats ghCommandCacheStats
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
}
if stats.Counters.StaleHits != 1 || stats.Counters.BackendMisses != 2 || stats.CacheHits != 1 || stats.TotalReads != 3 {
t.Fatalf("stats = %+v", stats)
}
}
func TestGHShimServesStaleWhileAnotherProcessRefreshes(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
if [ "$count" != "1" ]; then
sleep 1
fi
echo "release-$count"
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1ns")
t.Setenv("GITCRAWL_GH_STALE_GRACE", "1h")
args := []string{"--config", configPath, "gh", "release", "view", "v1", "-R", "openclaw/openclaw"}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, args); err != nil {
t.Fatalf("seed read: %v", err)
}
stdout.Reset()
var wg sync.WaitGroup
outputs := make(chan string, 2)
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
run := New()
var out bytes.Buffer
run.Stdout = &out
if err := run.Run(ctx, args); err != nil {
errs <- err
return
}
outputs <- strings.TrimSpace(out.String())
}()
}
wg.Wait()
close(errs)
close(outputs)
for err := range errs {
t.Fatalf("stale while refresh run: %v", err)
}
seen := map[string]int{}
for out := range outputs {
seen[out]++
}
if seen["release-1"] != 1 || seen["release-2"] != 1 {
t.Fatalf("outputs = %#v, want one stale and one refresh", seen)
}
countData, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count: %v", err)
}
if strings.TrimSpace(string(countData)) != "2" {
t.Fatalf("fake gh call count = %q, want 2", countData)
}
}
func TestGHShimMutatingFallbackClearsMatchingCacheForGHXStyleMutations(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
echo "call-$count:$*"
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
t.Setenv("GH_REPO", "mutation-cache/"+filepath.Base(dir))
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
readArgs := []string{"--config", configPath, "gh", "run", "view", "123", "-R", "openclaw/openclaw"}
if err := run.Run(ctx, readArgs); err != nil {
t.Fatalf("first read: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, readArgs); err != nil {
t.Fatalf("second read: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "run", "rerun", "123", "-R", "openclaw/openclaw"}); err != nil {
t.Fatalf("mutation: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, readArgs); err != nil {
t.Fatalf("third read: %v", err)
}
countData, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count: %v", err)
}
if strings.TrimSpace(string(countData)) != "3" {
t.Fatalf("fake gh call count = %q, want 3", countData)
}
}
func TestGHShimMutatingFallbackInvalidatesTargetedTags(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
echo "call-$count:$*"
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
releaseArgs := []string{"--config", configPath, "gh", "release", "view", "v1", "-R", "openclaw/openclaw"}
issueArgs := []string{"--config", configPath, "gh", "api", "repos/openclaw/openclaw/issues/12"}
for _, args := range [][]string{releaseArgs, issueArgs} {
stdout.Reset()
if err := run.Run(ctx, args); err != nil {
t.Fatalf("seed read %v: %v", args, err)
}
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "comment", "12", "-R", "openclaw/openclaw", "--body", "fixed"}); err != nil {
t.Fatalf("mutation: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, releaseArgs); err != nil {
t.Fatalf("release should remain cached: %v", err)
}
if !strings.Contains(stdout.String(), "call-1:") {
t.Fatalf("release cache was invalidated: %q", stdout.String())
}
stdout.Reset()
if err := run.Run(ctx, issueArgs); err != nil {
t.Fatalf("issue should be refetched: %v", err)
}
if !strings.Contains(stdout.String(), "call-4:") {
t.Fatalf("issue cache was not invalidated: %q", stdout.String())
}
}
func TestGHShimCachesPRDiffByHeadSHA(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
echo "diff-$count:$*"
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
args := []string{"--config", configPath, "gh", "pr", "diff", "12", "-R", "openclaw/openclaw"}
if err := run.Run(ctx, args); err != nil {
t.Fatalf("first pr diff: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, args); err != nil {
t.Fatalf("second pr diff: %v", err)
}
countData, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count: %v", err)
}
if strings.TrimSpace(string(countData)) != "1" {
t.Fatalf("fake gh call count = %q, want 1", countData)
}
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
st, err := store.Open(ctx, cfg.DBPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repo, err := st.RepositoryByFullName(ctx, "openclaw/openclaw")
if err != nil {
t.Fatalf("repo: %v", err)
}
if _, err := st.UpsertThread(ctx, store.Thread{
RepoID: repo.ID, GitHubID: "12", Number: 12, Kind: "pull_request", State: "open",
Title: "Manifest cache update", AuthorLogin: "bob", AuthorType: "User",
HTMLURL: "https://github.com/openclaw/openclaw/pull/12", LabelsJSON: "[]", AssigneesJSON: "[]",
RawJSON: `{"head":{"sha":"def456"}}`, ContentHash: "pr-12-new", IsDraft: true,
UpdatedAtGitHub: "2026-04-27T03:00:00Z", UpdatedAt: "2026-04-27T03:00:00Z",
}); err != nil {
t.Fatalf("update pr head: %v", err)
}
if err := st.UpsertPullRequestCache(ctx, store.PullRequestDetail{
ThreadID: prIDForTest(t, ctx, st, repo.ID, 12),
RepoID: repo.ID,
Number: 12,
HeadSHA: "def456",
RawJSON: `{"head":{"sha":"def456"}}`,
FetchedAt: "2026-04-27T03:00:00Z",
UpdatedAt: "2026-04-27T03:00:00Z",
}, nil, nil, nil, nil); err != nil {
t.Fatalf("update pr cache head: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, args); err != nil {
t.Fatalf("third pr diff after head change: %v", err)
}
countData, err = os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count after update: %v", err)
}
if strings.TrimSpace(string(countData)) != "2" {
t.Fatalf("fake gh call count after head update = %q, want 2", countData)
}
}
func TestGHShimXCacheGCRemovesExpiredEntries(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
ghPath := filepath.Join(dir, "gh")
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho cached:$*\n"), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1ns")
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "gh", "run", "view", "789", "-R", "openclaw/openclaw"}); err != nil {
t.Fatalf("cached read: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "gc", "--json"}); err != nil {
t.Fatalf("xcache gc: %v", err)
}
var result map[string]any
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("decode gc: %v\n%s", err, stdout.String())
}
if int(result["removed"].(float64)) != 1 {
t.Fatalf("gc = %#v", result)
}
}
func TestGHShimCoalescesConcurrentReadOnlyFallbacks(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimRepo(t, ctx)
dir := t.TempDir()
countPath := filepath.Join(dir, "count")
ghPath := filepath.Join(dir, "gh")
script := `#!/bin/sh
count=0
if [ -f "$GH_SHIM_COUNT" ]; then
count=$(cat "$GH_SHIM_COUNT")
fi
count=$((count + 1))
printf "%s" "$count" > "$GH_SHIM_COUNT"
sleep 0.2
echo "call-$count:$*"
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
t.Setenv("GH_SHIM_COUNT", countPath)
t.Setenv("GH_REPO", "coalesce-test/"+filepath.Base(dir))
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
args := []string{"--config", configPath, "gh", "run", "view", "456", "-R", "openclaw/openclaw", "--json", "status"}
var wg sync.WaitGroup
errs := make(chan error, 2)
outputs := make(chan string, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, args); err != nil {
errs <- err
return
}
outputs <- stdout.String()
}()
}
wg.Wait()
close(errs)
close(outputs)
for err := range errs {
t.Fatalf("coalesced run: %v", err)
}
if len(outputs) != 2 {
t.Fatalf("outputs = %d, want 2", len(outputs))
}
var first string
for out := range outputs {
if first == "" {
first = out
} else if out != first {
t.Fatalf("coalesced outputs differ: %q vs %q", first, out)
}
}
countData, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count: %v", err)
}
if strings.TrimSpace(string(countData)) != "1" {
t.Fatalf("fake gh call count = %q, want 1", countData)
}
}