test: enforce 85 percent coverage gate

This commit is contained in:
Peter Steinberger 2026-05-05 22:00:07 +01:00
parent e5621d1b78
commit 54f7107df9
No known key found for this signature in database
13 changed files with 1099 additions and 4 deletions

View File

@ -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

View File

@ -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`.

View File

@ -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)

View File

@ -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"},

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}