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>
267 lines
7.2 KiB
Go
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
|
|
}
|