From ba175deae59ae5f90318c5f45d0e45b77d4eb8da Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 14:15:57 -0700 Subject: [PATCH] feat: implement cluster close commands --- internal/cli/app.go | 98 +++++++++++++++++++++++++++++++++++++++- internal/cli/app_test.go | 98 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 5df33c6..2bc8540 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -118,6 +118,10 @@ func (a *App) Run(ctx context.Context, args []string) error { return a.runCloseThread(ctx, rest[1:]) case "reopen-thread": return a.runReopenThread(ctx, rest[1:]) + case "close-cluster": + return a.runCloseCluster(ctx, rest[1:]) + case "reopen-cluster": + return a.runReopenCluster(ctx, rest[1:]) case "runs": return a.runRuns(ctx, rest[1:]) case "search": @@ -134,7 +138,7 @@ func (a *App) Run(ctx context.Context, args []string) error { return a.runPortable(ctx, rest[1:]) case "tui": return a.runTUI(ctx, rest[1:]) - case "refresh", "summarize", "key-summaries", "embed", "cluster", "cluster-experiment", "durable-clusters", "cluster-explain", "close-cluster", "exclude-cluster-member", "include-cluster-member", "set-cluster-canonical", "merge-clusters", "split-cluster", "export-sync", "import-sync", "validate-sync", "portable-size", "sync-status", "optimize", "completion": + case "refresh", "summarize", "key-summaries", "embed", "cluster", "cluster-experiment", "durable-clusters", "cluster-explain", "exclude-cluster-member", "include-cluster-member", "set-cluster-canonical", "merge-clusters", "split-cluster", "export-sync", "import-sync", "validate-sync", "portable-size", "sync-status", "optimize", "completion": _ = ctx return notImplemented(rest[0]) default: @@ -801,6 +805,96 @@ func (a *App) runReopenThread(ctx context.Context, args []string) error { }, true) } +func (a *App) runCloseCluster(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("close-cluster", flag.ContinueOnError) + fs.SetOutput(io.Discard) + idRaw := fs.String("id", "", "cluster id") + reason := fs.String("reason", "CLI manual close", "local close reason") + jsonOut := fs.Bool("json", false, "write JSON output") + if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"id": true, "reason": true})); err != nil { + return usageErr(err) + } + a.applyCommandJSON(*jsonOut) + if fs.NArg() != 1 { + return usageErr(fmt.Errorf("close-cluster requires owner/repo")) + } + owner, repoName, err := parseOwnerRepo(fs.Arg(0)) + if err != nil { + return usageErr(err) + } + clusterID, err := parseOptionalPositiveInt(*idRaw) + if err != nil { + return usageErr(err) + } + if clusterID == 0 { + return usageErr(fmt.Errorf("close-cluster requires --id")) + } + + rt, err := a.openLocalRuntime(ctx) + if err != nil { + return err + } + defer rt.Store.Close() + + repo, err := rt.repository(ctx, owner, repoName) + if err != nil { + return err + } + if err := rt.Store.CloseClusterLocally(ctx, repo.ID, int64(clusterID), *reason); err != nil { + return err + } + return a.writeOutput("close-cluster", map[string]any{ + "repository": repo.FullName, + "id": clusterID, + "reason": strings.TrimSpace(*reason), + "closed": true, + }, true) +} + +func (a *App) runReopenCluster(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("reopen-cluster", flag.ContinueOnError) + fs.SetOutput(io.Discard) + idRaw := fs.String("id", "", "cluster id") + jsonOut := fs.Bool("json", false, "write JSON output") + if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"id": true})); err != nil { + return usageErr(err) + } + a.applyCommandJSON(*jsonOut) + if fs.NArg() != 1 { + return usageErr(fmt.Errorf("reopen-cluster requires owner/repo")) + } + owner, repoName, err := parseOwnerRepo(fs.Arg(0)) + if err != nil { + return usageErr(err) + } + clusterID, err := parseOptionalPositiveInt(*idRaw) + if err != nil { + return usageErr(err) + } + if clusterID == 0 { + return usageErr(fmt.Errorf("reopen-cluster requires --id")) + } + + rt, err := a.openLocalRuntime(ctx) + if err != nil { + return err + } + defer rt.Store.Close() + + repo, err := rt.repository(ctx, owner, repoName) + if err != nil { + return err + } + if err := rt.Store.ReopenClusterLocally(ctx, repo.ID, int64(clusterID)); err != nil { + return err + } + return a.writeOutput("reopen-cluster", map[string]any{ + "repository": repo.FullName, + "id": clusterID, + "reopened": true, + }, true) +} + func (a *App) runSync(ctx context.Context, args []string) error { fs := flag.NewFlagSet("sync", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -1275,6 +1369,8 @@ Core commands: threads list local issue and pull request rows close-thread locally hide one issue or pull request row reopen-thread clear a local hide for one issue or pull request row + close-cluster locally hide one durable cluster + reopen-cluster clear a local hide for one durable cluster clusters list cluster summaries cluster-detail dump one durable cluster neighbors list vector-nearest local issue and pull request rows diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 3d99b64..ce05014 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -282,6 +282,104 @@ func TestCloseThreadCommandLocallyClosesThread(t *testing.T) { } } +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 TestTUIHelp(t *testing.T) { app := New() var stdout bytes.Buffer