Add remaining multi-repo files from bd-307
This commit is contained in:
184
internal/storage/sqlite/multirepo_export.go
Normal file
184
internal/storage/sqlite/multirepo_export.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Package sqlite implements multi-repo export for the SQLite storage backend.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/steveyegge/beads/internal/config"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// ExportToMultiRepo writes issues to their respective JSONL files based on source_repo.
|
||||
// Issues are grouped by source_repo and written atomically to each repository.
|
||||
// Returns a map of repo path -> exported issue count.
|
||||
// Returns nil with no error if not in multi-repo mode (backward compatibility).
|
||||
func (s *SQLiteStorage) ExportToMultiRepo(ctx context.Context) (map[string]int, error) {
|
||||
// Get multi-repo config
|
||||
multiRepo := config.GetMultiRepoConfig()
|
||||
if multiRepo == nil {
|
||||
// Single-repo mode - not an error, just no-op
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get all issues
|
||||
allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query issues: %w", err)
|
||||
}
|
||||
|
||||
// Populate dependencies for all issues (avoid N+1)
|
||||
allDeps, err := s.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependencies: %w", err)
|
||||
}
|
||||
for _, issue := range allIssues {
|
||||
issue.Dependencies = allDeps[issue.ID]
|
||||
}
|
||||
|
||||
// Populate labels for all issues
|
||||
for _, issue := range allIssues {
|
||||
labels, err := s.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get labels for %s: %w", issue.ID, err)
|
||||
}
|
||||
issue.Labels = labels
|
||||
}
|
||||
|
||||
// Group issues by source_repo
|
||||
issuesByRepo := make(map[string][]*types.Issue)
|
||||
for _, issue := range allIssues {
|
||||
sourceRepo := issue.SourceRepo
|
||||
if sourceRepo == "" {
|
||||
sourceRepo = "." // Default to primary repo
|
||||
}
|
||||
issuesByRepo[sourceRepo] = append(issuesByRepo[sourceRepo], issue)
|
||||
}
|
||||
|
||||
results := make(map[string]int)
|
||||
|
||||
// Export primary repo
|
||||
if issues, ok := issuesByRepo["."]; ok {
|
||||
repoPath := multiRepo.Primary
|
||||
if repoPath == "" {
|
||||
repoPath = "."
|
||||
}
|
||||
count, err := s.exportToRepo(ctx, repoPath, issues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to export primary repo: %w", err)
|
||||
}
|
||||
results["."] = count
|
||||
}
|
||||
|
||||
// Export additional repos
|
||||
for _, repoPath := range multiRepo.Additional {
|
||||
issues := issuesByRepo[repoPath]
|
||||
if len(issues) == 0 {
|
||||
// No issues for this repo - write empty JSONL to keep in sync
|
||||
count, err := s.exportToRepo(ctx, repoPath, []*types.Issue{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to export repo %s: %w", repoPath, err)
|
||||
}
|
||||
results[repoPath] = count
|
||||
continue
|
||||
}
|
||||
|
||||
count, err := s.exportToRepo(ctx, repoPath, issues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to export repo %s: %w", repoPath, err)
|
||||
}
|
||||
results[repoPath] = count
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// exportToRepo writes issues to a single repository's JSONL file atomically.
|
||||
func (s *SQLiteStorage) exportToRepo(ctx context.Context, repoPath string, issues []*types.Issue) (int, error) {
|
||||
// Expand tilde in path
|
||||
expandedPath, err := expandTilde(repoPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to expand path: %w", err)
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
absRepoPath, err := filepath.Abs(expandedPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
// Construct JSONL path
|
||||
jsonlPath := filepath.Join(absRepoPath, ".beads", "issues.jsonl")
|
||||
|
||||
// Ensure .beads directory exists
|
||||
beadsDir := filepath.Dir(jsonlPath)
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
return 0, fmt.Errorf("failed to create .beads directory: %w", err)
|
||||
}
|
||||
|
||||
// Sort issues by ID for consistent output
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].ID < issues[j].ID
|
||||
})
|
||||
|
||||
// Write atomically using temp file + rename
|
||||
tempPath := fmt.Sprintf("%s.tmp.%d", jsonlPath, os.Getpid())
|
||||
f, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
// Ensure cleanup on failure
|
||||
defer func() {
|
||||
if f != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Write JSONL
|
||||
encoder := json.NewEncoder(f)
|
||||
for _, issue := range issues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
return 0, fmt.Errorf("failed to encode issue %s: %w", issue.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close before rename
|
||||
if err := f.Close(); err != nil {
|
||||
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
f = nil // Prevent defer cleanup
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tempPath, jsonlPath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
// Set file permissions
|
||||
if err := os.Chmod(jsonlPath, 0644); err != nil {
|
||||
// Non-fatal
|
||||
if os.Getenv("BD_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "Debug: failed to set permissions on %s: %v\n", jsonlPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update mtime cache for this repo
|
||||
fileInfo, err := os.Stat(jsonlPath)
|
||||
if err == nil {
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO repo_mtimes (repo_path, jsonl_path, mtime_ns, last_checked)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
`, absRepoPath, jsonlPath, fileInfo.ModTime().UnixNano())
|
||||
if err != nil && os.Getenv("BD_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "Debug: failed to update mtime cache for %s: %v\n", absRepoPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return len(issues), nil
|
||||
}
|
||||
Reference in New Issue
Block a user