3154 lines
109 KiB
Go
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))
|
|
}
|
|
}
|