Files
beads/cmd/bd/show_thread.go
Steve Yegge 6c14fd2225 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>
2025-12-28 18:43:09 -08:00

267 lines
7.2 KiB
Go

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
}