feat: implement cluster close commands

This commit is contained in:
Vincent Koc 2026-04-27 14:15:57 -07:00
parent 6e93570cfb
commit ba175deae5
No known key found for this signature in database
2 changed files with 195 additions and 1 deletions

View File

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

View File

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