diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 359c607..6f6bda2 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -1024,6 +1024,16 @@ func (m *clusterBrowserModel) openActionMenu() { } if member, ok := m.selectedMember(); ok { sectionAdded := false + if cluster, clusterOK := m.selectedCluster(); clusterOK { + if member.State == "excluded" { + m.menuItems = append(m.menuItems, tuiMenuItem{label: fmt.Sprintf("Include #%d in C%d...", member.Thread.Number, cluster.ID), action: "include-member-confirm"}) + } else { + m.menuItems = append(m.menuItems, + tuiMenuItem{label: fmt.Sprintf("Exclude #%d from C%d...", member.Thread.Number, cluster.ID), action: "exclude-member-confirm"}, + tuiMenuItem{label: fmt.Sprintf("Set #%d as canonical...", member.Thread.Number), action: "canonical-member-confirm"}, + ) + } + } if strings.TrimSpace(member.BodySnippet) != "" { if !sectionAdded && !menuHasSection(m.menuItems, "Thread") { m.menuItems = append(m.menuItems, tuiMenuSection("Thread")) @@ -1280,6 +1290,24 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool { case "reopen-cluster-local": m.reopenSelectedClusterLocally() return true + case "exclude-member-confirm": + m.openExcludeMemberMenu() + return false + case "exclude-member-local": + m.excludeSelectedClusterMemberLocally() + return true + case "include-member-confirm": + m.openIncludeMemberMenu() + return false + case "include-member-local": + m.includeSelectedClusterMemberLocally() + return true + case "canonical-member-confirm": + m.openCanonicalMemberMenu() + return false + case "canonical-member-local": + m.setSelectedClusterCanonicalLocally() + return true case "load-neighbors": m.loadSelectedThreadNeighbors(10, 0.2) return true @@ -1693,6 +1721,57 @@ func (m *clusterBrowserModel) openReopenClusterMenu() { m.status = fmt.Sprintf("Confirm local reopen for cluster C%d", cluster.ID) } +func (m *clusterBrowserModel) openExcludeMemberMenu() { + cluster, clusterOK := m.selectedCluster() + member, memberOK := m.selectedMember() + if !clusterOK || !memberOK { + m.status = "No selected cluster member" + return + } + m.menuTitle = "Exclude Member" + m.menuItems = []tuiMenuItem{ + {label: fmt.Sprintf("Exclude #%d from C%d", member.Thread.Number, cluster.ID), action: "exclude-member-local"}, + {label: "Back to actions", action: "back-to-actions"}, + } + m.menuIndex = 0 + m.menuOff = 0 + m.status = fmt.Sprintf("Confirm local exclude for #%d", member.Thread.Number) +} + +func (m *clusterBrowserModel) openIncludeMemberMenu() { + cluster, clusterOK := m.selectedCluster() + member, memberOK := m.selectedMember() + if !clusterOK || !memberOK { + m.status = "No selected cluster member" + return + } + m.menuTitle = "Include Member" + m.menuItems = []tuiMenuItem{ + {label: fmt.Sprintf("Include #%d in C%d", member.Thread.Number, cluster.ID), action: "include-member-local"}, + {label: "Back to actions", action: "back-to-actions"}, + } + m.menuIndex = 0 + m.menuOff = 0 + m.status = fmt.Sprintf("Confirm local include for #%d", member.Thread.Number) +} + +func (m *clusterBrowserModel) openCanonicalMemberMenu() { + cluster, clusterOK := m.selectedCluster() + member, memberOK := m.selectedMember() + if !clusterOK || !memberOK { + m.status = "No selected cluster member" + return + } + m.menuTitle = "Canonical Member" + m.menuItems = []tuiMenuItem{ + {label: fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID), action: "canonical-member-local"}, + {label: "Back to actions", action: "back-to-actions"}, + } + m.menuIndex = 0 + m.menuOff = 0 + m.status = fmt.Sprintf("Confirm canonical member #%d", member.Thread.Number) +} + func (m *clusterBrowserModel) closeSelectedThreadLocally() { thread, ok := m.selectedThread() if !ok { @@ -1766,6 +1845,64 @@ func (m *clusterBrowserModel) reopenSelectedClusterLocally() { m.status = fmt.Sprintf("Reopened cluster C%d locally", cluster.ID) } +func (m *clusterBrowserModel) excludeSelectedClusterMemberLocally() { + cluster, clusterOK := m.selectedCluster() + member, memberOK := m.selectedMember() + if !clusterOK || !memberOK { + m.status = "No selected cluster member" + return + } + if m.store == nil || m.repoID == 0 { + m.status = "Local member exclude unavailable for this view" + return + } + if _, err := m.store.ExcludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual exclude"); err != nil { + m.status = err.Error() + return + } + delete(m.neighborCache, member.Thread.ID) + m.refreshFromStore() + m.status = fmt.Sprintf("Excluded #%d from C%d locally", member.Thread.Number, cluster.ID) +} + +func (m *clusterBrowserModel) includeSelectedClusterMemberLocally() { + cluster, clusterOK := m.selectedCluster() + member, memberOK := m.selectedMember() + if !clusterOK || !memberOK { + m.status = "No selected cluster member" + return + } + if m.store == nil || m.repoID == 0 { + m.status = "Local member include unavailable for this view" + return + } + if _, err := m.store.IncludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual include"); err != nil { + m.status = err.Error() + return + } + m.refreshFromStore() + m.status = fmt.Sprintf("Included #%d in C%d locally", member.Thread.Number, cluster.ID) +} + +func (m *clusterBrowserModel) setSelectedClusterCanonicalLocally() { + cluster, clusterOK := m.selectedCluster() + member, memberOK := m.selectedMember() + if !clusterOK || !memberOK { + m.status = "No selected cluster member" + return + } + if m.store == nil || m.repoID == 0 { + m.status = "Local canonical unavailable for this view" + return + } + if _, err := m.store.SetClusterCanonicalLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual canonical"); err != nil { + m.status = err.Error() + return + } + m.refreshFromStore() + m.status = fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID) +} + func (m clusterBrowserModel) menuVisibleCount() int { height := m.detailView.Height if height <= 0 { @@ -2108,7 +2245,7 @@ func (m clusterBrowserModel) memberTableRows() []table.Row { thread := member.thread() rows = append(rows, table.Row{ fmt.Sprintf("#%d", thread.Number), - stateGlyph(threadDisplayState(thread)), + stateGlyph(memberDisplayState(member.member)), formatRelativeTime(thread.UpdatedAtGitHub), thread.Title, }) @@ -2579,7 +2716,7 @@ func (m *clusterBrowserModel) sortMembers() { } members := make([]store.ClusterMemberDetail, 0, len(m.detail.Members)) for _, member := range m.detail.Members { - if !threadVisible(member.Thread, m.showClosed) { + if !memberVisible(member, m.showClosed) { continue } members = append(members, member) @@ -2842,7 +2979,7 @@ func (m clusterBrowserModel) threadDetailClipboardText() string { thread := member.Thread lines := []string{ fmt.Sprintf("%s #%d: %s", kindTitle(thread.Kind), thread.Number, thread.Title), - "State: " + threadDisplayState(thread), + "State: " + memberDisplayState(member), "Author: " + firstNonEmpty(thread.AuthorLogin, "unknown"), "Updated: " + firstNonEmpty(thread.UpdatedAtGitHub, thread.UpdatedAt, "unknown"), "URL: " + thread.HTMLURL, @@ -2958,7 +3095,7 @@ func (m clusterBrowserModel) memberListClipboardText() string { thread := row.thread() lines = append(lines, fmt.Sprintf("#%d [%s] %s %s %s", thread.Number, - threadDisplayState(thread), + memberDisplayState(row.member), kindTitle(thread.Kind), thread.Title, thread.HTMLURL, @@ -2969,7 +3106,7 @@ func (m clusterBrowserModel) memberListClipboardText() string { func (r memberRow) format(width int) string { thread := r.thread() - return truncateCells(fmt.Sprintf("#%-7d %-7s %-8s %s", thread.Number, threadDisplayState(thread), formatRelativeTime(thread.UpdatedAtGitHub), thread.Title), width) + return truncateCells(fmt.Sprintf("#%-7d %-7s %-8s %s", thread.Number, memberDisplayState(r.member), formatRelativeTime(thread.UpdatedAtGitHub), thread.Title), width) } func (r memberRow) thread() store.Thread { @@ -3257,6 +3394,8 @@ func stateGlyph(state string) string { return "opn" case "closed": return "cls" + case "excluded": + return "exc" case "local": return "loc" case "merged": @@ -3280,6 +3419,20 @@ func threadVisible(thread store.Thread, showClosed bool) bool { return thread.State == "open" && thread.ClosedAtLocal == "" } +func memberDisplayState(member store.ClusterMemberDetail) string { + if member.State != "" && member.State != "active" { + return member.State + } + return threadDisplayState(member.Thread) +} + +func memberVisible(member store.ClusterMemberDetail, showClosed bool) bool { + if showClosed { + return true + } + return (member.State == "" || member.State == "active") && threadVisible(member.Thread, false) +} + func closedLabel(thread store.Thread) string { if thread.ClosedAtLocal == "" && thread.State == "open" { return "no" diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 4e46ad9..58176c9 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -1518,6 +1518,94 @@ func TestTUIReopenClusterLocallyRestoresCluster(t *testing.T) { } } +func TestTUIClusterMemberOverrideActions(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: "2026-04-27T00:00:00Z"}) + if err != nil { + t.Fatalf("repo: %v", err) + } + firstID, secondID, err := seedTUIClusterPair(ctx, st, repoID, 54, 540, 541) + if err != nil { + t.Fatalf("seed cluster pair: %v", err) + } + clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"}) + if err != nil { + t.Fatalf("clusters: %v", err) + } + model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "recent", + HideClosed: true, + MinSize: 1, + Clusters: clusters, + }) + model.openActionMenu() + if menuLabelIndex(model.menuItems, "Exclude #540 from C54...") < 0 { + t.Fatalf("action menu missing member exclude: %+v", model.menuItems) + } + if menuLabelIndex(model.menuItems, "Set #540 as canonical...") < 0 { + t.Fatalf("action menu missing canonical action: %+v", model.menuItems) + } + model.runAction("exclude-member-confirm") + if model.menuTitle != "Exclude Member" || !strings.Contains(model.menuItems[0].label, "Exclude #540 from C54") { + t.Fatalf("exclude member confirmation menu = %q %+v", model.menuTitle, model.menuItems) + } + + model.runAction("exclude-member-local") + + if model.status != "Excluded #540 from C54 locally" { + t.Fatalf("exclude status = %q", model.status) + } + if len(model.memberRows) < 2 || model.memberRows[1].thread().Number != 541 { + t.Fatalf("excluded member should be hidden while closed rows are hidden: %#v", model.memberRows) + } + detail, err := st.ClusterDetail(ctx, store.ClusterDetailOptions{RepoID: repoID, ClusterID: 54, IncludeClosed: false, MemberLimit: 10}) + if err != nil { + t.Fatalf("detail after exclude: %v", err) + } + if detail.Cluster.RepresentativeThreadID != secondID { + t.Fatalf("representative should refresh after excluding first member: %#v", detail.Cluster) + } + + model.showClosed = true + model.refreshFromStore() + model.memberIndex = memberRowIndex(model.memberRows, 540) + model.openActionMenu() + if menuLabelIndex(model.menuItems, "Include #540 in C54...") < 0 { + t.Fatalf("action menu missing member include: %+v", model.menuItems) + } + model.runAction("include-member-confirm") + if model.menuTitle != "Include Member" || !strings.Contains(model.menuItems[0].label, "Include #540 in C54") { + t.Fatalf("include member confirmation menu = %q %+v", model.menuTitle, model.menuItems) + } + model.runAction("include-member-local") + if model.status != "Included #540 in C54 locally" { + t.Fatalf("include status = %q", model.status) + } + model.memberIndex = memberRowIndex(model.memberRows, 541) + model.runAction("canonical-member-confirm") + if model.menuTitle != "Canonical Member" || !strings.Contains(model.menuItems[0].label, "Set #541 as canonical for C54") { + t.Fatalf("canonical confirmation menu = %q %+v", model.menuTitle, model.menuItems) + } + model.runAction("canonical-member-local") + if model.status != "Set #541 as canonical for C54" { + t.Fatalf("canonical status = %q", model.status) + } + detail, err = st.ClusterDetail(ctx, store.ClusterDetailOptions{RepoID: repoID, ClusterID: 54, IncludeClosed: false, MemberLimit: 10}) + if err != nil { + t.Fatalf("detail after canonical: %v", err) + } + if detail.Cluster.RepresentativeThreadID != secondID || detail.Members[0].Thread.ID != secondID || detail.Members[0].Role != "canonical" || detail.Members[1].Thread.ID != firstID { + t.Fatalf("canonical member should sort first and become representative: %#v", detail) + } +} + func TestTUIRepositoryPickerKeepsCurrentRepoVisible(t *testing.T) { ctx := context.Background() st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) @@ -2062,6 +2150,15 @@ func menuLabelIndex(items []tuiMenuItem, label string) int { return -1 } +func memberRowIndex(rows []memberRow, number int) int { + for index, row := range rows { + if row.selectable && row.thread().Number == number { + return index + } + } + return -1 +} + func seedTUIThreadVector(ctx context.Context, st *store.Store, repoID int64, number int, title string, vector []float64) (int64, error) { threadID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, @@ -2123,3 +2220,59 @@ func seedTUICluster(ctx context.Context, st *store.Store, repoID, clusterID int6 `, clusterID, threadID) return err } + +func seedTUIClusterPair(ctx context.Context, st *store.Store, repoID, clusterID int64, firstNumber, secondNumber int) (int64, int64, error) { + firstID, err := st.UpsertThread(ctx, store.Thread{ + RepoID: repoID, + GitHubID: fmt.Sprintf("%d", firstNumber), + Number: firstNumber, + Kind: "issue", + State: "open", + Title: fmt.Sprintf("member %d", firstNumber), + HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", firstNumber), + LabelsJSON: "[]", + AssigneesJSON: "[]", + RawJSON: "{}", + ContentHash: fmt.Sprintf("cluster-pair-hash-%d", firstNumber), + UpdatedAt: "2026-04-27T00:00:00Z", + }) + if err != nil { + return 0, 0, err + } + secondID, err := st.UpsertThread(ctx, store.Thread{ + RepoID: repoID, + GitHubID: fmt.Sprintf("%d", secondNumber), + Number: secondNumber, + Kind: "issue", + State: "open", + Title: fmt.Sprintf("member %d", secondNumber), + HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", secondNumber), + LabelsJSON: "[]", + AssigneesJSON: "[]", + RawJSON: "{}", + ContentHash: fmt.Sprintf("cluster-pair-hash-%d", secondNumber), + UpdatedAt: "2026-04-27T00:00:00Z", + }) + if err != nil { + return 0, 0, 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(?, ?, ?, ?, 'active', ?, ?, '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') + `, clusterID, repoID, fmt.Sprintf("cluster-%d", clusterID), fmt.Sprintf("repo-%d", clusterID), firstID, fmt.Sprintf("cluster %d", clusterID)); err != nil { + return 0, 0, 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(?, ?, 'representative', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') + `, clusterID, firstID); err != nil { + return 0, 0, 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(?, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z') + `, clusterID, secondID); err != nil { + return 0, 0, err + } + return firstID, secondID, nil +}