gitcrawl/internal/cli/app_test.go
2026-05-08 09:50:17 +01:00

3154 lines
109 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
clusterer "github.com/openclaw/gitcrawl/internal/cluster"
"github.com/openclaw/gitcrawl/internal/config"
"github.com/openclaw/gitcrawl/internal/store"
)
func TestInitWritesConfig(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
err := app.Run(context.Background(), []string{"--config", configPath, "--json", "init", "--db", dbPath})
if err != nil {
t.Fatalf("run init: %v", err)
}
if !strings.Contains(stdout.String(), `"config_path"`) {
t.Fatalf("expected json init output, got %q", stdout.String())
}
}
func TestInitDefaultOutputIsHumanReadable(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
err := app.Run(context.Background(), []string{"--config", configPath, "init", "--db", dbPath})
if err != nil {
t.Fatalf("run init: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "gitcrawl init") {
t.Fatalf("expected human init output, got %q", out)
}
if strings.Contains(out, `"config_path"`) || strings.Contains(out, "{") {
t.Fatalf("default init output should not be json, got %q", out)
}
}
func TestMetadataStatusAndControlStatusJSON(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
init := New()
if err := init.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
if err := os.WriteFile(dbPath+"-wal", []byte("wal"), 0o600); err != nil {
t.Fatalf("write wal: %v", err)
}
for _, tc := range []struct {
name string
args []string
want string
}{
{name: "metadata", args: []string{"--config", configPath, "metadata", "--json"}, want: "commands"},
{name: "status", args: []string{"--config", configPath, "status", "--json"}, want: "databases"},
{name: "status missing config", args: []string{"--config", filepath.Join(dir, "missing.toml"), "status", "--json"}, want: "counts"},
} {
t.Run(tc.name, func(t *testing.T) {
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
if err := app.Run(ctx, tc.args); err != nil {
t.Fatalf("run %s: %v", tc.name, err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode %s output %q: %v", tc.name, stdout.String(), err)
}
if payload["app_id"] != "gitcrawl" && payload["id"] != "gitcrawl" {
t.Fatalf("expected gitcrawl payload, got %#v", payload)
}
if _, ok := payload[tc.want]; !ok {
t.Fatalf("expected %s in %#v", tc.want, payload)
}
})
}
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
sizePath := filepath.Join(dir, "sized.db")
if err := os.WriteFile(sizePath, []byte("db"), 0o600); err != nil {
t.Fatalf("write sized db: %v", err)
}
if err := os.WriteFile(sizePath+"-wal", []byte("wal"), 0o600); err != nil {
t.Fatalf("write sized wal: %v", err)
}
lastSync := time.Unix(100, 0)
out := controlStatus(configPath, cfg, store.Status{
DBPath: sizePath,
RepositoryCount: 2,
ThreadCount: 3,
OpenThreadCount: 1,
ClusterCount: 4,
LastSyncAt: lastSync,
})
if out.DatabaseBytes == 0 {
t.Fatalf("database bytes should be populated: %#v", out)
}
if out.WALBytes != 3 {
t.Fatalf("wal bytes = %d, want 3", out.WALBytes)
}
if out.LastSyncAt != lastSync.UTC().Format(time.RFC3339) {
t.Fatalf("last sync = %q", out.LastSyncAt)
}
if len(out.Databases) != 1 || out.Databases[0].Path != sizePath || !out.Databases[0].IsPrimary {
t.Fatalf("database metadata = %#v", out.Databases)
}
if got := fileSize(filepath.Join(dir, "missing.db")); got != 0 {
t.Fatalf("missing file size = %d, want 0", got)
}
var helpOut bytes.Buffer
help := New()
help.Stdout = &helpOut
if err := help.printCommandUsage("portable"); err != nil {
t.Fatalf("portable help: %v", err)
}
if !strings.Contains(helpOut.String(), "portable") {
t.Fatalf("portable help output = %q", helpOut.String())
}
helpOut.Reset()
if err := help.printCommandUsage("tui"); err != nil {
t.Fatalf("tui help: %v", err)
}
if !strings.Contains(helpOut.String(), "cluster browser") {
t.Fatalf("tui help output = %q", helpOut.String())
}
for _, topic := range []string{"metadata", "status", "init", "configure", "doctor", "sync", "refresh", "embed", "threads", "search", "cluster", "clusters", "durable-clusters", "cluster-detail", "cluster-explain", "neighbors", "runs", "close-thread", "reopen-thread", "close-cluster", "reopen-cluster", "exclude-cluster-member", "include-cluster-member", "set-cluster-canonical", "gh"} {
helpOut.Reset()
if err := help.printCommandUsage(topic); err != nil {
t.Fatalf("%s help: %v", topic, err)
}
if !strings.Contains(helpOut.String(), "Usage:") {
t.Fatalf("%s help output = %q", topic, helpOut.String())
}
}
if err := New().Run(ctx, []string{"--config", configPath, "status", "extra"}); err == nil {
t.Fatal("status extra arg should fail")
}
}
func TestControlRepositoryAndClusterHelperBranches(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
cfg := config.Default()
cfg.DBPath = filepath.Join(dir, "gitcrawl.db")
payload := emptyClusterBrowserPayload(ctx, cfg, "", "recent", 2, 50, true)
if payload.DBSource != "local" || payload.DBLocation != "gitcrawl.db" {
t.Fatalf("empty payload source = %s/%s", payload.DBSource, payload.DBLocation)
}
if payload.Sort != "recent" || payload.MinSize != 2 || payload.Limit != 50 || !payload.HideClosed {
t.Fatalf("empty payload options = %#v", payload)
}
rt := localRuntime{Config: cfg}
if got := remoteRefreshSource(rt); got != "" {
t.Fatalf("local refresh source = %q", got)
}
if got := remoteRuntimePath(rt); got != "" {
t.Fatalf("local runtime path = %q", got)
}
rt.RemoteSource = true
rt.SourceDBPath = filepath.Join(dir, "store", "data", "archive.db")
if got := remoteRefreshSource(rt); got != rt.SourceDBPath {
t.Fatalf("remote refresh source = %q", got)
}
if got := remoteRuntimePath(rt); got != cfg.DBPath {
t.Fatalf("remote runtime path = %q", got)
}
if got := githubRepoFromRemote("git@github.com:openclaw/gitcrawl-store.git"); got != "openclaw/gitcrawl-store" {
t.Fatalf("ssh remote repo = %q", got)
}
if got := githubRepoFromRemote("https://github.com/openclaw/gitcrawl-store.git"); got != "openclaw/gitcrawl-store" {
t.Fatalf("https remote repo = %q", got)
}
if got := githubRepoFromRemote("ssh://git@github.com/openclaw/gitcrawl-store.git"); got != "openclaw/gitcrawl-store" {
t.Fatalf("ssh url remote repo = %q", got)
}
if got := githubRepoFromRemote("https://example.com/openclaw/gitcrawl-store.git"); got != "" {
t.Fatalf("non-github remote repo = %q", got)
}
if got := githubRepoFromRemote("https://github.com/openclaw"); got != "" {
t.Fatalf("short github remote repo = %q", got)
}
with, err := parseSyncWith(" pr-details, ")
if err != nil || !with["pr-details"] {
t.Fatalf("parse sync with = %#v, %v", with, err)
}
if _, err := parseSyncWith("reviews"); err == nil {
t.Fatal("unsupported sync --with value should fail")
}
maxSize, fanout, crossKind, err := parseClusterShapeOptions("cluster", "", "", "")
if err != nil {
t.Fatalf("default cluster shape: %v", err)
}
if maxSize != defaultClusterMaxSize || fanout != defaultClusterFanout || crossKind != defaultCrossKindMinScore {
t.Fatalf("default cluster shape = %d/%d/%f", maxSize, fanout, crossKind)
}
if _, _, _, err := parseClusterShapeOptions("cluster", "2", "3", "1.5"); err == nil {
t.Fatal("out-of-range cross-kind threshold should fail")
}
if !stateIncludesClosed("all") || !stateIncludesClosed(" closed ") || stateIncludesClosed("open") {
t.Fatal("state closed helper mismatch")
}
}
func TestInitRejectsDBAndPortableStore(t *testing.T) {
dir := t.TempDir()
app := New()
err := app.Run(context.Background(), []string{
"--config", filepath.Join(dir, "config.toml"),
"init",
"--db", filepath.Join(dir, "gitcrawl.db"),
"--portable-store", "https://github.com/openclaw/gitcrawl-store.git",
})
if err == nil {
t.Fatal("expected init to reject conflicting database options")
}
if ExitCode(err) != 2 {
t.Fatalf("exit code: got %d want 2", ExitCode(err))
}
}
func TestDefaultPortableStoreDir(t *testing.T) {
got := defaultPortableStoreDir("/tmp/gitcrawl/config.toml", "https://github.com/openclaw/gitcrawl-store.git")
want := filepath.Join("/tmp/gitcrawl", "stores", "gitcrawl-store")
if got != want {
t.Fatalf("store dir: got %q want %q", got, want)
}
}
func TestDatabaseSourceLocationLocal(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "gitcrawl.db")
if got := databaseSourceKind(dbPath); got != "local" {
t.Fatalf("source kind = %q, want local", got)
}
if got := databaseSourceLocation(context.Background(), dbPath); got != "gitcrawl.db" {
t.Fatalf("source location = %q, want db filename", got)
}
}
func TestDatabaseSourceLocationRemoteGitHubStore(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
storeDir := filepath.Join(dir, "gitcrawl-store")
dbPath := filepath.Join(storeDir, "data", "openclaw__openclaw.sync.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
t.Fatalf("mkdir store data: %v", err)
}
if err := runGit(ctx, storeDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
if err := runGit(ctx, storeDir, "remote", "add", "origin", "https://github.com/openclaw/gitcrawl-store.git"); err != nil {
t.Fatalf("git remote add: %v", err)
}
if got := databaseSourceKind(dbPath); got != "remote" {
t.Fatalf("source kind = %q, want remote", got)
}
want := "openclaw/gitcrawl-store:openclaw__openclaw.sync.db"
if got := databaseSourceLocation(ctx, dbPath); got != want {
t.Fatalf("source location = %q, want %q", got, want)
}
}
func TestSyncPortableStoreResetsDirtyCache(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
dbPath := filepath.Join(remoteDir, "data", "openclaw__openclaw.sync.db")
if err := os.WriteFile(dbPath, []byte("remote-v1"), 0o644); err != nil {
t.Fatalf("write remote db: %v", err)
}
if err := runGit(ctx, remoteDir, "add", "data/openclaw__openclaw.sync.db"); err != nil {
t.Fatalf("git add: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
action, err := syncPortableStore(ctx, remoteDir, checkoutDir)
if err != nil {
t.Fatalf("initial portable sync: %v", err)
}
if action != "cloned" {
t.Fatalf("initial action = %q, want cloned", action)
}
if err := os.WriteFile(filepath.Join(checkoutDir, "data", "openclaw__openclaw.sync.db"), []byte("local-cache-edit"), 0o644); err != nil {
t.Fatalf("dirty checkout db: %v", err)
}
if err := os.WriteFile(filepath.Join(checkoutDir, "data", "openclaw__openclaw.sync.db-wal"), []byte("stale wal"), 0o644); err != nil {
t.Fatalf("write stale wal: %v", err)
}
if err := os.WriteFile(filepath.Join(checkoutDir, "data", "openclaw__openclaw.sync.db-shm"), []byte("stale shm"), 0o644); err != nil {
t.Fatalf("write stale shm: %v", err)
}
if err := os.WriteFile(dbPath, []byte("remote-v2"), 0o644); err != nil {
t.Fatalf("write updated remote db: %v", err)
}
if err := runGit(ctx, remoteDir, "add", "data/openclaw__openclaw.sync.db"); err != nil {
t.Fatalf("git add update: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "update store"); err != nil {
t.Fatalf("git commit update: %v", err)
}
action, err = syncPortableStore(ctx, remoteDir, checkoutDir)
if err != nil {
t.Fatalf("dirty portable sync: %v", err)
}
if action != "reset-pulled" {
t.Fatalf("dirty action = %q, want reset-pulled", action)
}
got, err := os.ReadFile(filepath.Join(checkoutDir, "data", "openclaw__openclaw.sync.db"))
if err != nil {
t.Fatalf("read checkout db: %v", err)
}
if string(got) != "remote-v2" {
t.Fatalf("checkout db = %q, want remote-v2", string(got))
}
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(filepath.Join(checkoutDir, "data", "openclaw__openclaw.sync.db"+suffix)); !os.IsNotExist(err) {
t.Fatalf("stale sqlite sidecar %s was not removed: %v", suffix, err)
}
}
}
func TestSyncPortableStoreIgnoresBrokenPullRebaseConfig(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
dbPath := filepath.Join(remoteDir, "data", "openclaw__openclaw.sync.db")
if err := os.WriteFile(dbPath, []byte("remote-v1"), 0o644); err != nil {
t.Fatalf("write remote db: %v", err)
}
if err := runGit(ctx, remoteDir, "add", "data/openclaw__openclaw.sync.db"); err != nil {
t.Fatalf("git add: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
if _, err := syncPortableStore(ctx, remoteDir, checkoutDir); err != nil {
t.Fatalf("initial portable sync: %v", err)
}
if err := runGit(ctx, "", "-C", checkoutDir, "config", "pull.rebase", "true"); err != nil {
t.Fatalf("set pull rebase: %v", err)
}
if err := runGit(ctx, "", "-C", checkoutDir, "config", "--add", "branch.main.merge", "refs/heads/backup"); err != nil {
t.Fatalf("add second merge branch: %v", err)
}
if err := os.WriteFile(dbPath, []byte("remote-v2"), 0o644); err != nil {
t.Fatalf("write updated remote db: %v", err)
}
if err := runGit(ctx, remoteDir, "add", "data/openclaw__openclaw.sync.db"); err != nil {
t.Fatalf("git add update: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "update store"); err != nil {
t.Fatalf("git commit update: %v", err)
}
action, err := syncPortableStore(ctx, remoteDir, checkoutDir)
if err != nil {
t.Fatalf("portable sync with broken pull config: %v", err)
}
if action != "pulled" {
t.Fatalf("action = %q, want pulled", action)
}
got, err := os.ReadFile(filepath.Join(checkoutDir, "data", "openclaw__openclaw.sync.db"))
if err != nil {
t.Fatalf("read checkout db: %v", err)
}
if string(got) != "remote-v2" {
t.Fatalf("checkout db = %q, want remote-v2", string(got))
}
}
func TestInitWithPortableStoreCloneAndPull(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
dbRel := filepath.Join("data", "openclaw__openclaw.sync.db")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote data: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 7, "portable init issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add seed: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
configPath := filepath.Join(dir, "config.toml")
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
if err := app.Run(ctx, []string{"--config", configPath, "init", "--portable-store", remoteDir, "--store-dir", checkoutDir, "--portable-db", filepath.ToSlash(dbRel)}); err != nil {
t.Fatalf("portable init: %v", err)
}
if !strings.Contains(stdout.String(), "Portable store") || !strings.Contains(stdout.String(), "cloned") {
t.Fatalf("portable init output = %q", stdout.String())
}
action, err := syncPortableStore(ctx, remoteDir, checkoutDir)
if err != nil {
t.Fatalf("portable pull: %v", err)
}
if action != "pulled" {
t.Fatalf("portable action = %q, want pulled", action)
}
invalid := New()
if err := invalid.Run(ctx, []string{"--config", filepath.Join(dir, "bad.toml"), "init", "--portable-store", remoteDir, "--store-dir", filepath.Join(dir, "bad-checkout"), "--portable-db", "../bad.db"}); err == nil {
t.Fatal("invalid portable db should fail")
}
if _, err := syncPortableStore(ctx, "", checkoutDir); err == nil {
t.Fatal("missing portable URL should fail")
}
if _, err := syncPortableStore(ctx, remoteDir, ""); err == nil {
t.Fatal("missing portable dir should fail")
}
nonGitDir := filepath.Join(dir, "not-git")
if err := os.MkdirAll(nonGitDir, 0o755); err != nil {
t.Fatalf("mkdir non-git: %v", err)
}
if err := os.WriteFile(filepath.Join(nonGitDir, "file"), []byte("x"), 0o644); err != nil {
t.Fatalf("write non-git file: %v", err)
}
if _, err := syncPortableStore(ctx, remoteDir, nonGitDir); err == nil {
t.Fatal("non-git existing dir should fail")
}
}
func TestReadCommandRefreshesPortableStore(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
dbRel := filepath.Join("data", "openclaw__openclaw.sync.db")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote data: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 1, "initial issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add seed: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
if _, err := syncPortableStore(ctx, remoteDir, checkoutDir); err != nil {
t.Fatalf("clone portable store: %v", err)
}
configPath := filepath.Join(dir, "config.toml")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", filepath.Join(checkoutDir, dbRel)}); err != nil {
t.Fatalf("init config: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 2, "refreshed issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add update: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "update store"); err != nil {
t.Fatalf("git commit update: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "threads", "openclaw/openclaw", "--numbers", "2", "--json"}); err != nil {
t.Fatalf("threads: %v", err)
}
if !strings.Contains(stdout.String(), "refreshed issue") {
t.Fatalf("read command did not refresh portable store, got %q", stdout.String())
}
if !gitWorktreeClean(ctx, checkoutDir) {
t.Fatal("portable checkout should stay clean after read-only command")
}
mirrorPath, err := run.portableRuntimeDBPath(filepath.Join(checkoutDir, dbRel))
if err != nil {
t.Fatalf("runtime db path: %v", err)
}
if _, err := os.Stat(mirrorPath); err != nil {
t.Fatalf("runtime mirror db was not created: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 3, "too soon issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add second update: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "second update"); err != nil {
t.Fatalf("git commit second update: %v", err)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "threads", "openclaw/openclaw", "--numbers", "3", "--json"}); err != nil {
t.Fatalf("threads within refresh ttl: %v", err)
}
if strings.Contains(stdout.String(), "too soon issue") {
t.Fatalf("read command should not refresh portable store again within ttl, got %q", stdout.String())
}
}
func TestReadCommandUsesCachedPortableStoreWhenRefreshFails(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
dbRel := filepath.Join("data", "openclaw__openclaw.sync.db")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote data: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 1, "cached issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add seed: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
if _, err := syncPortableStore(ctx, remoteDir, checkoutDir); err != nil {
t.Fatalf("clone portable store: %v", err)
}
if err := runGit(ctx, "", "-C", checkoutDir, "remote", "set-url", "origin", filepath.Join(dir, "missing-remote")); err != nil {
t.Fatalf("break portable remote: %v", err)
}
configPath := filepath.Join(dir, "config.toml")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", filepath.Join(checkoutDir, dbRel)}); err != nil {
t.Fatalf("init config: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "threads", "openclaw/openclaw", "--numbers", "1", "--json"}); err != nil {
t.Fatalf("threads should use cached portable store after refresh failure: %v", err)
}
if !strings.Contains(stdout.String(), "cached issue") {
t.Fatalf("cached portable store was not queried, got %q", stdout.String())
}
}
func TestWritableRuntimeUsesPortableMirror(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
dbRel := filepath.Join("data", "openclaw__openclaw.sync.db")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote data: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 1, "portable issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add seed: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
if _, err := syncPortableStore(ctx, remoteDir, checkoutDir); err != nil {
t.Fatalf("clone portable store: %v", err)
}
configPath := filepath.Join(dir, "config.toml")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", filepath.Join(checkoutDir, dbRel)}); err != nil {
t.Fatalf("init config: %v", err)
}
run := New()
run.configPath = configPath
rt, err := run.openLocalRuntime(ctx)
if err != nil {
t.Fatalf("open writable runtime: %v", err)
}
repo, err := rt.repository(ctx, "openclaw", "openclaw")
if err != nil {
t.Fatalf("repository: %v", err)
}
threads, err := rt.Store.ListThreadsFiltered(ctx, store.ThreadListOptions{RepoID: repo.ID, Numbers: []int{1}})
if err != nil {
t.Fatalf("list threads: %v", err)
}
if len(threads) != 1 {
t.Fatalf("threads = %d, want 1", len(threads))
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if err := rt.Store.UpsertThreadVector(ctx, store.ThreadVector{
ThreadID: threads[0].ID,
Basis: "title_original",
Model: "text-embedding-3-small",
Dimensions: 3,
ContentHash: "hash-vector",
Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("upsert runtime vector: %v", err)
}
if err := rt.Store.Close(); err != nil {
t.Fatalf("close writable runtime: %v", err)
}
if rt.SourceDBPath == rt.Config.DBPath {
t.Fatalf("runtime db path should differ from portable source: %s", rt.Config.DBPath)
}
if !gitWorktreeClean(ctx, checkoutDir) {
t.Fatal("portable checkout should stay clean after writable runtime command")
}
read := New()
read.configPath = configPath
readRT, err := read.openLocalRuntimeReadOnly(ctx)
if err != nil {
t.Fatalf("open read-only runtime: %v", err)
}
defer readRT.Store.Close()
if _, _, err := readRT.Store.ThreadVectorByNumber(ctx, store.ThreadVectorQuery{
RepoID: repo.ID,
Model: "text-embedding-3-small",
Basis: "title_original",
Dimensions: 3,
}, 1); err != nil {
t.Fatalf("read runtime vector: %v", err)
}
}
func TestDoctorRefreshesPortableStore(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
dbRel := filepath.Join("data", "openclaw__openclaw.sync.db")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote data: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 1, "initial issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add seed: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
if _, err := syncPortableStore(ctx, remoteDir, checkoutDir); err != nil {
t.Fatalf("clone portable store: %v", err)
}
configPath := filepath.Join(dir, "config.toml")
init := New()
if err := init.Run(ctx, []string{"--config", configPath, "init", "--db", filepath.Join(checkoutDir, dbRel)}); err != nil {
t.Fatalf("init config: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 2, "refreshed issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add update: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "update store"); err != nil {
t.Fatalf("git commit update: %v", err)
}
doctor := New()
var stdout bytes.Buffer
doctor.Stdout = &stdout
if err := doctor.Run(ctx, []string{"--config", configPath, "doctor", "--json"}); err != nil {
t.Fatalf("doctor: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("parse doctor json: %v\n%s", err, stdout.String())
}
if got := payload["thread_count"]; got != float64(2) {
t.Fatalf("doctor thread_count = %#v, want 2; payload=%s", got, stdout.String())
}
}
func seedPortableThread(t *testing.T, dbPath string, number int, title string) {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open portable db: %v", err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: now,
})
if err != nil {
t.Fatalf("upsert repository: %v", err)
}
if _, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: strconv.Itoa(number),
Number: number,
Kind: "issue",
State: "open",
Title: title,
Body: title,
HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", number),
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: fmt.Sprintf("hash-%d", number),
UpdatedAt: now,
}); err != nil {
t.Fatalf("upsert thread: %v", err)
}
if _, err := st.DB().ExecContext(ctx, `pragma wal_checkpoint(TRUNCATE)`); err != nil {
t.Fatalf("checkpoint portable db: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close portable db: %v", err)
}
}
func TestPortablePruneCommand(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(context.Background(), []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
seed := New()
if err := seed.Run(context.Background(), []string{"--config", configPath, "portable", "prune", "--body-chars", "8", "--no-vacuum", "--json"}); err != nil {
t.Fatalf("portable prune: %v", err)
}
}
func TestMainHelpListsNeighbors(t *testing.T) {
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
if err := app.Run(context.Background(), nil); err != nil {
t.Fatalf("help: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "neighbors") {
t.Fatalf("main help should list neighbors command, got %q", out)
}
}
func TestAppOutputModesAndUsageBranches(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
for _, tc := range []struct {
name string
args []string
want string
}{
{name: "version flag text", args: []string{"--version"}, want: "dev"},
{name: "version command json", args: []string{"--json", "version"}, want: `"version"`},
{name: "version command log fallback", args: []string{"--format", "log", "version"}, want: "dev"},
{name: "help tui", args: []string{"help", "tui"}, want: "cluster browser"},
{name: "configure creates config", args: []string{"--config", filepath.Join(dir, "configure.toml"), "--format", "log", "configure"}, want: "configure="},
{name: "doctor default json", args: []string{"--config", filepath.Join(dir, "missing.toml"), "--json", "doctor"}, want: `"config_exists": false`},
} {
t.Run(tc.name, func(t *testing.T) {
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
if err := app.Run(ctx, tc.args); err != nil {
t.Fatalf("run: %v", err)
}
if !strings.Contains(stdout.String(), tc.want) {
t.Fatalf("output = %q, want %q", stdout.String(), tc.want)
}
})
}
init := New()
if err := init.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
seedCommandFlowStore(t, dbPath)
errorCases := [][]string{
{"--format", "xml", "version"},
{"help", "unknown"},
{"serve"},
{"summarize"},
{"configure", "--unknown"},
{"refresh", "--unknown"},
{"refresh", "openclaw/openclaw", "--no-sync", "--no-embed", "--no-cluster"},
{"refresh", "badrepo"},
{"search", "--unknown"},
{"search", "openclaw/openclaw"},
{"search", "openclaw/openclaw", "--query", "x", "--mode", "bogus"},
{"neighbors", "--unknown"},
{"neighbors", "openclaw/openclaw"},
{"cluster", "--unknown"},
{"cluster", "openclaw/openclaw", "--threshold", "2"},
{"cluster", "openclaw/openclaw", "--cross-kind-threshold", "2"},
{"embed", "--unknown"},
{"embed", "openclaw/openclaw", "--number", "bad"},
{"clusters", "--unknown"},
{"clusters", "openclaw/openclaw", "--sort", "bogus"},
{"tui", "--unknown"},
{"tui", "openclaw/openclaw", "extra"},
{"tui", "openclaw/openclaw", "--min-size", "bad"},
{"tui", "openclaw/openclaw", "--limit", "bad"},
{"tui", "openclaw/openclaw", "--sort", "bad", "--json"},
{"cluster-detail", "--unknown"},
{"cluster-detail", "openclaw/openclaw"},
{"runs", "--unknown"},
{"runs", "openclaw/openclaw", "--limit", "nope"},
{"threads", "--unknown"},
{"threads", "openclaw/openclaw", "--numbers", "1,nope"},
{"sync", "--unknown"},
{"close-thread", "--unknown"},
{"close-thread", "openclaw/openclaw"},
{"reopen-thread", "--unknown"},
{"reopen-thread", "openclaw/openclaw"},
{"close-cluster", "--unknown"},
{"close-cluster", "openclaw/openclaw"},
{"reopen-cluster", "--unknown"},
{"reopen-cluster", "openclaw/openclaw"},
{"exclude-cluster-member", "--unknown"},
{"exclude-cluster-member", "openclaw/openclaw", "--id", "1"},
{"include-cluster-member", "--unknown"},
{"include-cluster-member", "openclaw/openclaw", "--number", "1"},
{"set-cluster-canonical", "--unknown"},
{"set-cluster-canonical", "openclaw/openclaw", "--id", "bad", "--number", "1"},
{"portable"},
{"portable", "unknown"},
{"portable", "prune", "--unknown"},
{"portable", "prune", "extra"},
}
for _, args := range errorCases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
app := New()
err := app.Run(ctx, append([]string{"--config", configPath}, args...))
if err == nil {
t.Fatalf("expected error for %v", args)
}
if ExitCode(err) == 0 {
t.Fatalf("expected nonzero exit for %v", args)
}
if err.Error() == "" {
t.Fatalf("empty error for %v", args)
}
})
}
emptyConfig := filepath.Join(dir, "empty.toml")
emptyDB := filepath.Join(dir, "empty.db")
if err := New().Run(ctx, []string{"--config", emptyConfig, "init", "--db", emptyDB}); err != nil {
t.Fatalf("empty init: %v", err)
}
runtimeErrorCases := [][]string{
{"search", "openclaw/openclaw", "--query", "x"},
{"neighbors", "openclaw/openclaw", "--number", "1"},
{"cluster", "openclaw/openclaw"},
{"clusters", "openclaw/openclaw"},
{"cluster-detail", "openclaw/openclaw", "--id", "1"},
{"runs", "openclaw/openclaw"},
{"threads", "openclaw/openclaw"},
{"close-thread", "openclaw/openclaw", "--number", "1"},
{"reopen-thread", "openclaw/openclaw", "--number", "1"},
}
for _, args := range runtimeErrorCases {
t.Run("empty "+strings.Join(args, " "), func(t *testing.T) {
err := New().Run(ctx, append([]string{"--config", emptyConfig}, args...))
if err == nil {
t.Fatalf("expected runtime error for %v", args)
}
})
}
if _, err := resolveOutputFormat("bad", false); err == nil {
t.Fatal("bad output format should fail")
}
if _, _, err := parseOwnerRepo("bad"); err == nil {
t.Fatal("bad owner/repo should fail")
}
if _, err := parseOptionalPositiveInt("0"); err == nil {
t.Fatal("zero int should fail")
}
if _, err := parseOptionalPositiveIntList("1, 0"); err == nil {
t.Fatal("bad int list should fail")
}
if owner, repo, err := parseOwnerRepo("https://github.com/openclaw/openclaw/issues/78601"); err != nil || owner != "openclaw" || repo != "openclaw" {
t.Fatalf("full issue URL owner/repo = %q/%q err=%v", owner, repo, err)
}
if got, err := parseOptionalThreadNumber("https://github.com/openclaw/openclaw/issues/78601"); err != nil || got != 78601 {
t.Fatalf("full issue URL number = %d err=%v", got, err)
}
if got, err := parseOptionalThreadNumber("https://github.com/openclaw/openclaw/pull/78602#issuecomment-1"); err != nil || got != 78602 {
t.Fatalf("full pull URL number = %d err=%v", got, err)
}
if got, err := parseOptionalThreadNumberList("https://github.com/openclaw/openclaw/issues/78601, openclaw/openclaw#78602, pull/78603, #78604"); err != nil || len(got) != 4 || got[0] != 78601 || got[1] != 78602 || got[2] != 78603 || got[3] != 78604 {
t.Fatalf("thread ref list = %#v err=%v", got, err)
}
if _, _, _, err := parseClusterShapeOptions("test", "bad", "1", "0.5"); err == nil {
t.Fatal("bad cluster shape should fail")
}
if !isDirtyPortablePullError(fmt.Errorf("Your local changes would be overwritten by merge")) {
t.Fatal("dirty portable pull error not detected")
}
t.Setenv("OPENAI_BASE_URL", "https://openai.example/v1")
if got := openAIBaseURL(); got != "https://openai.example/v1" {
t.Fatalf("openAIBaseURL fallback = %q", got)
}
t.Setenv("GITCRAWL_OPENAI_BASE_URL", "https://gitcrawl-openai.example/v1")
if got := openAIBaseURL(); got != "https://gitcrawl-openai.example/v1" {
t.Fatalf("openAIBaseURL override = %q", got)
}
t.Setenv("GITHUB_BASE_URL", "https://github.example")
if got := githubBaseURL(); got != "https://github.example" {
t.Fatalf("githubBaseURL fallback = %q", got)
}
t.Setenv("GITCRAWL_GITHUB_BASE_URL", "https://gitcrawl-github.example")
if got := githubBaseURL(); got != "https://gitcrawl-github.example" {
t.Fatalf("githubBaseURL override = %q", got)
}
if got := formatOptionalTime(time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)); got == "" {
t.Fatal("non-zero optional time should be formatted")
}
}
func TestGlobalCommandBranches(t *testing.T) {
ctx := context.Background()
cases := []struct {
args []string
wantErr bool
wantOut string
exitCode int
jsonShape bool
}{
{args: []string{"--help"}, wantOut: "Usage:"},
{args: []string{"help"}, wantOut: "Usage:"},
{args: []string{"help", "sync"}, wantOut: "gitcrawl sync"},
{args: []string{"--version"}, wantOut: "dev"},
{args: []string{"version"}, wantOut: "dev"},
{args: []string{"--json", "version"}, wantOut: `"version"`},
{args: []string{"--bad"}, wantErr: true, exitCode: 2},
{args: []string{"--format", "bad", "version"}, wantErr: true, exitCode: 2},
{args: []string{"serve"}, wantErr: true, exitCode: 2},
{args: []string{"completion"}, wantErr: true, exitCode: 1},
{args: []string{"unknown"}, wantErr: true, exitCode: 2},
{args: []string{"cluster-explain"}, wantErr: true, exitCode: 2},
}
for _, tc := range cases {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
err := app.Run(ctx, tc.args)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error")
}
if tc.exitCode != 0 && ExitCode(err) != tc.exitCode {
t.Fatalf("exit code = %d, want %d: %v", ExitCode(err), tc.exitCode, err)
}
return
}
if err != nil {
t.Fatalf("run: %v", err)
}
if tc.wantOut != "" && !strings.Contains(stdout.String(), tc.wantOut) {
t.Fatalf("output missing %q: %q", tc.wantOut, stdout.String())
}
})
}
}
func TestGHSearchSyntaxUsesLocalCache(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: "2026-04-27T00:00:00Z",
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
issueID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "10",
Number: 10,
Kind: "issue",
State: "open",
Title: "Hot loop burns CPU",
Body: "the runtime has a hot loop",
AuthorLogin: "alice",
AuthorType: "User",
HTMLURL: "https://github.com/openclaw/openclaw/issues/10",
LabelsJSON: `[{"name":"bug","color":"d73a4a"}]`,
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "issue-10",
UpdatedAtGitHub: "2026-04-27T01:00:00Z",
UpdatedAt: "2026-04-27T01:00:00Z",
})
if err != nil {
t.Fatalf("seed issue: %v", err)
}
if _, err := st.UpsertDocument(ctx, store.Document{ThreadID: issueID, Title: "Hot loop burns CPU", RawText: "runtime hot loop burns CPU", DedupeText: "runtime hot loop burns cpu", UpdatedAt: "2026-04-27T01:00:00Z"}); err != nil {
t.Fatalf("seed issue document: %v", err)
}
closedID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "11",
Number: 11,
Kind: "issue",
State: "closed",
Title: "Hot loop old report",
HTMLURL: "https://github.com/openclaw/openclaw/issues/11",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "issue-11",
UpdatedAt: "2026-04-27T00:00:00Z",
})
if err != nil {
t.Fatalf("seed closed issue: %v", err)
}
if _, err := st.UpsertDocument(ctx, store.Document{ThreadID: closedID, Title: "Hot loop old report", RawText: "old hot loop", DedupeText: "old hot loop", UpdatedAt: "2026-04-27T00:00:00Z"}); err != nil {
t.Fatalf("seed closed document: %v", err)
}
prID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "12",
Number: 12,
Kind: "pull_request",
State: "open",
Title: "Manifest cache update",
AuthorLogin: "bob",
AuthorType: "User",
HTMLURL: "https://github.com/openclaw/openclaw/pull/12",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "pr-12",
IsDraft: true,
UpdatedAtGitHub: "2026-04-27T02:00:00Z",
UpdatedAt: "2026-04-27T02:00:00Z",
})
if err != nil {
t.Fatalf("seed pr: %v", err)
}
if _, err := st.UpsertDocument(ctx, store.Document{ThreadID: prID, Title: "Manifest cache update", RawText: "manifest cache refresh", DedupeText: "manifest cache refresh", UpdatedAt: "2026-04-27T02:00:00Z"}); err != nil {
t.Fatalf("seed pr document: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "search", "issues", "hot loop", "-R", "openclaw/openclaw", "--state", "open", "--json", "number,title,state,url,updatedAt,labels", "--limit", "30"}); err != nil {
t.Fatalf("gh issue search: %v", err)
}
var issues []map[string]any
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
t.Fatalf("decode issue search: %v\n%s", err, stdout.String())
}
if len(issues) != 1 || int(issues[0]["number"].(float64)) != 10 {
t.Fatalf("issue search should return only open cached issue, got %#v", issues)
}
labels := issues[0]["labels"].([]any)
if len(labels) != 1 || labels[0].(map[string]any)["name"] != "bug" {
t.Fatalf("issue labels = %#v", labels)
}
stdout.Reset()
if err := run.Run(ctx, []string{"--config", configPath, "search", "prs", "manifest cache", "-R", "openclaw/openclaw", "--state", "open", "--json", "number,title,state,url,updatedAt,isDraft,author", "--limit", "20"}); err != nil {
t.Fatalf("gh pr search: %v", err)
}
var prs []map[string]any
if err := json.Unmarshal(stdout.Bytes(), &prs); err != nil {
t.Fatalf("decode pr search: %v\n%s", err, stdout.String())
}
if len(prs) != 1 || int(prs[0]["number"].(float64)) != 12 || prs[0]["isDraft"] != true {
t.Fatalf("pr search should return cached draft PR, got %#v", prs)
}
author := prs[0]["author"].(map[string]any)
if author["login"] != "bob" {
t.Fatalf("author = %#v", author)
}
}
func TestTUIInfersRepository(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if _, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
}); err != nil {
t.Fatalf("seed repository: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
before, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read db before tui: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "tui", "--json"}); err != nil {
t.Fatalf("tui: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"repository": "openclaw/openclaw"`) {
t.Fatalf("expected inferred repository, got %q", out)
}
if !strings.Contains(out, `"inferred_repository": true`) {
t.Fatalf("expected inferred flag, got %q", out)
}
if !strings.Contains(out, `"min_size": 5`) {
t.Fatalf("expected default tui min size, got %q", out)
}
after, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read db after tui: %v", err)
}
if !bytes.Equal(after, before) {
t.Fatal("tui mutated database bytes")
}
}
func TestTUIJSONUsesDefaultsWhenConfigMissing(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "missing.toml")
t.Setenv("GITCRAWL_DB_PATH", filepath.Join(dir, "missing.db"))
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "tui", "--json"}); err != nil {
t.Fatalf("tui: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode tui payload: %v\n%s", err, stdout.String())
}
if payload["mode"] != "cluster-browser" {
t.Fatalf("mode = %#v", payload["mode"])
}
clusters, ok := payload["clusters"].([]any)
if !ok || len(clusters) != 0 {
t.Fatalf("clusters = %#v", payload["clusters"])
}
if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("config file should not be created, stat err=%v", err)
}
}
func TestTUIJSONHandlesEmptyStoreWithoutRepository(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "tui", "--json"}); err != nil {
t.Fatalf("tui: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode tui payload: %v\n%s", err, stdout.String())
}
clusters, ok := payload["clusters"].([]any)
if !ok || len(clusters) != 0 {
t.Fatalf("clusters = %#v", payload["clusters"])
}
}
func TestTUIRequiresInteractiveTerminalByDefault(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
var initOut bytes.Buffer
app.Stdout = &initOut
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if _, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
}); err != nil {
t.Fatalf("seed repository: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
err = run.Run(ctx, []string{"--config", configPath, "tui"})
if err == nil {
t.Fatal("expected tui to require a tty")
}
if ExitCode(err) != 2 {
t.Fatalf("exit code: got %d want 2", ExitCode(err))
}
if stdout.Len() != 0 {
t.Fatalf("tui should not dump json by default, got %q", stdout.String())
}
}
func TestResolveOptionalRepositoryBranches(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
dbPath := filepath.Join(dir, "gitcrawl.db")
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
app := New()
rt := localRuntime{Store: st}
if _, _, err := app.resolveOptionalRepository(ctx, rt, nil); err == nil {
t.Fatal("empty store should not infer repository")
}
first, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "one", FullName: "openclaw/one", UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano)})
if err != nil {
t.Fatalf("first repo: %v", err)
}
if _, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "two", FullName: "openclaw/two", UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano)}); err != nil {
t.Fatalf("second repo: %v", err)
}
if repo, inferred, err := app.resolveOptionalRepository(ctx, rt, nil); err != nil || !inferred || repo.FullName == "" {
t.Fatalf("multi repo inference repo=%+v inferred=%v err=%v", repo, inferred, err)
}
repo, inferred, err := app.resolveOptionalRepository(ctx, rt, []string{"openclaw/one"})
if err != nil {
t.Fatalf("explicit repo: %v", err)
}
if inferred || repo.ID != first {
t.Fatalf("explicit repo=%+v inferred=%v", repo, inferred)
}
}
func TestCommandFlowCoversSearchEmbedNeighborsClusterDetailRunsAndRefresh(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
init := New()
if err := init.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
configure := New()
var stdout bytes.Buffer
configure.Stdout = &stdout
if err := configure.Run(ctx, []string{
"--config", configPath,
"configure",
"--summary-model", "summary-test",
"--embed-model", "embed-test",
"--embedding-basis", "title_original",
"--json",
}); err != nil {
t.Fatalf("configure: %v", err)
}
if !strings.Contains(stdout.String(), `"updated": true`) {
t.Fatalf("configure output = %q", stdout.String())
}
repoID, firstID, secondID := seedCommandFlowStore(t, dbPath)
search := New()
stdout.Reset()
search.Stdout = &stdout
if err := search.Run(ctx, []string{"--config", configPath, "search", "openclaw/openclaw", "--query", "gateway websocket", "--mode", "hybrid", "--limit", "5", "--json"}); err != nil {
t.Fatalf("search: %v", err)
}
if !strings.Contains(stdout.String(), "Gateway websocket stalls") {
t.Fatalf("search output missing seeded hit: %s", stdout.String())
}
openAIServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/embeddings" {
t.Fatalf("unexpected OpenAI path: %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-openai-key" {
t.Fatalf("authorization = %q", got)
}
var payload struct {
Input []string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode embeddings request: %v", err)
}
data := make([]map[string]any, 0, len(payload.Input))
for index, text := range payload.Input {
vector := []float64{0, 1}
if strings.Contains(text, "Gateway") || strings.Contains(text, "websocket") {
vector = []float64{1, 0}
}
if strings.Contains(text, "typing") {
vector = []float64{0.92, 0.08}
}
data = append(data, map[string]any{"index": index, "embedding": vector})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
}))
defer openAIServer.Close()
t.Setenv("OPENAI_API_KEY", "test-openai-key")
t.Setenv("GITCRAWL_OPENAI_BASE_URL", openAIServer.URL)
embed := New()
stdout.Reset()
embed.Stdout = &stdout
if err := embed.Run(ctx, []string{"--config", configPath, "embed", "openclaw/openclaw", "--limit", "3", "--json"}); err != nil {
t.Fatalf("embed: %v", err)
}
if !strings.Contains(stdout.String(), `"embedded": 3`) {
t.Fatalf("embed output = %q", stdout.String())
}
neighbors := New()
stdout.Reset()
neighbors.Stdout = &stdout
if err := neighbors.Run(ctx, []string{"--config", configPath, "neighbors", "openclaw/openclaw", "--number", "101", "--limit", "2", "--threshold", "0.1", "--json"}); err != nil {
t.Fatalf("neighbors: %v", err)
}
if !strings.Contains(stdout.String(), `"number": 102`) {
t.Fatalf("neighbors output = %q", stdout.String())
}
cluster := New()
stdout.Reset()
cluster.Stdout = &stdout
if err := cluster.Run(ctx, []string{"--config", configPath, "cluster", "openclaw/openclaw", "--threshold", "0.7", "--min-size", "2", "--k", "2", "--json"}); err != nil {
t.Fatalf("cluster: %v", err)
}
if !strings.Contains(stdout.String(), `"cluster_count": 1`) {
t.Fatalf("cluster output = %q", stdout.String())
}
clusters := New()
stdout.Reset()
clusters.Stdout = &stdout
if err := clusters.Run(ctx, []string{"--config", configPath, "clusters", "openclaw/openclaw", "--sort", "recent", "--min-size", "1", "--limit", "5", "--json"}); err != nil {
t.Fatalf("clusters: %v", err)
}
if !strings.Contains(stdout.String(), `"clusters"`) {
t.Fatalf("clusters output = %q", stdout.String())
}
durable := New()
stdout.Reset()
durable.Stdout = &stdout
if err := durable.Run(ctx, []string{"--config", configPath, "durable-clusters", "openclaw/openclaw", "--include-closed", "--min-size", "1", "--json"}); err != nil {
t.Fatalf("durable-clusters: %v", err)
}
var durablePayload struct {
Clusters []store.ClusterSummary `json:"clusters"`
}
if err := json.Unmarshal(stdout.Bytes(), &durablePayload); err != nil {
t.Fatalf("decode durable clusters: %v\n%s", err, stdout.String())
}
if len(durablePayload.Clusters) == 0 {
t.Fatalf("durable clusters output = %q", stdout.String())
}
detail := New()
stdout.Reset()
detail.Stdout = &stdout
if err := detail.Run(ctx, []string{"--config", configPath, "cluster-detail", "openclaw/openclaw", "--id", strconv.FormatInt(durablePayload.Clusters[0].ID, 10), "--member-limit", "5", "--body-chars", "12", "--json"}); err != nil {
t.Fatalf("cluster-detail: %v", err)
}
if !strings.Contains(stdout.String(), "Gateway") {
t.Fatalf("cluster-detail output = %q", stdout.String())
}
runs := New()
stdout.Reset()
runs.Stdout = &stdout
if err := runs.Run(ctx, []string{"--config", configPath, "runs", "openclaw/openclaw", "--kind", "embedding", "--limit", "3", "--json"}); err != nil {
t.Fatalf("runs: %v", err)
}
if !strings.Contains(stdout.String(), `"kind": "embedding"`) {
t.Fatalf("runs output = %q", stdout.String())
}
refresh := New()
stdout.Reset()
refresh.Stdout = &stdout
if err := refresh.Run(ctx, []string{"--config", configPath, "refresh", "openclaw/openclaw", "--no-sync", "--no-embed", "--threshold", "0.7", "--min-size", "2", "--json"}); err != nil {
t.Fatalf("refresh cluster-only: %v", err)
}
if !strings.Contains(stdout.String(), `"cluster":`) {
t.Fatalf("refresh output = %q", stdout.String())
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open final store: %v", err)
}
defer st.Close()
threads, err := st.ThreadsByIDs(ctx, repoID, []int64{firstID, secondID})
if err != nil {
t.Fatalf("threads by ids: %v", err)
}
if len(threads) != 2 {
t.Fatalf("threads by ids = %+v", threads)
}
}
func TestSyncCommandUsesConfiguredGitHubBaseURLAndHydratesComments(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
init := New()
if err := init.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-gh-token" {
t.Fatalf("authorization = %q", got)
}
switch r.URL.Path {
case "/repos/openclaw/openclaw":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 12345, "full_name": "openclaw/openclaw"})
case "/repos/openclaw/openclaw/issues/101":
_ = json.NewEncoder(w).Encode(githubIssueJSON(101, "issue", "Gateway websocket stalls"))
case "/repos/openclaw/openclaw/issues/102":
row := githubIssueJSON(102, "pull_request", "Fix Discord typing timeout")
row["pull_request"] = map[string]any{"url": "https://api.github.test/pulls/102"}
_ = json.NewEncoder(w).Encode(row)
case "/repos/openclaw/openclaw/issues/101/comments":
_ = json.NewEncoder(w).Encode([]map[string]any{githubCommentJSON(1001, "issue comment")})
case "/repos/openclaw/openclaw/issues/102/comments":
_ = json.NewEncoder(w).Encode([]map[string]any{githubCommentJSON(1002, "pr issue comment")})
case "/repos/openclaw/openclaw/pulls/102/reviews":
_ = json.NewEncoder(w).Encode([]map[string]any{githubCommentJSON(1003, "review body")})
case "/repos/openclaw/openclaw/pulls/102/comments":
_ = json.NewEncoder(w).Encode([]map[string]any{githubCommentJSON(1004, "review line")})
default:
t.Fatalf("unexpected GitHub path: %s", r.URL.String())
}
}))
defer server.Close()
t.Setenv("GITHUB_TOKEN", "test-gh-token")
t.Setenv("GITCRAWL_GITHUB_BASE_URL", server.URL)
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "sync", "openclaw/openclaw", "--numbers", "101,102", "--include-comments", "--json"}); err != nil {
t.Fatalf("sync: %v", err)
}
var stats struct {
ThreadsSynced int `json:"threads_synced"`
PullRequestsSynced int `json:"pull_requests_synced"`
CommentsSynced int `json:"comments_synced"`
}
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
t.Fatalf("decode sync stats: %v\n%s", err, stdout.String())
}
if stats.ThreadsSynced != 2 || stats.PullRequestsSynced != 1 || stats.CommentsSynced != 4 {
t.Fatalf("sync stats = %+v", stats)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
status, err := st.Status(ctx)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.ThreadCount != 2 || status.RepositoryCount != 1 {
t.Fatalf("status after sync = %+v", status)
}
}
func TestRefreshRunsSyncEmbedAndClusterWithLocalServers(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
init := New()
if err := init.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
githubServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/openclaw/openclaw":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 12345, "full_name": "openclaw/openclaw"})
case "/repos/openclaw/openclaw/issues":
_ = json.NewEncoder(w).Encode([]map[string]any{
githubIssueJSON(201, "issue", "Gateway reconnect loop"),
githubIssueJSON(202, "issue", "Gateway reconnect timeout"),
})
case "/repos/openclaw/openclaw/issues/201/comments":
_ = json.NewEncoder(w).Encode([]map[string]any{githubCommentJSON(2001, "same reconnect loop")})
case "/repos/openclaw/openclaw/issues/202/comments":
_ = json.NewEncoder(w).Encode([]map[string]any{githubCommentJSON(2002, "same reconnect timeout")})
default:
t.Fatalf("unexpected GitHub path: %s", r.URL.String())
}
}))
defer githubServer.Close()
openAIServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/embeddings" {
t.Fatalf("unexpected OpenAI path: %s", r.URL.Path)
}
var payload struct {
Input []string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode embeddings request: %v", err)
}
data := make([]map[string]any, 0, len(payload.Input))
for index := range payload.Input {
data = append(data, map[string]any{"index": index, "embedding": []float64{1, 0.01 * float64(index)}})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
}))
defer openAIServer.Close()
t.Setenv("GITHUB_TOKEN", "test-gh-token")
t.Setenv("OPENAI_API_KEY", "test-openai-key")
t.Setenv("GITCRAWL_GITHUB_BASE_URL", githubServer.URL)
t.Setenv("GITCRAWL_OPENAI_BASE_URL", openAIServer.URL)
run := New()
var stdout, stderr bytes.Buffer
run.Stdout = &stdout
run.Stderr = &stderr
if err := run.Run(ctx, []string{"--config", configPath, "refresh", "openclaw/openclaw", "--include-comments", "--limit", "2", "--threshold", "0.5", "--min-size", "1", "--json"}); err != nil {
t.Fatalf("refresh: %v\nstderr=%s", err, stderr.String())
}
var payload refreshResult
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode refresh: %v\n%s", err, stdout.String())
}
if payload.Sync == nil || payload.Sync.ThreadsSynced != 2 || payload.Sync.CommentsSynced != 2 {
t.Fatalf("sync payload = %+v", payload.Sync)
}
if payload.Embed == nil || payload.Embed.Embedded != 2 {
t.Fatalf("embed payload = %+v", payload.Embed)
}
if payload.Cluster == nil || int(payload.Cluster["vector_count"].(float64)) != 2 {
t.Fatalf("cluster payload = %+v", payload.Cluster)
}
for _, want := range []string{"[refresh] sync", "[refresh] embed", "[refresh] cluster"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("stderr missing %q: %s", want, stderr.String())
}
}
}
func TestEmbedErrorBranchesRecordFailures(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
init := New()
if err := init.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
seedCommandFlowStore(t, dbPath)
t.Setenv("OPENAI_API_KEY", "")
if err := New().Run(ctx, []string{"--config", configPath, "embed", "openclaw/openclaw"}); err == nil {
t.Fatal("missing OpenAI key should fail")
}
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
cfg.EmbeddingBasis = "title_summary"
if err := config.Save(configPath, cfg); err != nil {
t.Fatalf("save title summary config: %v", err)
}
t.Setenv("OPENAI_API_KEY", "test-openai-key")
if err := New().Run(ctx, []string{"--config", configPath, "embed", "openclaw/openclaw"}); err == nil {
t.Fatal("title_summary embed should fail")
}
cfg.EmbeddingBasis = "title_original"
cfg.OpenAI.BatchSize = 0
if err := config.Save(configPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "embed failed", http.StatusInternalServerError)
}))
defer server.Close()
t.Setenv("GITCRAWL_OPENAI_BASE_URL", server.URL)
t.Setenv("GITCRAWL_OPENAI_RETRY_DISABLED", "1")
if err := New().Run(ctx, []string{"--config", configPath, "embed", "openclaw/openclaw", "--limit", "1"}); err == nil {
t.Fatal("OpenAI error should fail")
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repo, err := st.RepositoryByFullName(ctx, "openclaw/openclaw")
if err != nil {
t.Fatalf("repo: %v", err)
}
runs, err := st.ListRuns(ctx, repo.ID, "embedding", 1)
if err != nil {
t.Fatalf("list embedding runs: %v", err)
}
if len(runs) != 1 || runs[0].Status != "error" || runs[0].ErrorText == "" {
t.Fatalf("embedding error run = %+v", runs)
}
}
func TestEmbedRunPartialOnSomeFailedBatches(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
seedCommandFlowStore(t, dbPath)
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
cfg.OpenAI.BatchSize = 1
if err := config.Save(configPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var payload struct {
Input []string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode: %v", err)
}
// First input is permanently bad — return non-retryable 400.
if len(payload.Input) == 1 && strings.Contains(payload.Input[0], "Gateway websocket stalls") {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"message": "bad input", "type": "invalid_request_error"},
})
return
}
data := make([]map[string]any, 0, len(payload.Input))
for index := range payload.Input {
data = append(data, map[string]any{"index": index, "embedding": []float64{1, 0.5 * float64(index)}})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
}))
defer server.Close()
t.Setenv("OPENAI_API_KEY", "test-openai-key")
t.Setenv("GITCRAWL_OPENAI_BASE_URL", server.URL)
t.Setenv("GITCRAWL_OPENAI_RETRY_DISABLED", "1")
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
if err := app.Run(ctx, []string{"--config", configPath, "embed", "openclaw/openclaw", "--json"}); err != nil {
t.Fatalf("embed: %v", err)
}
var result embedResult
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("decode embed result: %v\n%s", err, stdout.String())
}
if result.Status != "partial" {
t.Fatalf("status = %q, want partial", result.Status)
}
if result.Embedded != 2 {
t.Fatalf("embedded = %d, want 2", result.Embedded)
}
if result.Failed != 1 {
t.Fatalf("failed = %d, want 1", result.Failed)
}
if len(result.Failures) != 1 {
t.Fatalf("failures = %+v", result.Failures)
}
if result.Failures[0].Status != http.StatusBadRequest {
t.Fatalf("failure status = %d", result.Failures[0].Status)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
repo, err := st.RepositoryByFullName(ctx, "openclaw/openclaw")
if err != nil {
t.Fatalf("repo: %v", err)
}
runs, err := st.ListRuns(ctx, repo.ID, "embedding", 1)
if err != nil {
t.Fatalf("runs: %v", err)
}
if len(runs) != 1 || runs[0].Status != "partial" {
t.Fatalf("run = %+v", runs)
}
}
func TestEmbedRunCancelledRecordsCancelledStatus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
seedCommandFlowStore(t, dbPath)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cancel()
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
defer server.Close()
t.Setenv("OPENAI_API_KEY", "test-openai-key")
t.Setenv("GITCRAWL_OPENAI_BASE_URL", server.URL)
t.Setenv("GITCRAWL_OPENAI_RETRY_DISABLED", "1")
if err := New().Run(ctx, []string{"--config", configPath, "embed", "openclaw/openclaw"}); err == nil {
t.Fatal("expected cancellation error")
}
st, err := store.Open(context.Background(), dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repo, err := st.RepositoryByFullName(context.Background(), "openclaw/openclaw")
if err != nil {
t.Fatalf("repo: %v", err)
}
runs, err := st.ListRuns(context.Background(), repo.ID, "embedding", 1)
if err != nil {
t.Fatalf("runs: %v", err)
}
if len(runs) != 1 || runs[0].Status != "cancelled" {
t.Fatalf("expected cancelled run, got %+v", runs)
}
}
func TestTruncatedEmbeddingTaskCount(t *testing.T) {
tasks := []store.EmbeddingTask{
{Number: 1},
{Number: 2, TextTruncated: true},
{Number: 3, TextTruncated: true},
}
if got := truncatedEmbeddingTaskCount(tasks); got != 2 {
t.Fatalf("truncated count = %d, want 2", got)
}
}
func githubIssueJSON(number int, kind string, title string) map[string]any {
return map[string]any{
"id": number + 10000,
"number": number,
"state": "open",
"title": title,
"body": title + " body",
"html_url": fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", number),
"labels": []map[string]any{{"name": "bug"}},
"assignees": []map[string]any{},
"user": map[string]any{"login": kind + "-author", "type": "User"},
"created_at": "2026-04-30T01:00:00Z",
"updated_at": "2026-04-30T02:00:00Z",
}
}
func githubCommentJSON(id int, body string) map[string]any {
return map[string]any{
"id": id,
"body": body,
"user": map[string]any{"login": "commenter", "type": "User"},
"created_at": "2026-04-30T03:00:00Z",
"updated_at": "2026-04-30T04:00:00Z",
}
}
func TestCloseThreadCommandLocallyClosesThread(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
if _, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "42",
Number: 42,
Kind: "issue",
State: "open",
Title: "Close me",
HTMLURL: "https://github.com/openclaw/openclaw/issues/42",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
}); err != nil {
t.Fatalf("seed thread: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "close-thread", "openclaw/openclaw", "--number", "42", "--reason", "test close", "--json"}); err != nil {
t.Fatalf("close-thread: %v", err)
}
if !strings.Contains(stdout.String(), `"closed": true`) {
t.Fatalf("close-thread output = %q", stdout.String())
}
st, err = store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer st.Close()
rows, err := st.ListThreads(ctx, repoID, false)
if err != nil {
t.Fatalf("list open threads: %v", err)
}
if len(rows) != 0 {
t.Fatalf("closed thread should be hidden, got %#v", rows)
}
reopen := New()
stdout.Reset()
reopen.Stdout = &stdout
if err := reopen.Run(ctx, []string{"--config", configPath, "reopen-thread", "openclaw/openclaw", "--number", "42", "--json"}); err != nil {
t.Fatalf("reopen-thread: %v", err)
}
if !strings.Contains(stdout.String(), `"reopened": true`) {
t.Fatalf("reopen-thread output = %q", stdout.String())
}
rows, err = st.ListThreads(ctx, repoID, false)
if err != nil {
t.Fatalf("list reopened threads: %v", err)
}
if len(rows) != 1 || rows[0].ClosedAtLocal != "" {
t.Fatalf("reopened thread should be visible, got %#v", rows)
}
}
func TestClusterLocalOverrideCommands(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
repoID, firstID, secondID := seedCommandFlowStore(t, dbPath)
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if _, err := st.SaveDurableClusters(ctx, repoID, []store.DurableClusterInput{{
StableKey: "cli-cluster",
StableSlug: "cli-cluster",
RepresentativeThreadID: firstID,
Title: "CLI cluster",
Members: []store.DurableClusterMemberInput{
{ThreadID: firstID, Role: "canonical"},
{ThreadID: secondID, Role: "member"},
},
}}); err != nil {
t.Fatalf("save cluster: %v", err)
}
clusterIDValue, err := st.ClusterIDForThreadNumber(ctx, repoID, 101, false)
if err != nil {
t.Fatalf("cluster id: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
var stdout bytes.Buffer
run := func(args ...string) string {
t.Helper()
stdout.Reset()
cmd := New()
cmd.Stdout = &stdout
if err := cmd.Run(ctx, append([]string{"--config", configPath}, args...)); err != nil {
t.Fatalf("%v: %v", args, err)
}
return stdout.String()
}
clusterID := strconv.FormatInt(clusterIDValue, 10)
if out := run("exclude-cluster-member", "openclaw/openclaw", "--id", clusterID, "--number", "102", "--reason", "duplicate", "--json"); !strings.Contains(out, `"action": "exclude"`) {
t.Fatalf("exclude output = %q", out)
}
if out := run("include-cluster-member", "openclaw/openclaw", "--id", clusterID, "--number", "102", "--reason", "needed", "--json"); !strings.Contains(out, `"action": "include"`) {
t.Fatalf("include output = %q", out)
}
if out := run("set-cluster-canonical", "openclaw/openclaw", "--id", clusterID, "--number", "102", "--reason", "better", "--json"); !strings.Contains(out, `"action": "canonical"`) {
t.Fatalf("canonical output = %q", out)
}
if out := run("close-cluster", "openclaw/openclaw", "--id", clusterID, "--reason", "resolved", "--json"); !strings.Contains(out, `"closed": true`) {
t.Fatalf("close cluster output = %q", out)
}
if out := run("reopen-cluster", "openclaw/openclaw", "--id", clusterID, "--json"); !strings.Contains(out, `"reopened": true`) {
t.Fatalf("reopen cluster output = %q", out)
}
}
func seedCommandFlowStore(t *testing.T, dbPath string) (int64, int64, int64) {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
now := time.Now().UTC().Format(time.RFC3339Nano)
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
GitHubRepoID: "12345",
RawJSON: `{"full_name":"openclaw/openclaw"}`,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
threads := []store.Thread{
{
RepoID: repoID,
GitHubID: "101",
Number: 101,
Kind: "issue",
State: "open",
Title: "Gateway websocket stalls",
Body: "Gateway websocket stalls when messages arrive.",
AuthorLogin: "alice",
AuthorType: "User",
HTMLURL: "https://github.com/openclaw/openclaw/issues/101",
LabelsJSON: `[{"name":"bug"}]`,
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "thread-101",
UpdatedAtGitHub: "2026-04-30T01:00:00Z",
UpdatedAt: now,
},
{
RepoID: repoID,
GitHubID: "102",
Number: 102,
Kind: "issue",
State: "open",
Title: "Discord typing timeout",
Body: "typing TTL stops while gateway websocket is slow.",
AuthorLogin: "bob",
AuthorType: "User",
HTMLURL: "https://github.com/openclaw/openclaw/issues/102",
LabelsJSON: `[]`,
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "thread-102",
UpdatedAtGitHub: "2026-04-30T02:00:00Z",
UpdatedAt: now,
},
{
RepoID: repoID,
GitHubID: "103",
Number: 103,
Kind: "pull_request",
State: "open",
Title: "Refactor portable cache",
Body: "Portable cache maintenance update.",
AuthorLogin: "carol",
AuthorType: "User",
HTMLURL: "https://github.com/openclaw/openclaw/pull/103",
LabelsJSON: `[]`,
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "thread-103",
IsDraft: true,
UpdatedAtGitHub: "2026-04-30T03:00:00Z",
UpdatedAt: now,
},
}
ids := make([]int64, 0, len(threads))
for _, thread := range threads {
id, err := st.UpsertThread(ctx, thread)
if err != nil {
t.Fatalf("seed thread %d: %v", thread.Number, err)
}
ids = append(ids, id)
if _, err := st.UpsertDocument(ctx, store.Document{
ThreadID: id,
Title: thread.Title,
RawText: thread.Title + "\n\n" + thread.Body,
DedupeText: strings.ToLower(thread.Title + " " + thread.Body),
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed document %d: %v", thread.Number, err)
}
}
return repoID, ids[0], ids[1]
}
func TestCloseClusterCommandLocallyClosesCluster(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
threadID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "77",
Number: 77,
Kind: "issue",
State: "open",
Title: "Cluster member",
HTMLURL: "https://github.com/openclaw/openclaw/issues/77",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed thread: %v", err)
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_groups(id, repo_id, stable_key, stable_slug, status, representative_thread_id, title, created_at, updated_at)
values(77, ?, 'cluster-77', 'cluster-77', 'active', ?, 'Cluster 77', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z');
insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at)
values(77, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z');
`, repoID, threadID, threadID); err != nil {
t.Fatalf("seed cluster: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "close-cluster", "openclaw/openclaw", "--id", "77", "--reason", "handled", "--json"}); err != nil {
t.Fatalf("close-cluster: %v", err)
}
if !strings.Contains(stdout.String(), `"closed": true`) {
t.Fatalf("close-cluster output = %q", stdout.String())
}
st, err = store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
active, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20})
if err != nil {
t.Fatalf("list active clusters: %v", err)
}
if len(active) != 0 {
t.Fatalf("closed cluster should be hidden, got %#v", active)
}
if err := st.Close(); err != nil {
t.Fatalf("close store after close check: %v", err)
}
reopen := New()
stdout.Reset()
reopen.Stdout = &stdout
if err := reopen.Run(ctx, []string{"--config", configPath, "reopen-cluster", "openclaw/openclaw", "--id", "77", "--json"}); err != nil {
t.Fatalf("reopen-cluster: %v", err)
}
if !strings.Contains(stdout.String(), `"reopened": true`) {
t.Fatalf("reopen-cluster output = %q", stdout.String())
}
st, err = store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("reopen store after cluster reopen: %v", err)
}
defer st.Close()
active, err = st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20})
if err != nil {
t.Fatalf("list reopened clusters: %v", err)
}
if len(active) != 1 || active[0].ClosedAt != "" {
t.Fatalf("reopened cluster should be visible, got %#v", active)
}
}
func TestClustersDefaultShowsActivePrimaryMembers(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
openID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "90",
Number: 90,
Kind: "issue",
State: "open",
Title: "Open member",
HTMLURL: "https://github.com/openclaw/openclaw/issues/90",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-90",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed open thread: %v", err)
}
closedID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "91",
Number: 91,
Kind: "issue",
State: "closed",
Title: "Closed historical member",
HTMLURL: "https://github.com/openclaw/openclaw/issues/91",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-91",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed closed thread: %v", err)
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_groups(id, repo_id, stable_key, stable_slug, status, representative_thread_id, title, created_at, updated_at)
values(90, ?, 'cluster-90', 'cluster-90', 'active', ?, 'Cluster 90', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z');
`, repoID, openID); err != nil {
t.Fatalf("seed cluster group: %v", err)
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at)
values(90, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z'),
(90, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z');
`, openID, closedID); err != nil {
t.Fatalf("seed cluster memberships: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "--json", "clusters", "openclaw/openclaw", "--sort", "size", "--min-size", "1"}); err != nil {
t.Fatalf("clusters: %v", err)
}
var active struct {
Clusters []store.ClusterSummary `json:"clusters"`
}
if err := json.Unmarshal(stdout.Bytes(), &active); err != nil {
t.Fatalf("decode active clusters: %v\n%s", err, stdout.String())
}
if len(active.Clusters) != 1 || active.Clusters[0].MemberCount != 2 {
t.Fatalf("default clusters should include closed historical members, got %#v", active.Clusters)
}
stdout.Reset()
withClosed := New()
withClosed.Stdout = &stdout
if err := withClosed.Run(ctx, []string{"--config", configPath, "--json", "clusters", "openclaw/openclaw", "--sort", "size", "--min-size", "1", "--hide-closed"}); err != nil {
t.Fatalf("clusters hide closed: %v", err)
}
var all struct {
Clusters []store.ClusterSummary `json:"clusters"`
}
if err := json.Unmarshal(stdout.Bytes(), &all); err != nil {
t.Fatalf("decode all clusters: %v\n%s", err, stdout.String())
}
if len(all.Clusters) != 1 || all.Clusters[0].MemberCount != 1 {
t.Fatalf("hide-closed should focus active members, got %#v", all.Clusters)
}
stdout.Reset()
detail := New()
detail.Stdout = &stdout
if err := detail.Run(ctx, []string{"--config", configPath, "--json", "cluster-detail", "openclaw/openclaw", "--id", "90"}); err != nil {
t.Fatalf("cluster-detail: %v", err)
}
var detailPayload struct {
Members []store.ClusterMemberDetail `json:"members"`
}
if err := json.Unmarshal(stdout.Bytes(), &detailPayload); err != nil {
t.Fatalf("decode cluster detail: %v\n%s", err, stdout.String())
}
if len(detailPayload.Members) != 2 {
t.Fatalf("default cluster-detail should match visible cluster members, got %#v", detailPayload.Members)
}
stdout.Reset()
hideDetail := New()
hideDetail.Stdout = &stdout
if err := hideDetail.Run(ctx, []string{"--config", configPath, "--json", "cluster-detail", "openclaw/openclaw", "--id", "90", "--hide-closed"}); err != nil {
t.Fatalf("cluster-detail hide closed: %v", err)
}
detailPayload.Members = nil
if err := json.Unmarshal(stdout.Bytes(), &detailPayload); err != nil {
t.Fatalf("decode hide-closed cluster detail: %v\n%s", err, stdout.String())
}
if len(detailPayload.Members) != 1 || detailPayload.Members[0].Thread.Number != 90 {
t.Fatalf("hide-closed cluster-detail should focus open members, got %#v", detailPayload.Members)
}
}
func TestClusterMemberOverrideCommands(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
firstID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "81",
Number: 81,
Kind: "issue",
State: "open",
Title: "First member",
HTMLURL: "https://github.com/openclaw/openclaw/issues/81",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-81",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed first thread: %v", err)
}
secondID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "82",
Number: 82,
Kind: "issue",
State: "open",
Title: "Second member",
HTMLURL: "https://github.com/openclaw/openclaw/issues/82",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-82",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed second thread: %v", err)
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_groups(id, repo_id, stable_key, stable_slug, status, representative_thread_id, title, created_at, updated_at)
values(81, ?, 'cluster-81', 'cluster-81', 'active', ?, 'Cluster 81', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z')
`, repoID, firstID); err != nil {
t.Fatalf("seed cluster: %v", err)
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at)
values(81, ?, 'representative', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z')
`, firstID); err != nil {
t.Fatalf("seed first member: %v", err)
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at)
values(81, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z')
`, secondID); err != nil {
t.Fatalf("seed second member: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "exclude-cluster-member", "openclaw/openclaw", "--id", "81", "--number", "81", "--reason", "bad match", "--json"}); err != nil {
t.Fatalf("exclude-cluster-member: %v", err)
}
if !strings.Contains(stdout.String(), `"excluded": true`) {
t.Fatalf("exclude-cluster-member output = %q", stdout.String())
}
stdout.Reset()
run = New()
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "include-cluster-member", "openclaw/openclaw", "--id", "81", "--number", "81", "--json"}); err != nil {
t.Fatalf("include-cluster-member: %v", err)
}
if !strings.Contains(stdout.String(), `"included": true`) {
t.Fatalf("include-cluster-member output = %q", stdout.String())
}
stdout.Reset()
run = New()
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "set-cluster-canonical", "openclaw/openclaw", "--id", "81", "--number", "82", "--json"}); err != nil {
t.Fatalf("set-cluster-canonical: %v", err)
}
if !strings.Contains(stdout.String(), `"canonical": true`) {
t.Fatalf("set-cluster-canonical output = %q", stdout.String())
}
st, err = store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer st.Close()
detail, err := st.ClusterDetail(ctx, store.ClusterDetailOptions{RepoID: repoID, ClusterID: 81, IncludeClosed: false, MemberLimit: 10})
if err != nil {
t.Fatalf("cluster detail: %v", err)
}
if detail.Cluster.RepresentativeThreadID != secondID || detail.Members[0].Thread.Number != 82 || detail.Members[0].Role != "canonical" {
t.Fatalf("canonical command did not update cluster detail: %#v", detail)
}
}
func TestClusterCommandPersistsDurableClusters(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
firstID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "91",
Number: 91,
Kind: "issue",
State: "open",
Title: "First duplicate",
HTMLURL: "https://github.com/openclaw/openclaw/issues/91",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-91",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed first thread: %v", err)
}
secondID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "92",
Number: 92,
Kind: "issue",
State: "open",
Title: "Second duplicate",
HTMLURL: "https://github.com/openclaw/openclaw/issues/92",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-92",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed second thread: %v", err)
}
thirdID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "93",
Number: 93,
Kind: "issue",
State: "open",
Title: "Unrelated issue",
HTMLURL: "https://github.com/openclaw/openclaw/issues/93",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-93",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed third thread: %v", err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, vector := range []store.ThreadVector{
{ThreadID: firstID, Basis: "title_original", Model: "text-embedding-3-small", Dimensions: 2, ContentHash: "hash-91", Vector: []float64{1, 0}, CreatedAt: now, UpdatedAt: now},
{ThreadID: secondID, Basis: "title_original", Model: "text-embedding-3-small", Dimensions: 2, ContentHash: "hash-92", Vector: []float64{0.95, 0.05}, CreatedAt: now, UpdatedAt: now},
{ThreadID: thirdID, Basis: "title_original", Model: "text-embedding-3-small", Dimensions: 2, ContentHash: "hash-93", Vector: []float64{0, 1}, CreatedAt: now, UpdatedAt: now},
} {
if err := st.UpsertThreadVector(ctx, vector); err != nil {
t.Fatalf("upsert vector: %v", err)
}
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "cluster", "openclaw/openclaw", "--threshold", "0.90", "--json"}); err != nil {
t.Fatalf("cluster: %v", err)
}
if !strings.Contains(stdout.String(), `"cluster_count": 2`) {
t.Fatalf("cluster output = %q", stdout.String())
}
st, err = store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer st.Close()
clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20})
if err != nil {
t.Fatalf("list clusters: %v", err)
}
memberCounts := []int{}
for _, cluster := range clusters {
memberCounts = append(memberCounts, cluster.MemberCount)
}
sort.Ints(memberCounts)
if len(memberCounts) != 2 || memberCounts[0] != 1 || memberCounts[1] != 2 {
t.Fatalf("expected duplicate cluster plus singleton, got %#v", clusters)
}
}
func TestBuildDurableClusterInputsPrunesWeakCrossKindEdges(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
issueID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "201",
Number: 201,
Kind: "issue",
State: "open",
Title: "Slack zero inbound events",
HTMLURL: "https://github.com/openclaw/openclaw/issues/201",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-201",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed issue: %v", err)
}
prID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "202",
Number: 202,
Kind: "pull_request",
State: "open",
Title: "Slack socket mode import fix",
HTMLURL: "https://github.com/openclaw/openclaw/pull/202",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-202",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed pull request: %v", err)
}
vectors := []store.ThreadVector{
{ThreadID: issueID, Vector: []float64{1, 0}},
{ThreadID: prID, Vector: []float64{0.9, 0.435889894}},
}
inputs, edgeCount, err := buildDurableClusterInputs(ctx, st, repoID, vectors, clusterBuildOptions{
Threshold: 0.82,
MinSize: 2,
MaxClusterSize: defaultClusterMaxSize,
Fanout: 16,
CrossKindThreshold: 0.93,
})
if err != nil {
t.Fatalf("build inputs: %v", err)
}
if edgeCount != 0 || len(inputs) != 0 {
t.Fatalf("weak cross-kind edge should be pruned, edges=%d inputs=%#v", edgeCount, inputs)
}
inputs, edgeCount, err = buildDurableClusterInputs(ctx, st, repoID, vectors, clusterBuildOptions{
Threshold: 0.82,
MinSize: 2,
MaxClusterSize: defaultClusterMaxSize,
Fanout: 16,
CrossKindThreshold: 0.89,
})
if err != nil {
t.Fatalf("build relaxed inputs: %v", err)
}
if edgeCount != 1 || len(inputs) != 1 {
t.Fatalf("relaxed cross-kind threshold should keep edge, edges=%d inputs=%#v", edgeCount, inputs)
}
}
func TestBuildDurableClusterInputsKeepsDeterministicReferenceEdges(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
issueID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "301",
Number: 301,
Kind: "issue",
State: "open",
Title: "Gateway token regression",
Body: "Users cannot authorize device tokens.",
HTMLURL: "https://github.com/openclaw/openclaw/issues/301",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-301",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed issue: %v", err)
}
prID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "302",
Number: 302,
Kind: "pull_request",
State: "open",
Title: "Repair auth scope migration",
Body: "Fixes #301 by preserving the device-token scope during upgrade.",
HTMLURL: "https://github.com/openclaw/openclaw/pull/302",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-302",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed pull request: %v", err)
}
vectors := []store.ThreadVector{
{ThreadID: issueID, Vector: []float64{1, 0}},
{ThreadID: prID, Vector: []float64{0, 1}},
}
inputs, edgeCount, err := buildDurableClusterInputs(ctx, st, repoID, vectors, clusterBuildOptions{
Threshold: 0.99,
MinSize: 2,
MaxClusterSize: defaultClusterMaxSize,
Fanout: 16,
CrossKindThreshold: 0.99,
})
if err != nil {
t.Fatalf("build inputs: %v", err)
}
if edgeCount != 1 || len(inputs) != 1 {
t.Fatalf("direct issue/PR reference should form an evidence edge, edges=%d inputs=%#v", edgeCount, inputs)
}
}
func TestBuildDurableClusterInputsIgnoresBareOneDigitProseRefs(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
firstID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "401",
Number: 401,
Kind: "pull_request",
State: "open",
Title: "Background task notification",
Body: "This is the #1 UX gap for orchestration.",
HTMLURL: "https://github.com/openclaw/openclaw/pull/401",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-401",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed first thread: %v", err)
}
secondID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "402",
Number: 402,
Kind: "pull_request",
State: "open",
Title: "Plugin config overlay",
Body: "This is #1 for locked-down deployments.",
HTMLURL: "https://github.com/openclaw/openclaw/pull/402",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-402",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed second thread: %v", err)
}
inputs, edgeCount, err := buildDurableClusterInputs(ctx, st, repoID, []store.ThreadVector{
{ThreadID: firstID, Vector: []float64{1, 0}},
{ThreadID: secondID, Vector: []float64{0, 1}},
}, clusterBuildOptions{
Threshold: 0.99,
MinSize: 2,
MaxClusterSize: defaultClusterMaxSize,
Fanout: 16,
CrossKindThreshold: 0.99,
})
if err != nil {
t.Fatalf("build inputs: %v", err)
}
if edgeCount != 0 || len(inputs) != 0 {
t.Fatalf("bare one-digit prose refs should not form evidence edges, edges=%d inputs=%#v", edgeCount, inputs)
}
}
func TestBuildDurableClusterInputsPrunesBodyOnlyUnrelatedReferences(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
watchdogID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "601",
Number: 601,
Kind: "pull_request",
State: "open",
Title: "feat: add external rescue watchdog",
Body: "Adds a rescue watchdog service.",
HTMLURL: "https://github.com/openclaw/openclaw/pull/601",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-601",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed watchdog thread: %v", err)
}
windowsID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "602",
Number: 602,
Kind: "pull_request",
State: "open",
Title: "fix: align windows path tests with runtime behavior",
Body: strings.Repeat("Windows path normalization changed in this shard. ", 8) + "The Windows shard failures inherited by #601 are unrelated to the watchdog feature itself.",
HTMLURL: "https://github.com/openclaw/openclaw/pull/602",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-602",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed windows thread: %v", err)
}
inputs, edgeCount, err := buildDurableClusterInputs(ctx, st, repoID, []store.ThreadVector{
{ThreadID: watchdogID, Vector: []float64{1, 0}},
{ThreadID: windowsID, Vector: []float64{0, 1}},
}, clusterBuildOptions{
Threshold: 0.99,
MinSize: 2,
MaxClusterSize: defaultClusterMaxSize,
Fanout: 16,
CrossKindThreshold: 0.99,
})
if err != nil {
t.Fatalf("build inputs: %v", err)
}
if edgeCount != 0 || len(inputs) != 0 {
t.Fatalf("body-only reference without title overlap should not form evidence edge, edges=%d inputs=%#v", edgeCount, inputs)
}
}
func TestBuildDurableClusterInputsPrunesWeakGenericTitleEdges(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
firstID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "501",
Number: 501,
Kind: "pull_request",
State: "open",
Title: "fix: improve error handling and logging for security-critical operations",
HTMLURL: "https://github.com/openclaw/openclaw/pull/501",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-501",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed first thread: %v", err)
}
secondID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "502",
Number: 502,
Kind: "pull_request",
State: "open",
Title: "fix(gateway): isolate control-plane write rate limits by connection",
HTMLURL: "https://github.com/openclaw/openclaw/pull/502",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "hash-502",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed second thread: %v", err)
}
vectors := []store.ThreadVector{
{ThreadID: firstID, Vector: []float64{1, 0}},
{ThreadID: secondID, Vector: []float64{0.84, 0.5425863986500217}},
}
inputs, edgeCount, err := buildDurableClusterInputs(ctx, st, repoID, vectors, clusterBuildOptions{
Threshold: 0.82,
MinSize: 2,
MaxClusterSize: defaultClusterMaxSize,
Fanout: 16,
CrossKindThreshold: defaultCrossKindMinScore,
})
if err != nil {
t.Fatalf("build inputs: %v", err)
}
if edgeCount != 0 || len(inputs) != 0 {
t.Fatalf("weak generic title edge should be pruned, edges=%d inputs=%#v", edgeCount, inputs)
}
}
func TestKeepTopEdgesKeepsOneSidedNearestNeighbors(t *testing.T) {
edges := keepTopEdges([]clusterer.Edge{
{LeftThreadID: 1, RightThreadID: 2, Score: 0.95},
{LeftThreadID: 1, RightThreadID: 3, Score: 0.90},
}, 1)
if len(edges) != 2 {
t.Fatalf("one-sided top-k edges should be kept, got %#v", edges)
}
}
func TestRefreshEmbedsAndClustersWithoutSync(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatalf("init: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
seedEmbeddingDocument(t, ctx, st, repoID, 101, "Duplicate crash one")
seedEmbeddingDocument(t, ctx, st, repoID, 102, "Duplicate crash two")
seedEmbeddingDocument(t, ctx, st, repoID, 103, "Unrelated settings request")
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/embeddings" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var request struct {
Input []string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
response := struct {
Data []struct {
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
} `json:"data"`
}{}
for index, input := range request.Input {
vector := []float64{0, 1}
if strings.Contains(strings.ToLower(input), "duplicate") {
vector = []float64{1, 0.01}
}
response.Data = append(response.Data, struct {
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}{Index: index, Embedding: vector})
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
t.Fatalf("encode response: %v", err)
}
}))
defer server.Close()
t.Setenv("OPENAI_API_KEY", "test-key")
t.Setenv("GITCRAWL_OPENAI_BASE_URL", server.URL)
run := New()
var stdout, stderr bytes.Buffer
run.Stdout = &stdout
run.Stderr = &stderr
if err := run.Run(ctx, []string{"--config", configPath, "refresh", "openclaw/openclaw", "--no-sync", "--threshold", "0.90", "--json"}); err != nil {
t.Fatalf("refresh: %v\nstderr:\n%s", err, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, `"embedded": 3`) {
t.Fatalf("refresh did not embed rows: %q", out)
}
if !strings.Contains(out, `"cluster_count": 2`) {
t.Fatalf("refresh did not persist cluster: %q", out)
}
st, err = store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer st.Close()
clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20})
if err != nil {
t.Fatalf("list clusters: %v", err)
}
memberCounts := []int{}
for _, cluster := range clusters {
memberCounts = append(memberCounts, cluster.MemberCount)
}
sort.Ints(memberCounts)
if len(memberCounts) != 2 || memberCounts[0] != 1 || memberCounts[1] != 2 {
t.Fatalf("expected duplicate cluster plus singleton, got %#v", clusters)
}
}
func seedEmbeddingDocument(t *testing.T, ctx context.Context, st *store.Store, repoID int64, number int, title string) {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339Nano)
threadID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: strconv.Itoa(number),
Number: number,
Kind: "issue",
State: "open",
Title: title,
Body: title,
HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", number),
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: fmt.Sprintf("hash-%d", number),
UpdatedAt: now,
})
if err != nil {
t.Fatalf("seed thread %d: %v", number, err)
}
if _, err := st.UpsertDocument(ctx, store.Document{
ThreadID: threadID,
Title: title,
Body: title,
RawText: title,
DedupeText: strings.ToLower(title),
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed document %d: %v", number, err)
}
}
func TestTUIHelp(t *testing.T) {
app := New()
var stdout bytes.Buffer
app.Stdout = &stdout
if err := app.Run(context.Background(), []string{"help", "tui"}); err != nil {
t.Fatalf("help tui: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "gitcrawl tui [owner/repo]") {
t.Fatalf("expected tui usage, got %q", out)
}
if !strings.Contains(out, "right-click for actions") {
t.Fatalf("tui help should mention mouse actions, got %q", out)
}
if !strings.Contains(out, "Press a to open") {
t.Fatalf("tui help should mention keyboard action menu, got %q", out)
}
if !strings.Contains(out, "Press # to jump") {
t.Fatalf("tui help should mention number jump, got %q", out)
}
if !strings.Contains(out, "Press p to switch") {
t.Fatalf("tui help should mention repository switching, got %q", out)
}
if !strings.Contains(out, "Press n to load neighbors") {
t.Fatalf("tui help should mention neighbor loading, got %q", out)
}
if strings.Contains(strings.ToLower(out), "future tui") {
t.Fatalf("tui help still implies future-only support: %q", out)
}
}
func TestServeIsUnsupported(t *testing.T) {
app := New()
err := app.Run(context.Background(), []string{"serve"})
if err == nil {
t.Fatal("expected serve to fail")
}
if ExitCode(err) != 2 {
t.Fatalf("exit code: got %d want 2", ExitCode(err))
}
}