feat: add tui cluster member overrides
This commit is contained in:
parent
a35eaee41a
commit
216a5b1728
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user