refactor: Split large cmd/bd files to meet 800-line limit (bd-xtf5)
Split 6 files exceeding 800 lines by extracting cohesive function groups: - show.go (1592→578): extracted show_thread.go, close.go, edit.go, update.go - doctor.go (1295→690): extracted doctor_fix.go, doctor_health.go, doctor_pollution.go - sync.go (1201→749): extracted sync_git.go - compact.go (1199→775): extracted compact_tombstone.go, compact_rpc.go - linear.go (1190→641): extracted linear_sync.go, linear_conflict.go - main.go (1148→800): extracted main_help.go, main_errors.go, main_daemon.go All files now under 800-line acceptance criteria. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
266
cmd/bd/show_thread.go
Normal file
266
cmd/bd/show_thread.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
|
||||
// showMessageThread displays a full conversation thread for a message
|
||||
func showMessageThread(ctx context.Context, messageID string, jsonOutput bool) {
|
||||
// Get the starting message
|
||||
var startMsg *types.Issue
|
||||
var err error
|
||||
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &startMsg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
startMsg, err = store.GetIssue(ctx, messageID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if startMsg == nil {
|
||||
fmt.Fprintf(os.Stderr, "Message %s not found\n", messageID)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Find the root of the thread by following replies-to dependencies upward
|
||||
// Per Decision 004, RepliesTo is now stored as a dependency, not an Issue field
|
||||
rootMsg := startMsg
|
||||
seen := make(map[string]bool)
|
||||
seen[rootMsg.ID] = true
|
||||
|
||||
for {
|
||||
// Find parent via replies-to dependency
|
||||
parentID := findRepliesTo(ctx, rootMsg.ID, daemonClient, store)
|
||||
if parentID == "" {
|
||||
break // No parent, this is the root
|
||||
}
|
||||
if seen[parentID] {
|
||||
break // Avoid infinite loops
|
||||
}
|
||||
seen[parentID] = true
|
||||
|
||||
var parentMsg *types.Issue
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: parentID})
|
||||
if err != nil {
|
||||
break // Parent not found, use current as root
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &parentMsg); err != nil {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
parentMsg, _ = store.GetIssue(ctx, parentID)
|
||||
}
|
||||
if parentMsg == nil {
|
||||
break
|
||||
}
|
||||
rootMsg = parentMsg
|
||||
}
|
||||
|
||||
// Now collect all messages in the thread
|
||||
// Start from root and find all replies
|
||||
// Build a map of child ID -> parent ID for display purposes
|
||||
threadMessages := []*types.Issue{rootMsg}
|
||||
threadIDs := map[string]bool{rootMsg.ID: true}
|
||||
repliesTo := map[string]string{} // child ID -> parent ID
|
||||
queue := []string{rootMsg.ID}
|
||||
|
||||
// BFS to find all replies
|
||||
for len(queue) > 0 {
|
||||
currentID := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
// Find all messages that reply to currentID via replies-to dependency
|
||||
// Per Decision 004, replies are found via dependents with type replies-to
|
||||
replies := findReplies(ctx, currentID, daemonClient, store)
|
||||
|
||||
for _, reply := range replies {
|
||||
if threadIDs[reply.ID] {
|
||||
continue // Already seen
|
||||
}
|
||||
threadMessages = append(threadMessages, reply)
|
||||
threadIDs[reply.ID] = true
|
||||
repliesTo[reply.ID] = currentID // Track parent for display
|
||||
queue = append(queue, reply.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation time
|
||||
slices.SortFunc(threadMessages, func(a, b *types.Issue) int {
|
||||
return a.CreatedAt.Compare(b.CreatedAt)
|
||||
})
|
||||
|
||||
if jsonOutput {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
_ = encoder.Encode(threadMessages)
|
||||
return
|
||||
}
|
||||
|
||||
// Display the thread
|
||||
fmt.Printf("\n%s Thread: %s\n", ui.RenderAccent("📬"), rootMsg.Title)
|
||||
fmt.Println(strings.Repeat("─", 66))
|
||||
|
||||
for _, msg := range threadMessages {
|
||||
// Show indent based on depth (count replies_to chain using our map)
|
||||
depth := 0
|
||||
parent := repliesTo[msg.ID]
|
||||
for parent != "" && depth < 5 {
|
||||
depth++
|
||||
parent = repliesTo[parent]
|
||||
}
|
||||
indent := strings.Repeat(" ", depth)
|
||||
|
||||
// Format timestamp
|
||||
timeStr := msg.CreatedAt.Format("2006-01-02 15:04")
|
||||
|
||||
// Status indicator
|
||||
statusIcon := "📧"
|
||||
if msg.Status == types.StatusClosed {
|
||||
statusIcon = "✓"
|
||||
}
|
||||
|
||||
fmt.Printf("%s%s %s %s\n", indent, statusIcon, ui.RenderAccent(msg.ID), ui.RenderMuted(timeStr))
|
||||
fmt.Printf("%s From: %s To: %s\n", indent, msg.Sender, msg.Assignee)
|
||||
if parentID := repliesTo[msg.ID]; parentID != "" {
|
||||
fmt.Printf("%s Re: %s\n", indent, parentID)
|
||||
}
|
||||
fmt.Printf("%s %s: %s\n", indent, ui.RenderMuted("Subject"), msg.Title)
|
||||
if msg.Description != "" {
|
||||
// Indent the body
|
||||
bodyLines := strings.Split(msg.Description, "\n")
|
||||
for _, line := range bodyLines {
|
||||
fmt.Printf("%s %s\n", indent, line)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("Total: %d messages in thread\n\n", len(threadMessages))
|
||||
}
|
||||
|
||||
// findRepliesTo finds the parent ID that this issue replies to via replies-to dependency.
|
||||
// Returns empty string if no parent found.
|
||||
func findRepliesTo(ctx context.Context, issueID string, daemonClient *rpc.Client, store storage.Storage) string {
|
||||
if daemonClient != nil {
|
||||
// In daemon mode, use Show to get dependencies with metadata
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// Parse the full show response to get dependencies
|
||||
type showResponse struct {
|
||||
Dependencies []struct {
|
||||
ID string `json:"id"`
|
||||
DependencyType string `json:"dependency_type"`
|
||||
} `json:"dependencies"`
|
||||
}
|
||||
var details showResponse
|
||||
if err := json.Unmarshal(resp.Data, &details); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, dep := range details.Dependencies {
|
||||
if dep.DependencyType == string(types.DepRepliesTo) {
|
||||
return dep.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// Direct mode - query storage
|
||||
deps, err := store.GetDependencyRecords(ctx, issueID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, dep := range deps {
|
||||
if dep.Type == types.DepRepliesTo {
|
||||
return dep.DependsOnID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findReplies finds all issues that reply to this issue via replies-to dependency.
|
||||
func findReplies(ctx context.Context, issueID string, daemonClient *rpc.Client, store storage.Storage) []*types.Issue {
|
||||
if daemonClient != nil {
|
||||
// In daemon mode, use Show to get dependents with metadata
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Parse the full show response to get dependents
|
||||
type showResponse struct {
|
||||
Dependents []struct {
|
||||
types.Issue
|
||||
DependencyType string `json:"dependency_type"`
|
||||
} `json:"dependents"`
|
||||
}
|
||||
var details showResponse
|
||||
if err := json.Unmarshal(resp.Data, &details); err != nil {
|
||||
return nil
|
||||
}
|
||||
var replies []*types.Issue
|
||||
for _, dep := range details.Dependents {
|
||||
if dep.DependencyType == string(types.DepRepliesTo) {
|
||||
issue := dep.Issue // Copy to avoid aliasing
|
||||
replies = append(replies, &issue)
|
||||
}
|
||||
}
|
||||
return replies
|
||||
}
|
||||
// Direct mode - query storage
|
||||
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
|
||||
deps, err := sqliteStore.GetDependentsWithMetadata(ctx, issueID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var replies []*types.Issue
|
||||
for _, dep := range deps {
|
||||
if dep.DependencyType == types.DepRepliesTo {
|
||||
issue := dep.Issue // Copy to avoid aliasing
|
||||
replies = append(replies, &issue)
|
||||
}
|
||||
}
|
||||
return replies
|
||||
}
|
||||
|
||||
allDeps, err := store.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var replies []*types.Issue
|
||||
for childID, deps := range allDeps {
|
||||
for _, dep := range deps {
|
||||
if dep.Type == types.DepRepliesTo && dep.DependsOnID == issueID {
|
||||
issue, _ := store.GetIssue(ctx, childID)
|
||||
if issue != nil {
|
||||
replies = append(replies, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return replies
|
||||
}
|
||||
Reference in New Issue
Block a user