From 67861b5d323323e4ff315b6b7217024200ffa67a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 11:23:59 +0100 Subject: [PATCH] fix: hide closed run cluster members --- CHANGELOG.md | 1 + internal/store/clusters.go | 24 ++++++++++++------- internal/store/clusters_test.go | 41 +++++++++++++++++++++++++++++---- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86fe9f3..768696e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ - Split generated clusters with bounded nearest-neighbor graph safeguards, GitHub reference evidence, and cross-kind score pruning so weak similarity bridges stop merging unrelated reports into one mega-cluster. - Tighten clustering precision by ignoring ambiguous one-digit prose references and requiring weak embedding edges to share concrete title tokens unless they have high similarity or direct GitHub reference evidence. - Treat later body-only issue references as weak evidence unless they share title overlap, while still preserving title and lead-body references for canonical issue/PR fix clusters. +- Hide GitHub-closed members from latest-run cluster summaries and details by default; `--include-closed` still shows the full historical cluster. diff --git a/internal/store/clusters.go b/internal/store/clusters.go index 0df457a..4cfb38e 100644 --- a/internal/store/clusters.go +++ b/internal/store/clusters.go @@ -143,16 +143,20 @@ func (s *Store) ListRunClusterSummaries(ctx context.Context, options ClusterSumm } where := `c.repo_id = ? and c.cluster_run_id = ?` args := []any{options.RepoID, runID, minSize} - having := `c.member_count >= ?` + memberCountExpr := `c.member_count` + updatedAtExpr := `coalesce(max(coalesce(t.updated_at_gh, t.updated_at)), c.created_at)` + having := memberCountExpr + ` >= ?` if !options.IncludeClosed { - having += ` and c.close_reason_local is null and closed_member_count < c.member_count` + memberCountExpr = `sum(case when t.state = 'open' and t.closed_at_local is null then 1 else 0 end)` + updatedAtExpr = `coalesce(max(case when t.state = 'open' and t.closed_at_local is null then coalesce(t.updated_at_gh, t.updated_at) end), c.created_at)` + having = memberCountExpr + ` >= ? and c.close_reason_local is null` } args = append(args, limit) rows, err := s.db.QueryContext(ctx, ` select c.id, c.representative_thread_id, rt.number, rt.kind, rt.title, - c.member_count, - coalesce(max(coalesce(t.updated_at_gh, t.updated_at)), c.created_at) as latest_updated_at, + `+memberCountExpr+` as member_count, + `+updatedAtExpr+` as latest_updated_at, c.closed_at_local, c.close_reason_local, sum(case when t.closed_at_local is not null or t.state <> 'open' then 1 else 0 end) as closed_member_count from clusters c @@ -515,7 +519,7 @@ func (s *Store) RunClusterDetail(ctx context.Context, options ClusterDetailOptio where := `cm.cluster_id = ?` args := []any{options.ClusterID} if !options.IncludeClosed { - where += ` and t.closed_at_local is null` + where += ` and t.state = 'open' and t.closed_at_local is null` } args = append(args, limit) rows, err := s.db.QueryContext(ctx, ` @@ -1195,14 +1199,18 @@ func (s *Store) runClusterSummaryByID(ctx context.Context, repoID, clusterID int return ClusterSummary{}, 0, fmt.Errorf("cluster %d was not found", clusterID) } having := `1 = 1` + memberCountExpr := `c.member_count` + updatedAtExpr := `coalesce(max(coalesce(t.updated_at_gh, t.updated_at)), c.created_at)` if !includeClosed { - having = `c.close_reason_local is null and closed_member_count < c.member_count` + memberCountExpr = `sum(case when t.state = 'open' and t.closed_at_local is null then 1 else 0 end)` + updatedAtExpr = `coalesce(max(case when t.state = 'open' and t.closed_at_local is null then coalesce(t.updated_at_gh, t.updated_at) end), c.created_at)` + having = memberCountExpr + ` > 0 and c.close_reason_local is null` } row := s.db.QueryRowContext(ctx, ` select c.id, c.representative_thread_id, rt.number, rt.kind, rt.title, - c.member_count, - coalesce(max(coalesce(t.updated_at_gh, t.updated_at)), c.created_at) as latest_updated_at, + `+memberCountExpr+` as member_count, + `+updatedAtExpr+` as latest_updated_at, c.closed_at_local, c.close_reason_local, sum(case when t.closed_at_local is not null or t.state <> 'open' then 1 else 0 end) as closed_member_count from clusters c diff --git a/internal/store/clusters_test.go b/internal/store/clusters_test.go index 7d7126c..65bee8b 100644 --- a/internal/store/clusters_test.go +++ b/internal/store/clusters_test.go @@ -189,6 +189,14 @@ func TestListDisplayClusterSummariesPrefersLatestRawRun(t *testing.T) { if err != nil { t.Fatalf("raw second thread: %v", err) } + rawClosed, err := st.UpsertThread(ctx, Thread{ + RepoID: repoID, GitHubID: "103", Number: 103, Kind: "issue", State: "closed", + Title: "raw closed", HTMLURL: "https://github.com/openclaw/openclaw/issues/103", + LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "raw-103", UpdatedAt: "2026-04-26T04:00:00Z", + }) + if err != nil { + t.Fatalf("raw closed thread: %v", err) + } durableID, err := st.UpsertThread(ctx, Thread{ RepoID: repoID, GitHubID: "201", Number: 201, Kind: "issue", State: "open", Title: "durable member", HTMLURL: "https://github.com/openclaw/openclaw/issues/201", @@ -201,15 +209,16 @@ func TestListDisplayClusterSummariesPrefersLatestRawRun(t *testing.T) { insert into cluster_runs(id, repo_id, scope, status, started_at, finished_at, stats_json) values(7, ?, 'repo', 'completed', '2026-04-26T00:00:00Z', '2026-04-26T00:01:00Z', '{}'); insert into clusters(id, repo_id, cluster_run_id, representative_thread_id, member_count, created_at) - values(70, ?, 7, ?, 2, '2026-04-26T00:01:00Z'); + values(70, ?, 7, ?, 3, '2026-04-26T00:01:00Z'); `, repoID, repoID, rawOne); err != nil { t.Fatalf("seed raw cluster: %v", err) } if _, err := st.DB().ExecContext(ctx, ` insert into cluster_members(cluster_id, thread_id, score_to_representative, created_at) values(70, ?, 1.0, '2026-04-26T00:01:00Z'), - (70, ?, 0.91, '2026-04-26T00:01:00Z'); - `, rawOne, rawTwo); err != nil { + (70, ?, 0.91, '2026-04-26T00:01:00Z'), + (70, ?, 0.90, '2026-04-26T00:01:00Z'); + `, rawOne, rawTwo, rawClosed); err != nil { t.Fatalf("seed raw members: %v", err) } if _, err := st.DB().ExecContext(ctx, ` @@ -221,11 +230,33 @@ func TestListDisplayClusterSummariesPrefersLatestRawRun(t *testing.T) { t.Fatalf("seed durable cluster: %v", err) } + activeDisplay, err := st.ListDisplayClusterSummaries(ctx, ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "size"}) + if err != nil { + t.Fatalf("list active display clusters: %v", err) + } + if len(activeDisplay) != 1 || activeDisplay[0].ID != 70 || activeDisplay[0].MemberCount != 2 { + t.Fatalf("active display clusters should hide closed raw members, got %#v", activeDisplay) + } + activeDetail, err := st.ClusterDetail(ctx, ClusterDetailOptions{RepoID: repoID, ClusterID: 70, IncludeClosed: false, MemberLimit: 10}) + if err != nil { + t.Fatalf("active raw detail: %v", err) + } + if len(activeDetail.Members) != 2 || activeDetail.Members[0].Thread.Number != 101 || activeDetail.Members[1].Thread.Number == 103 { + t.Fatalf("active raw detail should hide closed members, got %#v", activeDetail) + } + hiddenByMinSize, err := st.ListDisplayClusterSummaries(ctx, ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 3, Limit: 20, Sort: "size"}) + if err != nil { + t.Fatalf("list active display clusters with min size: %v", err) + } + if len(hiddenByMinSize) != 0 { + t.Fatalf("active display min-size should count visible members only, got %#v", hiddenByMinSize) + } + display, err := st.ListDisplayClusterSummaries(ctx, ClusterSummaryOptions{RepoID: repoID, IncludeClosed: true, MinSize: 1, Limit: 20, Sort: "size"}) if err != nil { t.Fatalf("list display clusters: %v", err) } - if len(display) != 1 || display[0].ID != 70 || display[0].Source != ClusterSourceRun || display[0].MemberCount != 2 { + if len(display) != 1 || display[0].ID != 70 || display[0].Source != ClusterSourceRun || display[0].MemberCount != 3 { t.Fatalf("display clusters should prefer raw run, got %#v", display) } durable, err := st.ListClusterSummaries(ctx, ClusterSummaryOptions{RepoID: repoID, IncludeClosed: true, MinSize: 1, Limit: 20, Sort: "size"}) @@ -240,7 +271,7 @@ func TestListDisplayClusterSummariesPrefersLatestRawRun(t *testing.T) { if err != nil { t.Fatalf("raw detail: %v", err) } - if detail.Cluster.Source != ClusterSourceRun || len(detail.Members) != 2 || detail.Members[0].Thread.Number != 101 { + if detail.Cluster.Source != ClusterSourceRun || len(detail.Members) != 3 || detail.Members[0].Thread.Number != 101 { t.Fatalf("unexpected raw detail: %#v", detail) } }