From 54f7107df924593cb71cb20d2d46552fd2ca5194 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 22:00:07 +0100 Subject: [PATCH] test: enforce 85 percent coverage gate --- .github/workflows/ci.yml | 8 +- CHANGELOG.md | 2 +- Makefile | 8 +- internal/cli/gh_shim_cache_test.go | 104 +++++++ internal/cli/gh_shim_detail_test.go | 23 ++ internal/cli/gh_shim_policy_extra_test.go | 231 +++++++++++++++ internal/cli/runtime_extra_test.go | 80 ++++++ internal/cli/tui_render_extra_test.go | 326 ++++++++++++++++++++++ internal/openai/client_test.go | 83 ++++++ internal/store/comments_test.go | 14 + internal/store/coverage_test.go | 100 +++++++ internal/store/pull_requests_test.go | 87 ++++++ internal/syncer/syncer_test.go | 37 +++ 13 files changed, 1099 insertions(+), 4 deletions(-) create mode 100644 internal/cli/gh_shim_policy_extra_test.go create mode 100644 internal/cli/runtime_extra_test.go create mode 100644 internal/cli/tui_render_extra_test.go create mode 100644 internal/store/pull_requests_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ecd341..399a52e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,12 @@ jobs: - name: Vet run: go vet ./... - - name: Test - run: go test ./... + - name: Test with coverage + run: | + go test ./... -covermode=atomic -coverprofile=coverage.out + total="$(go tool cover -func=coverage.out | awk '/^total:/ { sub(/%/, "", $3); print $3 }')" + echo "total coverage: ${total}%" + awk -v total="$total" 'BEGIN { if (total + 0 < 85.0) { printf("coverage %.1f%% is below 85.0%%\n", total); exit 1 } }' - name: Build run: go build -ldflags "-X github.com/openclaw/gitcrawl/internal/cli.version=${GITHUB_SHA:0:7}" -o bin/gitcrawl ./cmd/gitcrawl diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d13fa3..78bb0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.3.0 - Unreleased +## 0.2.1 - 2026-05-05 - Improve `gh` shim cache coordination and observability with stale-while-revalidate reads, finer Actions/API TTLs, recent-window stats, top miss keys, and `xcache snapshot`. diff --git a/Makefile b/Makefile index 3a75e07..b415084 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ BINARY := gitcrawl VERSION ?= dev -.PHONY: build test run clean +.PHONY: build test test-coverage run clean build: go build -ldflags "-X github.com/openclaw/gitcrawl/internal/cli.version=$(VERSION)" -o bin/$(BINARY) ./cmd/gitcrawl @@ -9,6 +9,12 @@ build: test: go test ./... +test-coverage: + go test ./... -covermode=atomic -coverprofile=coverage.out + @total="$$(go tool cover -func=coverage.out | awk '/^total:/ { sub(/%/, "", $$3); print $$3 }')"; \ + echo "total coverage: $${total}%"; \ + awk -v total="$$total" 'BEGIN { if (total + 0 < 85.0) { printf("coverage %.1f%% is below 85.0%%\n", total); exit 1 } }' + run: go run ./cmd/gitcrawl $(ARGS) diff --git a/internal/cli/gh_shim_cache_test.go b/internal/cli/gh_shim_cache_test.go index d79ad2f..3b808a9 100644 --- a/internal/cli/gh_shim_cache_test.go +++ b/internal/cli/gh_shim_cache_test.go @@ -121,6 +121,110 @@ echo "call-$count:$*" } } +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"}, diff --git a/internal/cli/gh_shim_detail_test.go b/internal/cli/gh_shim_detail_test.go index ea09b4d..5d43198 100644 --- a/internal/cli/gh_shim_detail_test.go +++ b/internal/cli/gh_shim_detail_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/openclaw/gitcrawl/internal/config" @@ -112,6 +113,28 @@ func TestGHShimViewAndListUseLocalCache(t *testing.T) { if len(list) != 1 || int(list[0]["number"].(float64)) != 10 { t.Fatalf("filtered list = %#v", list) } + + stdout.Reset() + if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "view", "10", "-R", "openclaw/openclaw"}); err != nil { + t.Fatalf("gh issue human view: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "title:\tHot loop burns CPU") || !strings.Contains(got, "runtime has a hot loop") { + t.Fatalf("human issue view = %q", got) + } + stdout.Reset() + if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "list", "-R", "openclaw/openclaw", "--limit", "1"}); err != nil { + t.Fatalf("gh issue human list: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "10\tHot loop burns CPU") { + t.Fatalf("human issue list = %q", got) + } + stdout.Reset() + if err := run.Run(ctx, []string{"--config", configPath, "gh", "pr", "list", "-R", "openclaw/openclaw", "--limit", "1"}); err != nil { + t.Fatalf("gh pr human list: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "12\tManifest cache update") { + t.Fatalf("human pr list = %q", got) + } } func TestGHShimAutoHydratesPRDetailsOnMiss(t *testing.T) { diff --git a/internal/cli/gh_shim_policy_extra_test.go b/internal/cli/gh_shim_policy_extra_test.go new file mode 100644 index 0000000..3a8d732 --- /dev/null +++ b/internal/cli/gh_shim_policy_extra_test.go @@ -0,0 +1,231 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/openclaw/gitcrawl/internal/store" +) + +func TestGHShimPRCacheAndPolicyHelperBranches(t *testing.T) { + ctx := context.Background() + configPath := seedGHShimRepo(t, ctx) + app := New() + app.configPath = configPath + var stdout bytes.Buffer + app.Stdout = &stdout + + if err := app.Run(ctx, []string{"--config", configPath, "gh", "pr", "checks", "12", "-R", "openclaw/openclaw"}); err != nil { + t.Fatalf("human pr checks: %v", err) + } + if !strings.Contains(stdout.String(), "test\tcompleted\tsuccess") { + t.Fatalf("human checks = %q", stdout.String()) + } + cache, err := app.localGHPullRequestCache(ctx, "openclaw/openclaw", 12) + if err != nil { + t.Fatalf("local pr cache: %v", err) + } + if _, err := app.loadGHPullRequestCache(ctx, "openclaw/openclaw", 12, false); err != nil { + t.Fatalf("load cached pr detail without freshness: %v", err) + } + if _, err := app.loadGHPullRequestCache(ctx, "openclaw/openclaw", 12, true); err != nil { + t.Fatalf("load fresh cached pr detail: %v", err) + } + if !ghPullRequestCacheFresh(cache) { + t.Fatalf("seeded cache should be fresh: %+v", cache.Detail) + } + cache.Detail.RawJSON = `{"head":{"sha":"different"}}` + if ghPullRequestCacheFresh(cache) { + t.Fatal("mismatched raw head sha should be stale") + } + cache.Detail.RawJSON = `{"head":{"sha":"abc123"}}` + cache.Detail.FetchedAt = "bad" + if ghPullRequestCacheFresh(cache) { + t.Fatal("bad fetched timestamp should be stale") + } + if !app.shouldAutoHydrateGHPRDetails(localGHUnsupported(errors.New("pull request detail: sql: no rows in result set"))) { + t.Fatal("missing local PR cache should auto-hydrate") + } + t.Setenv("GITCRAWL_GH_AUTO_HYDRATE", "0") + if app.shouldAutoHydrateGHThread(nil) { + t.Fatal("auto-hydrate env disable not honored") + } + if _, err := app.loadGHPullRequestCache(ctx, "openclaw/openclaw", 9999, true); err == nil { + t.Fatal("missing PR cache with auto-hydrate disabled should fail") + } + t.Setenv("GITCRAWL_GH_AUTO_HYDRATE", "") + if isMissingLocalPRCache(nil) || !isMissingLocalPRCache(localGHUnsupported(errors.New("cached PR branch \"x\" was not found"))) { + t.Fatal("missing cache classification mismatch") + } + number, err := app.findGHPullRequestNumberByBranch(ctx, "openclaw/openclaw", "manifest-cache") + if err != nil || number != 12 { + t.Fatalf("branch lookup number=%d err=%v", number, err) + } + if _, err := app.findGHPullRequestNumberByBranch(ctx, "openclaw/openclaw", "missing"); err == nil { + t.Fatal("missing branch lookup should fail") + } + if got := ghPRHeadRefFromRawJSON(`{"head":{"ref":" feature/cache "}}`); got != "feature/cache" { + t.Fatalf("head ref = %q", got) + } + if got := ghPRHeadRefFromRawJSON(`{`); got != "" { + t.Fatalf("invalid head ref = %q", got) + } + if !ghPRFieldsNeedFresh([]string{"number", "statusCheckRollup"}) || !ghPRFieldsNeedFresh([]string{"mergeStateStatus"}) || ghPRFieldsNeedFresh([]string{"files"}) { + t.Fatal("fresh field detection mismatch") + } + thread := store.Thread{IsDraft: true} + for _, field := range []string{"headRepositoryOwner", "headRepository", "mergeStateStatus", "additions", "deletions", "changedFiles", "isDraft"} { + if _, err := ghPRDetailJSONValue(thread, cache, field); err != nil { + t.Fatalf("field %s: %v", field, err) + } + } + if _, err := ghPRDetailJSONValue(thread, cache, "unsupported"); err == nil { + t.Fatal("unsupported PR detail field should fail") + } + var out bytes.Buffer + app.Stdout = &out + if err := app.writeJSONValue(map[string]any{"value": 1}, ""); err != nil || !strings.Contains(out.String(), `"value": 1`) { + t.Fatalf("write json out=%q err=%v", out.String(), err) + } + if err := app.writeJSONValue(make(chan int), ""); err == nil { + t.Fatal("unmarshalable JSON value should fail") + } + out.Reset() + if err := app.writeJSONValue(map[string]any{"value": 2}, ".value"); err != nil || strings.TrimSpace(out.String()) != "2" { + t.Fatalf("write json jq out=%q err=%v", out.String(), err) + } + t.Setenv("PATH", "") + if err := app.writeJSONValue(map[string]any{"value": 2}, ".value"); err == nil { + t.Fatal("jq expression without jq executable should fail") + } +} + +func TestGHShimCachePolicyExtraBranches(t *testing.T) { + if cacheableGHRead(nil) || cacheableGHRead([]string{"repo", "view", "--web"}) { + t.Fatal("interactive or empty gh commands should not be cacheable") + } + if !cacheableGHRead([]string{"gist", "view", "1"}) || !cacheableGHRead([]string{"project", "item-list"}) || !cacheableGHRead([]string{"cache", "list"}) { + t.Fatal("expected read-only command to be cacheable") + } + if ghAPIReadOnly([]string{"repos/openclaw/gitcrawl/issues", "-f", "title=x"}) || ghAPIReadOnly([]string{"repos/openclaw/gitcrawl", "-X"}) || ghAPIReadOnly([]string{"repos/openclaw/gitcrawl", "--method=PATCH"}) { + t.Fatal("mutating or malformed API command should not be read-only") + } + if got := ghAPIPathArg([]string{"--paginate", "-H", "Accept: json", "--jq", ".[]", "--template", "{{.}}", "repos/openclaw/gitcrawl/issues"}); got != "repos/openclaw/gitcrawl/issues" { + t.Fatalf("api path with skipped flags = %q", got) + } + if got := ghAPIPathArg([]string{"-f", "x=y"}); got != "" { + t.Fatalf("api path with only fields = %q", got) + } + if !ghAPIReadOnly([]string{"repos/openclaw/gitcrawl", "--method=GET"}) { + t.Fatal("GET API command should be read-only") + } + if ghGraphQLReadOnly([]string{"graphql"}) || ghGraphQLReadOnly([]string{"graphql", "-X"}) || ghGraphQLReadOnly([]string{"graphql", "-X", "PUT", "-f", "query={ viewer { login } }"}) || ghGraphQLReadOnly([]string{"graphql", "--field=query=@query.graphql"}) { + t.Fatal("malformed or mutating GraphQL command should not be read-only") + } + if !ghGraphQLReadOnly([]string{"graphql", "--field=query=query { viewer { login } }"}) { + t.Fatal("GraphQL query should be read-only") + } + t.Setenv("GITCRAWL_GH_CACHE_TTL", "2m") + if got := ghCommandCacheTTL([]string{"repo", "view"}); got != 2*time.Minute { + t.Fatalf("env ttl = %s", got) + } + t.Setenv("GITCRAWL_GH_CACHE_TTL", "") + ttlCases := []struct { + args []string + want time.Duration + }{ + {[]string{"api", "repos/openclaw/gitcrawl/pages/builds/latest"}, 2 * time.Minute}, + {[]string{"api", "repos/openclaw/gitcrawl/pages/health"}, 15 * time.Minute}, + {[]string{"api", "repos/openclaw/gitcrawl/actions/jobs/123/logs"}, 12 * time.Hour}, + {[]string{"api", "repos/openclaw/gitcrawl/actions/jobs/123"}, time.Minute}, + {[]string{"api", "repos/openclaw/gitcrawl/actions/runs/123/pending_deployments"}, 30 * time.Second}, + {[]string{"api", "repos/openclaw/gitcrawl/actions/workflows/ci.yml"}, 15 * time.Minute}, + {[]string{"api", "repos/openclaw/gitcrawl/releases/latest"}, time.Hour}, + {[]string{"api", "repos/openclaw/gitcrawl/branches/main"}, 10 * time.Minute}, + {[]string{"workflow", "list"}, 15 * time.Minute}, + {[]string{"issue", "view"}, 5 * time.Minute}, + {[]string{"unknown"}, 5 * time.Minute}, + } + for _, tc := range ttlCases { + if got := ghCommandCacheTTL(tc.args); got != tc.want { + t.Fatalf("ttl %v = %s, want %s", tc.args, got, tc.want) + } + } + if !ghAPIContentRefIsStable([]string{"repos/openclaw/gitcrawl/contents/a?ref=v1.2.3-beta+1"}) || ghAPIContentRefIsStable([]string{"repos/openclaw/gitcrawl/contents/a?ref=refs/heads/v1.2.3"}) || ghAPIContentRefIsStable([]string{"repos/openclaw/gitcrawl/contents/a?ref=v1.2"}) { + t.Fatal("stable content ref classification mismatch") + } + t.Setenv("GH_REPO", "openclaw/from-env") + repo, number, ok := parseGHPRDiffIdentityArgs([]string{"pr", "diff", "42"}) + if !ok || repo != "openclaw/from-env" || number != 42 { + t.Fatalf("diff identity repo=%q number=%d ok=%v", repo, number, ok) + } + for _, args := range [][]string{{"issue", "close"}, {"pr", "merge"}, {"project", "item-add"}, {"release", "upload"}, {"repo", "delete"}, {"run", "rerun"}, {"secret", "set"}, {"variable", "delete"}, {"workflow", "disable"}, {"api", "repos/openclaw/gitcrawl/issues", "-f", "title=x"}} { + if !mutatingGHCommand(args) { + t.Fatalf("%v should be mutating", args) + } + } + if mutatingGHCommand([]string{"pr", "checkout"}) || mutatingGHCommand([]string{"repo", "view"}) || mutatingGHCommand([]string{"api", "repos/openclaw/gitcrawl"}) { + t.Fatal("read-only commands classified as mutating") + } + for _, remote := range []string{"git@github.com:openclaw/gitcrawl.git", "https://github.com/openclaw/gitcrawl.git", "ssh://git@github.com/openclaw/gitcrawl.git"} { + if got, err := ownerRepoFromGitRemote(remote); err != nil || got != "openclaw/gitcrawl" { + t.Fatalf("remote %q => %q err=%v", remote, got, err) + } + } + if _, err := ownerRepoFromGitRemote("not-a-github-remote"); err == nil { + t.Fatal("bad remote should fail") + } + app := New() + if got, err := app.resolveGHRepo(context.Background(), " openclaw/explicit "); err != nil || got != "openclaw/explicit" { + t.Fatalf("explicit repo = %q err=%v", got, err) + } + if got, err := app.resolveGHRepo(context.Background(), ""); err != nil || got != "openclaw/from-env" { + t.Fatalf("env repo = %q err=%v", got, err) + } + t.Setenv("GH_REPO", "") + repoDir := t.TempDir() + if err := runGit(context.Background(), repoDir, "init", "-b", "main"); err != nil { + t.Fatalf("init git repo: %v", err) + } + if err := runGit(context.Background(), repoDir, "remote", "add", "origin", "https://github.com/openclaw/gitcrawl.git"); err != nil { + t.Fatalf("add origin: %v", err) + } + original, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { _ = os.Chdir(original) }() + if err := os.Chdir(repoDir); err != nil { + t.Fatalf("chdir repo: %v", err) + } + if got, err := app.resolveGHRepo(context.Background(), ""); err != nil || got != "openclaw/gitcrawl" { + t.Fatalf("git remote repo = %q err=%v", got, err) + } + ghPath := filepath.Join(t.TempDir(), "gh") + if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho real-gh:$*\n"), 0o755); err != nil { + t.Fatalf("write fake gh: %v", err) + } + t.Setenv("GITCRAWL_GH_PATH", ghPath) + var ghOut bytes.Buffer + app.Stdout = &ghOut + if err := app.runGHShim(context.Background(), nil); err != nil { + t.Fatalf("empty gh shim fallback: %v", err) + } + if strings.TrimSpace(ghOut.String()) != "real-gh:" { + t.Fatalf("empty gh shim output = %q", ghOut.String()) + } + t.Setenv("GITCRAWL_GH_STALE_GRACE", "3m") + if got := ghCommandCacheStaleGrace([]string{"api", "users/octocat"}); got != 3*time.Minute { + t.Fatalf("env stale grace = %s", got) + } + t.Setenv("GITCRAWL_GH_STALE_GRACE", "") + if got := ghCommandCacheStaleGrace([]string{"api", "users/octocat"}); got != 24*time.Hour { + t.Fatalf("user stale grace = %s", got) + } +} diff --git a/internal/cli/runtime_extra_test.go b/internal/cli/runtime_extra_test.go new file mode 100644 index 0000000..0bc94e9 --- /dev/null +++ b/internal/cli/runtime_extra_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPortableRuntimeUtilityBranches(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source.db") + mirror := filepath.Join(dir, "runtime", "source.db") + if _, err := portableRuntimeNeedsCopy(source, mirror); err == nil { + t.Fatal("missing source should fail") + } + if err := os.WriteFile(source, []byte("v1"), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + needs, err := portableRuntimeNeedsCopy(source, mirror) + if err != nil || !needs { + t.Fatalf("missing mirror needs copy=%v err=%v", needs, err) + } + if err := copyFileAtomic(source, mirror); err != nil { + t.Fatalf("copy mirror: %v", err) + } + if err := os.WriteFile(mirror+"-wal", []byte("wal"), 0o644); err != nil { + t.Fatalf("write wal: %v", err) + } + if err := os.WriteFile(mirror+"-shm", []byte("shm"), 0o644); err != nil { + t.Fatalf("write shm: %v", err) + } + if err := os.Chtimes(mirror, time.Now().Add(time.Hour), time.Now().Add(time.Hour)); err != nil { + t.Fatalf("age mirror: %v", err) + } + needs, err = portableRuntimeNeedsCopy(source, mirror) + if err != nil || needs { + t.Fatalf("fresh mirror needs copy=%v err=%v", needs, err) + } + if err := copyFileAtomic(source, mirror); err != nil { + t.Fatalf("recopy mirror: %v", err) + } + if _, err := os.Stat(mirror + "-wal"); !os.IsNotExist(err) { + t.Fatalf("wal sidecar should be removed, err=%v", err) + } + if _, err := os.Stat(mirror + "-shm"); !os.IsNotExist(err) { + t.Fatalf("shm sidecar should be removed, err=%v", err) + } + + statePath := portableStoreRefreshStatePath(mirror) + state := portableStoreRefreshState{LastAttempt: "attempt", LastSuccess: time.Now().UTC().Format(time.RFC3339Nano)} + if err := writePortableStoreRefreshState(statePath, state); err != nil { + t.Fatalf("write state: %v", err) + } + if got := readPortableStoreRefreshState(statePath); got.LastAttempt != "attempt" || got.LastSuccess == "" { + t.Fatalf("state = %+v", got) + } + if err := os.WriteFile(statePath, []byte("{"), 0o600); err != nil { + t.Fatalf("write invalid state: %v", err) + } + if got := readPortableStoreRefreshState(statePath); got.LastAttempt != "" { + t.Fatalf("invalid state should decode empty, got %+v", got) + } + now := time.Now().UTC() + if recentPortableRefresh("", now, time.Minute) || recentPortableRefresh("bad", now, time.Minute) || !recentPortableRefresh(now.Format(time.RFC3339Nano), now, time.Minute) { + t.Fatal("recent refresh classification mismatch") + } + t.Setenv("GITCRAWL_PORTABLE_REFRESH_TTL", "0") + if got := portableStoreRefreshInterval(); got != 0 { + t.Fatalf("zero ttl = %s", got) + } + t.Setenv("GITCRAWL_PORTABLE_REFRESH_TTL", "bad") + if got := portableStoreRefreshInterval(); got != portableStoreRefreshTTL { + t.Fatalf("bad ttl fallback = %s", got) + } + if err := refreshPortableStoreForDB(context.Background(), source); err != nil { + t.Fatalf("non-portable refresh should be no-op: %v", err) + } +} diff --git a/internal/cli/tui_render_extra_test.go b/internal/cli/tui_render_extra_test.go new file mode 100644 index 0000000..a08e00a --- /dev/null +++ b/internal/cli/tui_render_extra_test.go @@ -0,0 +1,326 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/openclaw/gitcrawl/internal/store" +) + +func TestFloatingMenuRenderingBranches(t *testing.T) { + base := strings.Join([]string{ + "01234567890123456789", + "01234567890123456789", + "01234567890123456789", + "01234567890123456789", + "01234567890123456789", + "01234567890123456789", + "01234567890123456789", + "01234567890123456789", + "01234567890123456789", + }, "\n") + model := clusterBrowserModel{ + width: 20, + height: 6, + menuTitle: "Actions", + menuContext: focusClusters, + menuIndex: 2, + menuOff: 1, + menuFloating: true, + menuRect: tuiRect{x: 2, y: 1, w: 16, h: 8}, + menuItems: []tuiMenuItem{ + tuiMenuSection("Hidden"), + {label: "Open", action: "open"}, + {label: "Close", action: "close"}, + {label: "Skip", action: ""}, + {label: "Refresh", action: "refresh"}, + }, + } + rendered := model.renderFloatingMenu(base) + if rendered == base || !strings.Contains(rendered, "Actions") || !strings.Contains(rendered, "Open") { + t.Fatalf("rendered menu = %q", rendered) + } + if got := (clusterBrowserModel{}).renderFloatingMenu(base); got != base { + t.Fatalf("empty rect should keep base view") + } + submenu := model + submenu.menuTitle = "Repository" + if lines := submenu.menuLines(14); !strings.Contains(strings.Join(lines, "\n"), "b back") { + t.Fatalf("submenu lines = %#v", lines) + } + if got := actionMenuSubtitle(focusMembers); got != "selected member scope" { + t.Fatalf("member subtitle = %q", got) + } + if got := actionMenuSubtitle(focusDetail); got != "detail scope" { + t.Fatalf("detail subtitle = %q", got) + } + if got := actionMenuSubtitle(""); got != "current selection" { + t.Fatalf("default subtitle = %q", got) + } + if palette := actionMenuColors(focusMembers); palette.accent == "" || palette.background == "" { + t.Fatalf("member palette = %+v", palette) + } + if style := floatingMenuStyle(1, 1, actionMenuColors("")); style.GetWidth() != 1 || style.GetHeight() != 1 { + t.Fatalf("minimum style size width=%d height=%d", style.GetWidth(), style.GetHeight()) + } + if index, ok := visibleMenuShortcutIndex("2", model.menuItems, 1, 4); !ok || index != 2 { + t.Fatalf("shortcut index=%d ok=%v", index, ok) + } + if _, ok := visibleMenuShortcutIndex("x", model.menuItems, 1, 4); ok { + t.Fatal("non-numeric shortcut should not match") + } +} + +func TestTUIMenuNavigationAndWheelBranches(t *testing.T) { + model := clusterBrowserModel{ + width: 100, + height: 30, + menuIndex: 0, + menuOff: 4, + menuFloating: true, + menuRect: tuiRect{x: 0, y: 0, w: 20, h: 8}, + menuItems: []tuiMenuItem{ + tuiMenuSection("top"), + {label: "one", action: "one"}, + {label: "two", action: "two"}, + tuiMenuSection("middle"), + {label: "three", action: "three"}, + {label: "four", action: "four"}, + }, + payload: clusterBrowserPayload{Clusters: []store.ClusterSummary{ + {ID: 10, Title: "first"}, + {ID: 11, Title: "second"}, + }}, + } + if model.firstSelectableMenuIndex() != 1 || model.lastSelectableMenuIndex() != 5 { + t.Fatalf("selectable bounds first=%d last=%d", model.firstSelectableMenuIndex(), model.lastSelectableMenuIndex()) + } + if got := model.nextSelectableMenuIndex(1); got != 1 { + t.Fatalf("next selectable = %d", got) + } + if got := model.nearestSelectableMenuIndex(3, 1); got != 4 { + t.Fatalf("nearest forward = %d", got) + } + if got := model.nearestSelectableMenuIndex(3, -1); got != 2 { + t.Fatalf("nearest backward = %d", got) + } + empty := clusterBrowserModel{} + if got := empty.nearestSelectableMenuIndex(10, 1); got != 0 { + t.Fatalf("empty nearest = %d", got) + } + model.menuIndex = 5 + model.keepMenuVisible() + if model.menuOff > model.menuIndex { + t.Fatalf("menu off=%d index=%d", model.menuOff, model.menuIndex) + } + layout := tuiLayout{ + clusters: tuiRect{x: 0, y: 2, w: 20, h: 8}, + members: tuiRect{x: 20, y: 2, w: 20, h: 8}, + detail: tuiRect{x: 40, y: 2, w: 20, h: 8}, + } + if got := model.actionMenuContextAt(layout, 1, 3); got != focusClusters { + t.Fatalf("cluster context = %q", got) + } + if got := model.actionMenuContextAt(layout, 21, 3); got != focusMembers { + t.Fatalf("member context = %q", got) + } + if got := model.actionMenuContextAt(layout, 41, 3); got != focusDetail { + t.Fatalf("detail context = %q", got) + } + if got := model.actionMenuContextAt(layout, 99, 99); got != "" { + t.Fatalf("outside context = %q", got) + } + if index, ok := model.menuIndexAtMouse(layout, 1, 4); !ok || index != 6 { + t.Fatalf("menu index at mouse index=%d ok=%v", index, ok) + } + model.menuFloating = false + if index, ok := model.menuIndexAtMouse(layout, 41, 6); !ok || index != 5 { + t.Fatalf("detail menu index at mouse index=%d ok=%v", index, ok) + } + if _, ok := model.menuIndexAtMouse(layout, 99, 99); ok { + t.Fatal("outside mouse should not hit menu") + } + if step := (clusterBrowserModel{width: 100, height: 30}).pageStep(); step <= 0 { + t.Fatalf("cluster page step = %d", step) + } + detailModel := clusterBrowserModel{focus: focusDetail} + detailModel.detailView.Height = 3 + if step := detailModel.pageStep(); step != 3 { + t.Fatalf("detail page step = %d", step) + } + model.selected = 0 + cmd := model.moveClusterByWheel(1) + if cmd == nil || model.selected != 1 || model.status != "Cluster 11" { + t.Fatalf("wheel move selected=%d status=%q cmd=%v", model.selected, model.status, cmd) + } + if cmd := model.moveClusterByWheel(1); cmd != nil { + t.Fatalf("boundary wheel move should not tick: %v", cmd) + } + model.wheelDelta = -1 + model.wheelFocus = focusClusters + if cmd := model.applyQueuedWheelScroll(); cmd == nil || model.focus != focusClusters { + t.Fatalf("queued wheel cmd=%v focus=%q", cmd, model.focus) + } + model.wheelDelta = 0 + if cmd := model.applyQueuedWheelScroll(); cmd != nil { + t.Fatalf("zero queued wheel should be nil: %v", cmd) + } +} + +func TestTUISelectionAndVisibilityHelperBranches(t *testing.T) { + model := clusterBrowserModel{ + payload: clusterBrowserPayload{Limit: 2, Clusters: []store.ClusterSummary{ + {ID: 1, RepresentativeNumber: 101, MemberCount: 2, UpdatedAt: "2026-05-05T10:00:00Z"}, + {ID: 2, RepresentativeNumber: 202, MemberCount: 1, UpdatedAt: "2026-05-05T11:00:00Z"}, + }}, + allClusters: []store.ClusterSummary{ + {ID: 3, RepresentativeNumber: 303, MemberCount: 5, UpdatedAt: "2026-05-05T12:00:00Z"}, + }, + hasDetail: true, + detail: store.ClusterDetail{ + Cluster: store.ClusterSummary{ID: 9, RepresentativeNumber: 909}, + Members: []store.ClusterMemberDetail{{ + Thread: store.Thread{Number: 909, State: "open"}, + }}, + }, + detailCache: map[int64]store.ClusterDetail{ + 8: {Cluster: store.ClusterSummary{ID: 8}, Members: []store.ClusterMemberDetail{{Thread: store.Thread{Number: 808, State: "open"}}}}, + }, + memberRows: []memberRow{ + {label: "header"}, + {selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 202, State: "open"}}}, + }, + } + if got := model.currentClusterID(); got != 1 { + t.Fatalf("current cluster = %d", got) + } + if got := model.clusterRefreshLimit(); got != 2 { + t.Fatalf("refresh limit = %d", got) + } + if got := model.findLoadedClusterIDForThreadNumber(909); got != 9 { + t.Fatalf("detail cluster lookup = %d", got) + } + if got := model.findLoadedClusterIDForThreadNumber(808); got != 8 { + t.Fatalf("cache cluster lookup = %d", got) + } + if got := model.findLoadedClusterIDForThreadNumber(303); got != 3 { + t.Fatalf("working-set cluster lookup = %d", got) + } + if _, ok := model.clusterFromWorkingSet(404); ok { + t.Fatal("missing cluster should not be found") + } + if !model.selectMemberByNumber(202) || model.memberIndex != 1 { + t.Fatalf("member selection index = %d", model.memberIndex) + } + if model.selectMemberByNumber(999) { + t.Fatal("missing member should not be selected") + } + openThread := store.Thread{State: "open"} + closedThread := store.Thread{State: "closed"} + localClosedThread := store.Thread{State: "open", ClosedAtLocal: "2026-05-05T00:00:00Z"} + if !threadVisible(openThread, false) || threadVisible(closedThread, false) || threadVisible(localClosedThread, false) || !threadVisible(closedThread, true) { + t.Fatal("thread visibility mismatch") + } + if got := memberDisplayState(store.ClusterMemberDetail{State: "removed", Thread: openThread}); got != "removed" { + t.Fatalf("member state = %q", got) + } + if got := memberDisplayState(store.ClusterMemberDetail{Thread: localClosedThread}); got != "local" { + t.Fatalf("local member state = %q", got) + } + if memberVisible(store.ClusterMemberDetail{State: "removed", Thread: openThread}, false) || !memberVisible(store.ClusterMemberDetail{State: "removed", Thread: closedThread}, true) { + t.Fatal("member visibility mismatch") + } + noLimit := clusterBrowserModel{payload: clusterBrowserPayload{Clusters: model.payload.Clusters}, allClusters: model.allClusters} + if got := noLimit.clusterRefreshLimit(); got < len(model.allClusters) { + t.Fatalf("no-limit refresh limit = %d", got) + } +} + +func TestTUIJumpToThreadNumberLoadsClusterFromStore(t *testing.T) { + st, repoID, clusterID := seedTUIDurableStore(t) + defer st.Close() + model := clusterBrowserModel{ + ctx: context.Background(), + store: st, + repoID: repoID, + detailCache: map[int64]store.ClusterDetail{}, + payload: clusterBrowserPayload{Limit: 1, Sort: "recent"}, + minSize: 99, + } + model.jumpToThreadNumber(0) + if model.status != "Enter a positive issue or PR number" { + t.Fatalf("bad jump status = %q", model.status) + } + model.jumpToThreadNumber(202) + if model.focus != focusMembers || !strings.Contains(model.status, "Jumped to #202") { + t.Fatalf("jump focus=%q status=%q", model.focus, model.status) + } + if len(model.payload.Clusters) == 0 || model.payload.Clusters[model.selected].ID != clusterID { + t.Fatalf("selected clusters = %+v selected=%d want cluster %d", model.payload.Clusters, model.selected, clusterID) + } + if model.memberIndex < 0 || model.memberRows[model.memberIndex].thread().Number != 202 { + t.Fatalf("member rows index=%d rows=%+v", model.memberIndex, model.memberRows) + } + if _, ok := model.detailCache[clusterID]; !ok { + t.Fatalf("detail cache missing cluster %d", clusterID) + } + model.jumpToThreadNumber(999) + if model.status == "" || strings.Contains(model.status, "Jumped") { + t.Fatalf("missing jump status = %q", model.status) + } +} + +func TestTUIJumpKeyAndRefreshCommandBranches(t *testing.T) { + input := textinput.New() + input.SetValue("#0") + model := clusterBrowserModel{searchInput: input, jumping: true} + next, cmd := model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil || next.jumping || next.status != "Enter a positive issue or PR number" { + t.Fatalf("bad enter next=%+v cmd=%v", next, cmd) + } + input = textinput.New() + input.SetValue("123") + model = clusterBrowserModel{ + searchInput: input, + jumping: true, + payload: clusterBrowserPayload{Clusters: []store.ClusterSummary{{ID: 1, RepresentativeNumber: 123}}}, + allClusters: []store.ClusterSummary{{ID: 1, RepresentativeNumber: 123}}, + detailCache: map[int64]store.ClusterDetail{}, + } + next, cmd = model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil || next.jumping || !strings.Contains(next.status, "outside loaded members") { + t.Fatalf("valid enter next status=%q cmd=%v", next.status, cmd) + } + model = clusterBrowserModel{searchInput: textinput.New(), jumping: true} + next, cmd = model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEsc}) + if cmd != nil || next.jumping || next.status != "Jump cancelled" { + t.Fatalf("esc next=%+v cmd=%v", next, cmd) + } + next, cmd = model.handleJumpKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("4")}) + if next.jumping != true { + t.Fatalf("rune input should keep jump mode, next=%+v cmd=%v", next, cmd) + } + if (clusterBrowserModel{}).remoteRefreshTickCmd() == nil || (clusterBrowserModel{}).autoRefreshCmd() != nil || (clusterBrowserModel{store: &store.Store{}, repoID: 1}).autoRefreshCmd() == nil { + t.Fatal("refresh tick commands should be scheduled") + } +} + +func TestInteractiveTUIFallsBackToJSONForNonFileOutput(t *testing.T) { + app := New() + var out bytes.Buffer + app.Stdout = &out + if app.canRunInteractiveTUI() { + t.Fatal("buffer stdout should not be interactive") + } + payload := clusterBrowserPayload{Repository: "openclaw/openclaw", Mode: "clusters", Clusters: []store.ClusterSummary{{ID: 1, MemberCount: 2}}} + if err := app.runInteractiveTUI(context.Background(), nil, 0, payload); err != nil { + t.Fatalf("run tui fallback: %v", err) + } + if !strings.Contains(out.String(), `"repository": "openclaw/openclaw"`) || !strings.Contains(out.String(), `"clusters"`) { + t.Fatalf("fallback tui output = %q", out.String()) + } +} diff --git a/internal/openai/client_test.go b/internal/openai/client_test.go index d3e4f8a..38f22b8 100644 --- a/internal/openai/client_test.go +++ b/internal/openai/client_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -13,6 +14,12 @@ import ( "unicode/utf8" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + func TestEmbedAcceptsLargeBatchResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request embeddingRequest @@ -357,3 +364,79 @@ func TestEmbedRetryAfterDateForm(t *testing.T) { t.Fatalf("expected ~3s sleep from HTTP-date Retry-After, got %v", slept) } } + +func TestOpenAIErrorAndRetryHelpers(t *testing.T) { + apiErr := &APIError{Status: http.StatusBadGateway, Type: "overloaded_error", Code: "overloaded", Message: "try later"} + if got := apiErr.Error(); !strings.Contains(got, "status=502") || !strings.Contains(got, "message=try later") { + t.Fatalf("error string = %q", got) + } + if !apiErr.Retryable() || !apiErr.IsOverloaded() { + t.Fatalf("retryable/overloaded = %v/%v", apiErr.Retryable(), apiErr.IsOverloaded()) + } + if (*APIError)(nil).Retryable() || !(&APIError{Status: http.StatusGatewayTimeout}).Retryable() || (&APIError{Status: http.StatusTooManyRequests, Type: "insufficient_quota"}).Retryable() { + t.Fatal("unexpected retryable classification") + } + if AsAPIError(nil) != nil || AsAPIError(errors.New("plain")) != nil { + t.Fatal("unexpected APIError extraction") + } + now := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + if got := parseRetryAfter("1.5", now); got != 1500*time.Millisecond { + t.Fatalf("float retry-after = %s", got) + } + if got := parseRetryAfter("-1", now); got != 0 { + t.Fatalf("negative retry-after = %s", got) + } + if got := parseRetryAfter(now.Add(-time.Minute).Format(http.TimeFormat), now); got != 0 { + t.Fatalf("past retry-after = %s", got) + } + retry := RetryConfig{MaxAttempts: -1, BaseDelay: 0, MaxDelay: 50 * time.Millisecond, MaxElapsed: 0, Jitter: 0} + client := New(Options{APIKey: "test", Retry: &retry}) + if client.retry.MaxAttempts != 1 { + t.Fatalf("max attempts = %d, want normalized 1", client.retry.MaxAttempts) + } + if got := client.backoff(10, 0, time.Second); got != 50*time.Millisecond { + t.Fatalf("retry-after should be clamped to max delay, got %s", got) + } + if got := client.backoff(10, 0, 0); got != 50*time.Millisecond { + t.Fatalf("exponential backoff should be clamped to max delay, got %s", got) + } + if !client.canSleep(now, 24*time.Hour) { + t.Fatal("max elapsed <= 0 should allow sleeping") + } + if err := sleepCtx(context.Background(), 0); err != nil { + t.Fatalf("zero sleep: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := sleepCtx(ctx, time.Hour); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled sleep err = %v", err) + } +} + +func TestEmbedRetriesTransportError(t *testing.T) { + var calls int + client := New(Options{ + APIKey: "test", + BaseURL: "https://example.invalid", + Retry: &RetryConfig{MaxAttempts: 2, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond, MaxElapsed: time.Hour, Jitter: 0}, + Sleep: func(context.Context, time.Duration) error { return nil }, + HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return nil, errors.New("temporary network break") + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.5]}]}`)), + }, nil + })}, + }) + vectors, err := client.Embed(context.Background(), "model", []string{"hi"}) + if err != nil { + t.Fatalf("embed: %v", err) + } + if calls != 2 || len(vectors) != 1 || vectors[0][0] != 0.5 { + t.Fatalf("calls=%d vectors=%v", calls, vectors) + } +} diff --git a/internal/store/comments_test.go b/internal/store/comments_test.go index 7a5fa69..d9a6025 100644 --- a/internal/store/comments_test.go +++ b/internal/store/comments_test.go @@ -36,4 +36,18 @@ func TestUpsertComment(t *testing.T) { if id == 0 { t.Fatal("expected comment id") } + if _, err := st.UpsertComment(ctx, Comment{ + ThreadID: threadID, GitHubID: "c0", CommentType: "issue_comment", + AuthorLogin: "octobot", AuthorType: "Bot", Body: "earlier bot note", IsBot: true, RawJSON: "{}", + CreatedAtGitHub: "2026-04-25T00:00:00Z", UpdatedAtGitHub: "2026-04-25T00:01:00Z", + }); err != nil { + t.Fatalf("second comment: %v", err) + } + comments, err := st.ListComments(ctx, threadID) + if err != nil { + t.Fatalf("list comments: %v", err) + } + if len(comments) != 2 || comments[0].GitHubID != "c0" || !comments[0].IsBot || comments[1].GitHubID != "c1" { + t.Fatalf("comments = %+v", comments) + } } diff --git a/internal/store/coverage_test.go b/internal/store/coverage_test.go index 47ca16d..738bd41 100644 --- a/internal/store/coverage_test.go +++ b/internal/store/coverage_test.go @@ -254,6 +254,70 @@ func TestPortablePruneCanonicalizesSchemaAndMetadata(t *testing.T) { } } +func TestPortablePruneClearsPRRawJSONBlobPointersAndFingerprints(t *testing.T) { + ctx := context.Background() + st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer st.Close() + repoID, threadIDs := seedVectorThreads(t, ctx, st) + threadID := threadIDs[1] + if _, err := st.DB().ExecContext(ctx, ` + insert into blobs(id, sha256, media_type, compression, size_bytes, storage_kind, inline_text, created_at) + values(1, 'sha', 'application/json', 'none', 2, 'inline', '{}', '2026-05-05T00:00:00Z'); + insert into thread_revisions(id, thread_id, source_updated_at, content_hash, title_hash, body_hash, labels_hash, raw_json_blob_id, created_at) + values(1, ?, '2026-05-05T00:00:00Z', 'content', 'title', 'body', 'labels', 1, '2026-05-05T00:00:00Z'); + insert into thread_fingerprints(thread_revision_id, algorithm_version, fingerprint_hash, fingerprint_slug, title_tokens_json, body_token_hash, linked_refs_json, file_set_hash, module_buckets_json, simhash64, feature_json, created_at) + values(1, 'v1', 'hash', 'slug', '["token"]', 'body', '["#1"]', 'files', '["module"]', '1', '{"x":1}', '2026-05-05T00:00:00Z'); + `, threadID); err != nil { + t.Fatalf("seed revision/fingerprint: %v", err) + } + if _, err := st.UpsertComment(ctx, Comment{ThreadID: threadID, GitHubID: "raw-comment", CommentType: "issue_comment", Body: "comment body that is long", RawJSON: `{"raw":true}`, CreatedAtGitHub: "2026-05-05T00:00:00Z"}); err != nil { + t.Fatalf("seed comment: %v", err) + } + if _, err := st.DB().ExecContext(ctx, `update comments set raw_json_blob_id = 1 where github_id = 'raw-comment'`); err != nil { + t.Fatalf("link comment blob: %v", err) + } + if err := st.UpsertPullRequestCache(ctx, + PullRequestDetail{ThreadID: threadID, RepoID: repoID, Number: 302, HeadSHA: "head", RawJSON: `{"detail":true}`, FetchedAt: "2026-05-05T00:00:00Z", UpdatedAt: "2026-05-05T00:00:00Z"}, + []PullRequestFile{{Path: "a.go", RawJSON: `{"file":true}`, FetchedAt: "2026-05-05T00:00:00Z"}}, + []PullRequestCommit{{SHA: "abc", RawJSON: `{"commit":true}`, FetchedAt: "2026-05-05T00:00:00Z"}}, + []PullRequestCheck{{Name: "ci", RawJSON: `{"check":true}`, FetchedAt: "2026-05-05T00:00:00Z"}}, + []WorkflowRun{{RepoID: repoID, RunID: "1", RawJSON: `{"run":true}`, FetchedAt: "2026-05-05T00:00:00Z"}}, + ); err != nil { + t.Fatalf("seed pr cache: %v", err) + } + stats, err := st.PrunePortablePayloads(ctx, PortablePruneOptions{BodyChars: 4}) + if err != nil { + t.Fatalf("prune portable: %v", err) + } + if stats.RawJSONPruned < 6 || stats.FingerprintsPruned != 1 || stats.CommentsPruned != 1 { + t.Fatalf("portable stats = %+v", stats) + } + var commentRaw string + var commentBlob, revisionBlob any + if err := st.DB().QueryRowContext(ctx, `select raw_json, raw_json_blob_id from comments where github_id = 'raw-comment'`).Scan(&commentRaw, &commentBlob); err != nil { + t.Fatalf("read pruned comment: %v", err) + } + if commentRaw != "" || commentBlob != nil { + t.Fatalf("comment raw=%q blob=%v", commentRaw, commentBlob) + } + if err := st.DB().QueryRowContext(ctx, `select raw_json_blob_id from thread_revisions where id = 1`).Scan(&revisionBlob); err != nil { + t.Fatalf("read pruned revision: %v", err) + } + if revisionBlob != nil { + t.Fatalf("revision blob=%v", revisionBlob) + } + var titleTokens, linkedRefs, modules, features string + if err := st.DB().QueryRowContext(ctx, `select title_tokens_json, linked_refs_json, module_buckets_json, feature_json from thread_fingerprints where id = 1`).Scan(&titleTokens, &linkedRefs, &modules, &features); err != nil { + t.Fatalf("read pruned fingerprint: %v", err) + } + if titleTokens != "[]" || linkedRefs != "[]" || modules != "[]" || features != "{}" { + t.Fatalf("fingerprint title=%q refs=%q modules=%q features=%q", titleTokens, linkedRefs, modules, features) + } +} + func TestClusterHelperBranches(t *testing.T) { summaries := []ClusterSummary{ {ID: 1, MemberCount: 1, UpdatedAt: "2026-04-30T01:00:00Z"}, @@ -267,6 +331,23 @@ func TestClusterHelperBranches(t *testing.T) { if summaries[0].ID != 1 { t.Fatalf("recent sort = %+v", summaries) } + summaries = []ClusterSummary{ + {ID: 3, MemberCount: 2, UpdatedAt: "2026-04-30T01:00:00Z"}, + {ID: 2, MemberCount: 2, UpdatedAt: "2026-04-30T01:00:00Z"}, + {ID: 1, MemberCount: 3, UpdatedAt: "2026-04-30T00:00:00Z"}, + } + sortClusterSummaries(summaries, "size") + if summaries[0].ID != 1 || summaries[1].ID != 2 { + t.Fatalf("size tie sort = %+v", summaries) + } + sortClusterSummaries(summaries, "oldest") + if summaries[0].ID != 1 || summaries[1].ID != 2 { + t.Fatalf("oldest tie sort = %+v", summaries) + } + sortClusterSummaries(summaries, "recent") + if summaries[0].ID != 2 || summaries[1].ID != 3 { + t.Fatalf("recent tie sort = %+v", summaries) + } if ids := parseIDSet(`1, 2, 0, bad, 3`); len(ids) != 3 || !ids[2] { t.Fatalf("parse id set = %+v", ids) } @@ -276,6 +357,15 @@ func TestClusterHelperBranches(t *testing.T) { if got := snippetRunes("abcdef", 3); got != "abc" { t.Fatalf("snippet = %q", got) } + if got := rowsAffected(errorResult{}); got != 0 { + t.Fatalf("error rows affected = %d", got) + } + if got := nullString(""); got.Valid { + t.Fatalf("empty null string = %+v", got) + } + if got := nullString("x"); !got.Valid || got.String != "x" { + t.Fatalf("non-empty null string = %+v", got) + } if func() (panicked bool) { defer func() { panicked = recover() != nil }() _ = sqliteIdentifier(`bad"name`) @@ -756,6 +846,16 @@ func TestPortableVacuumAndVectorQueryBranches(t *testing.T) { } } +type errorResult struct{} + +func (errorResult) LastInsertId() (int64, error) { + return 0, sql.ErrNoRows +} + +func (errorResult) RowsAffected() (int64, error) { + return 0, sql.ErrNoRows +} + func seedVectorThreads(t *testing.T, ctx context.Context, st *Store) (int64, []int64) { t.Helper() now := time.Now().UTC().Format(time.RFC3339Nano) diff --git a/internal/store/pull_requests_test.go b/internal/store/pull_requests_test.go new file mode 100644 index 0000000..687a05d --- /dev/null +++ b/internal/store/pull_requests_test.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "path/filepath" + "testing" +) + +func TestPullRequestCacheRoundTripAndWorkflowFilters(t *testing.T) { + ctx := context.Background() + st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer st.Close() + repoID, threadIDs := seedVectorThreads(t, ctx, st) + threadID := threadIDs[1] + fetchedAt := "2026-05-05T10:00:00Z" + + detail := PullRequestDetail{ + ThreadID: threadID, RepoID: repoID, Number: 302, + BaseSHA: "base", HeadSHA: "head", HeadRef: "feature/cache", HeadRepoFullName: "openclaw/gitcrawl-fork", + MergeableState: "clean", Additions: 12, Deletions: 3, ChangedFiles: 2, + RawJSON: "{}", FetchedAt: fetchedAt, UpdatedAt: "2026-05-05T09:59:00Z", + } + files := []PullRequestFile{ + {Path: "z.go", Status: "modified", Additions: 2, Deletions: 1, Changes: 3, Patch: "@@", RawJSON: "{}", FetchedAt: fetchedAt}, + {Path: "a.go", Status: "renamed", Additions: 10, Changes: 10, PreviousPath: "old.go", RawJSON: "{}", FetchedAt: fetchedAt}, + } + commits := []PullRequestCommit{ + {SHA: "abc", Message: "feat: cache", AuthorLogin: "alice", AuthorName: "Alice", CommittedAt: "2026-05-05T08:00:00Z", HTMLURL: "https://example.invalid/commit/abc", RawJSON: "{}", FetchedAt: fetchedAt}, + } + checks := []PullRequestCheck{ + {Name: "z-check", Status: "completed", Conclusion: "success", DetailsURL: "https://example.invalid/z", WorkflowName: "CI", StartedAt: "2026-05-05T09:00:00Z", CompletedAt: "2026-05-05T09:05:00Z", RawJSON: "{}", FetchedAt: fetchedAt}, + {Name: "a-check", Status: "queued", RawJSON: "{}", FetchedAt: fetchedAt}, + } + runs := []WorkflowRun{ + {RepoID: repoID, RunID: "100", RunNumber: 7, HeadBranch: "main", HeadSHA: "head", Status: "completed", Conclusion: "success", WorkflowName: "CI", Event: "push", HTMLURL: "https://example.invalid/run/100", CreatedAtGH: "2026-05-05T09:00:00Z", UpdatedAtGH: "2026-05-05T09:05:00Z", RawJSON: "{}", FetchedAt: fetchedAt}, + {RepoID: repoID, RunID: "101", RunNumber: 8, HeadBranch: "release", HeadSHA: "other", Status: "in_progress", WorkflowName: "release", Event: "workflow_dispatch", CreatedAtGH: "2026-05-05T09:10:00Z", UpdatedAtGH: "2026-05-05T09:11:00Z", RawJSON: "{}", FetchedAt: fetchedAt}, + } + if err := st.UpsertPullRequestCache(ctx, detail, files, commits, checks, runs); err != nil { + t.Fatalf("upsert pr cache: %v", err) + } + cache, err := st.PullRequestCache(ctx, repoID, 302) + if err != nil { + t.Fatalf("pull request cache: %v", err) + } + if cache.Detail.HeadSHA != "head" || cache.Detail.MergeableState != "clean" { + t.Fatalf("detail = %+v", cache.Detail) + } + if len(cache.Files) != 2 || cache.Files[0].Path != "a.go" || cache.Files[0].PreviousPath != "old.go" { + t.Fatalf("files = %+v", cache.Files) + } + if len(cache.Commits) != 1 || cache.Commits[0].SHA != "abc" || cache.Commits[0].AuthorName != "Alice" { + t.Fatalf("commits = %+v", cache.Commits) + } + if len(cache.Checks) != 2 || cache.Checks[0].Name != "a-check" || cache.Checks[1].Conclusion != "success" { + t.Fatalf("checks = %+v", cache.Checks) + } + + mainRuns, err := st.ListWorkflowRuns(ctx, repoID, WorkflowRunListOptions{Branch: "main", HeadSHA: "head", Limit: 5}) + if err != nil { + t.Fatalf("list filtered runs: %v", err) + } + if len(mainRuns) != 1 || mainRuns[0].RunID != "100" || mainRuns[0].HTMLURL == "" { + t.Fatalf("main runs = %+v", mainRuns) + } + allRuns, err := st.ListWorkflowRuns(ctx, repoID, WorkflowRunListOptions{}) + if err != nil { + t.Fatalf("list default runs: %v", err) + } + if len(allRuns) != 2 || allRuns[0].RunID != "101" { + t.Fatalf("all runs = %+v", allRuns) + } + + detail.HeadSHA = "head-v2" + if err := st.UpsertPullRequestCache(ctx, detail, files[:1], nil, nil, []WorkflowRun{{RepoID: repoID, RunID: "100", RunNumber: 9, HeadBranch: "main", HeadSHA: "head-v2", Status: "completed", Conclusion: "failure", UpdatedAtGH: "2026-05-05T10:00:00Z", RawJSON: "{}", FetchedAt: fetchedAt}}); err != nil { + t.Fatalf("update pr cache: %v", err) + } + cache, err = st.PullRequestCache(ctx, repoID, 302) + if err != nil { + t.Fatalf("updated pull request cache: %v", err) + } + if cache.Detail.HeadSHA != "head-v2" || len(cache.Files) != 1 || len(cache.Commits) != 0 || len(cache.Checks) != 0 { + t.Fatalf("updated cache = %+v", cache) + } +} diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index bac0344..7a2a606 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -644,3 +644,40 @@ func TestMappingHelperBranches(t *testing.T) { t.Fatalf("comment = %+v", comment) } } + +func TestMappingFallbackBranches(t *testing.T) { + now := time.Date(2026, 5, 5, 12, 0, 0, 123, time.UTC) + normalized, err := normalizeSince("2026-05-05T12:00:00+02:00", now) + if err != nil { + t.Fatalf("normalize iso since: %v", err) + } + if normalized != "2026-05-05T10:00:00Z" { + t.Fatalf("normalized iso since = %q", normalized) + } + if got, err := normalizeSince("2w", now); err != nil || got != "2026-04-21T12:00:00.000000123Z" { + t.Fatalf("normalize weeks = %q, %v", got, err) + } + if got := mustJSON(map[string]any{"bad": make(chan int)}); got != "{}" { + t.Fatalf("mustJSON marshal fallback = %q", got) + } + + thread := mapIssueToThread(99, map[string]any{ + "id": int64(123), + "number": 456, + "state": "closed", + "title": "fallbacks", + "body": "body", + "html_url": "https://github.com/openclaw/gitcrawl/issues/456", + "labels": nil, + "assignees": nil, + "created_at": "2026-05-05T10:00:00Z", + "updated_at": "2026-05-05T11:00:00Z", + "closed_at": "2026-05-05T12:00:00Z", + }, "2026-05-05T12:00:00Z") + if thread.LabelsJSON != "[]" || thread.AssigneesJSON != "[]" { + t.Fatalf("nullable label defaults: labels=%s assignees=%s", thread.LabelsJSON, thread.AssigneesJSON) + } + if thread.GitHubID != "123" || thread.Number != 456 || thread.AuthorLogin != "" || thread.ClosedAtGitHub == "" { + t.Fatalf("thread = %+v", thread) + } +}