Merge beads metadata

This commit is contained in:
Steve Yegge
2025-11-05 20:39:59 -08:00
31 changed files with 3251 additions and 355 deletions

View File

@@ -1,8 +1,11 @@
// Package merge implements 3-way merge for beads JSONL files.
//
// This code is vendored from https://github.com/neongreen/mono/tree/main/beads-merge
// Original author: @neongreen (https://github.com/neongreen)
// Used with permission - see ATTRIBUTION.md for full credits
// Original author: Emily (@neongreen, https://github.com/neongreen)
//
// MIT License
// Copyright (c) 2025 Emily
// See ATTRIBUTION.md for full license text
//
// The merge algorithm provides field-level intelligent merging for beads issues:
// - Matches issues by identity (id + created_at + created_by)
@@ -360,3 +363,68 @@ func makeConflictWithBase(base, left, right string) string {
conflict += ">>>>>>> right\n"
return conflict
}
// MergeFiles performs 3-way merge on JSONL files and writes result to output
// Returns true if conflicts were found, false if merge was clean
func MergeFiles(outputPath, basePath, leftPath, rightPath string, debug bool) (bool, error) {
// Read all input files
baseIssues, err := ReadIssues(basePath)
if err != nil {
return false, fmt.Errorf("failed to read base file: %w", err)
}
leftIssues, err := ReadIssues(leftPath)
if err != nil {
return false, fmt.Errorf("failed to read left file: %w", err)
}
rightIssues, err := ReadIssues(rightPath)
if err != nil {
return false, fmt.Errorf("failed to read right file: %w", err)
}
if debug {
fmt.Fprintf(os.Stderr, "Base issues: %d\n", len(baseIssues))
fmt.Fprintf(os.Stderr, "Left issues: %d\n", len(leftIssues))
fmt.Fprintf(os.Stderr, "Right issues: %d\n", len(rightIssues))
}
// Perform 3-way merge
merged, conflicts := Merge3Way(baseIssues, leftIssues, rightIssues)
if debug {
fmt.Fprintf(os.Stderr, "Merged issues: %d\n", len(merged))
fmt.Fprintf(os.Stderr, "Conflicts: %d\n", len(conflicts))
}
// Write output file
outFile, err := os.Create(outputPath)
if err != nil {
return false, fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
// Write merged issues
for _, issue := range merged {
data, err := json.Marshal(issue)
if err != nil {
return false, fmt.Errorf("failed to marshal issue: %w", err)
}
if _, err := outFile.Write(data); err != nil {
return false, fmt.Errorf("failed to write issue: %w", err)
}
if _, err := outFile.WriteString("\n"); err != nil {
return false, fmt.Errorf("failed to write newline: %w", err)
}
}
// Write conflict markers if any
for _, conflict := range conflicts {
if _, err := outFile.WriteString(conflict); err != nil {
return false, fmt.Errorf("failed to write conflict: %w", err)
}
}
hasConflicts := len(conflicts) > 0
return hasConflicts, nil
}

View File

@@ -1,7 +1,11 @@
// Package merge implements 3-way merge for beads JSONL files.
//
// This code is vendored from https://github.com/neongreen/mono/tree/main/beads-merge
// Original author: @neongreen (https://github.com/neongreen)
// Original author: Emily (@neongreen, https://github.com/neongreen)
//
// MIT License
// Copyright (c) 2025 Emily
// See ATTRIBUTION.md for full license text
package merge
import (

View File

@@ -650,6 +650,9 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err
// Helper function to scan issues from rows
func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*types.Issue, error) {
var issues []*types.Issue
var issueIDs []string
// First pass: scan all issues
for rows.Next() {
var issue types.Issue
var contentHash sql.NullString
@@ -689,14 +692,21 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
issue.SourceRepo = sourceRepo.String
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
if err != nil {
return nil, fmt.Errorf("failed to get labels for issue %s: %w", issue.ID, err)
}
issue.Labels = labels
issues = append(issues, &issue)
issueIDs = append(issueIDs, issue.ID)
}
// Second pass: batch-load labels for all issues
labelsMap, err := s.GetLabelsForIssues(ctx, issueIDs)
if err != nil {
return nil, fmt.Errorf("failed to batch get labels: %w", err)
}
// Assign labels to issues
for _, issue := range issues {
if labels, ok := labelsMap[issue.ID]; ok {
issue.Labels = labels
}
}
return issues, nil

View File

@@ -93,6 +93,56 @@ func (s *SQLiteStorage) GetLabels(ctx context.Context, issueID string) ([]string
return labels, nil
}
// GetLabelsForIssues fetches labels for multiple issues in a single query
// Returns a map of issue_id -> []labels
func (s *SQLiteStorage) GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) {
if len(issueIDs) == 0 {
return make(map[string][]string), nil
}
// Build placeholders for IN clause
placeholders := make([]interface{}, len(issueIDs))
for i, id := range issueIDs {
placeholders[i] = id
}
query := fmt.Sprintf(`
SELECT issue_id, label
FROM labels
WHERE issue_id IN (%s)
ORDER BY issue_id, label
`, buildPlaceholders(len(issueIDs)))
rows, err := s.db.QueryContext(ctx, query, placeholders...)
if err != nil {
return nil, fmt.Errorf("failed to batch get labels: %w", err)
}
defer func() { _ = rows.Close() }()
result := make(map[string][]string)
for rows.Next() {
var issueID, label string
if err := rows.Scan(&issueID, &label); err != nil {
return nil, err
}
result[issueID] = append(result[issueID], label)
}
return result, nil
}
// buildPlaceholders creates a comma-separated list of SQL placeholders
func buildPlaceholders(count int) string {
if count == 0 {
return ""
}
result := "?"
for i := 1; i < count; i++ {
result += ",?"
}
return result
}
// GetIssuesByLabel returns issues with a specific label
func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) {
rows, err := s.db.QueryContext(ctx, `