feat: implement cluster close commands
This commit is contained in:
parent
6e93570cfb
commit
ba175deae5
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user